@gobing-ai/ts-runtime 0.3.1 → 0.3.3

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.
Files changed (61) hide show
  1. package/README.md +234 -176
  2. package/dist/config.d.ts +13 -0
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +14 -0
  5. package/dist/context.d.ts +28 -1
  6. package/dist/context.d.ts.map +1 -1
  7. package/dist/context.js +45 -2
  8. package/dist/file-system-cf.d.ts +25 -0
  9. package/dist/file-system-cf.d.ts.map +1 -0
  10. package/dist/file-system-cf.js +59 -0
  11. package/dist/file-system-node.d.ts +29 -0
  12. package/dist/file-system-node.d.ts.map +1 -0
  13. package/dist/file-system-node.js +94 -0
  14. package/dist/file-system.d.ts +47 -0
  15. package/dist/file-system.d.ts.map +1 -0
  16. package/dist/file-system.js +0 -0
  17. package/dist/fs.d.ts +31 -1
  18. package/dist/fs.d.ts.map +1 -1
  19. package/dist/fs.js +32 -19
  20. package/dist/index.d.ts +21 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +10 -2
  23. package/dist/path.d.ts +12 -0
  24. package/dist/path.d.ts.map +1 -1
  25. package/dist/path.js +65 -4
  26. package/dist/platform.d.ts +12 -0
  27. package/dist/platform.d.ts.map +1 -0
  28. package/dist/platform.js +41 -0
  29. package/dist/process-executor.d.ts +77 -19
  30. package/dist/process-executor.d.ts.map +1 -1
  31. package/dist/process-executor.js +209 -37
  32. package/dist/runtime-cf.d.ts +6 -0
  33. package/dist/runtime-cf.d.ts.map +1 -0
  34. package/dist/runtime-cf.js +33 -0
  35. package/dist/runtime-factory.d.ts +24 -0
  36. package/dist/runtime-factory.d.ts.map +1 -0
  37. package/dist/runtime-factory.js +0 -0
  38. package/dist/runtime-node-bun.d.ts +8 -0
  39. package/dist/runtime-node-bun.d.ts.map +1 -0
  40. package/dist/runtime-node-bun.js +67 -0
  41. package/dist/schema-validation.d.ts +16 -0
  42. package/dist/schema-validation.d.ts.map +1 -1
  43. package/dist/schema-validation.js +9 -4
  44. package/dist/types.d.ts +4 -0
  45. package/dist/types.d.ts.map +1 -1
  46. package/package.json +2 -2
  47. package/src/config.ts +16 -4
  48. package/src/context.ts +58 -4
  49. package/src/file-system-cf.ts +74 -0
  50. package/src/file-system-node.ts +122 -0
  51. package/src/file-system.ts +55 -0
  52. package/src/fs.ts +35 -18
  53. package/src/index.ts +57 -2
  54. package/src/path.ts +68 -4
  55. package/src/platform.ts +47 -0
  56. package/src/process-executor.ts +296 -58
  57. package/src/runtime-cf.ts +44 -0
  58. package/src/runtime-factory.ts +28 -0
  59. package/src/runtime-node-bun.ts +83 -0
  60. package/src/schema-validation.ts +20 -4
  61. package/src/types.ts +4 -0
package/src/path.ts CHANGED
@@ -2,16 +2,42 @@
2
2
  // `cloudflare-workers` (no `node:*`) as on node-bun (ADR-008). POSIX-style separators throughout;
3
3
  // Windows drive paths (`C:/...`) are normalized and treated as absolute.
4
4
 
5
+ /** Replaces Windows backslashes with forward slashes for consistent POSIX-style path handling. */
5
6
  export function normalizeSeparators(path: string): string {
6
7
  return path.replaceAll('\\', '/');
7
8
  }
8
9
 
