@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.
- package/README.md +234 -176
- package/dist/config.d.ts +13 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +14 -0
- package/dist/context.d.ts +28 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +45 -2
- package/dist/file-system-cf.d.ts +25 -0
- package/dist/file-system-cf.d.ts.map +1 -0
- package/dist/file-system-cf.js +59 -0
- package/dist/file-system-node.d.ts +29 -0
- package/dist/file-system-node.d.ts.map +1 -0
- package/dist/file-system-node.js +94 -0
- package/dist/file-system.d.ts +47 -0
- package/dist/file-system.d.ts.map +1 -0
- package/dist/file-system.js +0 -0
- package/dist/fs.d.ts +31 -1
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +32 -19
- package/dist/index.d.ts +21 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -2
- package/dist/path.d.ts +12 -0
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +65 -4
- package/dist/platform.d.ts +12 -0
- package/dist/platform.d.ts.map +1 -0
- package/dist/platform.js +41 -0
- package/dist/process-executor.d.ts +77 -19
- package/dist/process-executor.d.ts.map +1 -1
- package/dist/process-executor.js +209 -37
- package/dist/runtime-cf.d.ts +6 -0
- package/dist/runtime-cf.d.ts.map +1 -0
- package/dist/runtime-cf.js +33 -0
- package/dist/runtime-factory.d.ts +24 -0
- package/dist/runtime-factory.d.ts.map +1 -0
- package/dist/runtime-factory.js +0 -0
- package/dist/runtime-node-bun.d.ts +8 -0
- package/dist/runtime-node-bun.d.ts.map +1 -0
- package/dist/runtime-node-bun.js +67 -0
- package/dist/schema-validation.d.ts +16 -0
- package/dist/schema-validation.d.ts.map +1 -1
- package/dist/schema-validation.js +9 -4
- package/dist/types.d.ts +4 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/config.ts +16 -4
- package/src/context.ts +58 -4
- package/src/file-system-cf.ts +74 -0
- package/src/file-system-node.ts +122 -0
- package/src/file-system.ts +55 -0
- package/src/fs.ts +35 -18
- package/src/index.ts +57 -2
- package/src/path.ts +68 -4
- package/src/platform.ts +47 -0
- package/src/process-executor.ts +296 -58
- package/src/runtime-cf.ts +44 -0
- package/src/runtime-factory.ts +28 -0
- package/src/runtime-node-bun.ts +83 -0
- package/src/schema-validation.ts +20 -4
- package/src/types.ts +4 -0
package/src/config.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { deepMerge } from '@gobing-ai/ts-utils';
|
|
|
2
2
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
3
3
|
import { type ZodIssue, z } from 'zod';
|
|
4
4
|
|
|
5
|
+
/** Zod schema for the application configuration object, providing defaults for app, database, and logging sections. */
|
|
5
6
|
export const configSchema = z.object({
|
|
6
7
|
app: z
|
|
7
8
|
.object({
|
|
@@ -26,6 +27,7 @@ export const configSchema = z.object({
|
|
|
26
27
|
.default({ level: 'info', console: true, file: false, json: false }),
|
|
27
28
|
});
|
|
28
29
|
|
|
30
|
+
/** Inferred TypeScript type of a validated configuration object, derived from {@link configSchema}. */
|
|
29
31
|
export type Config = z.output<typeof configSchema>;
|
|
30
32
|
|
|
31
33
|
const ENV_INTERPOLATION_RE = /\$\{([A-Z_][A-Z0-9_]*)\}/g;
|
|
@@ -64,6 +66,7 @@ export function stringifyYamlObject(value: Record<string, unknown>): string {
|
|
|
64
66
|
return stringifyYaml(value);
|
|
65
67
|
}
|
|
66
68
|
|
|
69
|
+
/** Error thrown when configuration validation fails, carrying the Zod validation issues for diagnostics. */
|
|
67
70
|
export class ConfigLoadError extends Error {
|
|
68
71
|
readonly issues: ZodIssue[];
|
|
69
72
|
|
|
@@ -77,27 +80,36 @@ export class ConfigLoadError extends Error {
|
|
|
77
80
|
// These accessors read `process.env` directly and are node-bun only (ADR-008). On
|
|
78
81
|
// `cloudflare-workers` there is no `process`; inject config explicitly rather than calling these.
|
|
79
82
|
|
|
83
|
+
/** Returns the value of `process.env.NODE_ENV`, or `"development"` as default. Node/Bun only. */
|
|
80
84
|
export function getNodeEnv(): string {
|
|
81
85
|
return process.env.NODE_ENV ?? 'development';
|
|
82
86
|
}
|
|
83
|
-
|
|
87
|
+
/** Returns `true` when `NODE_ENV` is `"test"`. Node/Bun only. */
|
|
84
88
|
export function isTestEnv(): boolean {
|
|
85
89
|
return getNodeEnv() === 'test';
|
|
86
90
|
}
|
|
87
91
|
|
|
92
|
+
/** Returns `process.env` as a plain object. Node/Bun only. */
|
|
88
93
|
export function getProcessEnv(): Record<string, string | undefined> {
|
|
89
94
|
return process.env;
|
|
90
95
|
}
|
|
91
96
|
|
|
97
|
+
/** Returns `process.env.DATABASE_URL`, or `undefined` if not set. Node/Bun only. */
|
|
92
98
|
export function getDatabaseUrl(): string | undefined {
|
|
93
99
|
return process.env.DATABASE_URL;
|
|
94
100
|
}
|
|
95
101
|
|
|
102
|
+
/** Returns the user's home directory (`HOME`, falling back to `USERPROFILE` on Windows), or `undefined` if unset. Node/Bun only. */
|
|
103
|
+
export function getHomeDir(): string | undefined {
|
|
104
|
+
return process.env.HOME ?? process.env.USERPROFILE;
|
|
105
|
+
}
|
|
106
|
+
|
|
96
107
|
/** Node-bun only: interpolates `${VAR}` from `process.env` (see note above). */
|
|
97
108
|
export function interpolateEnv(value: string): string {
|
|
98
109
|
return value.replace(ENV_INTERPOLATION_RE, (_match, name: string) => process.env[name] ?? `\${${name}}`);
|
|
99
110
|
}
|
|
100
111
|
|
|
112
|
+
/** Recursively interpolates `${VAR}` environment variables in all string leaves of a nested object or array. Node/Bun only. */
|
|
101
113
|
export function interpolateTree(value: unknown): unknown {
|
|
102
114
|
if (typeof value === 'string') return interpolateEnv(value);
|
|
103
115
|
if (Array.isArray(value)) return value.map(interpolateTree);
|
|
@@ -106,7 +118,7 @@ export function interpolateTree(value: unknown): unknown {
|
|
|
106
118
|
}
|
|
107
119
|
return value;
|
|
108
120
|
}
|
|
109
|
-
|
|
121
|
+
/** Interpolates env vars, merges overrides, validates against {@link configSchema}, and returns a frozen {@link Config}. */
|
|
110
122
|
export function buildConfigFromObject(
|
|
111
123
|
raw: Record<string, unknown>,
|
|
112
124
|
options: { overrides?: Partial<Config> } = {},
|
|
@@ -121,7 +133,7 @@ export function buildConfigFromObject(
|
|
|
121
133
|
}
|
|
122
134
|
return deepFreeze(result.data);
|
|
123
135
|
}
|
|
124
|
-
|
|
136
|
+
/** Parses a YAML configuration string into a raw object, throwing {@link ConfigLoadError} on failure. */
|
|
125
137
|
export function parseConfigYaml(yamlText: string): Record<string, unknown> {
|
|
126
138
|
try {
|
|
127
139
|
return parseYamlObject(yamlText);
|
|
@@ -130,7 +142,7 @@ export function parseConfigYaml(yamlText: string): Record<string, unknown> {
|
|
|
130
142
|
throw new ConfigLoadError(`Config YAML parsing failed: ${(error as Error).message}`);
|
|
131
143
|
}
|
|
132
144
|
}
|
|
133
|
-
|
|
145
|
+
/** Parses YAML text and builds a validated {@link Config}, equivalent to `buildConfigFromObject(parseConfigYaml(…))`. */
|
|
134
146
|
export function buildConfigFromYaml(yamlText: string, options: { overrides?: Partial<Config> } = {}): Config {
|
|
135
147
|
return buildConfigFromObject(parseConfigYaml(yamlText), options);
|
|
136
148
|
}
|
package/src/context.ts
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
import type { Config } from './config';
|
|
2
2
|
import { buildConfigFromObject } from './config';
|
|
3
|
-
import type { FileSystem } from './
|
|
4
|
-
import {
|
|
3
|
+
import type { FileSystem } from './file-system';
|
|
4
|
+
import { createNodeFileSystem } from './file-system-node';
|
|
5
|
+
import { loadRuntimeFactory } from './platform';
|
|
6
|
+
import type { ProcessExecutor as ProcessExecutorService } from './process-executor';
|
|
7
|
+
import { ProcessExecutor } from './process-executor';
|
|
5
8
|
import type { RuntimeCapabilities, RuntimeName } from './types';
|
|
6
|
-
|
|
9
|
+
/** Execution scope of a runtime context — determines service lifecycle and availability. */
|
|
7
10
|
export type RuntimeScope = 'process' | 'server-request' | 'scheduled-event' | 'test';
|
|
8
11
|
|
|
12
|
+
/** Map of named services available to a runtime context, including the required `config` and `fileSystem`. */
|
|
9
13
|
export interface RuntimeServiceMap {
|
|
10
14
|
config: Config;
|
|
11
15
|
fileSystem: FileSystem;
|
|
16
|
+
processExecutor?: ProcessExecutorService;
|
|
12
17
|
[serviceName: string]: unknown;
|
|
13
18
|
}
|
|
14
19
|
|
|
20
|
+
/** Options for constructing a {@link RuntimeContext}. */
|
|
15
21
|
export interface RuntimeContextOptions<TServices extends RuntimeServiceMap = RuntimeServiceMap> {
|
|
16
22
|
scope?: RuntimeScope;
|
|
17
23
|
runtimeName?: RuntimeName;
|
|
@@ -19,6 +25,7 @@ export interface RuntimeContextOptions<TServices extends RuntimeServiceMap = Run
|
|
|
19
25
|
services?: Partial<TServices>;
|
|
20
26
|
}
|
|
21
27
|
|
|
28
|
+
/** Injectable service container scoped to a runtime environment (process, request, event, or test). */
|
|
22
29
|
export class RuntimeContext<TServices extends RuntimeServiceMap = RuntimeServiceMap> {
|
|
23
30
|
readonly scope: RuntimeScope;
|
|
24
31
|
readonly runtimeName: RuntimeName;
|
|
@@ -37,7 +44,13 @@ export class RuntimeContext<TServices extends RuntimeServiceMap = RuntimeService
|
|
|
37
44
|
} satisfies RuntimeCapabilities);
|
|
38
45
|
|
|
39
46
|
this.register('config', (options.services?.config ?? buildConfigFromObject({})) as TServices['config']);
|
|
40
|
-
this.register(
|
|
47
|
+
this.register(
|
|
48
|
+
'fileSystem',
|
|
49
|
+
(options.services?.fileSystem ?? createNodeFileSystem()) as TServices['fileSystem'],
|
|
50
|
+
);
|
|
51
|
+
if (this.capabilities.hasProcessExecution && options.services?.processExecutor === undefined) {
|
|
52
|
+
this.register('processExecutor', new ProcessExecutor() as TServices['processExecutor']);
|
|
53
|
+
}
|
|
41
54
|
|
|
42
55
|
for (const [key, value] of Object.entries(options.services ?? {})) {
|
|
43
56
|
if (value !== undefined) {
|
|
@@ -88,6 +101,47 @@ function isDisposable(value: unknown): value is { dispose(): void | Promise<void
|
|
|
88
101
|
return typeof value === 'object' && value !== null && 'dispose' in value && typeof value.dispose === 'function';
|
|
89
102
|
}
|
|
90
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Create a {@link RuntimeContext} wired to the auto-detected runtime factory.
|
|
106
|
+
*
|
|
107
|
+
* Loads the platform-specific factory via {@link loadRuntimeFactory},
|
|
108
|
+
* auto-wires FileSystem, ProcessExecutor, and Config, then returns a
|
|
109
|
+
* ready-to-use context. Call this at the entry point of any app or worker.
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* ```ts
|
|
113
|
+
* const ctx = await createRuntimeContextFromFactory();
|
|
114
|
+
* const fs = ctx.require('fileSystem');
|
|
115
|
+
* const config = ctx.require('config');
|
|
116
|
+
* ```
|
|
117
|
+
*/
|
|
118
|
+
export async function createRuntimeContextFromFactory<TServices extends RuntimeServiceMap = RuntimeServiceMap>(
|
|
119
|
+
options?: RuntimeContextOptions<TServices>,
|
|
120
|
+
): Promise<RuntimeContext<TServices>> {
|
|
121
|
+
const factory = await loadRuntimeFactory();
|
|
122
|
+
const config = await factory.loadConfig();
|
|
123
|
+
const fileSystem = factory.createFileSystem();
|
|
124
|
+
const processExecutor = factory.capabilities.hasProcessExecution ? factory.createProcessExecutor() : undefined;
|
|
125
|
+
|
|
126
|
+
return new RuntimeContext<TServices>({
|
|
127
|
+
scope: 'process',
|
|
128
|
+
runtimeName: factory.runtimeName,
|
|
129
|
+
capabilities: factory.capabilities,
|
|
130
|
+
services: {
|
|
131
|
+
config,
|
|
132
|
+
fileSystem,
|
|
133
|
+
...(processExecutor !== undefined ? { processExecutor } : {}),
|
|
134
|
+
...options?.services,
|
|
135
|
+
},
|
|
136
|
+
} as RuntimeContextOptions<TServices>);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @deprecated Use {@link createRuntimeContextFromFactory} instead —
|
|
141
|
+
* it auto-detects the platform and wires the correct services from the factory.
|
|
142
|
+
* This function is kept for backward compatibility with synchronous callers
|
|
143
|
+
* that manually configure services.
|
|
144
|
+
*/
|
|
91
145
|
export function createRuntimeContext<TServices extends RuntimeServiceMap = RuntimeServiceMap>(
|
|
92
146
|
options: RuntimeContextOptions<TServices> = {},
|
|
93
147
|
): RuntimeContext<TServices> {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Workers {@link FileSystem} stub.
|
|
3
|
+
*
|
|
4
|
+
* CF Workers have only an ephemeral, per-request virtual filesystem.
|
|
5
|
+
* Persistent file operations are not available. This stub throws clear
|
|
6
|
+
* errors directing developers to use D1, KV, or R2 instead.
|
|
7
|
+
*
|
|
8
|
+
* Non-mutating operations (`resolve`, `getProjectRoot`) return
|
|
9
|
+
* Worker-appropriate values without throwing.
|
|
10
|
+
*
|
|
11
|
+
* Note: Cloudflare Workers with `nodejs_compat` + `compatibility_date >= 2025-09-01`
|
|
12
|
+
* DO expose `node:fs` as an ephemeral, per-request virtual filesystem.
|
|
13
|
+
* We deliberately do NOT use it — the ephemeral nature means files written
|
|
14
|
+
* in one request silently vanish in the next. Throwing with guidance toward
|
|
15
|
+
* D1/KV/R2 is better DX than silent data loss.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { FileSystem } from './file-system';
|
|
19
|
+
|
|
20
|
+
const UNSUPPORTED = 'FileSystem is not available on Cloudflare Workers. Use D1, KV, or R2 for persistent storage.';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create a Cloudflare Workers {@link FileSystem} stub.
|
|
24
|
+
*
|
|
25
|
+
* `resolve()` and `getProjectRoot()` work as path utilities.
|
|
26
|
+
* All other methods throw with guidance toward CF-native storage.
|
|
27
|
+
*/
|
|
28
|
+
export function createCfFileSystem(): FileSystem {
|
|
29
|
+
return {
|
|
30
|
+
getProjectRoot: () => '/bundle',
|
|
31
|
+
|
|
32
|
+
resolve: (...segments: string[]) => {
|
|
33
|
+
const joined = segments.join('/').replaceAll(/\/+/g, '/');
|
|
34
|
+
return joined.startsWith('/') ? joined : `/${joined}`;
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
exists: (_path: string) => false,
|
|
38
|
+
|
|
39
|
+
readFile: (_path: string): never => {
|
|
40
|
+
throw new Error(`FileSystem.readFile: ${UNSUPPORTED}`);
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
writeFile: (_path: string, _content: string): never => {
|
|
44
|
+
throw new Error(`FileSystem.writeFile: ${UNSUPPORTED}`);
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
appendFile: (_path: string, _content: string): never => {
|
|
48
|
+
throw new Error(`FileSystem.appendFile: ${UNSUPPORTED}`);
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
ensureDir: (_path: string): void => {
|
|
52
|
+
// No-op: CF Workers virtual filesystem handles directory
|
|
53
|
+
// creation internally when files are written.
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
readDir: (_path: string): never => {
|
|
57
|
+
throw new Error(`FileSystem.readDir: ${UNSUPPORTED}`);
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
deleteFile: (_path: string): never => {
|
|
61
|
+
throw new Error(`FileSystem.deleteFile: ${UNSUPPORTED}`);
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
createWriteStream: (_path: string): never => {
|
|
65
|
+
throw new Error(`FileSystem.createWriteStream: ${UNSUPPORTED}`);
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
copy: (_src: string, _dest: string): never => {
|
|
69
|
+
throw new Error(`FileSystem.copy: ${UNSUPPORTED}`);
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
stat: (_path: string) => null,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `node:fs`-backed {@link FileSystem} implementation for Bun/Node.js.
|
|
3
|
+
*
|
|
4
|
+
* This is the production implementation for local development, VPS, and
|
|
5
|
+
* any environment with a real filesystem. Tests should inject a virtual
|
|
6
|
+
* file system.
|
|
7
|
+
*
|
|
8
|
+
* The implementation uses `node:fs` sync APIs by default. Bun polyfills
|
|
9
|
+
* `node:fs` fully, so this works on both runtimes without a Bun-specific
|
|
10
|
+
* variant.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
appendFileSync,
|
|
15
|
+
cpSync,
|
|
16
|
+
createWriteStream,
|
|
17
|
+
existsSync,
|
|
18
|
+
mkdirSync,
|
|
19
|
+
readdirSync,
|
|
20
|
+
readFileSync,
|
|
21
|
+
rmSync,
|
|
22
|
+
statSync,
|
|
23
|
+
writeFileSync,
|
|
24
|
+
} from 'node:fs';
|
|
25
|
+
import { dirname, resolve as resolvePath } from 'node:path';
|
|
26
|
+
|
|
27
|
+
import type { FileSystem } from './file-system';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Create a {@link FileSystem} backed by `node:fs`.
|
|
31
|
+
*
|
|
32
|
+
* @param root - Project root directory (default: walks up from `process.cwd()` looking for `bun.lock` or `package.json`).
|
|
33
|
+
*/
|
|
34
|
+
export function createNodeFileSystem(root?: string): FileSystem {
|
|
35
|
+
const projectRoot = root ?? findProjectRoot(process.cwd());
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
getProjectRoot: () => projectRoot,
|
|
39
|
+
|
|
40
|
+
resolve: (...segments: string[]) => resolvePath(projectRoot, ...segments),
|
|
41
|
+
|
|
42
|
+
exists: (path: string) => existsSync(path),
|
|
43
|
+
|
|
44
|
+
readFile: (path: string) => readFileSync(path, 'utf-8'),
|
|
45
|
+
|
|
46
|
+
writeFile: (path: string, content: string) => {
|
|
47
|
+
ensureParentDir(path);
|
|
48
|
+
writeFileSync(path, content, 'utf-8');
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
appendFile: (path: string, content: string) => {
|
|
52
|
+
ensureParentDir(path);
|
|
53
|
+
appendFileSync(path, content, 'utf-8');
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
ensureDir: (path: string) => {
|
|
57
|
+
mkdirSync(path, { recursive: true });
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
readDir: (path: string) => readdirSync(path),
|
|
61
|
+
|
|
62
|
+
deleteFile: (path: string) => {
|
|
63
|
+
rmSync(path, { recursive: true, force: true });
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
createWriteStream: (path: string) => {
|
|
67
|
+
ensureParentDir(path);
|
|
68
|
+
return createWriteStream(path, { flags: 'a' });
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
copy: (src: string, dest: string) => {
|
|
72
|
+
cpSync(src, dest, { recursive: true });
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
stat: (path: string) => {
|
|
76
|
+
try {
|
|
77
|
+
const s = statSync(path);
|
|
78
|
+
return {
|
|
79
|
+
isFile: () => s.isFile(),
|
|
80
|
+
isDirectory: () => s.isDirectory(),
|
|
81
|
+
size: s.size,
|
|
82
|
+
mtimeMs: s.mtimeMs,
|
|
83
|
+
};
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
function ensureParentDir(filePath: string): void {
|
|
94
|
+
const dir = dirname(filePath);
|
|
95
|
+
if (!existsSync(dir)) {
|
|
96
|
+
mkdirSync(dir, { recursive: true });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Find the project root by walking up from `startDir` looking for a `bun.lock`
|
|
102
|
+
* or `package.json` marker. Uses `existsSync`, so it works on both Node and Bun.
|
|
103
|
+
*
|
|
104
|
+
* This is the single project-root discovery implementation; the deprecated
|
|
105
|
+
* {@link import('./fs').getProjectRoot} delegates here.
|
|
106
|
+
*
|
|
107
|
+
* @internal — exported for reuse by config loading.
|
|
108
|
+
*/
|
|
109
|
+
export function findProjectRoot(startDir: string): string {
|
|
110
|
+
let dir = resolvePath(startDir);
|
|
111
|
+
const root = resolvePath('/');
|
|
112
|
+
while (dir !== root) {
|
|
113
|
+
if (existsSync(resolvePath(dir, 'bun.lock')) || existsSync(resolvePath(dir, 'package.json'))) {
|
|
114
|
+
return dir;
|
|
115
|
+
}
|
|
116
|
+
const parent = resolvePath(dir, '..');
|
|
117
|
+
if (parent === dir) break;
|
|
118
|
+
dir = parent;
|
|
119
|
+
}
|
|
120
|
+
// Fallback: return the directory we started from.
|
|
121
|
+
return startDir;
|
|
122
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Portable file stat subset. */
|
|
2
|
+
export interface FileStat {
|
|
3
|
+
isFile(): boolean;
|
|
4
|
+
isDirectory(): boolean;
|
|
5
|
+
size: number;
|
|
6
|
+
mtimeMs: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Runtime-agnostic file system abstraction.
|
|
11
|
+
*
|
|
12
|
+
* Bun/Node backend uses `node:fs`. Cloudflare Workers backend provides stubs
|
|
13
|
+
* that throw clear "not available" errors, directing developers to use D1,
|
|
14
|
+
* KV, or R2 for persistent storage.
|
|
15
|
+
*
|
|
16
|
+
* All code outside `packages/runtime/src/file-system*.ts` MUST use this
|
|
17
|
+
* interface — never import `node:fs` or call `Bun.write`/`Bun.file` directly.
|
|
18
|
+
*/
|
|
19
|
+
export interface FileSystem {
|
|
20
|
+
/** Check whether a path exists. */
|
|
21
|
+
exists(path: string): boolean | Promise<boolean>;
|
|
22
|
+
|
|
23
|
+
/** Read file contents as UTF-8 string. Throws if not found. */
|
|
24
|
+
readFile(path: string): string | Promise<string>;
|
|
25
|
+
|
|
26
|
+
/** Write file contents, creating parent directories as needed. */
|
|
27
|
+
writeFile(path: string, content: string): void | Promise<void>;
|
|
28
|
+
|
|
29
|
+
/** Append content to a file, creating it if it doesn't exist. */
|
|
30
|
+
appendFile(path: string, content: string): void | Promise<void>;
|
|
31
|
+
|
|
32
|
+
/** Ensure a directory exists, creating it (and parents) recursively if needed. */
|
|
33
|
+
ensureDir(path: string): void | Promise<void>;
|
|
34
|
+
|
|
35
|
+
/** List directory entries (names only, not full paths). */
|
|
36
|
+
readDir(path: string): string[] | Promise<string[]>;
|
|
37
|
+
|
|
38
|
+
/** Delete a file or directory recursively. */
|
|
39
|
+
deleteFile(path: string): void | Promise<void>;
|
|
40
|
+
|
|
41
|
+
/** Recursively copy a file or directory. */
|
|
42
|
+
copy(src: string, dest: string): void | Promise<void>;
|
|
43
|
+
|
|
44
|
+
/** Get file or directory stats. Returns `null` if the path doesn't exist. */
|
|
45
|
+
stat(path: string): FileStat | null | Promise<FileStat | null>;
|
|
46
|
+
|
|
47
|
+
/** Create a writable stream for append-only output (Node/Bun only). Throws on CF Workers. */
|
|
48
|
+
createWriteStream(path: string): { write(chunk: string): void; end(): void };
|
|
49
|
+
|
|
50
|
+
/** Resolve path segments relative to the project root. */
|
|
51
|
+
resolve(...segments: string[]): string;
|
|
52
|
+
|
|
53
|
+
/** Get the project root directory path. */
|
|
54
|
+
getProjectRoot(): string;
|
|
55
|
+
}
|
package/src/fs.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { findProjectRoot } from './file-system-node';
|
|
2
3
|
import { dirnamePath, getProcessCwd, joinPath, resolvePath } from './path';
|
|
3
4
|
|
|
5
|
+
/** Portable file stat interface — mirrors the subset of `node:fs.Stats` used by the runtime. */
|
|
4
6
|
export interface FileStat {
|
|
5
7
|
isFile(): boolean;
|
|
6
8
|
isDirectory(): boolean;
|
|
@@ -8,11 +10,13 @@ export interface FileStat {
|
|
|
8
10
|
mtimeMs: number;
|
|
9
11
|
}
|
|
10
12
|
|
|
13
|
+
/** Write-only append stream for log output. */
|
|
11
14
|
export interface LogStream {
|
|
12
15
|
write(chunk: string): void;
|
|
13
16
|
end(): void;
|
|
14
17
|
}
|
|
15
18
|
|
|
19
|
+
/** @deprecated Use {@link import('./file-system').FileSystem} instead — the new interface has union return types and does not require a separate SyncFileSystem. */
|
|
16
20
|
export interface FileSystem {
|
|
17
21
|
readFile(path: string): Promise<string>;
|
|
18
22
|
writeFile(path: string, content: string): Promise<void>;
|
|
@@ -28,6 +32,7 @@ export interface FileSystem {
|
|
|
28
32
|
createLogStream(path: string): LogStream;
|
|
29
33
|
}
|
|
30
34
|
|
|
35
|
+
/** @deprecated Use {@link import('./file-system').FileSystem} instead — its union return types make a separate sync interface unnecessary. */
|
|
31
36
|
export interface SyncFileSystem {
|
|
32
37
|
readFile(path: string): string;
|
|
33
38
|
writeFile(path: string, content: string): void;
|
|
@@ -54,6 +59,8 @@ function nodeFs(): Promise<NodeFs> {
|
|
|
54
59
|
return fsModule;
|
|
55
60
|
}
|
|
56
61
|
|
|
62
|
+
/** {@link FileSystem} backed by `node:fs/promises`. Lazy-loads the module to avoid top-level import cost. */
|
|
63
|
+
/** @deprecated Use createNodeFileSystem() from './file-system-node' instead. */
|
|
57
64
|
export class NodeFileSystem implements FileSystem {
|
|
58
65
|
async readFile(path: string): Promise<string> {
|
|
59
66
|
const { readFile } = await nodeFsPromises();
|
|
@@ -132,6 +139,8 @@ export class NodeFileSystem implements FileSystem {
|
|
|
132
139
|
}
|
|
133
140
|
}
|
|
134
141
|
|
|
142
|
+
/** {@link SyncFileSystem} backed by `node:fs` synchronous APIs. */
|
|
143
|
+
/** @deprecated Use createNodeFileSystem() from './file-system-node' instead — its FileSystem interface has union return types, eliminating the need for a separate sync implementation. */
|
|
135
144
|
export class NodeSyncFileSystem implements SyncFileSystem {
|
|
136
145
|
readFile(path: string): string {
|
|
137
146
|
return readFileSync(path, 'utf-8');
|
|
@@ -214,6 +223,8 @@ class LazyNodeLogStream implements LogStream {
|
|
|
214
223
|
|
|
215
224
|
const CLOUDFLARE_FS_ERROR = 'FileSystem is not available on Cloudflare Workers. Use D1, KV, or R2.';
|
|
216
225
|
|
|
226
|
+
/** {@link FileSystem} stub for Cloudflare Workers — all file operations throw. Use D1, KV, or R2 instead. */
|
|
227
|
+
/** @deprecated Use createCfFileSystem() from './file-system-cf' instead. */
|
|
217
228
|
export class CloudflareFileSystem implements FileSystem {
|
|
218
229
|
async readFile(path: string): Promise<string> {
|
|
219
230
|
throw unsupportedCloudflareFs('readFile', path);
|
|
@@ -270,6 +281,8 @@ function unsupportedCloudflareFs(operation: string, path: string): Error {
|
|
|
270
281
|
|
|
271
282
|
let activeFileSystem: FileSystem = new NodeFileSystem();
|
|
272
283
|
|
|
284
|
+
/** Swaps the active global file system, returning a restore function for the previous instance. */
|
|
285
|
+
/** @deprecated Use RuntimeFactory.createFileSystem() or ctx.require('fileSystem') instead. The global swap is replaced by factory-based DI. */
|
|
273
286
|
export function setFileSystem(fileSystem: FileSystem): () => void {
|
|
274
287
|
const previous = activeFileSystem;
|
|
275
288
|
activeFileSystem = fileSystem;
|
|
@@ -278,18 +291,24 @@ export function setFileSystem(fileSystem: FileSystem): () => void {
|
|
|
278
291
|
};
|
|
279
292
|
}
|
|
280
293
|
|
|
294
|
+
/** Returns the currently active global {@link FileSystem} instance. */
|
|
295
|
+
/** @deprecated Use RuntimeFactory.createFileSystem() or ctx.require('fileSystem') instead. */
|
|
281
296
|
export function getFs(): FileSystem {
|
|
282
297
|
return activeFileSystem;
|
|
283
298
|
}
|
|
284
299
|
|
|
300
|
+
/** Creates parent directories for a file path before writing. */
|
|
285
301
|
export async function ensureDirForFile(path: string, fs = getFs()): Promise<void> {
|
|
286
302
|
await fs.mkdir(dirnamePath(path));
|
|
287
303
|
}
|
|
288
304
|
|
|
305
|
+
/** Synchronous variant of {@link ensureDirForFile}. */
|
|
306
|
+
/** @deprecated The new createNodeFileSystem() handles parent-directory creation internally. */
|
|
289
307
|
export function ensureDirForFileSync(path: string, fs: SyncFileSystem): void {
|
|
290
308
|
fs.mkdir(dirnamePath(path));
|
|
291
309
|
}
|
|
292
310
|
|
|
311
|
+
/** Atomically writes a file by writing to a temp path then renaming, avoiding partial writes on crash. */
|
|
293
312
|
export async function atomicWriteFile(path: string, content: string, fs = getFs()): Promise<void> {
|
|
294
313
|
await ensureDirForFile(path, fs);
|
|
295
314
|
const tempPath = `${path}.${getProcessPid()}.${uniqueToken()}.tmp`;
|
|
@@ -297,26 +316,31 @@ export async function atomicWriteFile(path: string, content: string, fs = getFs(
|
|
|
297
316
|
await fs.rename(tempPath, path);
|
|
298
317
|
}
|
|
299
318
|
|
|
319
|
+
/** Atomically writes a value as JSON with trailing newline. */
|
|
300
320
|
export async function atomicWriteJson(path: string, value: unknown, fs = getFs()): Promise<void> {
|
|
301
321
|
await atomicWriteFile(path, `${JSON.stringify(value, null, 2)}\n`, fs);
|
|
302
322
|
}
|
|
303
323
|
|
|
324
|
+
/** Reads and parses a JSON file. */
|
|
304
325
|
export async function readJsonFile<T = unknown>(path: string, fs = getFs()): Promise<T> {
|
|
305
326
|
return JSON.parse(await fs.readFile(path)) as T;
|
|
306
327
|
}
|
|
307
328
|
|
|
329
|
+
/** Writes a value as JSON with 2-space indentation and trailing newline. */
|
|
308
330
|
export async function writeJsonFile(path: string, value: unknown, fs = getFs()): Promise<void> {
|
|
309
331
|
await fs.writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
310
332
|
}
|
|
311
333
|
|
|
312
|
-
|
|
334
|
+
/** Recursively walks a directory, returning sorted paths to all files, optionally excluding entries by name. */
|
|
335
|
+
export async function walkDir(path: string, fs = getFs(), exclude?: Set<string>): Promise<string[]> {
|
|
313
336
|
const entries = (await fs.readDir(path)).sort();
|
|
314
337
|
const result: string[] = [];
|
|
315
338
|
for (const entry of entries) {
|
|
339
|
+
if (exclude?.has(entry)) continue;
|
|
316
340
|
const fullPath = joinPath(path, entry);
|
|
317
341
|
const entryStat = await fs.stat(fullPath);
|
|
318
342
|
if (entryStat?.isDirectory()) {
|
|
319
|
-
result.push(...(await walkDir(fullPath, fs)));
|
|
343
|
+
result.push(...(await walkDir(fullPath, fs, exclude)));
|
|
320
344
|
} else if (entryStat?.isFile()) {
|
|
321
345
|
result.push(fullPath);
|
|
322
346
|
}
|
|
@@ -324,33 +348,26 @@ export async function walkDir(path: string, fs = getFs()): Promise<string[]> {
|
|
|
324
348
|
return result;
|
|
325
349
|
}
|
|
326
350
|
|
|
351
|
+
/**
|
|
352
|
+
* Walks up from `startDir` looking for a `package.json` or `bun.lock` to locate the project root.
|
|
353
|
+
*
|
|
354
|
+
* @deprecated Use {@link import('./file-system-node').findProjectRoot} (or `createNodeFileSystem().getProjectRoot()`).
|
|
355
|
+
* This delegates to the single shared implementation.
|
|
356
|
+
*/
|
|
327
357
|
export function getProjectRoot(startDir = getProcessCwd()): string {
|
|
328
|
-
|
|
329
|
-
for (let i = 0; i < 12; i++) {
|
|
330
|
-
if (hasBunFile(joinPath(current, 'bun.lock')) || hasBunFile(joinPath(current, 'package.json'))) {
|
|
331
|
-
return current;
|
|
332
|
-
}
|
|
333
|
-
const parent = dirnamePath(current);
|
|
334
|
-
if (parent === current) return startDir;
|
|
335
|
-
current = parent;
|
|
336
|
-
}
|
|
337
|
-
return startDir;
|
|
358
|
+
return findProjectRoot(startDir);
|
|
338
359
|
}
|
|
339
360
|
|
|
361
|
+
/** Resolves path segments relative to the project root. */
|
|
340
362
|
export function resolveProjectPath(...segments: string[]): string {
|
|
341
363
|
return resolvePath(getProjectRoot(), ...segments);
|
|
342
364
|
}
|
|
343
365
|
|
|
366
|
+
/** Creates a {@link LogStream} at the given path using the active file system. */
|
|
344
367
|
export function createLogStream(path: string, fs = getFs()): LogStream {
|
|
345
368
|
return fs.createLogStream(path);
|
|
346
369
|
}
|
|
347
370
|
|
|
348
|
-
function hasBunFile(path: string): boolean {
|
|
349
|
-
const bun = (globalThis as { Bun?: { file: (path: string) => { size: number } } }).Bun;
|
|
350
|
-
if (bun === undefined) return false;
|
|
351
|
-
return bun.file(path).size !== 0;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
371
|
function getProcessPid(): number {
|
|
355
372
|
return (globalThis as { process?: { pid?: number } }).process?.pid ?? 0;
|
|
356
373
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,62 @@
|
|
|
1
1
|
export * from './config';
|
|
2
2
|
export * from './context';
|
|
3
|
-
export
|
|
3
|
+
export { createRuntimeContextFromFactory } from './context';
|
|
4
|
+
export type { FileStat, FileSystem } from './file-system';
|
|
5
|
+
export { createCfFileSystem } from './file-system-cf';
|
|
6
|
+
export { createNodeFileSystem, findProjectRoot } from './file-system-node';
|
|
7
|
+
export {
|
|
8
|
+
atomicWriteFile,
|
|
9
|
+
atomicWriteJson,
|
|
10
|
+
CloudflareFileSystem,
|
|
11
|
+
createLogStream,
|
|
12
|
+
ensureDirForFile,
|
|
13
|
+
ensureDirForFileSync,
|
|
14
|
+
type FileSystem as LegacyFileSystem,
|
|
15
|
+
getFs,
|
|
16
|
+
getProjectRoot,
|
|
17
|
+
NodeFileSystem,
|
|
18
|
+
NodeSyncFileSystem,
|
|
19
|
+
readJsonFile,
|
|
20
|
+
resolveProjectPath,
|
|
21
|
+
type SyncFileSystem,
|
|
22
|
+
setFileSystem,
|
|
23
|
+
walkDir,
|
|
24
|
+
writeJsonFile,
|
|
25
|
+
} from './fs';
|
|
4
26
|
export * from './path';
|
|
5
|
-
export
|
|
27
|
+
export { _resetRuntimeFactory, isCloudflareWorkerRuntime, loadRuntimeFactory } from './platform';
|
|
28
|
+
export type {
|
|
29
|
+
OutputPolicy,
|
|
30
|
+
PipeProcess,
|
|
31
|
+
PipeProcessOptions,
|
|
32
|
+
ProcessEventDetail,
|
|
33
|
+
ProcessEventSink,
|
|
34
|
+
ProcessEvents,
|
|
35
|
+
ProcessExecutorConfig,
|
|
36
|
+
ProcessExitReason,
|
|
37
|
+
ProcessOptions,
|
|
38
|
+
ProcessResult,
|
|
39
|
+
ProcessSignal,
|
|
40
|
+
TracerPort,
|
|
41
|
+
} from './process-executor';
|
|
42
|
+
export { ProcessExecutor } from './process-executor';
|
|
43
|
+
export { cloudflareWorkersFactory } from './runtime-cf';
|
|
44
|
+
export type { RuntimeFactory } from './runtime-factory';
|
|
45
|
+
export { _resetNodeFileSystem, nodeBunFactory } from './runtime-node-bun';
|
|
6
46
|
export * from './schema-validation';
|
|
7
47
|
export * from './types';
|
|
48
|
+
|
|
49
|
+
// ── Deprecated re-exports (backward compatibility) ──────────────────────
|
|
50
|
+
|
|
51
|
+
export { BunPipeProcessSpawner, BunSyncProcessExecutor, NodeProcessExecutor } from './process-executor';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @deprecated Use {@link ProcessExecutor} directly for async execution.
|
|
55
|
+
* Use `Bun.spawnSync` or `child_process.spawnSync` for sync.
|
|
56
|
+
*/
|
|
57
|
+
export type SyncProcessExecutor = InstanceType<typeof import('./process-executor').BunSyncProcessExecutor>;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @deprecated Use {@link ProcessExecutor.runStreaming} instead.
|
|
61
|
+
*/
|
|
62
|
+
export type PipeProcessSpawner = InstanceType<typeof import('./process-executor').BunPipeProcessSpawner>;
|