@gobing-ai/ts-runtime 0.2.8 → 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.
package/src/fs.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { mkdirSync, readdirSync, readFileSync, rmSync, 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,14 @@ 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
+ readDir(path: string): string[];
36
+ unlink(path: string): void;
37
+ }
38
+
28
39
  type NodeFsPromises = typeof import('node:fs/promises');
29
40
  type NodeFs = typeof import('node:fs');
30
41
 
@@ -119,39 +130,61 @@ export class NodeFileSystem implements FileSystem {
119
130
  }
120
131
  }
121
132
 
133
+ export class NodeSyncFileSystem implements SyncFileSystem {
134
+ readFile(path: string): string {
135
+ return readFileSync(path, 'utf-8');
136
+ }
137
+
138
+ writeFile(path: string, content: string): void {
139
+ ensureDirForFileSync(path, this);
140
+ writeFileSync(path, content, 'utf-8');
141
+ }
142
+
143
+ mkdir(path: string): void {
144
+ mkdirSync(path, { recursive: true });
145
+ }
146
+
147
+ readDir(path: string): string[] {
148
+ return readdirSync(path);
149
+ }
150
+
151
+ unlink(path: string): void {
152
+ rmSync(path, { recursive: true, force: true });
153
+ }
154
+ }
155
+
122
156
  class LazyNodeLogStream implements LogStream {
123
157
  private readonly ready: Promise<{
124
158
  write: (chunk: string) => void;
125
159
  end: () => void;
126
160
  }>;
127
161
  private ended = false;
128
- private readonly pending: string[] = [];
162
+ // Single serialized chain: every write/end is appended here, so the underlying stream observes
163
+ // them in call order regardless of how the resolving microtasks interleave. A per-write `shift()`
164
+ // off a shared buffer (the previous approach) could reorder writes that arrived in the same tick.
165
+ private tail: Promise<unknown>;
129
166
 
130
167
  constructor(path: string) {
131
168
  this.ready = nodeFs().then(({ createWriteStream, mkdirSync }) => {
132
169
  mkdirSync(dirnamePath(path), { recursive: true });
133
170
  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
171
  return {
137
172
  write: (chunk: string) => stream.write(chunk),
138
173
  end: () => stream.end(),
139
174
  };
140
175
  });
176
+ this.tail = this.ready;
141
177
  }
142
178
 
143
179
  write(chunk: string): void {
144
180
  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
- });
181
+ this.tail = this.tail.then(() => this.ready.then((stream) => stream.write(chunk)));
150
182
  }
151
183
 
152
184
  end(): void {
185
+ if (this.ended) return;
153
186
  this.ended = true;
154
- void this.ready.then((stream) => stream.end());
187
+ this.tail = this.tail.then(() => this.ready.then((stream) => stream.end()));
155
188
  }
156
189
  }
157
190
 
@@ -229,9 +262,13 @@ export async function ensureDirForFile(path: string, fs = getFs()): Promise<void
229
262
  await fs.mkdir(dirnamePath(path));
230
263
  }
231
264
 
265
+ export function ensureDirForFileSync(path: string, fs: SyncFileSystem): void {
266
+ fs.mkdir(dirnamePath(path));
267
+ }
268
+
232
269
  export async function atomicWriteFile(path: string, content: string, fs = getFs()): Promise<void> {
233
270
  await ensureDirForFile(path, fs);
234
- const tempPath = `${path}.${getProcessPid()}.${Date.now()}.tmp`;
271
+ const tempPath = `${path}.${getProcessPid()}.${uniqueToken()}.tmp`;
235
272
  await fs.writeFile(tempPath, content);
236
273
  await fs.rename(tempPath, path);
237
274
  }
@@ -294,53 +331,8 @@ function getProcessPid(): number {
294
331
  return (globalThis as { process?: { pid?: number } }).process?.pid ?? 0;
295
332
  }
296
333
 
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 ? '/' : '.');
334
+ // Two writers to the same path in the same millisecond must not share a temp name, or one clobbers
335
+ // the other before rename. randomUUID disambiguates; Date.now keeps names sortable for debugging.
336
+ function uniqueToken(): string {
337
+ return `${Date.now()}.${crypto.randomUUID()}`;
346
338
  }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from './config';
2
2
  export * from './context';
3
3
  export * from './fs';
4
+ export * from './path';
4
5
  export * from './process-executor';
5
6
  export * from './schema-validation';
6
7
  export * from './types';
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
+ }
@@ -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
+ }
@@ -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
- const resolved = resolveRef(schema.$ref, defs, schema.$defs);
120
- if (resolved !== undefined) violations.push(...validateJsonSchema(value, resolved, path, defs));
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(...validateJsonSchema(value[key], childSchema, path ? `${path}.${key}` : key, defs));
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(child, schema.additionalProperties, path ? `${path}.${key}` : key, defs),
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) || isAbsolute(schemaRef)) return 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 join(isRemoteRef(source) ? '.' : dirname(source), schemaRef);
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() : dirname(source);
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 join(dirname(manifest), subpath);
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.text();
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;