@gobing-ai/ts-runtime 0.2.8 → 0.3.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/README.md +116 -18
- package/dist/config.d.ts +10 -3
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +35 -58
- package/dist/context.d.ts +1 -2
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +1 -2
- package/dist/fs.d.ts +19 -0
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +59 -64
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/path.d.ts +7 -0
- package/dist/path.d.ts.map +1 -0
- package/dist/path.js +55 -0
- package/dist/process-executor.d.ts +36 -0
- package/dist/process-executor.d.ts.map +1 -1
- package/dist/process-executor.js +74 -0
- package/dist/schema-validation.d.ts +1 -1
- package/dist/schema-validation.d.ts.map +1 -1
- package/dist/schema-validation.js +70 -21
- package/dist/types.d.ts +0 -11
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/config.ts +41 -57
- package/src/context.ts +2 -4
- package/src/fs.ts +75 -59
- package/src/index.ts +1 -0
- package/src/path.ts +54 -0
- package/src/process-executor.ts +128 -0
- package/src/schema-validation.ts +84 -16
- package/src/types.ts +0 -10
package/src/fs.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirnamePath, getProcessCwd, joinPath, resolvePath } from './path';
|
|
3
|
+
|
|
1
4
|
export interface FileStat {
|
|
2
5
|
isFile(): boolean;
|
|
3
6
|
isDirectory(): boolean;
|
|
@@ -25,6 +28,16 @@ export interface FileSystem {
|
|
|
25
28
|
createLogStream(path: string): LogStream;
|
|
26
29
|
}
|
|
27
30
|
|
|
31
|
+
export interface SyncFileSystem {
|
|
32
|
+
readFile(path: string): string;
|
|
33
|
+
writeFile(path: string, content: string): void;
|
|
34
|
+
mkdir(path: string): void;
|
|
35
|
+
exists(path: string): boolean;
|
|
36
|
+
readDir(path: string): string[];
|
|
37
|
+
stat(path: string): FileStat | null;
|
|
38
|
+
unlink(path: string): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
28
41
|
type NodeFsPromises = typeof import('node:fs/promises');
|
|
29
42
|
type NodeFs = typeof import('node:fs');
|
|
30
43
|
|
|
@@ -119,39 +132,83 @@ export class NodeFileSystem implements FileSystem {
|
|
|
119
132
|
}
|
|
120
133
|
}
|
|
121
134
|
|
|
135
|
+
export class NodeSyncFileSystem implements SyncFileSystem {
|
|
136
|
+
readFile(path: string): string {
|
|
137
|
+
return readFileSync(path, 'utf-8');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
writeFile(path: string, content: string): void {
|
|
141
|
+
ensureDirForFileSync(path, this);
|
|
142
|
+
writeFileSync(path, content, 'utf-8');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
mkdir(path: string): void {
|
|
146
|
+
mkdirSync(path, { recursive: true });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
exists(path: string): boolean {
|
|
150
|
+
try {
|
|
151
|
+
return this.stat(path) !== null;
|
|
152
|
+
} catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
readDir(path: string): string[] {
|
|
158
|
+
return readdirSync(path);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
stat(path: string): FileStat | null {
|
|
162
|
+
try {
|
|
163
|
+
const value = statSync(path);
|
|
164
|
+
return {
|
|
165
|
+
isFile: () => value.isFile(),
|
|
166
|
+
isDirectory: () => value.isDirectory(),
|
|
167
|
+
size: value.size,
|
|
168
|
+
mtimeMs: value.mtimeMs,
|
|
169
|
+
};
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
unlink(path: string): void {
|
|
176
|
+
rmSync(path, { recursive: true, force: true });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
122
180
|
class LazyNodeLogStream implements LogStream {
|
|
123
181
|
private readonly ready: Promise<{
|
|
124
182
|
write: (chunk: string) => void;
|
|
125
183
|
end: () => void;
|
|
126
184
|
}>;
|
|
127
185
|
private ended = false;
|
|
128
|
-
|
|
186
|
+
// Single serialized chain: every write/end is appended here, so the underlying stream observes
|
|
187
|
+
// them in call order regardless of how the resolving microtasks interleave. A per-write `shift()`
|
|
188
|
+
// off a shared buffer (the previous approach) could reorder writes that arrived in the same tick.
|
|
189
|
+
private tail: Promise<unknown>;
|
|
129
190
|
|
|
130
191
|
constructor(path: string) {
|
|
131
192
|
this.ready = nodeFs().then(({ createWriteStream, mkdirSync }) => {
|
|
132
193
|
mkdirSync(dirnamePath(path), { recursive: true });
|
|
133
194
|
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
195
|
return {
|
|
137
196
|
write: (chunk: string) => stream.write(chunk),
|
|
138
197
|
end: () => stream.end(),
|
|
139
198
|
};
|
|
140
199
|
});
|
|
200
|
+
this.tail = this.ready;
|
|
141
201
|
}
|
|
142
202
|
|
|
143
203
|
write(chunk: string): void {
|
|
144
204
|
if (this.ended) return;
|
|
145
|
-
this.
|
|
146
|
-
void this.ready.then((stream) => {
|
|
147
|
-
const next = this.pending.shift();
|
|
148
|
-
if (next !== undefined) stream.write(next);
|
|
149
|
-
});
|
|
205
|
+
this.tail = this.tail.then(() => this.ready.then((stream) => stream.write(chunk)));
|
|
150
206
|
}
|
|
151
207
|
|
|
152
208
|
end(): void {
|
|
209
|
+
if (this.ended) return;
|
|
153
210
|
this.ended = true;
|
|
154
|
-
|
|
211
|
+
this.tail = this.tail.then(() => this.ready.then((stream) => stream.end()));
|
|
155
212
|
}
|
|
156
213
|
}
|
|
157
214
|
|
|
@@ -229,9 +286,13 @@ export async function ensureDirForFile(path: string, fs = getFs()): Promise<void
|
|
|
229
286
|
await fs.mkdir(dirnamePath(path));
|
|
230
287
|
}
|
|
231
288
|
|
|
289
|
+
export function ensureDirForFileSync(path: string, fs: SyncFileSystem): void {
|
|
290
|
+
fs.mkdir(dirnamePath(path));
|
|
291
|
+
}
|
|
292
|
+
|
|
232
293
|
export async function atomicWriteFile(path: string, content: string, fs = getFs()): Promise<void> {
|
|
233
294
|
await ensureDirForFile(path, fs);
|
|
234
|
-
const tempPath = `${path}.${getProcessPid()}.${
|
|
295
|
+
const tempPath = `${path}.${getProcessPid()}.${uniqueToken()}.tmp`;
|
|
235
296
|
await fs.writeFile(tempPath, content);
|
|
236
297
|
await fs.rename(tempPath, path);
|
|
237
298
|
}
|
|
@@ -294,53 +355,8 @@ function getProcessPid(): number {
|
|
|
294
355
|
return (globalThis as { process?: { pid?: number } }).process?.pid ?? 0;
|
|
295
356
|
}
|
|
296
357
|
|
|
297
|
-
|
|
298
|
-
|
|
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 ? '/' : '.');
|
|
358
|
+
// Two writers to the same path in the same millisecond must not share a temp name, or one clobbers
|
|
359
|
+
// the other before rename. randomUUID disambiguates; Date.now keeps names sortable for debugging.
|
|
360
|
+
function uniqueToken(): string {
|
|
361
|
+
return `${Date.now()}.${crypto.randomUUID()}`;
|
|
346
362
|
}
|
package/src/index.ts
CHANGED
package/src/path.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Runtime-portable path math. Deliberately avoids `node:path` so the same logic works on
|
|
2
|
+
// `cloudflare-workers` (no `node:*`) as on node-bun (ADR-008). POSIX-style separators throughout;
|
|
3
|
+
// Windows drive paths (`C:/...`) are normalized and treated as absolute.
|
|
4
|
+
|
|
5
|
+
export function normalizeSeparators(path: string): string {
|
|
6
|
+
return path.replaceAll('\\', '/');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isAbsolutePath(path: string): boolean {
|
|
10
|
+
return path.startsWith('/') || /^[A-Za-z]:\//.test(normalizeSeparators(path));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function dirnamePath(path: string): string {
|
|
14
|
+
const input = normalizeSeparators(path);
|
|
15
|
+
if (/^\/+$/.test(input)) return '/';
|
|
16
|
+
const normalized = input.replace(/\/+$/, '');
|
|
17
|
+
if (normalized === '' || normalized === '/') return normalized || '.';
|
|
18
|
+
const index = normalized.lastIndexOf('/');
|
|
19
|
+
if (index < 0) return '.';
|
|
20
|
+
if (index === 0) return '/';
|
|
21
|
+
return normalized.slice(0, index);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function joinPath(...segments: string[]): string {
|
|
25
|
+
const filtered = segments.filter((segment) => segment.length > 0).map(normalizeSeparators);
|
|
26
|
+
if (filtered.length === 0) return '.';
|
|
27
|
+
const absolute = isAbsolutePath(filtered[0] ?? '');
|
|
28
|
+
const joined = filtered.join('/').replace(/\/+/g, '/');
|
|
29
|
+
return absolute ? joined : joined.replace(/^\//, '');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function resolvePath(...segments: string[]): string {
|
|
33
|
+
const candidates = segments.length === 0 ? [getProcessCwd()] : segments;
|
|
34
|
+
let resolved = '';
|
|
35
|
+
for (const segment of candidates.map(normalizeSeparators)) {
|
|
36
|
+
if (segment.length === 0) continue;
|
|
37
|
+
resolved = isAbsolutePath(segment) ? segment : joinPath(resolved || getProcessCwd(), segment);
|
|
38
|
+
}
|
|
39
|
+
const parts: string[] = [];
|
|
40
|
+
const absolute = isAbsolutePath(resolved);
|
|
41
|
+
for (const part of resolved.split('/')) {
|
|
42
|
+
if (part === '' || part === '.') continue;
|
|
43
|
+
if (part === '..') {
|
|
44
|
+
parts.pop();
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
parts.push(part);
|
|
48
|
+
}
|
|
49
|
+
return `${absolute ? '/' : ''}${parts.join('/')}` || (absolute ? '/' : '.');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getProcessCwd(): string {
|
|
53
|
+
return (globalThis as { process?: { cwd?: () => string } }).process?.cwd?.() ?? '/';
|
|
54
|
+
}
|
package/src/process-executor.ts
CHANGED
|
@@ -13,6 +13,11 @@ export interface ProcessOptions {
|
|
|
13
13
|
command: string;
|
|
14
14
|
args?: string[];
|
|
15
15
|
cwd?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Environment forwarded verbatim to the child process. Caller-controlled — when launching an
|
|
18
|
+
* untrusted command, pass an explicit allowlist rather than the parent's full environment, so
|
|
19
|
+
* inherited secrets are not leaked into the subprocess.
|
|
20
|
+
*/
|
|
16
21
|
env?: Record<string, string>;
|
|
17
22
|
timeout?: number;
|
|
18
23
|
/** Maximum output buffer size in bytes (maps to execa `maxBuffer`). */
|
|
@@ -36,6 +41,32 @@ export interface ProcessExecutor {
|
|
|
36
41
|
run(options: ProcessOptions): Promise<ProcessResult>;
|
|
37
42
|
}
|
|
38
43
|
|
|
44
|
+
export interface SyncProcessExecutor {
|
|
45
|
+
runSync(options: Omit<ProcessOptions, 'timeout'>): ProcessResult;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PipeProcessOptions {
|
|
49
|
+
command: string;
|
|
50
|
+
args?: string[];
|
|
51
|
+
cwd?: string;
|
|
52
|
+
/** Forwarded verbatim to the child — pass an allowlist for untrusted commands (see {@link ProcessOptions.env}). */
|
|
53
|
+
env?: Record<string, string>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface PipeProcess {
|
|
57
|
+
readonly pid: number | null;
|
|
58
|
+
readonly stdout: ReadableStream<Uint8Array> | null;
|
|
59
|
+
readonly stderr: ReadableStream<Uint8Array> | null;
|
|
60
|
+
readonly exited: Promise<number | null>;
|
|
61
|
+
writeStdin(input: string | Uint8Array): void;
|
|
62
|
+
endStdin(): void;
|
|
63
|
+
kill(signal?: ProcessSignal): void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface PipeProcessSpawner {
|
|
67
|
+
spawn(options: PipeProcessOptions): PipeProcess;
|
|
68
|
+
}
|
|
69
|
+
|
|
39
70
|
export class NodeProcessExecutor implements ProcessExecutor {
|
|
40
71
|
constructor(private readonly config: ProcessExecutorConfig = {}) {}
|
|
41
72
|
|
|
@@ -84,6 +115,95 @@ export class NodeProcessExecutor implements ProcessExecutor {
|
|
|
84
115
|
}
|
|
85
116
|
}
|
|
86
117
|
|
|
118
|
+
export class BunSyncProcessExecutor implements SyncProcessExecutor {
|
|
119
|
+
runSync(options: Omit<ProcessOptions, 'timeout'>): ProcessResult {
|
|
120
|
+
const args = options.args ?? [];
|
|
121
|
+
const startedAt = Date.now();
|
|
122
|
+
const result = Bun.spawnSync({
|
|
123
|
+
cmd: [options.command, ...args],
|
|
124
|
+
stdout: 'pipe',
|
|
125
|
+
stderr: 'pipe',
|
|
126
|
+
stdin: 'ignore',
|
|
127
|
+
...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
|
|
128
|
+
...(options.env !== undefined ? { env: options.env } : {}),
|
|
129
|
+
});
|
|
130
|
+
if (options.rejectOnError === true && result.exitCode !== 0) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`${options.command} ${args.join(' ')} failed with exit code ${result.exitCode}: ${stripFinalNewline(
|
|
133
|
+
asString(result.stderr),
|
|
134
|
+
)}`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
command: options.command,
|
|
139
|
+
args,
|
|
140
|
+
exitCode: result.exitCode,
|
|
141
|
+
stdout: stripFinalNewline(asString(result.stdout)),
|
|
142
|
+
stderr: stripFinalNewline(asString(result.stderr)),
|
|
143
|
+
durationMs: Date.now() - startedAt,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export class BunPipeProcessSpawner implements PipeProcessSpawner {
|
|
149
|
+
spawn(options: PipeProcessOptions): PipeProcess {
|
|
150
|
+
const subprocess = Bun.spawn({
|
|
151
|
+
cmd: [options.command, ...(options.args ?? [])],
|
|
152
|
+
stdin: 'pipe',
|
|
153
|
+
stdout: 'pipe',
|
|
154
|
+
stderr: 'pipe',
|
|
155
|
+
...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
|
|
156
|
+
...(options.env !== undefined ? { env: options.env } : {}),
|
|
157
|
+
});
|
|
158
|
+
return new BunPipeProcess(subprocess);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
type BunSubprocess = ReturnType<typeof Bun.spawn>;
|
|
163
|
+
type ProcessSignal = Parameters<BunSubprocess['kill']>[0];
|
|
164
|
+
type StdinSink = {
|
|
165
|
+
write: (data: string | Uint8Array) => unknown;
|
|
166
|
+
end?: () => unknown;
|
|
167
|
+
flush?: () => unknown;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
class BunPipeProcess implements PipeProcess {
|
|
171
|
+
private readonly writer: StdinSink;
|
|
172
|
+
|
|
173
|
+
constructor(private readonly subprocess: BunSubprocess) {
|
|
174
|
+
this.writer = subprocess.stdin as StdinSink;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
get pid(): number | null {
|
|
178
|
+
return this.subprocess.pid ?? null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
get stdout(): ReadableStream<Uint8Array> | null {
|
|
182
|
+
return isReadableStream(this.subprocess.stdout) ? this.subprocess.stdout : null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
get stderr(): ReadableStream<Uint8Array> | null {
|
|
186
|
+
return isReadableStream(this.subprocess.stderr) ? this.subprocess.stderr : null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
get exited(): Promise<number | null> {
|
|
190
|
+
return this.subprocess.exited;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
writeStdin(input: string | Uint8Array): void {
|
|
194
|
+
this.writer.write(input);
|
|
195
|
+
this.writer.flush?.();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
endStdin(): void {
|
|
199
|
+
this.writer.end?.();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
kill(signal?: ProcessSignal): void {
|
|
203
|
+
this.subprocess.kill(signal);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
87
207
|
function buildExecaOptions(opts: {
|
|
88
208
|
cwd: string | undefined;
|
|
89
209
|
env: Record<string, string> | undefined;
|
|
@@ -116,3 +236,11 @@ function asString(value: string | string[] | unknown[] | Uint8Array | undefined)
|
|
|
116
236
|
if (Array.isArray(value)) return value.map(String).join('');
|
|
117
237
|
return '';
|
|
118
238
|
}
|
|
239
|
+
|
|
240
|
+
function stripFinalNewline(value: string): string {
|
|
241
|
+
return value.endsWith('\r\n') ? value.slice(0, -2) : value.endsWith('\n') ? value.slice(0, -1) : value;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function isReadableStream(value: unknown): value is ReadableStream<Uint8Array> {
|
|
245
|
+
return value instanceof ReadableStream;
|
|
246
|
+
}
|
package/src/schema-validation.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { dirname, isAbsolute, join } from 'node:path';
|
|
2
1
|
import { parse as parseYaml } from 'yaml';
|
|
3
2
|
import { getFs } from './fs';
|
|
3
|
+
import { dirnamePath, isAbsolutePath, joinPath } from './path';
|
|
4
4
|
|
|
5
5
|
/** Default time budget for a single remote schema fetch. */
|
|
6
6
|
const REMOTE_SCHEMA_FETCH_TIMEOUT_MS = 5_000;
|
|
7
7
|
|
|
8
|
+
/** Upper bound on a remote schema body. A timeout alone lets a slow multi-GB drip exhaust memory. */
|
|
9
|
+
const REMOTE_SCHEMA_MAX_BYTES = 5 * 1024 * 1024;
|
|
10
|
+
|
|
8
11
|
export interface JsonSchemaViolation {
|
|
9
12
|
path: string;
|
|
10
13
|
message: string;
|
|
@@ -110,22 +113,31 @@ export function validateJsonSchema(
|
|
|
110
113
|
schema: JsonSchema,
|
|
111
114
|
path = '',
|
|
112
115
|
defs: Record<string, JsonSchema> = {},
|
|
116
|
+
seenRefs: ReadonlySet<string> = new Set(),
|
|
113
117
|
): JsonSchemaViolation[] {
|
|
114
118
|
const violations: JsonSchemaViolation[] = [];
|
|
115
119
|
|
|
116
120
|
// Applicator keywords compose with their siblings (logical AND), per JSON Schema 2020-12 —
|
|
117
121
|
// a node may carry `$ref`/`oneOf`/`anyOf` *and* `type`/`properties`/... and all apply.
|
|
118
122
|
if (schema.$ref !== undefined) {
|
|
119
|
-
|
|
120
|
-
|
|
123
|
+
// Cyclic `$ref` (A→B→A) would recurse until stack overflow — a DoS surface for untrusted
|
|
124
|
+
// schemas. Following a ref already on the current path is a no-op: that branch is being
|
|
125
|
+
// validated higher up the stack, so re-entering adds nothing but unbounded depth.
|
|
126
|
+
if (!seenRefs.has(schema.$ref)) {
|
|
127
|
+
const resolved = resolveRef(schema.$ref, defs, schema.$defs);
|
|
128
|
+
if (resolved !== undefined) {
|
|
129
|
+
const nextSeen = new Set(seenRefs).add(schema.$ref);
|
|
130
|
+
violations.push(...validateJsonSchema(value, resolved, path, defs, nextSeen));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
121
133
|
}
|
|
122
134
|
|
|
123
135
|
if (schema.oneOf !== undefined) {
|
|
124
|
-
violations.push(...validateCombinator(value, schema.oneOf, path, defs, 'oneOf'));
|
|
136
|
+
violations.push(...validateCombinator(value, schema.oneOf, path, defs, 'oneOf', seenRefs));
|
|
125
137
|
}
|
|
126
138
|
|
|
127
139
|
if (schema.anyOf !== undefined) {
|
|
128
|
-
violations.push(...validateCombinator(value, schema.anyOf, path, defs, 'anyOf'));
|
|
140
|
+
violations.push(...validateCombinator(value, schema.anyOf, path, defs, 'anyOf', seenRefs));
|
|
129
141
|
}
|
|
130
142
|
|
|
131
143
|
if (schema.const !== undefined && !jsonEqual(value, schema.const)) {
|
|
@@ -150,11 +162,11 @@ export function validateJsonSchema(
|
|
|
150
162
|
const hasObjectKeywords =
|
|
151
163
|
schema.properties !== undefined || schema.required !== undefined || schema.additionalProperties !== undefined;
|
|
152
164
|
if (schema.type === 'object' || (hasObjectKeywords && isObject(value))) {
|
|
153
|
-
violations.push(...validateObject(value, schema, path, defs));
|
|
165
|
+
violations.push(...validateObject(value, schema, path, defs, seenRefs));
|
|
154
166
|
}
|
|
155
167
|
|
|
156
168
|
if (schema.type === 'array' || (schema.items !== undefined && Array.isArray(value))) {
|
|
157
|
-
violations.push(...validateArray(value, schema, path, defs));
|
|
169
|
+
violations.push(...validateArray(value, schema, path, defs, seenRefs));
|
|
158
170
|
}
|
|
159
171
|
|
|
160
172
|
return violations;
|
|
@@ -165,6 +177,7 @@ function validateObject(
|
|
|
165
177
|
schema: JsonSchema,
|
|
166
178
|
path: string,
|
|
167
179
|
defs: Record<string, JsonSchema>,
|
|
180
|
+
seenRefs: ReadonlySet<string>,
|
|
168
181
|
): JsonSchemaViolation[] {
|
|
169
182
|
if (!isObject(value)) return [{ path: path || '(root)', message: `expected object, got ${typeName(value)}` }];
|
|
170
183
|
|
|
@@ -178,7 +191,9 @@ function validateObject(
|
|
|
178
191
|
const properties = schema.properties ?? {};
|
|
179
192
|
for (const [key, childSchema] of Object.entries(properties)) {
|
|
180
193
|
if (key in value) {
|
|
181
|
-
violations.push(
|
|
194
|
+
violations.push(
|
|
195
|
+
...validateJsonSchema(value[key], childSchema, path ? `${path}.${key}` : key, defs, seenRefs),
|
|
196
|
+
);
|
|
182
197
|
}
|
|
183
198
|
}
|
|
184
199
|
|
|
@@ -193,7 +208,13 @@ function validateObject(
|
|
|
193
208
|
for (const [key, child] of Object.entries(value)) {
|
|
194
209
|
if (!(key in properties)) {
|
|
195
210
|
violations.push(
|
|
196
|
-
...validateJsonSchema(
|
|
211
|
+
...validateJsonSchema(
|
|
212
|
+
child,
|
|
213
|
+
schema.additionalProperties,
|
|
214
|
+
path ? `${path}.${key}` : key,
|
|
215
|
+
defs,
|
|
216
|
+
seenRefs,
|
|
217
|
+
),
|
|
197
218
|
);
|
|
198
219
|
}
|
|
199
220
|
}
|
|
@@ -207,11 +228,12 @@ function validateArray(
|
|
|
207
228
|
schema: JsonSchema,
|
|
208
229
|
path: string,
|
|
209
230
|
defs: Record<string, JsonSchema>,
|
|
231
|
+
seenRefs: ReadonlySet<string>,
|
|
210
232
|
): JsonSchemaViolation[] {
|
|
211
233
|
if (!Array.isArray(value)) return [{ path: path || '(root)', message: `expected array, got ${typeName(value)}` }];
|
|
212
234
|
if (schema.items === undefined) return [];
|
|
213
235
|
return value.flatMap((entry, index) =>
|
|
214
|
-
validateJsonSchema(entry, schema.items as JsonSchema, `${path}[${index}]`, defs),
|
|
236
|
+
validateJsonSchema(entry, schema.items as JsonSchema, `${path}[${index}]`, defs, seenRefs),
|
|
215
237
|
);
|
|
216
238
|
}
|
|
217
239
|
|
|
@@ -221,8 +243,9 @@ function validateCombinator(
|
|
|
221
243
|
path: string,
|
|
222
244
|
defs: Record<string, JsonSchema>,
|
|
223
245
|
mode: 'oneOf' | 'anyOf',
|
|
246
|
+
seenRefs: ReadonlySet<string>,
|
|
224
247
|
): JsonSchemaViolation[] {
|
|
225
|
-
const branchViolations = schemas.map((schema) => validateJsonSchema(value, schema, path, defs));
|
|
248
|
+
const branchViolations = schemas.map((schema) => validateJsonSchema(value, schema, path, defs, seenRefs));
|
|
226
249
|
const passing = branchViolations.filter((violations) => violations.length === 0).length;
|
|
227
250
|
if (mode === 'anyOf' && passing >= 1) return [];
|
|
228
251
|
if (mode === 'oneOf' && passing === 1) return [];
|
|
@@ -260,10 +283,10 @@ function resolveSchemaRef(
|
|
|
260
283
|
source: string,
|
|
261
284
|
resolve: ((specifier: string, from: string) => string) | undefined,
|
|
262
285
|
): string {
|
|
263
|
-
if (isRemoteRef(schemaRef) ||
|
|
286
|
+
if (isRemoteRef(schemaRef) || isAbsolutePath(schemaRef)) return schemaRef;
|
|
264
287
|
// Relative ref — resolve against the config file's directory.
|
|
265
288
|
if (schemaRef.startsWith('./') || schemaRef.startsWith('../')) {
|
|
266
|
-
return
|
|
289
|
+
return joinPath(isRemoteRef(source) ? '.' : dirnamePath(source), schemaRef);
|
|
267
290
|
}
|
|
268
291
|
// Bare package specifier (e.g. "@scope/pkg/schemas/x.json") — resolve through node_modules.
|
|
269
292
|
return resolvePackageSchema(schemaRef, source, resolve);
|
|
@@ -286,12 +309,12 @@ function resolvePackageSchema(
|
|
|
286
309
|
`Package schema ref "${specifier}" referenced by "${source}" must include a path within the package`,
|
|
287
310
|
);
|
|
288
311
|
}
|
|
289
|
-
const from = isRemoteRef(source) ? process.cwd() :
|
|
312
|
+
const from = isRemoteRef(source) ? process.cwd() : dirnamePath(source);
|
|
290
313
|
try {
|
|
291
314
|
// Resolve the package root via its always-present package.json, then join the subpath.
|
|
292
315
|
// This sidesteps `exports` gating on arbitrary JSON subpaths.
|
|
293
316
|
const manifest = resolveFn(`${pkg}/package.json`, from);
|
|
294
|
-
return
|
|
317
|
+
return joinPath(dirnamePath(manifest), subpath);
|
|
295
318
|
} catch (error) {
|
|
296
319
|
throw new StructuredConfigSchemaError(
|
|
297
320
|
`Cannot resolve package schema "${specifier}" referenced by "${source}": ${errorMessage(error)}`,
|
|
@@ -319,11 +342,56 @@ async function readSchema(schemaLocation: string, options: StructuredConfigLoadO
|
|
|
319
342
|
`Failed to fetch JSON schema "${schemaLocation}": HTTP ${response.status}`,
|
|
320
343
|
);
|
|
321
344
|
}
|
|
322
|
-
return await response
|
|
345
|
+
return await readBoundedBody(response, schemaLocation);
|
|
323
346
|
}
|
|
324
347
|
return await getFs().readFile(schemaLocation);
|
|
325
348
|
}
|
|
326
349
|
|
|
350
|
+
/**
|
|
351
|
+
* Read a response body under a hard byte cap. `Content-Length` is a fast-path reject, but servers
|
|
352
|
+
* lie or omit it, so the stream is also tallied chunk-by-chunk — a multi-GB drip is aborted before
|
|
353
|
+
* it can exhaust memory. Falls back to `.text()` only when the body is not a readable stream.
|
|
354
|
+
*/
|
|
355
|
+
async function readBoundedBody(response: Response, schemaLocation: string): Promise<string> {
|
|
356
|
+
const declared = Number(response.headers.get('content-length'));
|
|
357
|
+
if (Number.isFinite(declared) && declared > REMOTE_SCHEMA_MAX_BYTES) {
|
|
358
|
+
throw new StructuredConfigSchemaError(
|
|
359
|
+
`Remote JSON schema "${schemaLocation}" exceeds the ${REMOTE_SCHEMA_MAX_BYTES}-byte limit (Content-Length ${declared})`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const body = response.body;
|
|
364
|
+
if (body === null) return await response.text();
|
|
365
|
+
|
|
366
|
+
const reader = body.getReader();
|
|
367
|
+
const chunks: Uint8Array[] = [];
|
|
368
|
+
let total = 0;
|
|
369
|
+
try {
|
|
370
|
+
while (true) {
|
|
371
|
+
const { done, value } = await reader.read();
|
|
372
|
+
if (done) break;
|
|
373
|
+
total += value.byteLength;
|
|
374
|
+
if (total > REMOTE_SCHEMA_MAX_BYTES) {
|
|
375
|
+
await reader.cancel();
|
|
376
|
+
throw new StructuredConfigSchemaError(
|
|
377
|
+
`Remote JSON schema "${schemaLocation}" exceeds the ${REMOTE_SCHEMA_MAX_BYTES}-byte limit`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
chunks.push(value);
|
|
381
|
+
}
|
|
382
|
+
} finally {
|
|
383
|
+
reader.releaseLock();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const merged = new Uint8Array(total);
|
|
387
|
+
let offset = 0;
|
|
388
|
+
for (const chunk of chunks) {
|
|
389
|
+
merged.set(chunk, offset);
|
|
390
|
+
offset += chunk.byteLength;
|
|
391
|
+
}
|
|
392
|
+
return new TextDecoder().decode(merged);
|
|
393
|
+
}
|
|
394
|
+
|
|
327
395
|
const defaultResolve: ((specifier: string, from: string) => string) | undefined =
|
|
328
396
|
typeof Bun !== 'undefined' ? (specifier, from) => Bun.resolveSync(specifier, from) : undefined;
|
|
329
397
|
|
package/src/types.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import type { Config } from './config';
|
|
2
|
-
import type { RuntimeContext } from './context';
|
|
3
|
-
import type { FileSystem } from './fs';
|
|
4
2
|
|
|
5
3
|
export type RuntimeName = 'node-bun' | 'cloudflare-workers' | 'test';
|
|
6
4
|
|
|
@@ -15,14 +13,6 @@ export interface LoadConfigOptions {
|
|
|
15
13
|
envBindings?: Record<string, unknown>;
|
|
16
14
|
}
|
|
17
15
|
|
|
18
|
-
export interface RuntimeFactory {
|
|
19
|
-
readonly runtimeName: RuntimeName;
|
|
20
|
-
readonly capabilities: RuntimeCapabilities;
|
|
21
|
-
createFileSystem(): FileSystem;
|
|
22
|
-
loadConfig(options?: LoadConfigOptions): Promise<Config>;
|
|
23
|
-
createContext?(options?: { scope?: string }): RuntimeContext;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
16
|
export interface SpanContext {
|
|
27
17
|
traceId: string;
|
|
28
18
|
spanId: string;
|