@donkeylabs/server 2.0.7 → 2.0.11
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/docs/lifecycle-hooks.md +16 -16
- package/docs/processes.md +93 -0
- package/package.json +13 -3
- package/src/admin/dashboard.ts +717 -0
- package/src/admin/index.ts +85 -0
- package/src/admin/routes.ts +573 -0
- package/src/admin/styles.ts +422 -0
- package/src/core/index.ts +25 -0
- package/src/core/job-adapter-kysely.ts +22 -1
- package/src/core/job-adapter-sqlite.ts +22 -1
- package/src/core/jobs.ts +37 -0
- package/src/core/process-client.ts +121 -0
- package/src/core/processes.ts +67 -0
- package/src/core/storage-adapter-local.ts +403 -0
- package/src/core/storage-adapter-s3.ts +409 -0
- package/src/core/storage.ts +543 -0
- package/src/core/websocket.ts +13 -3
- package/src/core/workflow-adapter-kysely.ts +22 -1
- package/src/core/workflows.ts +37 -0
- package/src/core.ts +10 -1
- package/src/harness.ts +3 -0
- package/src/index.ts +19 -0
- package/src/process-client.ts +7 -0
- package/src/server.ts +71 -31
|
@@ -28,6 +28,44 @@ import { createConnection, type Socket } from "node:net";
|
|
|
28
28
|
// Types
|
|
29
29
|
// ============================================
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Process stats collected and emitted to the server.
|
|
33
|
+
*/
|
|
34
|
+
export interface ProcessStats {
|
|
35
|
+
/** CPU usage since last measurement */
|
|
36
|
+
cpu: {
|
|
37
|
+
/** User CPU time in microseconds */
|
|
38
|
+
user: number;
|
|
39
|
+
/** System CPU time in microseconds */
|
|
40
|
+
system: number;
|
|
41
|
+
/** CPU usage percentage (0-100) since last measurement */
|
|
42
|
+
percent: number;
|
|
43
|
+
};
|
|
44
|
+
/** Memory usage */
|
|
45
|
+
memory: {
|
|
46
|
+
/** Resident set size in bytes */
|
|
47
|
+
rss: number;
|
|
48
|
+
/** V8 heap total in bytes */
|
|
49
|
+
heapTotal: number;
|
|
50
|
+
/** V8 heap used in bytes */
|
|
51
|
+
heapUsed: number;
|
|
52
|
+
/** External memory in bytes (C++ objects bound to JS) */
|
|
53
|
+
external: number;
|
|
54
|
+
};
|
|
55
|
+
/** Process uptime in seconds */
|
|
56
|
+
uptime: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Configuration for stats emission.
|
|
61
|
+
*/
|
|
62
|
+
export interface StatsConfig {
|
|
63
|
+
/** Enable stats emission (default: false) */
|
|
64
|
+
enabled?: boolean;
|
|
65
|
+
/** Interval between stats emissions in ms (default: 5000) */
|
|
66
|
+
interval?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
31
69
|
export interface ProcessClientConfig {
|
|
32
70
|
/** Process ID (from DONKEYLABS_PROCESS_ID env var) */
|
|
33
71
|
processId: string;
|
|
@@ -43,6 +81,8 @@ export interface ProcessClientConfig {
|
|
|
43
81
|
reconnectInterval?: number;
|
|
44
82
|
/** Max reconnection attempts (default: 30) */
|
|
45
83
|
maxReconnectAttempts?: number;
|
|
84
|
+
/** Stats emission configuration */
|
|
85
|
+
stats?: StatsConfig;
|
|
46
86
|
}
|
|
47
87
|
|
|
48
88
|
export interface ProcessClient {
|
|
@@ -72,13 +112,19 @@ class ProcessClientImpl implements ProcessClient {
|
|
|
72
112
|
private heartbeatInterval: number;
|
|
73
113
|
private reconnectInterval: number;
|
|
74
114
|
private maxReconnectAttempts: number;
|
|
115
|
+
private statsConfig: StatsConfig;
|
|
75
116
|
|
|
76
117
|
private heartbeatTimer?: ReturnType<typeof setInterval>;
|
|
118
|
+
private statsTimer?: ReturnType<typeof setInterval>;
|
|
77
119
|
private reconnectTimer?: ReturnType<typeof setTimeout>;
|
|
78
120
|
private reconnectAttempts = 0;
|
|
79
121
|
private isDisconnecting = false;
|
|
80
122
|
private _connected = false;
|
|
81
123
|
|
|
124
|
+
// For CPU percentage calculation
|
|
125
|
+
private lastCpuUsage?: NodeJS.CpuUsage;
|
|
126
|
+
private lastCpuTime?: number;
|
|
127
|
+
|
|
82
128
|
constructor(config: ProcessClientConfig) {
|
|
83
129
|
this.processId = config.processId;
|
|
84
130
|
this.metadata = config.metadata ?? {};
|
|
@@ -87,6 +133,7 @@ class ProcessClientImpl implements ProcessClient {
|
|
|
87
133
|
this.heartbeatInterval = config.heartbeatInterval ?? 5000;
|
|
88
134
|
this.reconnectInterval = config.reconnectInterval ?? 2000;
|
|
89
135
|
this.maxReconnectAttempts = config.maxReconnectAttempts ?? 30;
|
|
136
|
+
this.statsConfig = config.stats ?? { enabled: false };
|
|
90
137
|
}
|
|
91
138
|
|
|
92
139
|
get connected(): boolean {
|
|
@@ -99,6 +146,7 @@ class ProcessClientImpl implements ProcessClient {
|
|
|
99
146
|
this._connected = true;
|
|
100
147
|
this.reconnectAttempts = 0;
|
|
101
148
|
this.startHeartbeat();
|
|
149
|
+
this.startStats();
|
|
102
150
|
|
|
103
151
|
// Send initial "connected" message
|
|
104
152
|
this.sendMessage({ type: "connected" });
|
|
@@ -122,6 +170,7 @@ class ProcessClientImpl implements ProcessClient {
|
|
|
122
170
|
const onClose = () => {
|
|
123
171
|
this._connected = false;
|
|
124
172
|
this.stopHeartbeat();
|
|
173
|
+
this.stopStats();
|
|
125
174
|
|
|
126
175
|
if (!this.isDisconnecting && this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
127
176
|
console.log(`[ProcessClient] Connection closed, attempting reconnect...`);
|
|
@@ -219,6 +268,69 @@ class ProcessClientImpl implements ProcessClient {
|
|
|
219
268
|
}
|
|
220
269
|
}
|
|
221
270
|
|
|
271
|
+
private startStats(): void {
|
|
272
|
+
if (!this.statsConfig.enabled) return;
|
|
273
|
+
|
|
274
|
+
this.stopStats();
|
|
275
|
+
|
|
276
|
+
// Initialize CPU tracking
|
|
277
|
+
this.lastCpuUsage = process.cpuUsage();
|
|
278
|
+
this.lastCpuTime = Date.now();
|
|
279
|
+
|
|
280
|
+
const interval = this.statsConfig.interval ?? 5000;
|
|
281
|
+
this.statsTimer = setInterval(() => {
|
|
282
|
+
this.sendStats();
|
|
283
|
+
}, interval);
|
|
284
|
+
|
|
285
|
+
// Send initial stats
|
|
286
|
+
this.sendStats();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private stopStats(): void {
|
|
290
|
+
if (this.statsTimer) {
|
|
291
|
+
clearInterval(this.statsTimer);
|
|
292
|
+
this.statsTimer = undefined;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private collectStats(): ProcessStats {
|
|
297
|
+
const now = Date.now();
|
|
298
|
+
const memUsage = process.memoryUsage();
|
|
299
|
+
const cpuUsage = process.cpuUsage(this.lastCpuUsage);
|
|
300
|
+
|
|
301
|
+
// Calculate CPU percentage
|
|
302
|
+
const elapsedMs = now - (this.lastCpuTime ?? now);
|
|
303
|
+
const elapsedUs = elapsedMs * 1000; // Convert to microseconds
|
|
304
|
+
const totalCpuUs = cpuUsage.user + cpuUsage.system;
|
|
305
|
+
// CPU percent = (CPU time used / elapsed time) * 100
|
|
306
|
+
// For multi-core, this can exceed 100%
|
|
307
|
+
const cpuPercent = elapsedUs > 0 ? (totalCpuUs / elapsedUs) * 100 : 0;
|
|
308
|
+
|
|
309
|
+
// Update for next calculation
|
|
310
|
+
this.lastCpuUsage = process.cpuUsage();
|
|
311
|
+
this.lastCpuTime = now;
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
cpu: {
|
|
315
|
+
user: cpuUsage.user,
|
|
316
|
+
system: cpuUsage.system,
|
|
317
|
+
percent: Math.round(cpuPercent * 100) / 100, // Round to 2 decimals
|
|
318
|
+
},
|
|
319
|
+
memory: {
|
|
320
|
+
rss: memUsage.rss,
|
|
321
|
+
heapTotal: memUsage.heapTotal,
|
|
322
|
+
heapUsed: memUsage.heapUsed,
|
|
323
|
+
external: memUsage.external,
|
|
324
|
+
},
|
|
325
|
+
uptime: process.uptime(),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private sendStats(): void {
|
|
330
|
+
const stats = this.collectStats();
|
|
331
|
+
this.sendMessage({ type: "stats", stats });
|
|
332
|
+
}
|
|
333
|
+
|
|
222
334
|
private sendMessage(message: { type: string; [key: string]: any }): boolean {
|
|
223
335
|
if (!this.socket || this.socket.destroyed || !this._connected) {
|
|
224
336
|
return false;
|
|
@@ -250,6 +362,7 @@ class ProcessClientImpl implements ProcessClient {
|
|
|
250
362
|
disconnect(): void {
|
|
251
363
|
this.isDisconnecting = true;
|
|
252
364
|
this.stopHeartbeat();
|
|
365
|
+
this.stopStats();
|
|
253
366
|
|
|
254
367
|
if (this.reconnectTimer) {
|
|
255
368
|
clearTimeout(this.reconnectTimer);
|
|
@@ -291,14 +404,22 @@ export function createProcessClient(config: ProcessClientConfig): ProcessClient
|
|
|
291
404
|
*
|
|
292
405
|
* @example
|
|
293
406
|
* ```ts
|
|
407
|
+
* // Basic connection
|
|
294
408
|
* const client = await ProcessClient.connect();
|
|
295
409
|
* client.emit("progress", { percent: 50 });
|
|
410
|
+
*
|
|
411
|
+
* // With stats emission
|
|
412
|
+
* const client = await ProcessClient.connect({
|
|
413
|
+
* stats: { enabled: true, interval: 2000 }
|
|
414
|
+
* });
|
|
296
415
|
* ```
|
|
297
416
|
*/
|
|
298
417
|
export async function connect(options?: {
|
|
299
418
|
heartbeatInterval?: number;
|
|
300
419
|
reconnectInterval?: number;
|
|
301
420
|
maxReconnectAttempts?: number;
|
|
421
|
+
/** Enable real-time CPU/memory stats emission */
|
|
422
|
+
stats?: StatsConfig;
|
|
302
423
|
}): Promise<ProcessClient> {
|
|
303
424
|
const processId = process.env.DONKEYLABS_PROCESS_ID;
|
|
304
425
|
const socketPath = process.env.DONKEYLABS_SOCKET_PATH;
|
package/src/core/processes.ts
CHANGED
|
@@ -81,6 +81,34 @@ export interface ManagedProcess {
|
|
|
81
81
|
error?: string;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Process stats received from a managed process.
|
|
86
|
+
*/
|
|
87
|
+
export interface ProcessStats {
|
|
88
|
+
/** CPU usage since last measurement */
|
|
89
|
+
cpu: {
|
|
90
|
+
/** User CPU time in microseconds */
|
|
91
|
+
user: number;
|
|
92
|
+
/** System CPU time in microseconds */
|
|
93
|
+
system: number;
|
|
94
|
+
/** CPU usage percentage (0-100) since last measurement */
|
|
95
|
+
percent: number;
|
|
96
|
+
};
|
|
97
|
+
/** Memory usage */
|
|
98
|
+
memory: {
|
|
99
|
+
/** Resident set size in bytes */
|
|
100
|
+
rss: number;
|
|
101
|
+
/** V8 heap total in bytes */
|
|
102
|
+
heapTotal: number;
|
|
103
|
+
/** V8 heap used in bytes */
|
|
104
|
+
heapUsed: number;
|
|
105
|
+
/** External memory in bytes (C++ objects bound to JS) */
|
|
106
|
+
external: number;
|
|
107
|
+
};
|
|
108
|
+
/** Process uptime in seconds */
|
|
109
|
+
uptime: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
84
112
|
export interface ProcessDefinition {
|
|
85
113
|
name: string;
|
|
86
114
|
config: Omit<ProcessConfig, "args"> & { args?: string[] };
|
|
@@ -107,6 +135,18 @@ export interface ProcessDefinition {
|
|
|
107
135
|
onUnhealthy?: (process: ManagedProcess) => void | Promise<void>;
|
|
108
136
|
/** Called when the process is restarted */
|
|
109
137
|
onRestart?: (oldProcess: ManagedProcess, newProcess: ManagedProcess, attempt: number) => void | Promise<void>;
|
|
138
|
+
/**
|
|
139
|
+
* Called when stats are received from the process.
|
|
140
|
+
* Stats are emitted by the process client when stats.enabled is true.
|
|
141
|
+
*
|
|
142
|
+
* @example
|
|
143
|
+
* ```ts
|
|
144
|
+
* onStats: (process, stats) => {
|
|
145
|
+
* console.log(`${process.name}: CPU ${stats.cpu.percent}%, Memory ${stats.memory.rss / 1e6}MB`);
|
|
146
|
+
* }
|
|
147
|
+
* ```
|
|
148
|
+
*/
|
|
149
|
+
onStats?: (process: ManagedProcess, stats: ProcessStats) => void | Promise<void>;
|
|
110
150
|
}
|
|
111
151
|
|
|
112
152
|
export interface SpawnOptions {
|
|
@@ -526,6 +566,33 @@ export class ProcessesImpl implements Processes {
|
|
|
526
566
|
return;
|
|
527
567
|
}
|
|
528
568
|
|
|
569
|
+
// Handle stats messages
|
|
570
|
+
if (type === "stats" && message.stats) {
|
|
571
|
+
const stats = message.stats as ProcessStats;
|
|
572
|
+
|
|
573
|
+
// Emit to events service as "process.<name>.stats"
|
|
574
|
+
await this.emitEvent(`process.${proc.name}.stats`, {
|
|
575
|
+
processId,
|
|
576
|
+
name: proc.name,
|
|
577
|
+
stats,
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
// Generic stats event
|
|
581
|
+
await this.emitEvent("process.stats", {
|
|
582
|
+
processId,
|
|
583
|
+
name: proc.name,
|
|
584
|
+
stats,
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// Call definition callback
|
|
588
|
+
const definition = this.definitions.get(proc.name);
|
|
589
|
+
if (definition?.onStats) {
|
|
590
|
+
await definition.onStats(proc, stats);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
|
|
529
596
|
// Handle typed event messages from ProcessClient.emit()
|
|
530
597
|
if (type === "event" && message.event) {
|
|
531
598
|
const eventName = message.event as string;
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
// Local Filesystem Storage Adapter
|
|
2
|
+
// Stores files in a local directory with metadata in .meta.json sidecar files
|
|
3
|
+
|
|
4
|
+
import { mkdir, readFile, writeFile, unlink, readdir, stat, rm } from "node:fs/promises";
|
|
5
|
+
import { join, dirname, basename, relative } from "node:path";
|
|
6
|
+
import { existsSync, createReadStream } from "node:fs";
|
|
7
|
+
import { Readable } from "node:stream";
|
|
8
|
+
import type {
|
|
9
|
+
StorageAdapter,
|
|
10
|
+
StorageFile,
|
|
11
|
+
UploadOptions,
|
|
12
|
+
UploadResult,
|
|
13
|
+
DownloadResult,
|
|
14
|
+
ListOptions,
|
|
15
|
+
ListResult,
|
|
16
|
+
GetUrlOptions,
|
|
17
|
+
CopyOptions,
|
|
18
|
+
LocalProviderConfig,
|
|
19
|
+
StorageVisibility,
|
|
20
|
+
} from "./storage";
|
|
21
|
+
|
|
22
|
+
interface FileMetadata {
|
|
23
|
+
contentType?: string;
|
|
24
|
+
metadata?: Record<string, string>;
|
|
25
|
+
visibility?: StorageVisibility;
|
|
26
|
+
contentDisposition?: string;
|
|
27
|
+
cacheControl?: string;
|
|
28
|
+
size: number;
|
|
29
|
+
lastModified: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Local filesystem storage adapter */
|
|
33
|
+
export class LocalStorageAdapter implements StorageAdapter {
|
|
34
|
+
private directory: string;
|
|
35
|
+
private baseUrl: string;
|
|
36
|
+
|
|
37
|
+
constructor(config: LocalProviderConfig) {
|
|
38
|
+
this.directory = config.directory;
|
|
39
|
+
this.baseUrl = config.baseUrl || "/storage";
|
|
40
|
+
|
|
41
|
+
// Ensure directory exists
|
|
42
|
+
this.ensureDirectory(this.directory);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private async ensureDirectory(dir: string): Promise<void> {
|
|
46
|
+
try {
|
|
47
|
+
await mkdir(dir, { recursive: true });
|
|
48
|
+
} catch (err) {
|
|
49
|
+
// Ignore if already exists
|
|
50
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private getFilePath(key: string): string {
|
|
57
|
+
// Normalize key to prevent directory traversal attacks
|
|
58
|
+
const normalizedKey = key.replace(/\.\./g, "").replace(/^\/+/, "");
|
|
59
|
+
return join(this.directory, normalizedKey);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private getMetaPath(key: string): string {
|
|
63
|
+
const filePath = this.getFilePath(key);
|
|
64
|
+
const dir = dirname(filePath);
|
|
65
|
+
const name = basename(filePath);
|
|
66
|
+
return join(dir, `.${name}.meta.json`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private async readMetadata(key: string): Promise<FileMetadata | null> {
|
|
70
|
+
const metaPath = this.getMetaPath(key);
|
|
71
|
+
try {
|
|
72
|
+
const content = await readFile(metaPath, "utf-8");
|
|
73
|
+
return JSON.parse(content);
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private async writeMetadata(key: string, metadata: FileMetadata): Promise<void> {
|
|
80
|
+
const metaPath = this.getMetaPath(key);
|
|
81
|
+
await this.ensureDirectory(dirname(metaPath));
|
|
82
|
+
await writeFile(metaPath, JSON.stringify(metadata, null, 2));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private async deleteMetadata(key: string): Promise<void> {
|
|
86
|
+
const metaPath = this.getMetaPath(key);
|
|
87
|
+
try {
|
|
88
|
+
await unlink(metaPath);
|
|
89
|
+
} catch {
|
|
90
|
+
// Ignore if doesn't exist
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async upload(options: UploadOptions): Promise<UploadResult> {
|
|
95
|
+
const filePath = this.getFilePath(options.key);
|
|
96
|
+
await this.ensureDirectory(dirname(filePath));
|
|
97
|
+
|
|
98
|
+
// Convert body to buffer
|
|
99
|
+
const buffer = await this.toBuffer(options.body);
|
|
100
|
+
|
|
101
|
+
// Write file
|
|
102
|
+
await writeFile(filePath, buffer);
|
|
103
|
+
|
|
104
|
+
// Write metadata
|
|
105
|
+
const metadata: FileMetadata = {
|
|
106
|
+
contentType: options.contentType,
|
|
107
|
+
metadata: options.metadata,
|
|
108
|
+
visibility: options.visibility,
|
|
109
|
+
contentDisposition: options.contentDisposition,
|
|
110
|
+
cacheControl: options.cacheControl,
|
|
111
|
+
size: buffer.byteLength,
|
|
112
|
+
lastModified: new Date().toISOString(),
|
|
113
|
+
};
|
|
114
|
+
await this.writeMetadata(options.key, metadata);
|
|
115
|
+
|
|
116
|
+
const url =
|
|
117
|
+
options.visibility === "public" ? `${this.baseUrl}/${options.key}` : undefined;
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
key: options.key,
|
|
121
|
+
size: buffer.byteLength,
|
|
122
|
+
url,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async download(key: string): Promise<DownloadResult | null> {
|
|
127
|
+
const filePath = this.getFilePath(key);
|
|
128
|
+
|
|
129
|
+
if (!existsSync(filePath)) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const meta = await this.readMetadata(key);
|
|
134
|
+
const fileStat = await stat(filePath);
|
|
135
|
+
|
|
136
|
+
// Create readable stream
|
|
137
|
+
const nodeStream = createReadStream(filePath);
|
|
138
|
+
const webStream = Readable.toWeb(nodeStream) as unknown as ReadableStream<Uint8Array>;
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
body: webStream,
|
|
142
|
+
size: fileStat.size,
|
|
143
|
+
contentType: meta?.contentType,
|
|
144
|
+
lastModified: new Date(meta?.lastModified || fileStat.mtime),
|
|
145
|
+
metadata: meta?.metadata,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async delete(key: string): Promise<boolean> {
|
|
150
|
+
const filePath = this.getFilePath(key);
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
await unlink(filePath);
|
|
154
|
+
await this.deleteMetadata(key);
|
|
155
|
+
return true;
|
|
156
|
+
} catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async deleteMany(keys: string[]): Promise<{ deleted: string[]; errors: string[] }> {
|
|
162
|
+
const deleted: string[] = [];
|
|
163
|
+
const errors: string[] = [];
|
|
164
|
+
|
|
165
|
+
for (const key of keys) {
|
|
166
|
+
if (await this.delete(key)) {
|
|
167
|
+
deleted.push(key);
|
|
168
|
+
} else {
|
|
169
|
+
errors.push(key);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { deleted, errors };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async list(options: ListOptions = {}): Promise<ListResult> {
|
|
177
|
+
const { prefix = "", limit = 1000, cursor, delimiter } = options;
|
|
178
|
+
|
|
179
|
+
const prefixPath = prefix ? join(this.directory, prefix) : this.directory;
|
|
180
|
+
const files: StorageFile[] = [];
|
|
181
|
+
const prefixes: string[] = [];
|
|
182
|
+
const prefixSet = new Set<string>();
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
await this.walkDirectory(
|
|
186
|
+
prefixPath,
|
|
187
|
+
this.directory,
|
|
188
|
+
prefix,
|
|
189
|
+
delimiter,
|
|
190
|
+
files,
|
|
191
|
+
prefixSet,
|
|
192
|
+
limit,
|
|
193
|
+
cursor
|
|
194
|
+
);
|
|
195
|
+
} catch (err) {
|
|
196
|
+
// Directory doesn't exist, return empty
|
|
197
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
198
|
+
return { files: [], prefixes: [], cursor: null, hasMore: false };
|
|
199
|
+
}
|
|
200
|
+
throw err;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Sort files by key for consistent pagination
|
|
204
|
+
files.sort((a, b) => a.key.localeCompare(b.key));
|
|
205
|
+
|
|
206
|
+
// Apply cursor
|
|
207
|
+
let startIndex = 0;
|
|
208
|
+
if (cursor) {
|
|
209
|
+
startIndex = files.findIndex((f) => f.key > cursor);
|
|
210
|
+
if (startIndex === -1) startIndex = files.length;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const resultFiles = files.slice(startIndex, startIndex + limit);
|
|
214
|
+
const hasMore = startIndex + limit < files.length;
|
|
215
|
+
const nextCursor = hasMore ? resultFiles[resultFiles.length - 1]?.key || null : null;
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
files: resultFiles,
|
|
219
|
+
prefixes: Array.from(prefixSet).sort(),
|
|
220
|
+
cursor: nextCursor,
|
|
221
|
+
hasMore,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private async walkDirectory(
|
|
226
|
+
dirPath: string,
|
|
227
|
+
baseDir: string,
|
|
228
|
+
prefix: string,
|
|
229
|
+
delimiter: string | undefined,
|
|
230
|
+
files: StorageFile[],
|
|
231
|
+
prefixSet: Set<string>,
|
|
232
|
+
limit: number,
|
|
233
|
+
cursor: string | undefined
|
|
234
|
+
): Promise<void> {
|
|
235
|
+
let entries;
|
|
236
|
+
try {
|
|
237
|
+
entries = await readdir(dirPath, { withFileTypes: true });
|
|
238
|
+
} catch {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
for (const entry of entries) {
|
|
243
|
+
// Skip metadata files
|
|
244
|
+
if (entry.name.endsWith(".meta.json")) continue;
|
|
245
|
+
|
|
246
|
+
const fullPath = join(dirPath, entry.name);
|
|
247
|
+
const key = relative(baseDir, fullPath);
|
|
248
|
+
|
|
249
|
+
if (entry.isDirectory()) {
|
|
250
|
+
if (delimiter) {
|
|
251
|
+
// Add as prefix
|
|
252
|
+
prefixSet.add(key + delimiter);
|
|
253
|
+
} else {
|
|
254
|
+
// Recurse into directory
|
|
255
|
+
await this.walkDirectory(
|
|
256
|
+
fullPath,
|
|
257
|
+
baseDir,
|
|
258
|
+
prefix,
|
|
259
|
+
delimiter,
|
|
260
|
+
files,
|
|
261
|
+
prefixSet,
|
|
262
|
+
limit,
|
|
263
|
+
cursor
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
} else {
|
|
267
|
+
// Check if key matches prefix
|
|
268
|
+
if (!key.startsWith(prefix.replace(/\/$/, ""))) continue;
|
|
269
|
+
|
|
270
|
+
// Check cursor
|
|
271
|
+
if (cursor && key <= cursor) continue;
|
|
272
|
+
|
|
273
|
+
// Get file stats and metadata
|
|
274
|
+
const fileStat = await stat(fullPath);
|
|
275
|
+
const meta = await this.readMetadata(key);
|
|
276
|
+
|
|
277
|
+
files.push({
|
|
278
|
+
key,
|
|
279
|
+
size: fileStat.size,
|
|
280
|
+
contentType: meta?.contentType,
|
|
281
|
+
lastModified: new Date(meta?.lastModified || fileStat.mtime),
|
|
282
|
+
metadata: meta?.metadata,
|
|
283
|
+
visibility: meta?.visibility,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Early exit if we have enough
|
|
287
|
+
if (files.length >= limit * 2) return;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async head(key: string): Promise<StorageFile | null> {
|
|
293
|
+
const filePath = this.getFilePath(key);
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
const fileStat = await stat(filePath);
|
|
297
|
+
const meta = await this.readMetadata(key);
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
key,
|
|
301
|
+
size: fileStat.size,
|
|
302
|
+
contentType: meta?.contentType,
|
|
303
|
+
lastModified: new Date(meta?.lastModified || fileStat.mtime),
|
|
304
|
+
metadata: meta?.metadata,
|
|
305
|
+
visibility: meta?.visibility,
|
|
306
|
+
};
|
|
307
|
+
} catch {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async exists(key: string): Promise<boolean> {
|
|
313
|
+
const filePath = this.getFilePath(key);
|
|
314
|
+
return existsSync(filePath);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async getUrl(key: string, options: GetUrlOptions = {}): Promise<string> {
|
|
318
|
+
// For local storage, we can only provide a path-based URL
|
|
319
|
+
// The actual serving needs to be handled by the application
|
|
320
|
+
let url = `${this.baseUrl}/${key}`;
|
|
321
|
+
|
|
322
|
+
if (options.download) {
|
|
323
|
+
const filename =
|
|
324
|
+
typeof options.download === "string" ? options.download : key.split("/").pop();
|
|
325
|
+
url += `?download=${encodeURIComponent(filename || "file")}`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return url;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async copy(options: CopyOptions): Promise<UploadResult> {
|
|
332
|
+
const sourcePath = this.getFilePath(options.source);
|
|
333
|
+
const destPath = this.getFilePath(options.destination);
|
|
334
|
+
|
|
335
|
+
if (!existsSync(sourcePath)) {
|
|
336
|
+
throw new Error(`Source file not found: ${options.source}`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
await this.ensureDirectory(dirname(destPath));
|
|
340
|
+
|
|
341
|
+
// Read source file
|
|
342
|
+
const content = await readFile(sourcePath);
|
|
343
|
+
const sourceMeta = await this.readMetadata(options.source);
|
|
344
|
+
|
|
345
|
+
// Write destination file
|
|
346
|
+
await writeFile(destPath, content);
|
|
347
|
+
|
|
348
|
+
// Write destination metadata
|
|
349
|
+
const destMeta: FileMetadata = {
|
|
350
|
+
...sourceMeta,
|
|
351
|
+
metadata: options.metadata ?? sourceMeta?.metadata,
|
|
352
|
+
visibility: options.visibility ?? sourceMeta?.visibility,
|
|
353
|
+
size: content.byteLength,
|
|
354
|
+
lastModified: new Date().toISOString(),
|
|
355
|
+
};
|
|
356
|
+
await this.writeMetadata(options.destination, destMeta);
|
|
357
|
+
|
|
358
|
+
const url =
|
|
359
|
+
destMeta.visibility === "public"
|
|
360
|
+
? `${this.baseUrl}/${options.destination}`
|
|
361
|
+
: undefined;
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
key: options.destination,
|
|
365
|
+
size: content.byteLength,
|
|
366
|
+
url,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
stop(): void {
|
|
371
|
+
// Nothing to clean up for local adapter
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Helper to convert various body types to Buffer */
|
|
375
|
+
private async toBuffer(
|
|
376
|
+
body: Buffer | Uint8Array | string | Blob | ReadableStream<Uint8Array>
|
|
377
|
+
): Promise<Buffer> {
|
|
378
|
+
if (Buffer.isBuffer(body)) {
|
|
379
|
+
return body;
|
|
380
|
+
}
|
|
381
|
+
if (body instanceof Uint8Array) {
|
|
382
|
+
return Buffer.from(body);
|
|
383
|
+
}
|
|
384
|
+
if (typeof body === "string") {
|
|
385
|
+
return Buffer.from(body, "utf-8");
|
|
386
|
+
}
|
|
387
|
+
if (body instanceof Blob) {
|
|
388
|
+
const arrayBuffer = await body.arrayBuffer();
|
|
389
|
+
return Buffer.from(arrayBuffer);
|
|
390
|
+
}
|
|
391
|
+
if (body instanceof ReadableStream) {
|
|
392
|
+
const reader = body.getReader();
|
|
393
|
+
const chunks: Uint8Array[] = [];
|
|
394
|
+
while (true) {
|
|
395
|
+
const { done, value } = await reader.read();
|
|
396
|
+
if (done) break;
|
|
397
|
+
chunks.push(value);
|
|
398
|
+
}
|
|
399
|
+
return Buffer.concat(chunks);
|
|
400
|
+
}
|
|
401
|
+
throw new Error("Unsupported body type");
|
|
402
|
+
}
|
|
403
|
+
}
|