10
+ /** Platform-specific path segment separator (`'/'` on POSIX, `'\\'` on Windows). */
11
+ export const SEP: string =
12
+ (globalThis as { process?: { platform?: string } }).process?.platform === 'win32' ? '\\' : '/';
13
+
14
+ /** Returns `true` for POSIX absolute paths and Windows drive paths (`C:/…`). */
9
15
  export function isAbsolutePath(path: string): boolean {
10
16
  return path.startsWith('/') || /^[A-Za-z]:\//.test(normalizeSeparators(path));
11
17
  }
12
18
 
19
+ function splitRoot(path: string): { root: string; rest: string } {
20
+ const normalized = normalizeSeparators(path);
21
+ const drive = normalized.match(/^([A-Za-z]:)(?:\/|$)/);
22
+ if (drive) {
23
+ return { root: drive[1] ?? '', rest: normalized.slice((drive[1] ?? '').length).replace(/^\/+/, '') };
24
+ }
25
+ if (normalized.startsWith('/')) {
26
+ return { root: '/', rest: normalized.replace(/^\/+/, '') };
27
+ }
28
+ return { root: '', rest: normalized };
29
+ }
30
+
31
+ function pathParts(path: string): { root: string; parts: string[] } {
32
+ const { root, rest } = splitRoot(path);
33
+ return { root, parts: rest.split('/').filter(Boolean) };
34
+ }
35
+
36
+ /** Returns the parent directory of a path. Platform-independent — avoids `node:path`. */
13
37
  export function dirnamePath(path: string): string {
14
38
  const input = normalizeSeparators(path);
39
+ const { root } = splitRoot(input);
40
+ if (root !== '' && input.replace(/\/+$/, '') === root) return root === '/' ? '/' : `${root}/`;
15
41
  if (/^\/+$/.test(input)) return '/';
16
42
  const normalized = input.replace(/\/+$/, '');
17
43
  if (normalized === '' || normalized === '/') return normalized || '.';
@@ -21,6 +47,39 @@ export function dirnamePath(path: string): string {
21
47
  return normalized.slice(0, index);
22
48
  }
23
49
 
50
+ /** Return the last segment of a path. Optionally strip a trailing extension. */
51
+ export function basenamePath(p: string, ext?: string): string {
52
+ const normalized = normalizeSeparators(p).replace(/\/+$/, '');
53
+ const index = normalized.lastIndexOf('/');
54
+ let base = index < 0 ? normalized : normalized.slice(index + 1);
55
+ if (ext !== undefined && base !== ext && base.endsWith(ext)) {
56
+ base = base.slice(0, -ext.length);
57
+ }
58
+ return base;
59
+ }
60
+
61
+ /** Compute a platform-independent relative path from `from` to `to`. Both paths should be absolute. */
62
+ export function relativePath(from: string, to: string): string {
63
+ const fromParsed = pathParts(resolvePath(from));
64
+ const toParsed = pathParts(resolvePath(to));
65
+ if (fromParsed.root.toLowerCase() !== toParsed.root.toLowerCase()) {
66
+ return resolvePath(to);
67
+ }
68
+ const fromParts = fromParsed.parts;
69
+ const toParts = toParsed.parts;
70
+
71
+ // Strip common prefix.
72
+ let i = 0;
73
+ const minLen = Math.min(fromParts.length, toParts.length);
74
+ while (i < minLen && fromParts[i] === toParts[i]) i++;
75
+
76
+ const up = fromParts.slice(i).map(() => '..');
77
+ const down = toParts.slice(i);
78
+ const result = [...up, ...down].join('/');
79
+ return result || '.';
80
+ }
81
+
82
+ /** Joins path segments with `/`, normalizing separators and collapsing redundant slashes. */
24
83
  export function joinPath(...segments: string[]): string {
25
84
  const filtered = segments.filter((segment) => segment.length > 0).map(normalizeSeparators);
26
85
  if (filtered.length === 0) return '.';
@@ -29,6 +88,7 @@ export function joinPath(...segments: string[]): string {
29
88
  return absolute ? joined : joined.replace(/^\//, '');
30
89
  }
31
90
 
91
+ /** Resolves a sequence of path segments to an absolute path, collapsing `..` and `.`. */
32
92
  export function resolvePath(...segments: string[]): string {
33
93
  const candidates = segments.length === 0 ? [getProcessCwd()] : segments;
34
94
  let resolved = '';
@@ -36,19 +96,23 @@ export function resolvePath(...segments: string[]): string {
36
96
  if (segment.length === 0) continue;
37
97
  resolved = isAbsolutePath(segment) ? segment : joinPath(resolved || getProcessCwd(), segment);
38
98
  }
99
+ const { root, rest } = splitRoot(resolved);
39
100
  const parts: string[] = [];
40
- const absolute = isAbsolutePath(resolved);
41
- for (const part of resolved.split('/')) {
101
+ const absolute = root !== '';
102
+ for (const part of rest.split('/')) {
42
103
  if (part === '' || part === '.') continue;
43
104
  if (part === '..') {
44
- parts.pop();
105
+ if (parts.length > 0) parts.pop();
45
106
  continue;
46
107
  }
47
108
  parts.push(part);
48
109
  }
49
- return `${absolute ? '/' : ''}${parts.join('/')}` || (absolute ? '/' : '.');
110
+ if (!absolute) return parts.join('/') || '.';
111
+ if (root === '/') return `/${parts.join('/')}` || '/';
112
+ return parts.length > 0 ? `${root}/${parts.join('/')}` : `${root}/`;
50
113
  }
51
114
 
115
+ /** Returns `process.cwd()` if available, or `/` as fallback (Cloudflare Workers). */
52
116
  export function getProcessCwd(): string {
53
117
  return (globalThis as { process?: { cwd?: () => string } }).process?.cwd?.() ?? '/';
54
118
  }
@@ -0,0 +1,47 @@
1
+ import type { RuntimeFactory } from './runtime-factory';
2
+ import type { RuntimeName } from './types';
3
+
4
+ // Single authoritative source for runtime detection.
5
+ // All other code MUST consume RuntimeFactory via loadRuntimeFactory().
6
+ // Probe-once contract: each primitive is invoked at most once per process and
7
+ // the result is cached in `_factory` / `_runtimeName`.
8
+
9
+ /** True when running in a Cloudflare Worker (server-tier only). */
10
+ export function isCloudflareWorkerRuntime(): boolean {
11
+ return globalThis.navigator?.userAgent?.startsWith('Cloudflare-Workers') ?? false;
12
+ }
13
+
14
+ let _factory: RuntimeFactory | undefined;
15
+ let _runtimeName: RuntimeName | undefined;
16
+
17
+ /**
18
+ * Lazy-load and cache the appropriate {@link RuntimeFactory} based on environment detection.
19
+ */
20
+ export async function loadRuntimeFactory(): Promise<RuntimeFactory> {
21
+ if (_factory) return _factory;
22
+
23
+ let factory: RuntimeFactory;
24
+ if (getRuntimeName() === 'cloudflare-workers') {
25
+ const { cloudflareWorkersFactory } = await import('./runtime-cf');
26
+ factory = cloudflareWorkersFactory;
27
+ } else {
28
+ const { nodeBunFactory } = await import('./runtime-node-bun');
29
+ factory = nodeBunFactory;
30
+ }
31
+ _factory = factory;
32
+ return factory;
33
+ }
34
+
35
+ function getRuntimeName(): RuntimeName {
36
+ if (_runtimeName) return _runtimeName;
37
+ _runtimeName = isCloudflareWorkerRuntime() ? 'cloudflare-workers' : 'node-bun';
38
+ return _runtimeName;
39
+ }
40
+
41
+ /**
42
+ * Reset the cached runtime factory and name (for test isolation).
43
+ */
44
+ export function _resetRuntimeFactory(): void {
45
+ _factory = undefined;
46
+ _runtimeName = undefined;
47
+ }
@@ -1,32 +1,34 @@
1
1
  import { isatty } from 'node:tty';
2
2
  import { type Options as ExecaOptions, execa } from 'execa';
3
3
 
4
+ // ── Types ────────────────────────────────────────────────────────────────
5
+
6
+ /** Controls how stdout/stderr is captured: buffered in memory or streamed to the caller's terminal. */
4
7
  export type OutputPolicy = { mode: 'buffered' } | { mode: 'stream'; isTTY?: boolean };
5
8
 
9
+ /** Shared configuration for a process executor (default timeout, output buffering, output policy). */
6
10
  export interface ProcessExecutorConfig {
7
11
  defaultTimeout?: number;
8
12
  defaultMaxOutput?: number;
9
13
  output?: OutputPolicy;
14
+ events?: ProcessEventSink;
15
+ tracer?: TracerPort;
10
16
  }
11
17
 
18
+ /** Options for spawning a child process. */
12
19
  export interface ProcessOptions {
13
20
  command: string;
14
21
  args?: string[];
15
22
  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
- */
21
23
  env?: Record<string, string>;
22
24
  timeout?: number;
23
- /** Maximum output buffer size in bytes (maps to execa `maxBuffer`). */
24
25
  maxOutput?: number;
25
26
  label?: string;
26
27
  rejectOnError?: boolean;
27
28
  forceBuffered?: boolean;
28
29
  }
29
30
 
31
+ /** Result of a completed child process, including exit code, captured output, and duration. */
30
32
  export interface ProcessResult {
31
33
  command: string;
32
34
  args: string[];
@@ -37,22 +39,54 @@ export interface ProcessResult {
37
39
  durationMs: number;
38
40
  }
39
41
 
40
- export interface ProcessExecutor {
41
- run(options: ProcessOptions): Promise<ProcessResult>;
42
+ /** Reason a process completion event was emitted. */
43
+ export type ProcessExitReason = 'exit' | 'signal' | 'timeout' | 'error';
44
+
45
+ /** Payload emitted for process execution observability. */
46
+ export interface ProcessEventDetail {
47
+ command: string;
48
+ args: string[];
49
+ exitCode: number | null;
50
+ signal?: string;
51
+ durationMs: number;
52
+ reason: ProcessExitReason;
53
+ timestamp: string;
54
+ label?: string;
55
+ error?: string;
56
+ }
57
+
58
+ /** Zero-dependency structural event sink for process observability. */
59
+ export interface ProcessEventSink {
60
+ emit(event: 'process.started' | 'process.exited', detail: ProcessEventDetail): void;
42
61
  }
43
62
 
44
- export interface SyncProcessExecutor {
45
- runSync(options: Omit<ProcessOptions, 'timeout'>): ProcessResult;
63
+ /** Typed process event map, consumable by `EventBus<ProcessEvents>` in higher layers. */
64
+ export type ProcessEvents = {
65
+ 'process.started': (detail: ProcessEventDetail) => void;
66
+ 'process.exited': (detail: ProcessEventDetail) => void;
67
+ };
68
+
69
+ /** Minimal structural tracing port; concrete adapters live above `ts-runtime`. */
70
+ export interface TracerPort {
71
+ traceAsync<T>(name: string, fn: (span: unknown) => Promise<T>): Promise<T>;
46
72
  }
47
73
 
74
+ /** Options for spawning a long-running interactive process. */
48
75
  export interface PipeProcessOptions {
49
76
  command: string;
50
77
  args?: string[];
51
78
  cwd?: string;
52
- /** Forwarded verbatim to the child — pass an allowlist for untrusted commands (see {@link ProcessOptions.env}). */
53
79
  env?: Record<string, string>;
80
+ label?: string;
54
81
  }
55
82
 
83
+ /** Signal values accepted by subprocess kill. */
84
+ type BunSubprocess = ReturnType<typeof Bun.spawn>;
85
+
86
+ /** Signal values accepted by subprocess kill (e.g. 'SIGTERM', 'SIGKILL'). */
87
+ export type ProcessSignal = Parameters<BunSubprocess['kill']>[0];
88
+
89
+ /** Handle to a running pipe process with streaming stdout/stderr and stdin write support. */
56
90
  export interface PipeProcess {
57
91
  readonly pid: number | null;
58
92
  readonly stdout: ReadableStream<Uint8Array> | null;
@@ -63,14 +97,30 @@ export interface PipeProcess {
63
97
  kill(signal?: ProcessSignal): void;
64
98
  }
65
99
 
66
- export interface PipeProcessSpawner {
67
- spawn(options: PipeProcessOptions): PipeProcess;
68
- }
100
+ // ── ProcessExecutor ───────────────────────────────────────────────────────
69
101
 
70
- export class NodeProcessExecutor implements ProcessExecutor {
71
- constructor(private readonly config: ProcessExecutorConfig = {}) {}
102
+ /**
103
+ * Runtime-agnostic process executor wrapping `execa`.
104
+ *
105
+ * Every invocation supports timeout enforcement, output capture, and
106
+ * configurable output policy (buffered vs streamed).
107
+ */
108
+ export class ProcessExecutor {
109
+ private readonly config: ProcessExecutorConfig;
72
110
 
111
+ constructor(config: ProcessExecutorConfig = {}) {
112
+ this.config = config;
113
+ }
114
+
115
+ /**
116
+ * Run a command, buffered by default. Returns a structured {@link ProcessResult}.
117
+ * Does NOT throw on non-zero exit codes unless `rejectOnError` is set.
118
+ */
73
119
  async run(options: ProcessOptions): Promise<ProcessResult> {
120
+ return this.trace('process.run', () => this.runUntraced(options));
121
+ }
122
+
123
+ private async runUntraced(options: ProcessOptions): Promise<ProcessResult> {
74
124
  const args = options.args ?? [];
75
125
  const execaOptions = buildExecaOptions({
76
126
  cwd: options.cwd,
@@ -81,10 +131,20 @@ export class NodeProcessExecutor implements ProcessExecutor {
81
131
  outputPolicy: this.config.output,
82
132
  forceBuffered: options.forceBuffered ?? false,
83
133
  });
134
+ const startedAt = Date.now();
135
+ this.emitProcessEvent('process.started', {
136
+ command: options.command,
137
+ args,
138
+ exitCode: null,
139
+ durationMs: 0,
140
+ reason: 'exit',
141
+ timestamp: new Date(startedAt).toISOString(),
142
+ ...(options.label !== undefined ? { label: options.label } : {}),
143
+ });
84
144
 
85
145
  try {
86
146
  const result = await execa(options.command, args, execaOptions);
87
- return {
147
+ const processResult = {
88
148
  command: options.command,
89
149
  args,
90
150
  exitCode: result.exitCode ?? null,
@@ -93,74 +153,181 @@ export class NodeProcessExecutor implements ProcessExecutor {
93
153
  ...(result.signalDescription !== undefined ? { signal: result.signalDescription } : {}),
94
154
  durationMs: result.durationMs,
95
155
  };
156
+ this.emitExitedFromResult(options, processResult, result);
157
+ return processResult;
96
158
  } catch (error) {
97
- if (options.rejectOnError) throw error;
98
159
  const failed = error as {
99
160
  exitCode?: number;
100
161
  stdout?: string | string[] | Uint8Array;
101
162
  stderr?: string | string[] | Uint8Array;
102
163
  signalDescription?: string;
164
+ signal?: string;
103
165
  durationMs?: number;
166
+ timedOut?: boolean;
167
+ message?: string;
104
168
  };
105
- return {
169
+ const processResult = {
106
170
  command: options.command,
107
171
  args,
108
172
  exitCode: failed.exitCode ?? null,
109
173
  stdout: asString(failed.stdout),
110
174
  stderr: asString(failed.stderr),
111
- ...(failed.signalDescription !== undefined ? { signal: failed.signalDescription } : {}),
112
- durationMs: failed.durationMs ?? 0,
175
+ ...(failed.signalDescription !== undefined
176
+ ? { signal: failed.signalDescription }
177
+ : failed.signal !== undefined
178
+ ? { signal: failed.signal }
179
+ : {}),
180
+ durationMs: failed.durationMs ?? Date.now() - startedAt,
113
181
  };
182
+ this.emitExitedFromResult(options, processResult, error, error);
183
+ if (options.rejectOnError) throw error;
184
+ return processResult;
114
185
  }
115
186
  }
116
- }
117
187
 
118
- export class BunSyncProcessExecutor implements SyncProcessExecutor {
119
- runSync(options: Omit<ProcessOptions, 'timeout'>): ProcessResult {
188
+ /**
189
+ * Spawn a long-running interactive process with streaming I/O.
190
+ *
191
+ * Uses `Bun.spawn` for bidirectional pipe communication (stdin write,
192
+ * stdout/stderr as ReadableStreams). Returns a {@link PipeProcess} handle.
193
+ */
194
+ runStreaming(options: PipeProcessOptions): PipeProcess {
120
195
  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
- );
196
+ void this.config.tracer?.traceAsync('process.runStreaming', async () => undefined).catch(() => undefined);
197
+ try {
198
+ const startedAt = Date.now();
199
+ this.emitProcessEvent('process.started', {
200
+ command: options.command,
201
+ args,
202
+ exitCode: null,
203
+ durationMs: 0,
204
+ reason: 'exit',
205
+ timestamp: new Date(startedAt).toISOString(),
206
+ ...(options.label !== undefined ? { label: options.label } : {}),
207
+ });
208
+ const subprocess = Bun.spawn({
209
+ cmd: [options.command, ...args],
210
+ stdin: 'pipe',
211
+ stdout: 'pipe',
212
+ stderr: 'pipe',
213
+ ...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
214
+ ...(options.env !== undefined ? { env: options.env } : {}),
215
+ });
216
+ return new ObservedPipeProcess(new BunPipeProcess(subprocess), this.config.events, {
217
+ command: options.command,
218
+ args,
219
+ startedAt,
220
+ ...(options.label !== undefined ? { label: options.label } : {}),
221
+ });
222
+ } catch (error) {
223
+ this.emitProcessEvent('process.exited', {
224
+ command: options.command,
225
+ args,
226
+ exitCode: null,
227
+ durationMs: 0,
228
+ reason: 'error',
229
+ timestamp: new Date().toISOString(),
230
+ ...(options.label !== undefined ? { label: options.label } : {}),
231
+ error: errorMessage(error),
232
+ });
233
+ throw error;
136
234
  }
137
- return {
138
- command: options.command,
139
- args,
235
+ }
236
+
237
+ private async trace<T>(name: string, fn: () => Promise<T>): Promise<T> {
238
+ if (this.config.tracer === undefined) return await fn();
239
+ return await this.config.tracer.traceAsync(name, async () => await fn());
240
+ }
241
+
242
+ private emitExitedFromResult(
243
+ options: ProcessOptions,
244
+ result: ProcessResult,
245
+ completion: unknown,
246
+ error?: unknown,
247
+ ): void {
248
+ const reason = isTimedOut(completion)
249
+ ? 'timeout'
250
+ : result.signal !== undefined
251
+ ? 'signal'
252
+ : error
253
+ ? 'error'
254
+ : 'exit';
255
+ this.emitProcessEvent('process.exited', {
256
+ command: result.command,
257
+ args: result.args,
140
258
  exitCode: result.exitCode,
141
- stdout: stripFinalNewline(asString(result.stdout)),
142
- stderr: stripFinalNewline(asString(result.stderr)),
143
- durationMs: Date.now() - startedAt,
144
- };
259
+ ...(result.signal !== undefined ? { signal: result.signal } : {}),
260
+ durationMs: result.durationMs,
261
+ reason,
262
+ timestamp: new Date().toISOString(),
263
+ ...(options.label !== undefined ? { label: options.label } : {}),
264
+ ...(error !== undefined ? { error: errorMessage(error) } : {}),
265
+ });
266
+ }
267
+
268
+ private emitProcessEvent(event: 'process.started' | 'process.exited', detail: ProcessEventDetail): void {
269
+ this.config.events?.emit(event, detail);
145
270
  }
146
271
  }
147
272
 
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 } : {}),
273
+ class ObservedPipeProcess implements PipeProcess {
274
+ private killedWith: ProcessSignal | undefined;
275
+
276
+ readonly exited: Promise<number | null>;
277
+
278
+ constructor(
279
+ private readonly inner: PipeProcess,
280
+ events: ProcessEventSink | undefined,
281
+ context: {
282
+ command: string;
283
+ args: string[];
284
+ startedAt: number;
285
+ label?: string;
286
+ },
287
+ ) {
288
+ this.exited = inner.exited.then((exitCode) => {
289
+ events?.emit('process.exited', {
290
+ command: context.command,
291
+ args: context.args,
292
+ exitCode,
293
+ ...(this.killedWith !== undefined ? { signal: String(this.killedWith) } : {}),
294
+ durationMs: Date.now() - context.startedAt,
295
+ reason: this.killedWith !== undefined ? 'signal' : 'exit',
296
+ timestamp: new Date().toISOString(),
297
+ ...(context.label !== undefined ? { label: context.label } : {}),
298
+ });
299
+ return exitCode;
157
300
  });
158
- return new BunPipeProcess(subprocess);
301
+ }
302
+
303
+ get pid(): number | null {
304
+ return this.inner.pid;
305
+ }
306
+
307
+ get stdout(): ReadableStream<Uint8Array> | null {
308
+ return this.inner.stdout;
309
+ }
310
+
311
+ get stderr(): ReadableStream<Uint8Array> | null {
312
+ return this.inner.stderr;
313
+ }
314
+
315
+ writeStdin(input: string | Uint8Array): void {
316
+ this.inner.writeStdin(input);
317
+ }
318
+
319
+ endStdin(): void {
320
+ this.inner.endStdin();
321
+ }
322
+
323
+ kill(signal?: ProcessSignal): void {
324
+ this.killedWith = signal;
325
+ this.inner.kill(signal);
159
326
  }
160
327
  }
161
328
 
162
- type BunSubprocess = ReturnType<typeof Bun.spawn>;
163
- type ProcessSignal = Parameters<BunSubprocess['kill']>[0];
329
+ // ── BunPipeProcess (internal) ─────────────────────────────────────────────
330
+
164
331
  type StdinSink = {
165
332
  write: (data: string | Uint8Array) => unknown;
166
333
  end?: () => unknown;
@@ -204,6 +371,69 @@ class BunPipeProcess implements PipeProcess {
204
371
  }
205
372
  }
206
373
 
374
+ // ── Deprecated backward-compatible subclasses ─────────────────────────────
375
+
376
+ /**
377
+ * @deprecated Use {@link ProcessExecutor} directly.
378
+ * This subclass is kept for backward compatibility.
379
+ */
380
+ export class NodeProcessExecutor extends ProcessExecutor {}
381
+
382
+ /**
383
+ * @deprecated Use `Bun.spawnSync` or `child_process.spawnSync` directly.
384
+ * Synchronous process execution is no longer recommended from ts-runtime.
385
+ * This class is kept for backward compatibility.
386
+ */
387
+ export class BunSyncProcessExecutor {
388
+ runSync(options: Omit<ProcessOptions, 'timeout'>): ProcessResult {
389
+ const args = options.args ?? [];
390
+ const startedAt = Date.now();
391
+ const result = Bun.spawnSync({
392
+ cmd: [options.command, ...args],
393
+ stdout: 'pipe',
394
+ stderr: 'pipe',
395
+ stdin: 'ignore',
396
+ ...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
397
+ ...(options.env !== undefined ? { env: options.env } : {}),
398
+ });
399
+ if (options.rejectOnError === true && result.exitCode !== 0) {
400
+ throw new Error(
401
+ `${options.command} ${args.join(' ')} failed with exit code ${result.exitCode}: ${stripFinalNewline(
402
+ asString(result.stderr),
403
+ )}`,
404
+ );
405
+ }
406
+ return {
407
+ command: options.command,
408
+ args,
409
+ exitCode: result.exitCode,
410
+ stdout: stripFinalNewline(asString(result.stdout)),
411
+ stderr: stripFinalNewline(asString(result.stderr)),
412
+ durationMs: Date.now() - startedAt,
413
+ };
414
+ }
415
+ }
416
+
417
+ /**
418
+ * @deprecated Use {@link ProcessExecutor.runStreaming} instead.
419
+ * This class is kept for backward compatibility.
420
+ */
421
+ export class BunPipeProcessSpawner {
422
+ spawn(options: PipeProcessOptions): PipeProcess {
423
+ const subprocess = Bun.spawn({
424
+ cmd: [options.command, ...(options.args ?? [])],
425
+ stdin: 'pipe',
426
+ stdout: 'pipe',
427
+ stderr: 'pipe',
428
+ ...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
429
+ ...(options.env !== undefined ? { env: options.env } : {}),
430
+ });
431
+ return new BunPipeProcess(subprocess);
432
+ }
433
+ }
434
+
435
+ // ── Helpers ───────────────────────────────────────────────────────────────
436
+
207
437
  function buildExecaOptions(opts: {
208
438
  cwd: string | undefined;
209
439
  env: Record<string, string> | undefined;
@@ -244,3 +474,11 @@ function stripFinalNewline(value: string): string {
244
474
  function isReadableStream(value: unknown): value is ReadableStream<Uint8Array> {
245
475
  return value instanceof ReadableStream;
246
476
  }
477
+
478
+ function isTimedOut(error: unknown): boolean {
479
+ return typeof error === 'object' && error !== null && 'timedOut' in error && error.timedOut === true;
480
+ }
481
+
482
+ function errorMessage(error: unknown): string {
483
+ return error instanceof Error ? error.message : String(error);
484
+ }
@@ -0,0 +1,44 @@
1
+ import { parse as parseYaml } from 'yaml';
2
+ import type { Config } from './config';
3
+ import { buildConfigFromObject } from './config';
4
+ import { createCfFileSystem } from './file-system-cf';
5
+ import type { ProcessExecutorConfig } from './process-executor';
6
+ import type { RuntimeFactory } from './runtime-factory';
7
+ import type { LoadConfigOptions } from './types';
8
+
9
+ /** Binding name for the YAML config text blob set in wrangler.toml. */
10
+ const CONFIG_YAML_BINDING = 'CONFIG_YAML';
11
+
12
+ /**
13
+ * Cloudflare Workers runtime factory (no filesystem, no process execution).
14
+ */
15
+ export const cloudflareWorkersFactory: RuntimeFactory = {
16
+ runtimeName: 'cloudflare-workers',
17
+
18
+ capabilities: {
19
+ hasFilesystem: false,
20
+ hasProcessExecution: false,
21
+ hasPersistentStorage: false,
22
+ },
23
+
24
+ createFileSystem: () => createCfFileSystem(),
25
+
26
+ createProcessExecutor: (_config?: ProcessExecutorConfig): never => {
27
+ throw new Error('ProcessExecutor is not available on Cloudflare Workers.');
28
+ },
29
+
30
+ async loadConfig(options?: LoadConfigOptions): Promise<Config> {
31
+ const yamlString = options?.envBindings?.[CONFIG_YAML_BINDING] as string | undefined;
32
+ let raw: Record<string, unknown> = {};
33
+
34
+ if (yamlString) {
35
+ try {
36
+ raw = parseYaml(yamlString) as Record<string, unknown>;
37
+ } catch {
38
+ // Fall through to schema defaults on parse failure
39
+ }
40
+ }
41
+
42
+ return buildConfigFromObject(raw, options);
43
+ },
44
+ };