@gobing-ai/ts-runtime 0.2.7 → 0.2.9

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.
@@ -0,0 +1,442 @@
1
+ import { parse as parseYaml } from 'yaml';
2
+ import { getFs } from './fs';
3
+ import { dirnamePath, isAbsolutePath, joinPath } from './path';
4
+
5
+ /** Default time budget for a single remote schema fetch. */
6
+ const REMOTE_SCHEMA_FETCH_TIMEOUT_MS = 5_000;
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
+
11
+ export interface JsonSchemaViolation {
12
+ path: string;
13
+ message: string;
14
+ }
15
+
16
+ export interface JsonSchema {
17
+ type?: string | string[];
18
+ required?: string[];
19
+ properties?: Record<string, JsonSchema>;
20
+ additionalProperties?: boolean | JsonSchema;
21
+ items?: JsonSchema;
22
+ enum?: unknown[];
23
+ const?: unknown;
24
+ oneOf?: JsonSchema[];
25
+ anyOf?: JsonSchema[];
26
+ $ref?: string;
27
+ $defs?: Record<string, JsonSchema>;
28
+ }
29
+
30
+ export interface StructuredConfigLoadOptions {
31
+ validateSchema?: boolean;
32
+ /**
33
+ * Allow `http(s)://` `$schema` refs. Off by default: remote fetches are an SSRF/DoS surface when
34
+ * configs are authored by third parties. Prefer bundled package-specifier refs (resolved from
35
+ * `node_modules`). Supplying `fetch` explicitly also opts into remote resolution.
36
+ */
37
+ allowRemote?: boolean;
38
+ fetch?: (input: string) => Promise<Response>;
39
+ /**
40
+ * Module resolver for bare package-specifier `$schema` refs (e.g.
41
+ * `@gobing-ai/ts-rule-engine/schemas/rule-file.schema.json`). Defaults to `Bun.resolveSync`.
42
+ * Injectable for testing.
43
+ */
44
+ resolve?: (specifier: string, from: string) => string;
45
+ }
46
+
47
+ export class StructuredConfigSchemaError extends Error {
48
+ constructor(
49
+ message: string,
50
+ readonly violations: readonly JsonSchemaViolation[] = [],
51
+ ) {
52
+ super(message);
53
+ this.name = 'StructuredConfigSchemaError';
54
+ }
55
+ }
56
+
57
+ export async function loadStructuredConfig(path: string, options: StructuredConfigLoadOptions = {}): Promise<unknown> {
58
+ const content = await getFs().readFile(path);
59
+ return await parseStructuredConfig(content, path, options);
60
+ }
61
+
62
+ export async function parseStructuredConfig(
63
+ content: string,
64
+ source: string,
65
+ options: StructuredConfigLoadOptions = {},
66
+ ): Promise<unknown> {
67
+ const parsed = source.endsWith('.json') ? JSON.parse(content) : parseYaml(content);
68
+ if (options.validateSchema !== false) {
69
+ await validateDeclaredJsonSchema(parsed, source, options);
70
+ }
71
+ return parsed;
72
+ }
73
+
74
+ export async function validateDeclaredJsonSchema(
75
+ value: unknown,
76
+ source: string,
77
+ options: StructuredConfigLoadOptions = {},
78
+ ): Promise<void> {
79
+ if (!isObject(value)) return;
80
+ const schemaRef = value.$schema;
81
+ if (typeof schemaRef !== 'string' || schemaRef.length === 0) return;
82
+
83
+ const schemaLocation = resolveSchemaRef(schemaRef, source, options.resolve);
84
+ const schemaText = await readSchema(schemaLocation, options);
85
+ let schema: unknown;
86
+ try {
87
+ schema = JSON.parse(schemaText);
88
+ } catch (error) {
89
+ throw new StructuredConfigSchemaError(
90
+ `Invalid JSON schema "${schemaLocation}" referenced by "${source}": ${errorMessage(error)}`,
91
+ );
92
+ }
93
+
94
+ if (!isObject(schema)) {
95
+ throw new StructuredConfigSchemaError(
96
+ `JSON schema "${schemaLocation}" referenced by "${source}" must be an object`,
97
+ );
98
+ }
99
+
100
+ const violations = validateJsonSchema(value, schema, '', (schema.$defs ?? {}) as Record<string, JsonSchema>);
101
+ if (violations.length > 0) {
102
+ throw new StructuredConfigSchemaError(
103
+ `Configuration "${source}" failed JSON schema validation against "${schemaLocation}": ${violations
104
+ .map((violation) => `${violation.path}: ${violation.message}`)
105
+ .join('; ')}`,
106
+ violations,
107
+ );
108
+ }
109
+ }
110
+
111
+ export function validateJsonSchema(
112
+ value: unknown,
113
+ schema: JsonSchema,
114
+ path = '',
115
+ defs: Record<string, JsonSchema> = {},
116
+ seenRefs: ReadonlySet<string> = new Set(),
117
+ ): JsonSchemaViolation[] {
118
+ const violations: JsonSchemaViolation[] = [];
119
+
120
+ // Applicator keywords compose with their siblings (logical AND), per JSON Schema 2020-12 —
121
+ // a node may carry `$ref`/`oneOf`/`anyOf` *and* `type`/`properties`/... and all apply.
122
+ if (schema.$ref !== undefined) {
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
+ }
133
+ }
134
+
135
+ if (schema.oneOf !== undefined) {
136
+ violations.push(...validateCombinator(value, schema.oneOf, path, defs, 'oneOf', seenRefs));
137
+ }
138
+
139
+ if (schema.anyOf !== undefined) {
140
+ violations.push(...validateCombinator(value, schema.anyOf, path, defs, 'anyOf', seenRefs));
141
+ }
142
+
143
+ if (schema.const !== undefined && !jsonEqual(value, schema.const)) {
144
+ violations.push({ path: path || '(root)', message: `expected constant ${JSON.stringify(schema.const)}` });
145
+ }
146
+
147
+ if (schema.enum !== undefined && !schema.enum.some((entry) => jsonEqual(value, entry))) {
148
+ violations.push({ path: path || '(root)', message: `expected one of ${schema.enum.map(String).join(', ')}` });
149
+ }
150
+
151
+ if (schema.type !== undefined) {
152
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type];
153
+ if (!types.some((type) => matchesType(value, type))) {
154
+ violations.push({
155
+ path: path || '(root)',
156
+ message: `expected ${types.join(' or ')}, got ${typeName(value)}`,
157
+ });
158
+ return violations;
159
+ }
160
+ }
161
+
162
+ const hasObjectKeywords =
163
+ schema.properties !== undefined || schema.required !== undefined || schema.additionalProperties !== undefined;
164
+ if (schema.type === 'object' || (hasObjectKeywords && isObject(value))) {
165
+ violations.push(...validateObject(value, schema, path, defs, seenRefs));
166
+ }
167
+
168
+ if (schema.type === 'array' || (schema.items !== undefined && Array.isArray(value))) {
169
+ violations.push(...validateArray(value, schema, path, defs, seenRefs));
170
+ }
171
+
172
+ return violations;
173
+ }
174
+
175
+ function validateObject(
176
+ value: unknown,
177
+ schema: JsonSchema,
178
+ path: string,
179
+ defs: Record<string, JsonSchema>,
180
+ seenRefs: ReadonlySet<string>,
181
+ ): JsonSchemaViolation[] {
182
+ if (!isObject(value)) return [{ path: path || '(root)', message: `expected object, got ${typeName(value)}` }];
183
+
184
+ const violations: JsonSchemaViolation[] = [];
185
+ for (const key of schema.required ?? []) {
186
+ if (!(key in value)) {
187
+ violations.push({ path: path ? `${path}.${key}` : key, message: `missing required field "${key}"` });
188
+ }
189
+ }
190
+
191
+ const properties = schema.properties ?? {};
192
+ for (const [key, childSchema] of Object.entries(properties)) {
193
+ if (key in value) {
194
+ violations.push(
195
+ ...validateJsonSchema(value[key], childSchema, path ? `${path}.${key}` : key, defs, seenRefs),
196
+ );
197
+ }
198
+ }
199
+
200
+ if (schema.additionalProperties === false) {
201
+ const allowed = new Set(Object.keys(properties));
202
+ for (const key of Object.keys(value)) {
203
+ if (!allowed.has(key)) {
204
+ violations.push({ path: path ? `${path}.${key}` : key, message: `unknown field "${key}"` });
205
+ }
206
+ }
207
+ } else if (isObject(schema.additionalProperties)) {
208
+ for (const [key, child] of Object.entries(value)) {
209
+ if (!(key in properties)) {
210
+ violations.push(
211
+ ...validateJsonSchema(
212
+ child,
213
+ schema.additionalProperties,
214
+ path ? `${path}.${key}` : key,
215
+ defs,
216
+ seenRefs,
217
+ ),
218
+ );
219
+ }
220
+ }
221
+ }
222
+
223
+ return violations;
224
+ }
225
+
226
+ function validateArray(
227
+ value: unknown,
228
+ schema: JsonSchema,
229
+ path: string,
230
+ defs: Record<string, JsonSchema>,
231
+ seenRefs: ReadonlySet<string>,
232
+ ): JsonSchemaViolation[] {
233
+ if (!Array.isArray(value)) return [{ path: path || '(root)', message: `expected array, got ${typeName(value)}` }];
234
+ if (schema.items === undefined) return [];
235
+ return value.flatMap((entry, index) =>
236
+ validateJsonSchema(entry, schema.items as JsonSchema, `${path}[${index}]`, defs, seenRefs),
237
+ );
238
+ }
239
+
240
+ function validateCombinator(
241
+ value: unknown,
242
+ schemas: JsonSchema[],
243
+ path: string,
244
+ defs: Record<string, JsonSchema>,
245
+ mode: 'oneOf' | 'anyOf',
246
+ seenRefs: ReadonlySet<string>,
247
+ ): JsonSchemaViolation[] {
248
+ const branchViolations = schemas.map((schema) => validateJsonSchema(value, schema, path, defs, seenRefs));
249
+ const passing = branchViolations.filter((violations) => violations.length === 0).length;
250
+ if (mode === 'anyOf' && passing >= 1) return [];
251
+ if (mode === 'oneOf' && passing === 1) return [];
252
+
253
+ const at = path || '(root)';
254
+ // oneOf matching more than one branch is a failure, not a pass — report it explicitly.
255
+ if (mode === 'oneOf' && passing > 1) {
256
+ return [{ path: at, message: `expected to match exactly one oneOf branch, matched ${passing}` }];
257
+ }
258
+
259
+ // No branch matched: surface every branch's reason so the author sees all options, not just branch 0.
260
+ const detail = branchViolations
261
+ .map((branch, index) => `[${index}] ${branch.map((v) => `${v.path}: ${v.message}`).join(', ')}`)
262
+ .join(' | ');
263
+ return [
264
+ {
265
+ path: at,
266
+ message: `expected to match ${mode === 'oneOf' ? 'exactly one' : 'at least one'} branch — ${detail}`,
267
+ },
268
+ ];
269
+ }
270
+
271
+ function resolveRef(
272
+ ref: string,
273
+ defs: Record<string, JsonSchema>,
274
+ localDefs?: Record<string, JsonSchema>,
275
+ ): JsonSchema | undefined {
276
+ if (!ref.startsWith('#/$defs/')) return undefined;
277
+ const name = ref.slice('#/$defs/'.length);
278
+ return defs[name] ?? localDefs?.[name];
279
+ }
280
+
281
+ function resolveSchemaRef(
282
+ schemaRef: string,
283
+ source: string,
284
+ resolve: ((specifier: string, from: string) => string) | undefined,
285
+ ): string {
286
+ if (isRemoteRef(schemaRef) || isAbsolutePath(schemaRef)) return schemaRef;
287
+ // Relative ref — resolve against the config file's directory.
288
+ if (schemaRef.startsWith('./') || schemaRef.startsWith('../')) {
289
+ return joinPath(isRemoteRef(source) ? '.' : dirnamePath(source), schemaRef);
290
+ }
291
+ // Bare package specifier (e.g. "@scope/pkg/schemas/x.json") — resolve through node_modules.
292
+ return resolvePackageSchema(schemaRef, source, resolve);
293
+ }
294
+
295
+ function resolvePackageSchema(
296
+ specifier: string,
297
+ source: string,
298
+ resolve: ((specifier: string, from: string) => string) | undefined,
299
+ ): string {
300
+ const resolveFn = resolve ?? defaultResolve;
301
+ if (resolveFn === undefined) {
302
+ throw new StructuredConfigSchemaError(
303
+ `Cannot resolve package schema "${specifier}" referenced by "${source}": no module resolver available`,
304
+ );
305
+ }
306
+ const { pkg, subpath } = splitPackageSpecifier(specifier);
307
+ if (subpath.length === 0) {
308
+ throw new StructuredConfigSchemaError(
309
+ `Package schema ref "${specifier}" referenced by "${source}" must include a path within the package`,
310
+ );
311
+ }
312
+ const from = isRemoteRef(source) ? process.cwd() : dirnamePath(source);
313
+ try {
314
+ // Resolve the package root via its always-present package.json, then join the subpath.
315
+ // This sidesteps `exports` gating on arbitrary JSON subpaths.
316
+ const manifest = resolveFn(`${pkg}/package.json`, from);
317
+ return joinPath(dirnamePath(manifest), subpath);
318
+ } catch (error) {
319
+ throw new StructuredConfigSchemaError(
320
+ `Cannot resolve package schema "${specifier}" referenced by "${source}": ${errorMessage(error)}`,
321
+ );
322
+ }
323
+ }
324
+
325
+ function splitPackageSpecifier(specifier: string): { pkg: string; subpath: string } {
326
+ const parts = specifier.split('/');
327
+ const segments = specifier.startsWith('@') ? 2 : 1;
328
+ return { pkg: parts.slice(0, segments).join('/'), subpath: parts.slice(segments).join('/') };
329
+ }
330
+
331
+ async function readSchema(schemaLocation: string, options: StructuredConfigLoadOptions): Promise<string> {
332
+ if (isRemoteRef(schemaLocation)) {
333
+ const fetchFn = options.fetch ?? (options.allowRemote ? boundedFetch : undefined);
334
+ if (fetchFn === undefined) {
335
+ throw new StructuredConfigSchemaError(
336
+ `Refusing to fetch remote JSON schema "${schemaLocation}": pass { allowRemote: true } or a fetch implementation to opt in`,
337
+ );
338
+ }
339
+ const response = await fetchFn(schemaLocation);
340
+ if (!response.ok) {
341
+ throw new StructuredConfigSchemaError(
342
+ `Failed to fetch JSON schema "${schemaLocation}": HTTP ${response.status}`,
343
+ );
344
+ }
345
+ return await readBoundedBody(response, schemaLocation);
346
+ }
347
+ return await getFs().readFile(schemaLocation);
348
+ }
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
+
395
+ const defaultResolve: ((specifier: string, from: string) => string) | undefined =
396
+ typeof Bun !== 'undefined' ? (specifier, from) => Bun.resolveSync(specifier, from) : undefined;
397
+
398
+ /** Default remote fetch, time-bounded so a slow/hung schema host cannot stall config loading. */
399
+ function boundedFetch(input: string): Promise<Response> {
400
+ return globalThis.fetch(input, { signal: AbortSignal.timeout(REMOTE_SCHEMA_FETCH_TIMEOUT_MS) });
401
+ }
402
+
403
+ function isObject(value: unknown): value is Record<string, unknown> {
404
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
405
+ }
406
+
407
+ function matchesType(value: unknown, type: string): boolean {
408
+ if (type === 'object') return isObject(value);
409
+ if (type === 'array') return Array.isArray(value);
410
+ if (type === 'integer') return typeof value === 'number' && Number.isInteger(value);
411
+ if (type === 'null') return value === null;
412
+ return typeof value === type;
413
+ }
414
+
415
+ function typeName(value: unknown): string {
416
+ if (Array.isArray(value)) return 'array';
417
+ if (value === null) return 'null';
418
+ return typeof value;
419
+ }
420
+
421
+ function jsonEqual(left: unknown, right: unknown): boolean {
422
+ if (left === right) return true;
423
+ if (Array.isArray(left) || Array.isArray(right)) {
424
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
425
+ return left.every((entry, index) => jsonEqual(entry, right[index]));
426
+ }
427
+ if (isObject(left) && isObject(right)) {
428
+ const keys = Object.keys(left);
429
+ if (keys.length !== Object.keys(right).length) return false;
430
+ // Object member order is insignificant in JSON — compare by key, not by serialization.
431
+ return keys.every((key) => key in right && jsonEqual(left[key], right[key]));
432
+ }
433
+ return false;
434
+ }
435
+
436
+ function errorMessage(error: unknown): string {
437
+ return error instanceof Error ? error.message : String(error);
438
+ }
439
+
440
+ function isRemoteRef(ref: string): boolean {
441
+ return /^https?:\/\//i.test(ref);
442
+ }
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;