@gobing-ai/ts-runtime 0.3.0 → 0.3.2
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/plugin/capability-registry.d.ts +35 -0
- package/dist/plugin/capability-registry.d.ts.map +1 -0
- package/dist/plugin/capability-registry.js +43 -0
- package/dist/plugin/extension-loader.d.ts +66 -0
- package/dist/plugin/extension-loader.d.ts.map +1 -0
- package/dist/plugin/extension-loader.js +47 -0
- package/dist/plugin/extension-path.d.ts +15 -0
- package/dist/plugin/extension-path.d.ts.map +1 -0
- package/dist/plugin/extension-path.js +20 -0
- package/dist/plugin/index.d.ts +4 -0
- package/dist/plugin/index.d.ts.map +1 -0
- package/dist/plugin/index.js +3 -0
- package/dist/plugin.d.ts +2 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +1 -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 +6 -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/plugin/capability-registry.ts +58 -0
- package/src/plugin/extension-loader.ts +105 -0
- package/src/plugin/extension-path.ts +20 -0
- package/src/plugin/index.ts +3 -0
- package/src/plugin.ts +1 -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/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>;
|
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 =
|
|
41
|
-
for (const part of
|
|
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
|
-
|
|
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
|
}
|
package/src/platform.ts
ADDED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Registry origin for a host capability. */
|
|
2
|
+
export type CapabilityOrigin = 'builtin' | 'extension';
|
|
3
|
+
|
|
4
|
+
/** Registry entry metadata. */
|
|
5
|
+
export interface CapabilityEntry<TCapability> {
|
|
6
|
+
/** Capability implementation. */
|
|
7
|
+
capability: TCapability;
|
|
8
|
+
/** Registration origin. */
|
|
9
|
+
origin: CapabilityOrigin;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Generic, domain-agnostic capability registry shared by engine hosts.
|
|
14
|
+
*
|
|
15
|
+
* Stores named capabilities of an opaque type `TCapability` with replace-by-name
|
|
16
|
+
* semantics and `origin` metadata. It knows nothing about what a capability *is* —
|
|
17
|
+
* each engine supplies its own type and owns its own error types and override
|
|
18
|
+
* semantics (the registry only reports what it stores).
|
|
19
|
+
*/
|
|
20
|
+
export class CapabilityRegistry<TCapability> {
|
|
21
|
+
private readonly capabilities = new Map<string, CapabilityEntry<TCapability>>();
|
|
22
|
+
|
|
23
|
+
constructor(private readonly kind: string) {}
|
|
24
|
+
|
|
25
|
+
/** Register or replace a capability. */
|
|
26
|
+
register(name: string, capability: TCapability, origin: CapabilityOrigin = 'extension'): void {
|
|
27
|
+
this.capabilities.set(name, { capability, origin });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Return true when a capability exists. */
|
|
31
|
+
has(name: string): boolean {
|
|
32
|
+
return this.capabilities.has(name);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Get a registered capability or throw a clear error. */
|
|
36
|
+
get(name: string): TCapability {
|
|
37
|
+
const entry = this.capabilities.get(name);
|
|
38
|
+
if (entry === undefined) {
|
|
39
|
+
throw new Error(`Unknown ${this.kind}: ${name}`);
|
|
40
|
+
}
|
|
41
|
+
return entry.capability;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Get a registered entry (capability plus origin) without throwing. */
|
|
45
|
+
getEntry(name: string): CapabilityEntry<TCapability> | undefined {
|
|
46
|
+
return this.capabilities.get(name);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** List registered capability names in insertion order. */
|
|
50
|
+
list(): string[] {
|
|
51
|
+
return [...this.capabilities.keys()];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** List registered entries (name + capability + origin) in insertion order. */
|
|
55
|
+
entries(): Array<readonly [string, CapabilityEntry<TCapability>]> {
|
|
56
|
+
return [...this.capabilities.entries()];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
2
|
+
import { assertRelativeExtensionPath } from './extension-path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A single extension module reference.
|
|
6
|
+
*
|
|
7
|
+
* `kind` is an engine-defined tag the registration callback uses to route the
|
|
8
|
+
* module to the right registry — the loader itself never interprets it. `path` is
|
|
9
|
+
* the relative path as authored; the loader **derives** the import target by
|
|
10
|
+
* resolving `path` against `baseDir` *after* validating it, so the trust guard
|
|
11
|
+
* always governs the actual module that gets imported. `sourceName` identifies the
|
|
12
|
+
* declaring config for diagnostics.
|
|
13
|
+
*/
|
|
14
|
+
export interface ExtensionRef<TExtensionKind extends string = string> {
|
|
15
|
+
/** Engine-defined capability tag, used only by the registration callback. */
|
|
16
|
+
readonly kind: TExtensionKind;
|
|
17
|
+
/** Relative path as authored, enforced by {@link assertRelativeExtensionPath}. */
|
|
18
|
+
readonly path: string;
|
|
19
|
+
/** Absolute directory the authored `path` is resolved against. */
|
|
20
|
+
readonly baseDir: string;
|
|
21
|
+
/** Name of the config (preset, workflow, …) that declared this ref. */
|
|
22
|
+
readonly sourceName: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Options controlling extension-module loading. */
|
|
26
|
+
export interface LoadExtensionsOptions {
|
|
27
|
+
/**
|
|
28
|
+
* Whether to actually import extension modules. Defaults to `false`: loading
|
|
29
|
+
* arbitrary code is a trust decision the caller must make explicitly. When refs
|
|
30
|
+
* exist and this is not `true`, loading throws **before any import**.
|
|
31
|
+
*/
|
|
32
|
+
readonly allowExtensions?: boolean;
|
|
33
|
+
/** Optional sink for non-fatal warnings (e.g. capability overrides). */
|
|
34
|
+
readonly logger?: { warn: (message: string) => void };
|
|
35
|
+
/**
|
|
36
|
+
* Required module loader. The generic core never performs a dynamic `import`
|
|
37
|
+
* itself — the embedder supplies the import policy (typically
|
|
38
|
+
* `(absPath) => import(absPath)`), and tests pass a stub. Keeping this explicit
|
|
39
|
+
* means the shared core has no ambient code-loading capability of its own.
|
|
40
|
+
*/
|
|
41
|
+
readonly moduleLoader: (absPath: string) => Promise<Record<string, unknown>>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A validated extension module export: an object carrying at least a string `name`. */
|
|
45
|
+
export interface LoadedExtension {
|
|
46
|
+
/** Stable name of the contributed capability bundle. */
|
|
47
|
+
readonly name: string;
|
|
48
|
+
/** Engine-specific contribution payload (actions, evaluators, …). */
|
|
49
|
+
readonly [key: string]: unknown;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Import each extension module behind an explicit trust gate, validate its export,
|
|
54
|
+
* then hand it to an engine-provided registration callback.
|
|
55
|
+
*
|
|
56
|
+
* The loader is domain-agnostic: it does not know which registry a module belongs to.
|
|
57
|
+
* It enforces the trust boundary (gate + relative-path guard), validates the
|
|
58
|
+
* default/named-`extension` export shape, and delegates routing to `register`.
|
|
59
|
+
*
|
|
60
|
+
* Security invariants:
|
|
61
|
+
* - No refs → no-op.
|
|
62
|
+
* - Refs present but `allowExtensions !== true` → throws **before** any `import` or
|
|
63
|
+
* `moduleLoader` call, so a declared extension is never silently dropped.
|
|
64
|
+
* - Every ref's authored path is re-validated by {@link assertRelativeExtensionPath}.
|
|
65
|
+
*
|
|
66
|
+
* @throws When extensions are present but `allowExtensions` is not `true`, when a path
|
|
67
|
+
* fails the trust guard, or when a module lacks a valid `name`.
|
|
68
|
+
*/
|
|
69
|
+
export async function loadExtensionModules<TExtensionKind extends string>(
|
|
70
|
+
refs: readonly ExtensionRef<TExtensionKind>[],
|
|
71
|
+
options: LoadExtensionsOptions,
|
|
72
|
+
register: (ref: ExtensionRef<TExtensionKind>, extension: LoadedExtension) => void | Promise<void>,
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
if (refs.length === 0) return;
|
|
75
|
+
|
|
76
|
+
// Fail closed before importing anything: a declared extension under a disabled gate
|
|
77
|
+
// is a hard error, never a silent drop.
|
|
78
|
+
if (options.allowExtensions !== true) {
|
|
79
|
+
const first = refs[0] as ExtensionRef<TExtensionKind>;
|
|
80
|
+
throw new Error(
|
|
81
|
+
`"${first.sourceName}" declares ${first.kind} extension "${first.path}", but extensions are disabled — pass allowExtensions: true to load extension modules`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const ref of refs) {
|
|
86
|
+
if (!isAbsolute(ref.baseDir)) {
|
|
87
|
+
throw new Error(`"${ref.sourceName}" extension baseDir "${ref.baseDir}" must be an absolute directory`);
|
|
88
|
+
}
|
|
89
|
+
// Validate the authored path, then derive the import target from it so the
|
|
90
|
+
// trust guard always governs the module actually imported — the loader never
|
|
91
|
+
// imports a caller-supplied absolute path it did not resolve itself.
|
|
92
|
+
assertRelativeExtensionPath(ref.path, { sourceName: ref.sourceName });
|
|
93
|
+
const absPath = resolve(ref.baseDir, ref.path);
|
|
94
|
+
const moduleExports = await options.moduleLoader(absPath);
|
|
95
|
+
const candidate = moduleExports.default ?? moduleExports.extension;
|
|
96
|
+
if (
|
|
97
|
+
candidate === null ||
|
|
98
|
+
typeof candidate !== 'object' ||
|
|
99
|
+
typeof (candidate as { name?: unknown }).name !== 'string'
|
|
100
|
+
) {
|
|
101
|
+
throw new Error(`"${ref.sourceName}" extension "${ref.path}" must export an object with a string "name"`);
|
|
102
|
+
}
|
|
103
|
+
await register(ref, candidate as LoadedExtension);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assert that an extension module path is relative and does not escape its
|
|
3
|
+
* declaring directory.
|
|
4
|
+
*
|
|
5
|
+
* Extension declarations are data, and a path that is absolute or escapes via `..`
|
|
6
|
+
* is a trust-boundary violation even when extension loading is explicitly allowed.
|
|
7
|
+
* This is a standalone validator (not a schema refinement) so the loader can enforce
|
|
8
|
+
* it at load time, independent of any engine's config schema — defense in depth.
|
|
9
|
+
*
|
|
10
|
+
* @throws When the path is absolute or contains a `..` traversal segment.
|
|
11
|
+
*/
|
|
12
|
+
export function assertRelativeExtensionPath(path: string, options: { sourceName?: string } = {}): void {
|
|
13
|
+
const where = options.sourceName !== undefined ? ` declared by "${options.sourceName}"` : '';
|
|
14
|
+
if (/^([/\\]|[A-Za-z]:[/\\])/.test(path)) {
|
|
15
|
+
throw new Error(`extension path "${path}"${where} must be relative (no absolute paths)`);
|
|
16
|
+
}
|
|
17
|
+
if (path.split(/[/\\]/).includes('..')) {
|
|
18
|
+
throw new Error(`extension path "${path}"${where} must not contain ".." traversal`);
|
|
19
|
+
}
|
|
20
|
+
}
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './plugin/index';
|