@gobing-ai/ts-runtime 0.2.6 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -4
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +124 -13
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/schema-validation.d.ts +42 -0
- package/dist/schema-validation.d.ts.map +1 -0
- package/dist/schema-validation.js +255 -0
- package/package.json +2 -2
- package/src/fs.ts +133 -26
- package/src/index.ts +1 -0
- package/src/schema-validation.ts +374 -0
package/README.md
CHANGED
|
@@ -4,13 +4,13 @@ Runtime abstraction layer — environment detection, file system, process execut
|
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
|
7
|
-
`ts-runtime` decouples application code from platform specifics. Instead of importing `node:fs` or `node:child_process` directly, consumers go through interfaces (`FileSystem`, `ProcessExecutor`) that resolve to the correct implementation at startup based on `RuntimeContext`.
|
|
7
|
+
`ts-runtime` decouples application code from platform specifics. Instead of importing `node:fs` or `node:child_process` directly, consumers go through interfaces (`FileSystem`, `ProcessExecutor`) that resolve to the correct implementation at startup based on `RuntimeContext`. Node filesystem modules are loaded lazily by `NodeFileSystem`, so importing the package remains safe for Worker bundles that select `CloudflareFileSystem`.
|
|
8
8
|
|
|
9
9
|
**Key abstractions:**
|
|
10
10
|
|
|
11
11
|
| Concept | Interface | Bun/Node impl | Cloudflare impl |
|
|
12
12
|
|---------|-----------|---------------|-----------------|
|
|
13
|
-
| File system | `FileSystem` | `NodeFileSystem` | `CloudflareFileSystem` (
|
|
13
|
+
| File system | `FileSystem` | `NodeFileSystem` | `CloudflareFileSystem` (unsupported filesystem facade) |
|
|
14
14
|
| Process execution | `ProcessExecutor` | `NodeProcessExecutor` | — |
|
|
15
15
|
| Configuration | `Config` (Zod schema) | YAML + env vars | YAML + env vars |
|
|
16
16
|
| Context | `RuntimeContext` | service locator | service locator |
|
|
@@ -174,7 +174,48 @@ const config = buildConfigFromYaml(yaml, {
|
|
|
174
174
|
// config.logging.level === 'debug' (from YAML)
|
|
175
175
|
```
|
|
176
176
|
|
|
177
|
-
### 4.
|
|
177
|
+
### 4. Structured config + JSON-schema validation
|
|
178
|
+
|
|
179
|
+
`loadStructuredConfig` reads a `.json`/`.yaml` file and — if it declares a top-level `$schema` — validates
|
|
180
|
+
it against that schema before returning. This powers schema-checked rule and workflow files in
|
|
181
|
+
`ts-rule-engine` and `ts-dual-workflow-engine`.
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
import { loadStructuredConfig } from '@gobing-ai/ts-runtime';
|
|
185
|
+
|
|
186
|
+
const config = await loadStructuredConfig('rules.yaml'); // validates against its $schema, throws on violation
|
|
187
|
+
const raw = await loadStructuredConfig('rules.yaml', { validateSchema: false }); // skip validation
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
A `StructuredConfigSchemaError` (with a `violations` array of `{ path, message }`) is thrown on any
|
|
191
|
+
schema violation.
|
|
192
|
+
|
|
193
|
+
#### `$schema` reference styles
|
|
194
|
+
|
|
195
|
+
There are three ways a config file can name its schema. **Prefer the bundled package specifier** — it is
|
|
196
|
+
the most secure and performant default:
|
|
197
|
+
|
|
198
|
+
| Style | Example | Resolution | Notes |
|
|
199
|
+
|-------|---------|------------|-------|
|
|
200
|
+
| **Package specifier** (recommended) | `$schema: "@gobing-ai/ts-rule-engine/schemas/rule-file.schema.json"` | Resolved through `node_modules` via the module resolver, then read from disk | No network, no path guessing; survives hoisting/pnpm/monorepo layouts. Schemas ship in each package's `schemas/` (declared in `files`). **Quote the value** — YAML treats a leading `@` as reserved. |
|
|
201
|
+
| Relative path | `$schema: ./schemas/rule-file.schema.json` | Resolved against the config file's directory | Fine for repo-local schemas; brittle if the config moves. |
|
|
202
|
+
| Remote URL | `$schema: https://json-schema.org/.../rule-file.schema.json` | Fetched over HTTP(S) — **off by default** | SSRF/DoS surface for third-party configs. Opt in with `{ allowRemote: true }` (5s timeout) or supply your own `fetch`. |
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
// Bundled package schema (default path — no extra options needed):
|
|
206
|
+
// $schema: "@gobing-ai/ts-rule-engine/schemas/rule-file.schema.json"
|
|
207
|
+
await loadStructuredConfig('rules.yaml');
|
|
208
|
+
|
|
209
|
+
// Remote schema is refused unless explicitly enabled:
|
|
210
|
+
await loadStructuredConfig('rules.yaml', { allowRemote: true }); // built-in fetch, time-bounded
|
|
211
|
+
await loadStructuredConfig('rules.yaml', { fetch: myFetch }); // or inject your own
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
> **Security:** remote schema fetching is disabled by default. Resolving a bundled schema from
|
|
215
|
+
> `node_modules` keeps validation entirely local — no outbound request, no dependency on a schema host's
|
|
216
|
+
> availability, and no chance for a malicious config to point validation at an internal URL.
|
|
217
|
+
|
|
218
|
+
### 5. File system abstraction
|
|
178
219
|
|
|
179
220
|
All file operations go through the `FileSystem` interface. Swap implementations for testing:
|
|
180
221
|
|
|
@@ -186,7 +227,7 @@ await fs.writeFile('output.json', JSON.stringify(data));
|
|
|
186
227
|
const content = await fs.readFile('output.json');
|
|
187
228
|
```
|
|
188
229
|
|
|
189
|
-
###
|
|
230
|
+
### 6. Graceful disposal
|
|
190
231
|
|
|
191
232
|
`RuntimeContext.dispose()` calls `dispose()` on every registered service that implements the pattern:
|
|
192
233
|
|
package/dist/fs.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fs.d.ts","sourceRoot":"","sources":["../src/fs.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"fs.d.ts","sourceRoot":"","sources":["../src/fs.ts"],"names":[],"mappings":"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"}
|
package/dist/fs.js
CHANGED
|
@@ -1,22 +1,34 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
let fsPromisesModule = null;
|
|
2
|
+
let fsModule = null;
|
|
3
|
+
function nodeFsPromises() {
|
|
4
|
+
fsPromisesModule ??= import('node:fs/promises');
|
|
5
|
+
return fsPromisesModule;
|
|
6
|
+
}
|
|
7
|
+
function nodeFs() {
|
|
8
|
+
fsModule ??= import('node:fs');
|
|
9
|
+
return fsModule;
|
|
10
|
+
}
|
|
4
11
|
export class NodeFileSystem {
|
|
5
12
|
async readFile(path) {
|
|
13
|
+
const { readFile } = await nodeFsPromises();
|
|
6
14
|
return await readFile(path, 'utf-8');
|
|
7
15
|
}
|
|
8
16
|
async writeFile(path, content) {
|
|
17
|
+
const { writeFile } = await nodeFsPromises();
|
|
9
18
|
await ensureDirForFile(path, this);
|
|
10
19
|
await writeFile(path, content, 'utf-8');
|
|
11
20
|
}
|
|
12
21
|
async appendFile(path, content) {
|
|
22
|
+
const { appendFile } = await nodeFsPromises();
|
|
13
23
|
await ensureDirForFile(path, this);
|
|
14
24
|
await appendFile(path, content, 'utf-8');
|
|
15
25
|
}
|
|
16
26
|
async mkdir(path) {
|
|
27
|
+
const { mkdir } = await nodeFsPromises();
|
|
17
28
|
await mkdir(path, { recursive: true });
|
|
18
29
|
}
|
|
19
30
|
async exists(path) {
|
|
31
|
+
const { access } = await nodeFsPromises();
|
|
20
32
|
try {
|
|
21
33
|
await access(path);
|
|
22
34
|
return true;
|
|
@@ -26,12 +38,15 @@ export class NodeFileSystem {
|
|
|
26
38
|
}
|
|
27
39
|
}
|
|
28
40
|
async readDir(path) {
|
|
41
|
+
const { readdir } = await nodeFsPromises();
|
|
29
42
|
return await readdir(path);
|
|
30
43
|
}
|
|
31
44
|
async unlink(path) {
|
|
45
|
+
const { rm } = await nodeFsPromises();
|
|
32
46
|
await rm(path, { recursive: true, force: true });
|
|
33
47
|
}
|
|
34
48
|
async stat(path) {
|
|
49
|
+
const { stat } = await nodeFsPromises();
|
|
35
50
|
try {
|
|
36
51
|
const value = await stat(path);
|
|
37
52
|
return {
|
|
@@ -46,17 +61,52 @@ export class NodeFileSystem {
|
|
|
46
61
|
}
|
|
47
62
|
}
|
|
48
63
|
async realpath(path) {
|
|
64
|
+
const { realpath } = await nodeFsPromises();
|
|
49
65
|
return await realpath(path);
|
|
50
66
|
}
|
|
51
67
|
async copy(src, dest) {
|
|
68
|
+
const { cp } = await nodeFsPromises();
|
|
52
69
|
await cp(src, dest, { recursive: true });
|
|
53
70
|
}
|
|
54
71
|
async rename(src, dest) {
|
|
72
|
+
const { rename } = await nodeFsPromises();
|
|
55
73
|
await rename(src, dest);
|
|
56
74
|
}
|
|
57
75
|
createLogStream(path) {
|
|
58
|
-
|
|
59
|
-
|
|
76
|
+
return new LazyNodeLogStream(path);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
class LazyNodeLogStream {
|
|
80
|
+
ready;
|
|
81
|
+
ended = false;
|
|
82
|
+
pending = [];
|
|
83
|
+
constructor(path) {
|
|
84
|
+
this.ready = nodeFs().then(({ createWriteStream, mkdirSync }) => {
|
|
85
|
+
mkdirSync(dirnamePath(path), { recursive: true });
|
|
86
|
+
const stream = createWriteStream(path, { flags: 'a' });
|
|
87
|
+
for (const chunk of this.pending.splice(0))
|
|
88
|
+
stream.write(chunk);
|
|
89
|
+
if (this.ended)
|
|
90
|
+
stream.end();
|
|
91
|
+
return {
|
|
92
|
+
write: (chunk) => stream.write(chunk),
|
|
93
|
+
end: () => stream.end(),
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
write(chunk) {
|
|
98
|
+
if (this.ended)
|
|
99
|
+
return;
|
|
100
|
+
this.pending.push(chunk);
|
|
101
|
+
void this.ready.then((stream) => {
|
|
102
|
+
const next = this.pending.shift();
|
|
103
|
+
if (next !== undefined)
|
|
104
|
+
stream.write(next);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
end() {
|
|
108
|
+
this.ended = true;
|
|
109
|
+
void this.ready.then((stream) => stream.end());
|
|
60
110
|
}
|
|
61
111
|
}
|
|
62
112
|
const CLOUDFLARE_FS_ERROR = 'FileSystem is not available on Cloudflare Workers. Use D1, KV, or R2.';
|
|
@@ -113,11 +163,11 @@ export function getFs() {
|
|
|
113
163
|
return activeFileSystem;
|
|
114
164
|
}
|
|
115
165
|
export async function ensureDirForFile(path, fs = getFs()) {
|
|
116
|
-
await fs.mkdir(
|
|
166
|
+
await fs.mkdir(dirnamePath(path));
|
|
117
167
|
}
|
|
118
168
|
export async function atomicWriteFile(path, content, fs = getFs()) {
|
|
119
169
|
await ensureDirForFile(path, fs);
|
|
120
|
-
const tempPath = `${path}.${
|
|
170
|
+
const tempPath = `${path}.${getProcessPid()}.${Date.now()}.tmp`;
|
|
121
171
|
await fs.writeFile(tempPath, content);
|
|
122
172
|
await fs.rename(tempPath, path);
|
|
123
173
|
}
|
|
@@ -134,7 +184,7 @@ export async function walkDir(path, fs = getFs()) {
|
|
|
134
184
|
const entries = (await fs.readDir(path)).sort();
|
|
135
185
|
const result = [];
|
|
136
186
|
for (const entry of entries) {
|
|
137
|
-
const fullPath =
|
|
187
|
+
const fullPath = joinPath(path, entry);
|
|
138
188
|
const entryStat = await fs.stat(fullPath);
|
|
139
189
|
if (entryStat?.isDirectory()) {
|
|
140
190
|
result.push(...(await walkDir(fullPath, fs)));
|
|
@@ -145,13 +195,13 @@ export async function walkDir(path, fs = getFs()) {
|
|
|
145
195
|
}
|
|
146
196
|
return result;
|
|
147
197
|
}
|
|
148
|
-
export function getProjectRoot(startDir =
|
|
149
|
-
let current =
|
|
198
|
+
export function getProjectRoot(startDir = getProcessCwd()) {
|
|
199
|
+
let current = resolvePath(startDir);
|
|
150
200
|
for (let i = 0; i < 12; i++) {
|
|
151
|
-
if (
|
|
201
|
+
if (hasBunFile(joinPath(current, 'bun.lock')) || hasBunFile(joinPath(current, 'package.json'))) {
|
|
152
202
|
return current;
|
|
153
203
|
}
|
|
154
|
-
const parent =
|
|
204
|
+
const parent = dirnamePath(current);
|
|
155
205
|
if (parent === current)
|
|
156
206
|
return startDir;
|
|
157
207
|
current = parent;
|
|
@@ -159,8 +209,69 @@ export function getProjectRoot(startDir = process.cwd()) {
|
|
|
159
209
|
return startDir;
|
|
160
210
|
}
|
|
161
211
|
export function resolveProjectPath(...segments) {
|
|
162
|
-
return
|
|
212
|
+
return resolvePath(getProjectRoot(), ...segments);
|
|
163
213
|
}
|
|
164
214
|
export function createLogStream(path, fs = getFs()) {
|
|
165
215
|
return fs.createLogStream(path);
|
|
166
216
|
}
|
|
217
|
+
function hasBunFile(path) {
|
|
218
|
+
const bun = globalThis.Bun;
|
|
219
|
+
if (bun === undefined)
|
|
220
|
+
return false;
|
|
221
|
+
return bun.file(path).size !== 0;
|
|
222
|
+
}
|
|
223
|
+
function getProcessPid() {
|
|
224
|
+
return globalThis.process?.pid ?? 0;
|
|
225
|
+
}
|
|
226
|
+
function getProcessCwd() {
|
|
227
|
+
return globalThis.process?.cwd?.() ?? '/';
|
|
228
|
+
}
|
|
229
|
+
function normalizeSeparators(path) {
|
|
230
|
+
return path.replaceAll('\\', '/');
|
|
231
|
+
}
|
|
232
|
+
function isAbsolutePath(path) {
|
|
233
|
+
return path.startsWith('/') || /^[A-Za-z]:\//.test(normalizeSeparators(path));
|
|
234
|
+
}
|
|
235
|
+
function dirnamePath(path) {
|
|
236
|
+
const input = normalizeSeparators(path);
|
|
237
|
+
if (/^\/+$/.test(input))
|
|
238
|
+
return '/';
|
|
239
|
+
const normalized = input.replace(/\/+$/, '');
|
|
240
|
+
if (normalized === '' || normalized === '/')
|
|
241
|
+
return normalized || '.';
|
|
242
|
+
const index = normalized.lastIndexOf('/');
|
|
243
|
+
if (index < 0)
|
|
244
|
+
return '.';
|
|
245
|
+
if (index === 0)
|
|
246
|
+
return '/';
|
|
247
|
+
return normalized.slice(0, index);
|
|
248
|
+
}
|
|
249
|
+
function joinPath(...segments) {
|
|
250
|
+
const filtered = segments.filter((segment) => segment.length > 0).map(normalizeSeparators);
|
|
251
|
+
if (filtered.length === 0)
|
|
252
|
+
return '.';
|
|
253
|
+
const absolute = isAbsolutePath(filtered[0] ?? '');
|
|
254
|
+
const joined = filtered.join('/').replace(/\/+/g, '/');
|
|
255
|
+
return absolute ? joined : joined.replace(/^\//, '');
|
|
256
|
+
}
|
|
257
|
+
function resolvePath(...segments) {
|
|
258
|
+
const candidates = segments.length === 0 ? [getProcessCwd()] : segments;
|
|
259
|
+
let resolved = '';
|
|
260
|
+
for (const segment of candidates.map(normalizeSeparators)) {
|
|
261
|
+
if (segment.length === 0)
|
|
262
|
+
continue;
|
|
263
|
+
resolved = isAbsolutePath(segment) ? segment : joinPath(resolved || getProcessCwd(), segment);
|
|
264
|
+
}
|
|
265
|
+
const parts = [];
|
|
266
|
+
const absolute = isAbsolutePath(resolved);
|
|
267
|
+
for (const part of resolved.split('/')) {
|
|
268
|
+
if (part === '' || part === '.')
|
|
269
|
+
continue;
|
|
270
|
+
if (part === '..') {
|
|
271
|
+
parts.pop();
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
parts.push(part);
|
|
275
|
+
}
|
|
276
|
+
return `${absolute ? '/' : ''}${parts.join('/')}` || (absolute ? '/' : '.');
|
|
277
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,MAAM,CAAC;AACrB,cAAc,oBAAoB,CAAC;AACnC,cAAc,SAAS,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,MAAM,CAAC;AACrB,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,SAAS,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface JsonSchemaViolation {
|
|
2
|
+
path: string;
|
|
3
|
+
message: string;
|
|
4
|
+
}
|
|
5
|
+
export interface JsonSchema {
|
|
6
|
+
type?: string | string[];
|
|
7
|
+
required?: string[];
|
|
8
|
+
properties?: Record<string, JsonSchema>;
|
|
9
|
+
additionalProperties?: boolean | JsonSchema;
|
|
10
|
+
items?: JsonSchema;
|
|
11
|
+
enum?: unknown[];
|
|
12
|
+
const?: unknown;
|
|
13
|
+
oneOf?: JsonSchema[];
|
|
14
|
+
anyOf?: JsonSchema[];
|
|
15
|
+
$ref?: string;
|
|
16
|
+
$defs?: Record<string, JsonSchema>;
|
|
17
|
+
}
|
|
18
|
+
export interface StructuredConfigLoadOptions {
|
|
19
|
+
validateSchema?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Allow `http(s)://` `$schema` refs. Off by default: remote fetches are an SSRF/DoS surface when
|
|
22
|
+
* configs are authored by third parties. Prefer bundled package-specifier refs (resolved from
|
|
23
|
+
* `node_modules`). Supplying `fetch` explicitly also opts into remote resolution.
|
|
24
|
+
*/
|
|
25
|
+
allowRemote?: boolean;
|
|
26
|
+
fetch?: (input: string) => Promise<Response>;
|
|
27
|
+
/**
|
|
28
|
+
* Module resolver for bare package-specifier `$schema` refs (e.g.
|
|
29
|
+
* `@gobing-ai/ts-rule-engine/schemas/rule-file.schema.json`). Defaults to `Bun.resolveSync`.
|
|
30
|
+
* Injectable for testing.
|
|
31
|
+
*/
|
|
32
|
+
resolve?: (specifier: string, from: string) => string;
|
|
33
|
+
}
|
|
34
|
+
export declare class StructuredConfigSchemaError extends Error {
|
|
35
|
+
readonly violations: readonly JsonSchemaViolation[];
|
|
36
|
+
constructor(message: string, violations?: readonly JsonSchemaViolation[]);
|
|
37
|
+
}
|
|
38
|
+
export declare function loadStructuredConfig(path: string, options?: StructuredConfigLoadOptions): Promise<unknown>;
|
|
39
|
+
export declare function parseStructuredConfig(content: string, source: string, options?: StructuredConfigLoadOptions): Promise<unknown>;
|
|
40
|
+
export declare function validateDeclaredJsonSchema(value: unknown, source: string, options?: StructuredConfigLoadOptions): Promise<void>;
|
|
41
|
+
export declare function validateJsonSchema(value: unknown, schema: JsonSchema, path?: string, defs?: Record<string, JsonSchema>): JsonSchemaViolation[];
|
|
42
|
+
//# sourceMappingURL=schema-validation.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema-validation.d.ts","sourceRoot":"","sources":["../src/schema-validation.ts"],"names":[],"mappings":"AAOA,MAAM,WAAW,mBAAmB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,UAAU;IACvB,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IACzB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;IACxC,oBAAoB,CAAC,EAAE,OAAO,GAAG,UAAU,CAAC;IAC5C,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB,IAAI,CAAC,EAAE,OAAO,EAAE,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,KAAK,CAAC,EAAE,UAAU,EAAE,CAAC;IACrB,KAAK,CAAC,EAAE,UAAU,EAAE,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;CACtC;AAED,MAAM,WAAW,2BAA2B;IACxC,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC7C;;;;OAIG;IACH,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;CACzD;AAED,qBAAa,2BAA4B,SAAQ,KAAK;IAG9C,QAAQ,CAAC,UAAU,EAAE,SAAS,mBAAmB,EAAE;gBADnD,OAAO,EAAE,MAAM,EACN,UAAU,GAAE,SAAS,mBAAmB,EAAO;CAK/D;AAED,wBAAsB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,2BAAgC,GAAG,OAAO,CAAC,OAAO,CAAC,CAGpH;AAED,wBAAsB,qBAAqB,CACvC,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,EACd,OAAO,GAAE,2BAAgC,GAC1C,OAAO,CAAC,OAAO,CAAC,CAMlB;AAED,wBAAsB,0BAA0B,CAC5C,KAAK,EAAE,OAAO,EACd,MAAM,EAAE,MAAM,EACd,OAAO,GAAE,2BAAgC,GAC1C,OAAO,CAAC,IAAI,CAAC,CA+Bf;AAED,wBAAgB,kBAAkB,CAC9B,KAAK,EAAE,OAAO,EACd,MAAM,EAAE,UAAU,EAClB,IAAI,SAAK,EACT,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAM,GACtC,mBAAmB,EAAE,CAgDvB"}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
2
|
+
import { parse as parseYaml } from 'yaml';
|
|
3
|
+
import { getFs } from './fs.js';
|
|
4
|
+
/** Default time budget for a single remote schema fetch. */
|
|
5
|
+
const REMOTE_SCHEMA_FETCH_TIMEOUT_MS = 5_000;
|
|
6
|
+
export class StructuredConfigSchemaError extends Error {
|
|
7
|
+
violations;
|
|
8
|
+
constructor(message, violations = []) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.violations = violations;
|
|
11
|
+
this.name = 'StructuredConfigSchemaError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export async function loadStructuredConfig(path, options = {}) {
|
|
15
|
+
const content = await getFs().readFile(path);
|
|
16
|
+
return await parseStructuredConfig(content, path, options);
|
|
17
|
+
}
|
|
18
|
+
export async function parseStructuredConfig(content, source, options = {}) {
|
|
19
|
+
const parsed = source.endsWith('.json') ? JSON.parse(content) : parseYaml(content);
|
|
20
|
+
if (options.validateSchema !== false) {
|
|
21
|
+
await validateDeclaredJsonSchema(parsed, source, options);
|
|
22
|
+
}
|
|
23
|
+
return parsed;
|
|
24
|
+
}
|
|
25
|
+
export async function validateDeclaredJsonSchema(value, source, options = {}) {
|
|
26
|
+
if (!isObject(value))
|
|
27
|
+
return;
|
|
28
|
+
const schemaRef = value.$schema;
|
|
29
|
+
if (typeof schemaRef !== 'string' || schemaRef.length === 0)
|
|
30
|
+
return;
|
|
31
|
+
const schemaLocation = resolveSchemaRef(schemaRef, source, options.resolve);
|
|
32
|
+
const schemaText = await readSchema(schemaLocation, options);
|
|
33
|
+
let schema;
|
|
34
|
+
try {
|
|
35
|
+
schema = JSON.parse(schemaText);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
throw new StructuredConfigSchemaError(`Invalid JSON schema "${schemaLocation}" referenced by "${source}": ${errorMessage(error)}`);
|
|
39
|
+
}
|
|
40
|
+
if (!isObject(schema)) {
|
|
41
|
+
throw new StructuredConfigSchemaError(`JSON schema "${schemaLocation}" referenced by "${source}" must be an object`);
|
|
42
|
+
}
|
|
43
|
+
const violations = validateJsonSchema(value, schema, '', (schema.$defs ?? {}));
|
|
44
|
+
if (violations.length > 0) {
|
|
45
|
+
throw new StructuredConfigSchemaError(`Configuration "${source}" failed JSON schema validation against "${schemaLocation}": ${violations
|
|
46
|
+
.map((violation) => `${violation.path}: ${violation.message}`)
|
|
47
|
+
.join('; ')}`, violations);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export function validateJsonSchema(value, schema, path = '', defs = {}) {
|
|
51
|
+
const violations = [];
|
|
52
|
+
// Applicator keywords compose with their siblings (logical AND), per JSON Schema 2020-12 —
|
|
53
|
+
// a node may carry `$ref`/`oneOf`/`anyOf` *and* `type`/`properties`/... and all apply.
|
|
54
|
+
if (schema.$ref !== undefined) {
|
|
55
|
+
const resolved = resolveRef(schema.$ref, defs, schema.$defs);
|
|
56
|
+
if (resolved !== undefined)
|
|
57
|
+
violations.push(...validateJsonSchema(value, resolved, path, defs));
|
|
58
|
+
}
|
|
59
|
+
if (schema.oneOf !== undefined) {
|
|
60
|
+
violations.push(...validateCombinator(value, schema.oneOf, path, defs, 'oneOf'));
|
|
61
|
+
}
|
|
62
|
+
if (schema.anyOf !== undefined) {
|
|
63
|
+
violations.push(...validateCombinator(value, schema.anyOf, path, defs, 'anyOf'));
|
|
64
|
+
}
|
|
65
|
+
if (schema.const !== undefined && !jsonEqual(value, schema.const)) {
|
|
66
|
+
violations.push({ path: path || '(root)', message: `expected constant ${JSON.stringify(schema.const)}` });
|
|
67
|
+
}
|
|
68
|
+
if (schema.enum !== undefined && !schema.enum.some((entry) => jsonEqual(value, entry))) {
|
|
69
|
+
violations.push({ path: path || '(root)', message: `expected one of ${schema.enum.map(String).join(', ')}` });
|
|
70
|
+
}
|
|
71
|
+
if (schema.type !== undefined) {
|
|
72
|
+
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
73
|
+
if (!types.some((type) => matchesType(value, type))) {
|
|
74
|
+
violations.push({
|
|
75
|
+
path: path || '(root)',
|
|
76
|
+
message: `expected ${types.join(' or ')}, got ${typeName(value)}`,
|
|
77
|
+
});
|
|
78
|
+
return violations;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const hasObjectKeywords = schema.properties !== undefined || schema.required !== undefined || schema.additionalProperties !== undefined;
|
|
82
|
+
if (schema.type === 'object' || (hasObjectKeywords && isObject(value))) {
|
|
83
|
+
violations.push(...validateObject(value, schema, path, defs));
|
|
84
|
+
}
|
|
85
|
+
if (schema.type === 'array' || (schema.items !== undefined && Array.isArray(value))) {
|
|
86
|
+
violations.push(...validateArray(value, schema, path, defs));
|
|
87
|
+
}
|
|
88
|
+
return violations;
|
|
89
|
+
}
|
|
90
|
+
function validateObject(value, schema, path, defs) {
|
|
91
|
+
if (!isObject(value))
|
|
92
|
+
return [{ path: path || '(root)', message: `expected object, got ${typeName(value)}` }];
|
|
93
|
+
const violations = [];
|
|
94
|
+
for (const key of schema.required ?? []) {
|
|
95
|
+
if (!(key in value)) {
|
|
96
|
+
violations.push({ path: path ? `${path}.${key}` : key, message: `missing required field "${key}"` });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const properties = schema.properties ?? {};
|
|
100
|
+
for (const [key, childSchema] of Object.entries(properties)) {
|
|
101
|
+
if (key in value) {
|
|
102
|
+
violations.push(...validateJsonSchema(value[key], childSchema, path ? `${path}.${key}` : key, defs));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (schema.additionalProperties === false) {
|
|
106
|
+
const allowed = new Set(Object.keys(properties));
|
|
107
|
+
for (const key of Object.keys(value)) {
|
|
108
|
+
if (!allowed.has(key)) {
|
|
109
|
+
violations.push({ path: path ? `${path}.${key}` : key, message: `unknown field "${key}"` });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
else if (isObject(schema.additionalProperties)) {
|
|
114
|
+
for (const [key, child] of Object.entries(value)) {
|
|
115
|
+
if (!(key in properties)) {
|
|
116
|
+
violations.push(...validateJsonSchema(child, schema.additionalProperties, path ? `${path}.${key}` : key, defs));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return violations;
|
|
121
|
+
}
|
|
122
|
+
function validateArray(value, schema, path, defs) {
|
|
123
|
+
if (!Array.isArray(value))
|
|
124
|
+
return [{ path: path || '(root)', message: `expected array, got ${typeName(value)}` }];
|
|
125
|
+
if (schema.items === undefined)
|
|
126
|
+
return [];
|
|
127
|
+
return value.flatMap((entry, index) => validateJsonSchema(entry, schema.items, `${path}[${index}]`, defs));
|
|
128
|
+
}
|
|
129
|
+
function validateCombinator(value, schemas, path, defs, mode) {
|
|
130
|
+
const branchViolations = schemas.map((schema) => validateJsonSchema(value, schema, path, defs));
|
|
131
|
+
const passing = branchViolations.filter((violations) => violations.length === 0).length;
|
|
132
|
+
if (mode === 'anyOf' && passing >= 1)
|
|
133
|
+
return [];
|
|
134
|
+
if (mode === 'oneOf' && passing === 1)
|
|
135
|
+
return [];
|
|
136
|
+
const at = path || '(root)';
|
|
137
|
+
// oneOf matching more than one branch is a failure, not a pass — report it explicitly.
|
|
138
|
+
if (mode === 'oneOf' && passing > 1) {
|
|
139
|
+
return [{ path: at, message: `expected to match exactly one oneOf branch, matched ${passing}` }];
|
|
140
|
+
}
|
|
141
|
+
// No branch matched: surface every branch's reason so the author sees all options, not just branch 0.
|
|
142
|
+
const detail = branchViolations
|
|
143
|
+
.map((branch, index) => `[${index}] ${branch.map((v) => `${v.path}: ${v.message}`).join(', ')}`)
|
|
144
|
+
.join(' | ');
|
|
145
|
+
return [
|
|
146
|
+
{
|
|
147
|
+
path: at,
|
|
148
|
+
message: `expected to match ${mode === 'oneOf' ? 'exactly one' : 'at least one'} branch — ${detail}`,
|
|
149
|
+
},
|
|
150
|
+
];
|
|
151
|
+
}
|
|
152
|
+
function resolveRef(ref, defs, localDefs) {
|
|
153
|
+
if (!ref.startsWith('#/$defs/'))
|
|
154
|
+
return undefined;
|
|
155
|
+
const name = ref.slice('#/$defs/'.length);
|
|
156
|
+
return defs[name] ?? localDefs?.[name];
|
|
157
|
+
}
|
|
158
|
+
function resolveSchemaRef(schemaRef, source, resolve) {
|
|
159
|
+
if (isRemoteRef(schemaRef) || isAbsolute(schemaRef))
|
|
160
|
+
return schemaRef;
|
|
161
|
+
// Relative ref — resolve against the config file's directory.
|
|
162
|
+
if (schemaRef.startsWith('./') || schemaRef.startsWith('../')) {
|
|
163
|
+
return join(isRemoteRef(source) ? '.' : dirname(source), schemaRef);
|
|
164
|
+
}
|
|
165
|
+
// Bare package specifier (e.g. "@scope/pkg/schemas/x.json") — resolve through node_modules.
|
|
166
|
+
return resolvePackageSchema(schemaRef, source, resolve);
|
|
167
|
+
}
|
|
168
|
+
function resolvePackageSchema(specifier, source, resolve) {
|
|
169
|
+
const resolveFn = resolve ?? defaultResolve;
|
|
170
|
+
if (resolveFn === undefined) {
|
|
171
|
+
throw new StructuredConfigSchemaError(`Cannot resolve package schema "${specifier}" referenced by "${source}": no module resolver available`);
|
|
172
|
+
}
|
|
173
|
+
const { pkg, subpath } = splitPackageSpecifier(specifier);
|
|
174
|
+
if (subpath.length === 0) {
|
|
175
|
+
throw new StructuredConfigSchemaError(`Package schema ref "${specifier}" referenced by "${source}" must include a path within the package`);
|
|
176
|
+
}
|
|
177
|
+
const from = isRemoteRef(source) ? process.cwd() : dirname(source);
|
|
178
|
+
try {
|
|
179
|
+
// Resolve the package root via its always-present package.json, then join the subpath.
|
|
180
|
+
// This sidesteps `exports` gating on arbitrary JSON subpaths.
|
|
181
|
+
const manifest = resolveFn(`${pkg}/package.json`, from);
|
|
182
|
+
return join(dirname(manifest), subpath);
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
throw new StructuredConfigSchemaError(`Cannot resolve package schema "${specifier}" referenced by "${source}": ${errorMessage(error)}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function splitPackageSpecifier(specifier) {
|
|
189
|
+
const parts = specifier.split('/');
|
|
190
|
+
const segments = specifier.startsWith('@') ? 2 : 1;
|
|
191
|
+
return { pkg: parts.slice(0, segments).join('/'), subpath: parts.slice(segments).join('/') };
|
|
192
|
+
}
|
|
193
|
+
async function readSchema(schemaLocation, options) {
|
|
194
|
+
if (isRemoteRef(schemaLocation)) {
|
|
195
|
+
const fetchFn = options.fetch ?? (options.allowRemote ? boundedFetch : undefined);
|
|
196
|
+
if (fetchFn === undefined) {
|
|
197
|
+
throw new StructuredConfigSchemaError(`Refusing to fetch remote JSON schema "${schemaLocation}": pass { allowRemote: true } or a fetch implementation to opt in`);
|
|
198
|
+
}
|
|
199
|
+
const response = await fetchFn(schemaLocation);
|
|
200
|
+
if (!response.ok) {
|
|
201
|
+
throw new StructuredConfigSchemaError(`Failed to fetch JSON schema "${schemaLocation}": HTTP ${response.status}`);
|
|
202
|
+
}
|
|
203
|
+
return await response.text();
|
|
204
|
+
}
|
|
205
|
+
return await getFs().readFile(schemaLocation);
|
|
206
|
+
}
|
|
207
|
+
const defaultResolve = typeof Bun !== 'undefined' ? (specifier, from) => Bun.resolveSync(specifier, from) : undefined;
|
|
208
|
+
/** Default remote fetch, time-bounded so a slow/hung schema host cannot stall config loading. */
|
|
209
|
+
function boundedFetch(input) {
|
|
210
|
+
return globalThis.fetch(input, { signal: AbortSignal.timeout(REMOTE_SCHEMA_FETCH_TIMEOUT_MS) });
|
|
211
|
+
}
|
|
212
|
+
function isObject(value) {
|
|
213
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
214
|
+
}
|
|
215
|
+
function matchesType(value, type) {
|
|
216
|
+
if (type === 'object')
|
|
217
|
+
return isObject(value);
|
|
218
|
+
if (type === 'array')
|
|
219
|
+
return Array.isArray(value);
|
|
220
|
+
if (type === 'integer')
|
|
221
|
+
return typeof value === 'number' && Number.isInteger(value);
|
|
222
|
+
if (type === 'null')
|
|
223
|
+
return value === null;
|
|
224
|
+
return typeof value === type;
|
|
225
|
+
}
|
|
226
|
+
function typeName(value) {
|
|
227
|
+
if (Array.isArray(value))
|
|
228
|
+
return 'array';
|
|
229
|
+
if (value === null)
|
|
230
|
+
return 'null';
|
|
231
|
+
return typeof value;
|
|
232
|
+
}
|
|
233
|
+
function jsonEqual(left, right) {
|
|
234
|
+
if (left === right)
|
|
235
|
+
return true;
|
|
236
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
237
|
+
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length)
|
|
238
|
+
return false;
|
|
239
|
+
return left.every((entry, index) => jsonEqual(entry, right[index]));
|
|
240
|
+
}
|
|
241
|
+
if (isObject(left) && isObject(right)) {
|
|
242
|
+
const keys = Object.keys(left);
|
|
243
|
+
if (keys.length !== Object.keys(right).length)
|
|
244
|
+
return false;
|
|
245
|
+
// Object member order is insignificant in JSON — compare by key, not by serialization.
|
|
246
|
+
return keys.every((key) => key in right && jsonEqual(left[key], right[key]));
|
|
247
|
+
}
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
function errorMessage(error) {
|
|
251
|
+
return error instanceof Error ? error.message : String(error);
|
|
252
|
+
}
|
|
253
|
+
function isRemoteRef(ref) {
|
|
254
|
+
return /^https?:\/\//i.test(ref);
|
|
255
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gobing-ai/ts-runtime",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
4
4
|
"description": "@gobing-ai/ts-runtime — Runtime abstractions for Bun, Node, and Cloudflare Workers.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"typescript",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"release": "echo 'Manual publish is disabled. Releases go through GitHub Actions via Trusted Publishing — push a tag: git tag @gobing-ai/ts-runtime-v<version> && git push --tags' && exit 1"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@gobing-ai/ts-utils": "^0.2.
|
|
57
|
+
"@gobing-ai/ts-utils": "^0.2.8",
|
|
58
58
|
"execa": "^9.5.0",
|
|
59
59
|
"yaml": "^2.7.0",
|
|
60
60
|
"zod": "^4.1.0"
|
package/src/fs.ts
CHANGED
|
@@ -1,19 +1,3 @@
|
|
|
1
|
-
import { createWriteStream, mkdirSync } from 'node:fs';
|
|
2
|
-
import {
|
|
3
|
-
access,
|
|
4
|
-
appendFile,
|
|
5
|
-
cp,
|
|
6
|
-
mkdir,
|
|
7
|
-
readdir,
|
|
8
|
-
readFile,
|
|
9
|
-
realpath,
|
|
10
|
-
rename,
|
|
11
|
-
rm,
|
|
12
|
-
stat,
|
|
13
|
-
writeFile,
|
|
14
|
-
} from 'node:fs/promises';
|
|
15
|
-
import { dirname, join, resolve } from 'node:path';
|
|
16
|
-
|
|
17
1
|
export interface FileStat {
|
|
18
2
|
isFile(): boolean;
|
|
19
3
|
isDirectory(): boolean;
|
|
@@ -41,26 +25,47 @@ export interface FileSystem {
|
|
|
41
25
|
createLogStream(path: string): LogStream;
|
|
42
26
|
}
|
|
43
27
|
|
|
28
|
+
type NodeFsPromises = typeof import('node:fs/promises');
|
|
29
|
+
type NodeFs = typeof import('node:fs');
|
|
30
|
+
|
|
31
|
+
let fsPromisesModule: Promise<NodeFsPromises> | null = null;
|
|
32
|
+
let fsModule: Promise<NodeFs> | null = null;
|
|
33
|
+
|
|
34
|
+
function nodeFsPromises(): Promise<NodeFsPromises> {
|
|
35
|
+
fsPromisesModule ??= import('node:fs/promises');
|
|
36
|
+
return fsPromisesModule;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function nodeFs(): Promise<NodeFs> {
|
|
40
|
+
fsModule ??= import('node:fs');
|
|
41
|
+
return fsModule;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
44
|
export class NodeFileSystem implements FileSystem {
|
|
45
45
|
async readFile(path: string): Promise<string> {
|
|
46
|
+
const { readFile } = await nodeFsPromises();
|
|
46
47
|
return await readFile(path, 'utf-8');
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
async writeFile(path: string, content: string): Promise<void> {
|
|
51
|
+
const { writeFile } = await nodeFsPromises();
|
|
50
52
|
await ensureDirForFile(path, this);
|
|
51
53
|
await writeFile(path, content, 'utf-8');
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
async appendFile(path: string, content: string): Promise<void> {
|
|
57
|
+
const { appendFile } = await nodeFsPromises();
|
|
55
58
|
await ensureDirForFile(path, this);
|
|
56
59
|
await appendFile(path, content, 'utf-8');
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
async mkdir(path: string): Promise<void> {
|
|
63
|
+
const { mkdir } = await nodeFsPromises();
|
|
60
64
|
await mkdir(path, { recursive: true });
|
|
61
65
|
}
|
|
62
66
|
|
|
63
67
|
async exists(path: string): Promise<boolean> {
|
|
68
|
+
const { access } = await nodeFsPromises();
|
|
64
69
|
try {
|
|
65
70
|
await access(path);
|
|
66
71
|
return true;
|
|
@@ -70,14 +75,17 @@ export class NodeFileSystem implements FileSystem {
|
|
|
70
75
|
}
|
|
71
76
|
|
|
72
77
|
async readDir(path: string): Promise<string[]> {
|
|
78
|
+
const { readdir } = await nodeFsPromises();
|
|
73
79
|
return await readdir(path);
|
|
74
80
|
}
|
|
75
81
|
|
|
76
82
|
async unlink(path: string): Promise<void> {
|
|
83
|
+
const { rm } = await nodeFsPromises();
|
|
77
84
|
await rm(path, { recursive: true, force: true });
|
|
78
85
|
}
|
|
79
86
|
|
|
80
87
|
async stat(path: string): Promise<FileStat | null> {
|
|
88
|
+
const { stat } = await nodeFsPromises();
|
|
81
89
|
try {
|
|
82
90
|
const value = await stat(path);
|
|
83
91
|
return {
|
|
@@ -92,20 +100,58 @@ export class NodeFileSystem implements FileSystem {
|
|
|
92
100
|
}
|
|
93
101
|
|
|
94
102
|
async realpath(path: string): Promise<string> {
|
|
103
|
+
const { realpath } = await nodeFsPromises();
|
|
95
104
|
return await realpath(path);
|
|
96
105
|
}
|
|
97
106
|
|
|
98
107
|
async copy(src: string, dest: string): Promise<void> {
|
|
108
|
+
const { cp } = await nodeFsPromises();
|
|
99
109
|
await cp(src, dest, { recursive: true });
|
|
100
110
|
}
|
|
101
111
|
|
|
102
112
|
async rename(src: string, dest: string): Promise<void> {
|
|
113
|
+
const { rename } = await nodeFsPromises();
|
|
103
114
|
await rename(src, dest);
|
|
104
115
|
}
|
|
105
116
|
|
|
106
117
|
createLogStream(path: string): LogStream {
|
|
107
|
-
|
|
108
|
-
|
|
118
|
+
return new LazyNodeLogStream(path);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
class LazyNodeLogStream implements LogStream {
|
|
123
|
+
private readonly ready: Promise<{
|
|
124
|
+
write: (chunk: string) => void;
|
|
125
|
+
end: () => void;
|
|
126
|
+
}>;
|
|
127
|
+
private ended = false;
|
|
128
|
+
private readonly pending: string[] = [];
|
|
129
|
+
|
|
130
|
+
constructor(path: string) {
|
|
131
|
+
this.ready = nodeFs().then(({ createWriteStream, mkdirSync }) => {
|
|
132
|
+
mkdirSync(dirnamePath(path), { recursive: true });
|
|
133
|
+
const stream = createWriteStream(path, { flags: 'a' });
|
|
134
|
+
for (const chunk of this.pending.splice(0)) stream.write(chunk);
|
|
135
|
+
if (this.ended) stream.end();
|
|
136
|
+
return {
|
|
137
|
+
write: (chunk: string) => stream.write(chunk),
|
|
138
|
+
end: () => stream.end(),
|
|
139
|
+
};
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
write(chunk: string): void {
|
|
144
|
+
if (this.ended) return;
|
|
145
|
+
this.pending.push(chunk);
|
|
146
|
+
void this.ready.then((stream) => {
|
|
147
|
+
const next = this.pending.shift();
|
|
148
|
+
if (next !== undefined) stream.write(next);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
end(): void {
|
|
153
|
+
this.ended = true;
|
|
154
|
+
void this.ready.then((stream) => stream.end());
|
|
109
155
|
}
|
|
110
156
|
}
|
|
111
157
|
|
|
@@ -180,12 +226,12 @@ export function getFs(): FileSystem {
|
|
|
180
226
|
}
|
|
181
227
|
|
|
182
228
|
export async function ensureDirForFile(path: string, fs = getFs()): Promise<void> {
|
|
183
|
-
await fs.mkdir(
|
|
229
|
+
await fs.mkdir(dirnamePath(path));
|
|
184
230
|
}
|
|
185
231
|
|
|
186
232
|
export async function atomicWriteFile(path: string, content: string, fs = getFs()): Promise<void> {
|
|
187
233
|
await ensureDirForFile(path, fs);
|
|
188
|
-
const tempPath = `${path}.${
|
|
234
|
+
const tempPath = `${path}.${getProcessPid()}.${Date.now()}.tmp`;
|
|
189
235
|
await fs.writeFile(tempPath, content);
|
|
190
236
|
await fs.rename(tempPath, path);
|
|
191
237
|
}
|
|
@@ -206,7 +252,7 @@ export async function walkDir(path: string, fs = getFs()): Promise<string[]> {
|
|
|
206
252
|
const entries = (await fs.readDir(path)).sort();
|
|
207
253
|
const result: string[] = [];
|
|
208
254
|
for (const entry of entries) {
|
|
209
|
-
const fullPath =
|
|
255
|
+
const fullPath = joinPath(path, entry);
|
|
210
256
|
const entryStat = await fs.stat(fullPath);
|
|
211
257
|
if (entryStat?.isDirectory()) {
|
|
212
258
|
result.push(...(await walkDir(fullPath, fs)));
|
|
@@ -217,13 +263,13 @@ export async function walkDir(path: string, fs = getFs()): Promise<string[]> {
|
|
|
217
263
|
return result;
|
|
218
264
|
}
|
|
219
265
|
|
|
220
|
-
export function getProjectRoot(startDir =
|
|
221
|
-
let current =
|
|
266
|
+
export function getProjectRoot(startDir = getProcessCwd()): string {
|
|
267
|
+
let current = resolvePath(startDir);
|
|
222
268
|
for (let i = 0; i < 12; i++) {
|
|
223
|
-
if (
|
|
269
|
+
if (hasBunFile(joinPath(current, 'bun.lock')) || hasBunFile(joinPath(current, 'package.json'))) {
|
|
224
270
|
return current;
|
|
225
271
|
}
|
|
226
|
-
const parent =
|
|
272
|
+
const parent = dirnamePath(current);
|
|
227
273
|
if (parent === current) return startDir;
|
|
228
274
|
current = parent;
|
|
229
275
|
}
|
|
@@ -231,9 +277,70 @@ export function getProjectRoot(startDir = process.cwd()): string {
|
|
|
231
277
|
}
|
|
232
278
|
|
|
233
279
|
export function resolveProjectPath(...segments: string[]): string {
|
|
234
|
-
return
|
|
280
|
+
return resolvePath(getProjectRoot(), ...segments);
|
|
235
281
|
}
|
|
236
282
|
|
|
237
283
|
export function createLogStream(path: string, fs = getFs()): LogStream {
|
|
238
284
|
return fs.createLogStream(path);
|
|
239
285
|
}
|
|
286
|
+
|
|
287
|
+
function hasBunFile(path: string): boolean {
|
|
288
|
+
const bun = (globalThis as { Bun?: { file: (path: string) => { size: number } } }).Bun;
|
|
289
|
+
if (bun === undefined) return false;
|
|
290
|
+
return bun.file(path).size !== 0;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function getProcessPid(): number {
|
|
294
|
+
return (globalThis as { process?: { pid?: number } }).process?.pid ?? 0;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function getProcessCwd(): string {
|
|
298
|
+
return (globalThis as { process?: { cwd?: () => string } }).process?.cwd?.() ?? '/';
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function normalizeSeparators(path: string): string {
|
|
302
|
+
return path.replaceAll('\\', '/');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function isAbsolutePath(path: string): boolean {
|
|
306
|
+
return path.startsWith('/') || /^[A-Za-z]:\//.test(normalizeSeparators(path));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function dirnamePath(path: string): string {
|
|
310
|
+
const input = normalizeSeparators(path);
|
|
311
|
+
if (/^\/+$/.test(input)) return '/';
|
|
312
|
+
const normalized = input.replace(/\/+$/, '');
|
|
313
|
+
if (normalized === '' || normalized === '/') return normalized || '.';
|
|
314
|
+
const index = normalized.lastIndexOf('/');
|
|
315
|
+
if (index < 0) return '.';
|
|
316
|
+
if (index === 0) return '/';
|
|
317
|
+
return normalized.slice(0, index);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function joinPath(...segments: string[]): string {
|
|
321
|
+
const filtered = segments.filter((segment) => segment.length > 0).map(normalizeSeparators);
|
|
322
|
+
if (filtered.length === 0) return '.';
|
|
323
|
+
const absolute = isAbsolutePath(filtered[0] ?? '');
|
|
324
|
+
const joined = filtered.join('/').replace(/\/+/g, '/');
|
|
325
|
+
return absolute ? joined : joined.replace(/^\//, '');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function resolvePath(...segments: string[]): string {
|
|
329
|
+
const candidates = segments.length === 0 ? [getProcessCwd()] : segments;
|
|
330
|
+
let resolved = '';
|
|
331
|
+
for (const segment of candidates.map(normalizeSeparators)) {
|
|
332
|
+
if (segment.length === 0) continue;
|
|
333
|
+
resolved = isAbsolutePath(segment) ? segment : joinPath(resolved || getProcessCwd(), segment);
|
|
334
|
+
}
|
|
335
|
+
const parts: string[] = [];
|
|
336
|
+
const absolute = isAbsolutePath(resolved);
|
|
337
|
+
for (const part of resolved.split('/')) {
|
|
338
|
+
if (part === '' || part === '.') continue;
|
|
339
|
+
if (part === '..') {
|
|
340
|
+
parts.pop();
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
parts.push(part);
|
|
344
|
+
}
|
|
345
|
+
return `${absolute ? '/' : ''}${parts.join('/')}` || (absolute ? '/' : '.');
|
|
346
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
2
|
+
import { parse as parseYaml } from 'yaml';
|
|
3
|
+
import { getFs } from './fs';
|
|
4
|
+
|
|
5
|
+
/** Default time budget for a single remote schema fetch. */
|
|
6
|
+
const REMOTE_SCHEMA_FETCH_TIMEOUT_MS = 5_000;
|
|
7
|
+
|
|
8
|
+
export interface JsonSchemaViolation {
|
|
9
|
+
path: string;
|
|
10
|
+
message: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface JsonSchema {
|
|
14
|
+
type?: string | string[];
|
|
15
|
+
required?: string[];
|
|
16
|
+
properties?: Record<string, JsonSchema>;
|
|
17
|
+
additionalProperties?: boolean | JsonSchema;
|
|
18
|
+
items?: JsonSchema;
|
|
19
|
+
enum?: unknown[];
|
|
20
|
+
const?: unknown;
|
|
21
|
+
oneOf?: JsonSchema[];
|
|
22
|
+
anyOf?: JsonSchema[];
|
|
23
|
+
$ref?: string;
|
|
24
|
+
$defs?: Record<string, JsonSchema>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface StructuredConfigLoadOptions {
|
|
28
|
+
validateSchema?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Allow `http(s)://` `$schema` refs. Off by default: remote fetches are an SSRF/DoS surface when
|
|
31
|
+
* configs are authored by third parties. Prefer bundled package-specifier refs (resolved from
|
|
32
|
+
* `node_modules`). Supplying `fetch` explicitly also opts into remote resolution.
|
|
33
|
+
*/
|
|
34
|
+
allowRemote?: boolean;
|
|
35
|
+
fetch?: (input: string) => Promise<Response>;
|
|
36
|
+
/**
|
|
37
|
+
* Module resolver for bare package-specifier `$schema` refs (e.g.
|
|
38
|
+
* `@gobing-ai/ts-rule-engine/schemas/rule-file.schema.json`). Defaults to `Bun.resolveSync`.
|
|
39
|
+
* Injectable for testing.
|
|
40
|
+
*/
|
|
41
|
+
resolve?: (specifier: string, from: string) => string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class StructuredConfigSchemaError extends Error {
|
|
45
|
+
constructor(
|
|
46
|
+
message: string,
|
|
47
|
+
readonly violations: readonly JsonSchemaViolation[] = [],
|
|
48
|
+
) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = 'StructuredConfigSchemaError';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function loadStructuredConfig(path: string, options: StructuredConfigLoadOptions = {}): Promise<unknown> {
|
|
55
|
+
const content = await getFs().readFile(path);
|
|
56
|
+
return await parseStructuredConfig(content, path, options);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function parseStructuredConfig(
|
|
60
|
+
content: string,
|
|
61
|
+
source: string,
|
|
62
|
+
options: StructuredConfigLoadOptions = {},
|
|
63
|
+
): Promise<unknown> {
|
|
64
|
+
const parsed = source.endsWith('.json') ? JSON.parse(content) : parseYaml(content);
|
|
65
|
+
if (options.validateSchema !== false) {
|
|
66
|
+
await validateDeclaredJsonSchema(parsed, source, options);
|
|
67
|
+
}
|
|
68
|
+
return parsed;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function validateDeclaredJsonSchema(
|
|
72
|
+
value: unknown,
|
|
73
|
+
source: string,
|
|
74
|
+
options: StructuredConfigLoadOptions = {},
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
if (!isObject(value)) return;
|
|
77
|
+
const schemaRef = value.$schema;
|
|
78
|
+
if (typeof schemaRef !== 'string' || schemaRef.length === 0) return;
|
|
79
|
+
|
|
80
|
+
const schemaLocation = resolveSchemaRef(schemaRef, source, options.resolve);
|
|
81
|
+
const schemaText = await readSchema(schemaLocation, options);
|
|
82
|
+
let schema: unknown;
|
|
83
|
+
try {
|
|
84
|
+
schema = JSON.parse(schemaText);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
throw new StructuredConfigSchemaError(
|
|
87
|
+
`Invalid JSON schema "${schemaLocation}" referenced by "${source}": ${errorMessage(error)}`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!isObject(schema)) {
|
|
92
|
+
throw new StructuredConfigSchemaError(
|
|
93
|
+
`JSON schema "${schemaLocation}" referenced by "${source}" must be an object`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const violations = validateJsonSchema(value, schema, '', (schema.$defs ?? {}) as Record<string, JsonSchema>);
|
|
98
|
+
if (violations.length > 0) {
|
|
99
|
+
throw new StructuredConfigSchemaError(
|
|
100
|
+
`Configuration "${source}" failed JSON schema validation against "${schemaLocation}": ${violations
|
|
101
|
+
.map((violation) => `${violation.path}: ${violation.message}`)
|
|
102
|
+
.join('; ')}`,
|
|
103
|
+
violations,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function validateJsonSchema(
|
|
109
|
+
value: unknown,
|
|
110
|
+
schema: JsonSchema,
|
|
111
|
+
path = '',
|
|
112
|
+
defs: Record<string, JsonSchema> = {},
|
|
113
|
+
): JsonSchemaViolation[] {
|
|
114
|
+
const violations: JsonSchemaViolation[] = [];
|
|
115
|
+
|
|
116
|
+
// Applicator keywords compose with their siblings (logical AND), per JSON Schema 2020-12 —
|
|
117
|
+
// a node may carry `$ref`/`oneOf`/`anyOf` *and* `type`/`properties`/... and all apply.
|
|
118
|
+
if (schema.$ref !== undefined) {
|
|
119
|
+
const resolved = resolveRef(schema.$ref, defs, schema.$defs);
|
|
120
|
+
if (resolved !== undefined) violations.push(...validateJsonSchema(value, resolved, path, defs));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (schema.oneOf !== undefined) {
|
|
124
|
+
violations.push(...validateCombinator(value, schema.oneOf, path, defs, 'oneOf'));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (schema.anyOf !== undefined) {
|
|
128
|
+
violations.push(...validateCombinator(value, schema.anyOf, path, defs, 'anyOf'));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (schema.const !== undefined && !jsonEqual(value, schema.const)) {
|
|
132
|
+
violations.push({ path: path || '(root)', message: `expected constant ${JSON.stringify(schema.const)}` });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (schema.enum !== undefined && !schema.enum.some((entry) => jsonEqual(value, entry))) {
|
|
136
|
+
violations.push({ path: path || '(root)', message: `expected one of ${schema.enum.map(String).join(', ')}` });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (schema.type !== undefined) {
|
|
140
|
+
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
141
|
+
if (!types.some((type) => matchesType(value, type))) {
|
|
142
|
+
violations.push({
|
|
143
|
+
path: path || '(root)',
|
|
144
|
+
message: `expected ${types.join(' or ')}, got ${typeName(value)}`,
|
|
145
|
+
});
|
|
146
|
+
return violations;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const hasObjectKeywords =
|
|
151
|
+
schema.properties !== undefined || schema.required !== undefined || schema.additionalProperties !== undefined;
|
|
152
|
+
if (schema.type === 'object' || (hasObjectKeywords && isObject(value))) {
|
|
153
|
+
violations.push(...validateObject(value, schema, path, defs));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (schema.type === 'array' || (schema.items !== undefined && Array.isArray(value))) {
|
|
157
|
+
violations.push(...validateArray(value, schema, path, defs));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return violations;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function validateObject(
|
|
164
|
+
value: unknown,
|
|
165
|
+
schema: JsonSchema,
|
|
166
|
+
path: string,
|
|
167
|
+
defs: Record<string, JsonSchema>,
|
|
168
|
+
): JsonSchemaViolation[] {
|
|
169
|
+
if (!isObject(value)) return [{ path: path || '(root)', message: `expected object, got ${typeName(value)}` }];
|
|
170
|
+
|
|
171
|
+
const violations: JsonSchemaViolation[] = [];
|
|
172
|
+
for (const key of schema.required ?? []) {
|
|
173
|
+
if (!(key in value)) {
|
|
174
|
+
violations.push({ path: path ? `${path}.${key}` : key, message: `missing required field "${key}"` });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const properties = schema.properties ?? {};
|
|
179
|
+
for (const [key, childSchema] of Object.entries(properties)) {
|
|
180
|
+
if (key in value) {
|
|
181
|
+
violations.push(...validateJsonSchema(value[key], childSchema, path ? `${path}.${key}` : key, defs));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (schema.additionalProperties === false) {
|
|
186
|
+
const allowed = new Set(Object.keys(properties));
|
|
187
|
+
for (const key of Object.keys(value)) {
|
|
188
|
+
if (!allowed.has(key)) {
|
|
189
|
+
violations.push({ path: path ? `${path}.${key}` : key, message: `unknown field "${key}"` });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
} else if (isObject(schema.additionalProperties)) {
|
|
193
|
+
for (const [key, child] of Object.entries(value)) {
|
|
194
|
+
if (!(key in properties)) {
|
|
195
|
+
violations.push(
|
|
196
|
+
...validateJsonSchema(child, schema.additionalProperties, path ? `${path}.${key}` : key, defs),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return violations;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function validateArray(
|
|
206
|
+
value: unknown,
|
|
207
|
+
schema: JsonSchema,
|
|
208
|
+
path: string,
|
|
209
|
+
defs: Record<string, JsonSchema>,
|
|
210
|
+
): JsonSchemaViolation[] {
|
|
211
|
+
if (!Array.isArray(value)) return [{ path: path || '(root)', message: `expected array, got ${typeName(value)}` }];
|
|
212
|
+
if (schema.items === undefined) return [];
|
|
213
|
+
return value.flatMap((entry, index) =>
|
|
214
|
+
validateJsonSchema(entry, schema.items as JsonSchema, `${path}[${index}]`, defs),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function validateCombinator(
|
|
219
|
+
value: unknown,
|
|
220
|
+
schemas: JsonSchema[],
|
|
221
|
+
path: string,
|
|
222
|
+
defs: Record<string, JsonSchema>,
|
|
223
|
+
mode: 'oneOf' | 'anyOf',
|
|
224
|
+
): JsonSchemaViolation[] {
|
|
225
|
+
const branchViolations = schemas.map((schema) => validateJsonSchema(value, schema, path, defs));
|
|
226
|
+
const passing = branchViolations.filter((violations) => violations.length === 0).length;
|
|
227
|
+
if (mode === 'anyOf' && passing >= 1) return [];
|
|
228
|
+
if (mode === 'oneOf' && passing === 1) return [];
|
|
229
|
+
|
|
230
|
+
const at = path || '(root)';
|
|
231
|
+
// oneOf matching more than one branch is a failure, not a pass — report it explicitly.
|
|
232
|
+
if (mode === 'oneOf' && passing > 1) {
|
|
233
|
+
return [{ path: at, message: `expected to match exactly one oneOf branch, matched ${passing}` }];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// No branch matched: surface every branch's reason so the author sees all options, not just branch 0.
|
|
237
|
+
const detail = branchViolations
|
|
238
|
+
.map((branch, index) => `[${index}] ${branch.map((v) => `${v.path}: ${v.message}`).join(', ')}`)
|
|
239
|
+
.join(' | ');
|
|
240
|
+
return [
|
|
241
|
+
{
|
|
242
|
+
path: at,
|
|
243
|
+
message: `expected to match ${mode === 'oneOf' ? 'exactly one' : 'at least one'} branch — ${detail}`,
|
|
244
|
+
},
|
|
245
|
+
];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function resolveRef(
|
|
249
|
+
ref: string,
|
|
250
|
+
defs: Record<string, JsonSchema>,
|
|
251
|
+
localDefs?: Record<string, JsonSchema>,
|
|
252
|
+
): JsonSchema | undefined {
|
|
253
|
+
if (!ref.startsWith('#/$defs/')) return undefined;
|
|
254
|
+
const name = ref.slice('#/$defs/'.length);
|
|
255
|
+
return defs[name] ?? localDefs?.[name];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function resolveSchemaRef(
|
|
259
|
+
schemaRef: string,
|
|
260
|
+
source: string,
|
|
261
|
+
resolve: ((specifier: string, from: string) => string) | undefined,
|
|
262
|
+
): string {
|
|
263
|
+
if (isRemoteRef(schemaRef) || isAbsolute(schemaRef)) return schemaRef;
|
|
264
|
+
// Relative ref — resolve against the config file's directory.
|
|
265
|
+
if (schemaRef.startsWith('./') || schemaRef.startsWith('../')) {
|
|
266
|
+
return join(isRemoteRef(source) ? '.' : dirname(source), schemaRef);
|
|
267
|
+
}
|
|
268
|
+
// Bare package specifier (e.g. "@scope/pkg/schemas/x.json") — resolve through node_modules.
|
|
269
|
+
return resolvePackageSchema(schemaRef, source, resolve);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function resolvePackageSchema(
|
|
273
|
+
specifier: string,
|
|
274
|
+
source: string,
|
|
275
|
+
resolve: ((specifier: string, from: string) => string) | undefined,
|
|
276
|
+
): string {
|
|
277
|
+
const resolveFn = resolve ?? defaultResolve;
|
|
278
|
+
if (resolveFn === undefined) {
|
|
279
|
+
throw new StructuredConfigSchemaError(
|
|
280
|
+
`Cannot resolve package schema "${specifier}" referenced by "${source}": no module resolver available`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
const { pkg, subpath } = splitPackageSpecifier(specifier);
|
|
284
|
+
if (subpath.length === 0) {
|
|
285
|
+
throw new StructuredConfigSchemaError(
|
|
286
|
+
`Package schema ref "${specifier}" referenced by "${source}" must include a path within the package`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
const from = isRemoteRef(source) ? process.cwd() : dirname(source);
|
|
290
|
+
try {
|
|
291
|
+
// Resolve the package root via its always-present package.json, then join the subpath.
|
|
292
|
+
// This sidesteps `exports` gating on arbitrary JSON subpaths.
|
|
293
|
+
const manifest = resolveFn(`${pkg}/package.json`, from);
|
|
294
|
+
return join(dirname(manifest), subpath);
|
|
295
|
+
} catch (error) {
|
|
296
|
+
throw new StructuredConfigSchemaError(
|
|
297
|
+
`Cannot resolve package schema "${specifier}" referenced by "${source}": ${errorMessage(error)}`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function splitPackageSpecifier(specifier: string): { pkg: string; subpath: string } {
|
|
303
|
+
const parts = specifier.split('/');
|
|
304
|
+
const segments = specifier.startsWith('@') ? 2 : 1;
|
|
305
|
+
return { pkg: parts.slice(0, segments).join('/'), subpath: parts.slice(segments).join('/') };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function readSchema(schemaLocation: string, options: StructuredConfigLoadOptions): Promise<string> {
|
|
309
|
+
if (isRemoteRef(schemaLocation)) {
|
|
310
|
+
const fetchFn = options.fetch ?? (options.allowRemote ? boundedFetch : undefined);
|
|
311
|
+
if (fetchFn === undefined) {
|
|
312
|
+
throw new StructuredConfigSchemaError(
|
|
313
|
+
`Refusing to fetch remote JSON schema "${schemaLocation}": pass { allowRemote: true } or a fetch implementation to opt in`,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
const response = await fetchFn(schemaLocation);
|
|
317
|
+
if (!response.ok) {
|
|
318
|
+
throw new StructuredConfigSchemaError(
|
|
319
|
+
`Failed to fetch JSON schema "${schemaLocation}": HTTP ${response.status}`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
return await response.text();
|
|
323
|
+
}
|
|
324
|
+
return await getFs().readFile(schemaLocation);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const defaultResolve: ((specifier: string, from: string) => string) | undefined =
|
|
328
|
+
typeof Bun !== 'undefined' ? (specifier, from) => Bun.resolveSync(specifier, from) : undefined;
|
|
329
|
+
|
|
330
|
+
/** Default remote fetch, time-bounded so a slow/hung schema host cannot stall config loading. */
|
|
331
|
+
function boundedFetch(input: string): Promise<Response> {
|
|
332
|
+
return globalThis.fetch(input, { signal: AbortSignal.timeout(REMOTE_SCHEMA_FETCH_TIMEOUT_MS) });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
336
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function matchesType(value: unknown, type: string): boolean {
|
|
340
|
+
if (type === 'object') return isObject(value);
|
|
341
|
+
if (type === 'array') return Array.isArray(value);
|
|
342
|
+
if (type === 'integer') return typeof value === 'number' && Number.isInteger(value);
|
|
343
|
+
if (type === 'null') return value === null;
|
|
344
|
+
return typeof value === type;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function typeName(value: unknown): string {
|
|
348
|
+
if (Array.isArray(value)) return 'array';
|
|
349
|
+
if (value === null) return 'null';
|
|
350
|
+
return typeof value;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function jsonEqual(left: unknown, right: unknown): boolean {
|
|
354
|
+
if (left === right) return true;
|
|
355
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
356
|
+
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
|
|
357
|
+
return left.every((entry, index) => jsonEqual(entry, right[index]));
|
|
358
|
+
}
|
|
359
|
+
if (isObject(left) && isObject(right)) {
|
|
360
|
+
const keys = Object.keys(left);
|
|
361
|
+
if (keys.length !== Object.keys(right).length) return false;
|
|
362
|
+
// Object member order is insignificant in JSON — compare by key, not by serialization.
|
|
363
|
+
return keys.every((key) => key in right && jsonEqual(left[key], right[key]));
|
|
364
|
+
}
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function errorMessage(error: unknown): string {
|
|
369
|
+
return error instanceof Error ? error.message : String(error);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function isRemoteRef(ref: string): boolean {
|
|
373
|
+
return /^https?:\/\//i.test(ref);
|
|
374
|
+
}
|