@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.
package/src/fs.ts CHANGED
@@ -1,18 +1,5 @@
1
- import { createWriteStream, mkdirSync } from 'node:fs';
2
- import {
3
- access,
4
- appendFile,
5
- cp,
6
- mkdir,
7
- readdir,
8
- readFile,
9
- realpath,
10
- rename,
11
- rm,
12
- stat,
13
- writeFile,
14
- } from 'node:fs/promises';
15
- import { dirname, join, resolve } from 'node:path';
1
+ import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { dirnamePath, getProcessCwd, joinPath, resolvePath } from './path';
16
3
 
17
4
  export interface FileStat {
18
5
  isFile(): boolean;
@@ -41,26 +28,55 @@ export interface FileSystem {
41
28
  createLogStream(path: string): LogStream;
42
29
  }
43
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
+
39
+ type NodeFsPromises = typeof import('node:fs/promises');
40
+ type NodeFs = typeof import('node:fs');
41
+
42
+ let fsPromisesModule: Promise<NodeFsPromises> | null = null;
43
+ let fsModule: Promise<NodeFs> | null = null;
44
+
45
+ function nodeFsPromises(): Promise<NodeFsPromises> {
46
+ fsPromisesModule ??= import('node:fs/promises');
47
+ return fsPromisesModule;
48
+ }
49
+
50
+ function nodeFs(): Promise<NodeFs> {
51
+ fsModule ??= import('node:fs');
52
+ return fsModule;
53
+ }
54
+
44
55
  export class NodeFileSystem implements FileSystem {
45
56
  async readFile(path: string): Promise<string> {
57
+ const { readFile } = await nodeFsPromises();
46
58
  return await readFile(path, 'utf-8');
47
59
  }
48
60
 
49
61
  async writeFile(path: string, content: string): Promise<void> {
62
+ const { writeFile } = await nodeFsPromises();
50
63
  await ensureDirForFile(path, this);
51
64
  await writeFile(path, content, 'utf-8');
52
65
  }
53
66
 
54
67
  async appendFile(path: string, content: string): Promise<void> {
68
+ const { appendFile } = await nodeFsPromises();
55
69
  await ensureDirForFile(path, this);
56
70
  await appendFile(path, content, 'utf-8');
57
71
  }
58
72
 
59
73
  async mkdir(path: string): Promise<void> {
74
+ const { mkdir } = await nodeFsPromises();
60
75
  await mkdir(path, { recursive: true });
61
76
  }
62
77
 
63
78
  async exists(path: string): Promise<boolean> {
79
+ const { access } = await nodeFsPromises();
64
80
  try {
65
81
  await access(path);
66
82
  return true;
@@ -70,14 +86,17 @@ export class NodeFileSystem implements FileSystem {
70
86
  }
71
87
 
72
88
  async readDir(path: string): Promise<string[]> {
89
+ const { readdir } = await nodeFsPromises();
73
90
  return await readdir(path);
74
91
  }
75
92
 
76
93
  async unlink(path: string): Promise<void> {
94
+ const { rm } = await nodeFsPromises();
77
95
  await rm(path, { recursive: true, force: true });
78
96
  }
79
97
 
80
98
  async stat(path: string): Promise<FileStat | null> {
99
+ const { stat } = await nodeFsPromises();
81
100
  try {
82
101
  const value = await stat(path);
83
102
  return {
@@ -92,20 +111,80 @@ export class NodeFileSystem implements FileSystem {
92
111
  }
93
112
 
94
113
  async realpath(path: string): Promise<string> {
114
+ const { realpath } = await nodeFsPromises();
95
115
  return await realpath(path);
96
116
  }
97
117
 
98
118
  async copy(src: string, dest: string): Promise<void> {
119
+ const { cp } = await nodeFsPromises();
99
120
  await cp(src, dest, { recursive: true });
100
121
  }
101
122
 
102
123
  async rename(src: string, dest: string): Promise<void> {
124
+ const { rename } = await nodeFsPromises();
103
125
  await rename(src, dest);
104
126
  }
105
127
 
106
128
  createLogStream(path: string): LogStream {
107
- mkdirSync(dirname(path), { recursive: true });
108
- return createWriteStream(path, { flags: 'a' });
129
+ return new LazyNodeLogStream(path);
130
+ }
131
+ }
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
+
156
+ class LazyNodeLogStream implements LogStream {
157
+ private readonly ready: Promise<{
158
+ write: (chunk: string) => void;
159
+ end: () => void;
160
+ }>;
161
+ private ended = false;
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>;
166
+
167
+ constructor(path: string) {
168
+ this.ready = nodeFs().then(({ createWriteStream, mkdirSync }) => {
169
+ mkdirSync(dirnamePath(path), { recursive: true });
170
+ const stream = createWriteStream(path, { flags: 'a' });
171
+ return {
172
+ write: (chunk: string) => stream.write(chunk),
173
+ end: () => stream.end(),
174
+ };
175
+ });
176
+ this.tail = this.ready;
177
+ }
178
+
179
+ write(chunk: string): void {
180
+ if (this.ended) return;
181
+ this.tail = this.tail.then(() => this.ready.then((stream) => stream.write(chunk)));
182
+ }
183
+
184
+ end(): void {
185
+ if (this.ended) return;
186
+ this.ended = true;
187
+ this.tail = this.tail.then(() => this.ready.then((stream) => stream.end()));
109
188
  }
110
189
  }
111
190
 
@@ -180,12 +259,16 @@ export function getFs(): FileSystem {
180
259
  }
181
260
 
182
261
  export async function ensureDirForFile(path: string, fs = getFs()): Promise<void> {
183
- await fs.mkdir(dirname(path));
262
+ await fs.mkdir(dirnamePath(path));
263
+ }
264
+
265
+ export function ensureDirForFileSync(path: string, fs: SyncFileSystem): void {
266
+ fs.mkdir(dirnamePath(path));
184
267
  }
185
268
 
186
269
  export async function atomicWriteFile(path: string, content: string, fs = getFs()): Promise<void> {
187
270
  await ensureDirForFile(path, fs);
188
- const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
271
+ const tempPath = `${path}.${getProcessPid()}.${uniqueToken()}.tmp`;
189
272
  await fs.writeFile(tempPath, content);
190
273
  await fs.rename(tempPath, path);
191
274
  }
@@ -206,7 +289,7 @@ export async function walkDir(path: string, fs = getFs()): Promise<string[]> {
206
289
  const entries = (await fs.readDir(path)).sort();
207
290
  const result: string[] = [];
208
291
  for (const entry of entries) {
209
- const fullPath = join(path, entry);
292
+ const fullPath = joinPath(path, entry);
210
293
  const entryStat = await fs.stat(fullPath);
211
294
  if (entryStat?.isDirectory()) {
212
295
  result.push(...(await walkDir(fullPath, fs)));
@@ -217,13 +300,13 @@ export async function walkDir(path: string, fs = getFs()): Promise<string[]> {
217
300
  return result;
218
301
  }
219
302
 
220
- export function getProjectRoot(startDir = process.cwd()): string {
221
- let current = resolve(startDir);
303
+ export function getProjectRoot(startDir = getProcessCwd()): string {
304
+ let current = resolvePath(startDir);
222
305
  for (let i = 0; i < 12; i++) {
223
- if (Bun.file(join(current, 'bun.lock')).size !== 0 || Bun.file(join(current, 'package.json')).size !== 0) {
306
+ if (hasBunFile(joinPath(current, 'bun.lock')) || hasBunFile(joinPath(current, 'package.json'))) {
224
307
  return current;
225
308
  }
226
- const parent = dirname(current);
309
+ const parent = dirnamePath(current);
227
310
  if (parent === current) return startDir;
228
311
  current = parent;
229
312
  }
@@ -231,9 +314,25 @@ export function getProjectRoot(startDir = process.cwd()): string {
231
314
  }
232
315
 
233
316
  export function resolveProjectPath(...segments: string[]): string {
234
- return resolve(getProjectRoot(), ...segments);
317
+ return resolvePath(getProjectRoot(), ...segments);
235
318
  }
236
319
 
237
320
  export function createLogStream(path: string, fs = getFs()): LogStream {
238
321
  return fs.createLogStream(path);
239
322
  }
323
+
324
+ function hasBunFile(path: string): boolean {
325
+ const bun = (globalThis as { Bun?: { file: (path: string) => { size: number } } }).Bun;
326
+ if (bun === undefined) return false;
327
+ return bun.file(path).size !== 0;
328
+ }
329
+
330
+ function getProcessPid(): number {
331
+ return (globalThis as { process?: { pid?: number } }).process?.pid ?? 0;
332
+ }
333
+
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()}`;
338
+ }
package/src/index.ts CHANGED
@@ -1,5 +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';
6
+ export * from './schema-validation';
5
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
+ }