@donkeylabs/server 2.0.19 → 2.0.21
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/caching-strategies.md +677 -0
- package/docs/dev-experience.md +656 -0
- package/docs/hot-reload-limitations.md +166 -0
- package/docs/load-testing.md +974 -0
- package/docs/plugin-registry-design.md +1064 -0
- package/docs/production.md +1229 -0
- package/docs/workflows.md +90 -3
- package/package.json +1 -1
- package/src/admin/routes.ts +153 -0
- package/src/core/cron.ts +90 -9
- package/src/core/index.ts +31 -0
- package/src/core/job-adapter-kysely.ts +176 -73
- package/src/core/job-adapter-sqlite.ts +10 -0
- package/src/core/jobs.ts +112 -17
- package/src/core/migrations/workflows/002_add_metadata_column.ts +28 -0
- package/src/core/process-adapter-kysely.ts +62 -21
- package/src/core/storage-adapter-local.test.ts +199 -0
- package/src/core/storage.test.ts +197 -0
- package/src/core/workflow-adapter-kysely.ts +66 -19
- package/src/core/workflow-executor.ts +239 -0
- package/src/core/workflow-proxy.ts +238 -0
- package/src/core/workflow-socket.ts +449 -0
- package/src/core/workflow-state-machine.ts +593 -0
- package/src/core/workflows.test.ts +758 -0
- package/src/core/workflows.ts +705 -595
- package/src/core.ts +17 -6
- package/src/index.ts +14 -0
- package/src/testing/database.test.ts +263 -0
- package/src/testing/database.ts +173 -0
- package/src/testing/e2e.test.ts +189 -0
- package/src/testing/e2e.ts +272 -0
- package/src/testing/index.ts +18 -0
|
@@ -31,6 +31,7 @@ interface WorkflowInstancesTable {
|
|
|
31
31
|
completed_at: string | null;
|
|
32
32
|
parent_id: string | null;
|
|
33
33
|
branch_name: string | null;
|
|
34
|
+
metadata: string | null;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
interface Database {
|
|
@@ -41,6 +42,7 @@ export class KyselyWorkflowAdapter implements WorkflowAdapter {
|
|
|
41
42
|
private db: Kysely<Database>;
|
|
42
43
|
private cleanupTimer?: ReturnType<typeof setInterval>;
|
|
43
44
|
private cleanupDays: number;
|
|
45
|
+
private stopped = false;
|
|
44
46
|
|
|
45
47
|
constructor(db: Kysely<any>, config: KyselyWorkflowAdapterConfig = {}) {
|
|
46
48
|
this.db = db as Kysely<Database>;
|
|
@@ -53,7 +55,16 @@ export class KyselyWorkflowAdapter implements WorkflowAdapter {
|
|
|
53
55
|
}
|
|
54
56
|
}
|
|
55
57
|
|
|
58
|
+
/** Check if adapter is stopped (for safe database access) */
|
|
59
|
+
private checkStopped(): boolean {
|
|
60
|
+
return this.stopped;
|
|
61
|
+
}
|
|
62
|
+
|
|
56
63
|
async createInstance(instance: Omit<WorkflowInstance, "id">): Promise<WorkflowInstance> {
|
|
64
|
+
if (this.checkStopped()) {
|
|
65
|
+
throw new Error("WorkflowAdapter has been stopped");
|
|
66
|
+
}
|
|
67
|
+
|
|
57
68
|
const id = `wf_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
58
69
|
|
|
59
70
|
await this.db
|
|
@@ -75,6 +86,7 @@ export class KyselyWorkflowAdapter implements WorkflowAdapter {
|
|
|
75
86
|
completed_at: instance.completedAt?.toISOString() ?? null,
|
|
76
87
|
parent_id: instance.parentId ?? null,
|
|
77
88
|
branch_name: instance.branchName ?? null,
|
|
89
|
+
metadata: instance.metadata ? JSON.stringify(instance.metadata) : null,
|
|
78
90
|
})
|
|
79
91
|
.execute();
|
|
80
92
|
|
|
@@ -82,17 +94,27 @@ export class KyselyWorkflowAdapter implements WorkflowAdapter {
|
|
|
82
94
|
}
|
|
83
95
|
|
|
84
96
|
async getInstance(instanceId: string): Promise<WorkflowInstance | null> {
|
|
85
|
-
|
|
86
|
-
.selectFrom("__donkeylabs_workflow_instances__")
|
|
87
|
-
.selectAll()
|
|
88
|
-
.where("id", "=", instanceId)
|
|
89
|
-
.executeTakeFirst();
|
|
97
|
+
if (this.checkStopped()) return null;
|
|
90
98
|
|
|
91
|
-
|
|
92
|
-
|
|
99
|
+
try {
|
|
100
|
+
const row = await this.db
|
|
101
|
+
.selectFrom("__donkeylabs_workflow_instances__")
|
|
102
|
+
.selectAll()
|
|
103
|
+
.where("id", "=", instanceId)
|
|
104
|
+
.executeTakeFirst();
|
|
105
|
+
|
|
106
|
+
if (!row) return null;
|
|
107
|
+
return this.rowToInstance(row);
|
|
108
|
+
} catch (err: any) {
|
|
109
|
+
// Silently ignore errors if adapter was stopped during query
|
|
110
|
+
if (this.stopped && err?.message?.includes("destroyed")) return null;
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
93
113
|
}
|
|
94
114
|
|
|
95
115
|
async updateInstance(instanceId: string, updates: Partial<WorkflowInstance>): Promise<void> {
|
|
116
|
+
if (this.checkStopped()) return;
|
|
117
|
+
|
|
96
118
|
const updateData: Partial<WorkflowInstancesTable> = {};
|
|
97
119
|
|
|
98
120
|
if (updates.status !== undefined) {
|
|
@@ -121,17 +143,28 @@ export class KyselyWorkflowAdapter implements WorkflowAdapter {
|
|
|
121
143
|
if (updates.completedAt !== undefined) {
|
|
122
144
|
updateData.completed_at = updates.completedAt?.toISOString() ?? null;
|
|
123
145
|
}
|
|
146
|
+
if (updates.metadata !== undefined) {
|
|
147
|
+
updateData.metadata = updates.metadata ? JSON.stringify(updates.metadata) : null;
|
|
148
|
+
}
|
|
124
149
|
|
|
125
150
|
if (Object.keys(updateData).length === 0) return;
|
|
126
151
|
|
|
127
|
-
|
|
128
|
-
.
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
152
|
+
try {
|
|
153
|
+
await this.db
|
|
154
|
+
.updateTable("__donkeylabs_workflow_instances__")
|
|
155
|
+
.set(updateData)
|
|
156
|
+
.where("id", "=", instanceId)
|
|
157
|
+
.execute();
|
|
158
|
+
} catch (err: any) {
|
|
159
|
+
// Silently ignore errors if adapter was stopped during query
|
|
160
|
+
if (this.stopped && err?.message?.includes("destroyed")) return;
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
132
163
|
}
|
|
133
164
|
|
|
134
165
|
async deleteInstance(instanceId: string): Promise<boolean> {
|
|
166
|
+
if (this.checkStopped()) return false;
|
|
167
|
+
|
|
135
168
|
// Check if exists first since BunSqliteDialect doesn't report numDeletedRows properly
|
|
136
169
|
const exists = await this.db
|
|
137
170
|
.selectFrom("__donkeylabs_workflow_instances__")
|
|
@@ -153,6 +186,8 @@ export class KyselyWorkflowAdapter implements WorkflowAdapter {
|
|
|
153
186
|
workflowName: string,
|
|
154
187
|
status?: WorkflowStatus
|
|
155
188
|
): Promise<WorkflowInstance[]> {
|
|
189
|
+
if (this.checkStopped()) return [];
|
|
190
|
+
|
|
156
191
|
let query = this.db
|
|
157
192
|
.selectFrom("__donkeylabs_workflow_instances__")
|
|
158
193
|
.selectAll()
|
|
@@ -167,16 +202,26 @@ export class KyselyWorkflowAdapter implements WorkflowAdapter {
|
|
|
167
202
|
}
|
|
168
203
|
|
|
169
204
|
async getRunningInstances(): Promise<WorkflowInstance[]> {
|
|
170
|
-
|
|
171
|
-
.selectFrom("__donkeylabs_workflow_instances__")
|
|
172
|
-
.selectAll()
|
|
173
|
-
.where("status", "=", "running")
|
|
174
|
-
.execute();
|
|
205
|
+
if (this.checkStopped()) return [];
|
|
175
206
|
|
|
176
|
-
|
|
207
|
+
try {
|
|
208
|
+
const rows = await this.db
|
|
209
|
+
.selectFrom("__donkeylabs_workflow_instances__")
|
|
210
|
+
.selectAll()
|
|
211
|
+
.where("status", "=", "running")
|
|
212
|
+
.execute();
|
|
213
|
+
|
|
214
|
+
return rows.map((r) => this.rowToInstance(r));
|
|
215
|
+
} catch (err: any) {
|
|
216
|
+
// Silently ignore errors if adapter was stopped during query
|
|
217
|
+
if (this.stopped && err?.message?.includes("destroyed")) return [];
|
|
218
|
+
throw err;
|
|
219
|
+
}
|
|
177
220
|
}
|
|
178
221
|
|
|
179
222
|
async getAllInstances(options: GetAllWorkflowsOptions = {}): Promise<WorkflowInstance[]> {
|
|
223
|
+
if (this.checkStopped()) return [];
|
|
224
|
+
|
|
180
225
|
const { status, workflowName, limit = 100, offset = 0 } = options;
|
|
181
226
|
|
|
182
227
|
let query = this.db.selectFrom("__donkeylabs_workflow_instances__").selectAll();
|
|
@@ -231,12 +276,13 @@ export class KyselyWorkflowAdapter implements WorkflowAdapter {
|
|
|
231
276
|
completedAt: row.completed_at ? new Date(row.completed_at) : undefined,
|
|
232
277
|
parentId: row.parent_id ?? undefined,
|
|
233
278
|
branchName: row.branch_name ?? undefined,
|
|
279
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
|
|
234
280
|
};
|
|
235
281
|
}
|
|
236
282
|
|
|
237
283
|
/** Clean up old completed/failed/cancelled workflows */
|
|
238
284
|
private async cleanup(): Promise<void> {
|
|
239
|
-
if (this.cleanupDays <= 0) return;
|
|
285
|
+
if (this.cleanupDays <= 0 || this.checkStopped()) return;
|
|
240
286
|
|
|
241
287
|
try {
|
|
242
288
|
const cutoff = new Date();
|
|
@@ -268,6 +314,7 @@ export class KyselyWorkflowAdapter implements WorkflowAdapter {
|
|
|
268
314
|
|
|
269
315
|
/** Stop the adapter and cleanup timer */
|
|
270
316
|
stop(): void {
|
|
317
|
+
this.stopped = true;
|
|
271
318
|
if (this.cleanupTimer) {
|
|
272
319
|
clearInterval(this.cleanupTimer);
|
|
273
320
|
this.cleanupTimer = undefined;
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Workflow Executor - Subprocess Entry Point
|
|
3
|
+
// Thin shell that creates a WorkflowStateMachine with IPC event bridge.
|
|
4
|
+
// The state machine owns all execution logic and persistence.
|
|
5
|
+
|
|
6
|
+
import { connect } from "node:net";
|
|
7
|
+
import type { Socket } from "node:net";
|
|
8
|
+
import { Kysely } from "kysely";
|
|
9
|
+
import { BunSqliteDialect } from "kysely-bun-sqlite";
|
|
10
|
+
import Database from "bun:sqlite";
|
|
11
|
+
import type { WorkflowEvent } from "./workflow-socket";
|
|
12
|
+
import { WorkflowProxyConnection, createPluginsProxy, createCoreServicesProxy } from "./workflow-proxy";
|
|
13
|
+
import type { WorkflowDefinition } from "./workflows";
|
|
14
|
+
import { KyselyWorkflowAdapter } from "./workflow-adapter-kysely";
|
|
15
|
+
import { WorkflowStateMachine, type StateMachineEvents } from "./workflow-state-machine";
|
|
16
|
+
|
|
17
|
+
// ============================================
|
|
18
|
+
// Types
|
|
19
|
+
// ============================================
|
|
20
|
+
|
|
21
|
+
interface ExecutorConfig {
|
|
22
|
+
instanceId: string;
|
|
23
|
+
workflowName: string;
|
|
24
|
+
input: any;
|
|
25
|
+
socketPath?: string;
|
|
26
|
+
tcpPort?: number;
|
|
27
|
+
modulePath: string;
|
|
28
|
+
dbPath: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ============================================
|
|
32
|
+
// Main Executor
|
|
33
|
+
// ============================================
|
|
34
|
+
|
|
35
|
+
async function main(): Promise<void> {
|
|
36
|
+
// Read config from stdin
|
|
37
|
+
const stdin = await Bun.stdin.text();
|
|
38
|
+
const config: ExecutorConfig = JSON.parse(stdin);
|
|
39
|
+
|
|
40
|
+
const { instanceId, workflowName, socketPath, tcpPort, modulePath, dbPath } = config;
|
|
41
|
+
|
|
42
|
+
// Connect to IPC socket
|
|
43
|
+
const socket = await connectToSocket(socketPath, tcpPort);
|
|
44
|
+
const proxyConnection = new WorkflowProxyConnection(socket);
|
|
45
|
+
|
|
46
|
+
// Create database connection + adapter (subprocess owns its own persistence)
|
|
47
|
+
const db = new Kysely<any>({
|
|
48
|
+
dialect: new BunSqliteDialect({
|
|
49
|
+
database: new Database(dbPath),
|
|
50
|
+
}),
|
|
51
|
+
});
|
|
52
|
+
const adapter = new KyselyWorkflowAdapter(db, { cleanupDays: 0 });
|
|
53
|
+
|
|
54
|
+
// Start heartbeat
|
|
55
|
+
const heartbeatInterval = setInterval(() => {
|
|
56
|
+
sendEvent(socket, {
|
|
57
|
+
type: "heartbeat",
|
|
58
|
+
instanceId,
|
|
59
|
+
timestamp: Date.now(),
|
|
60
|
+
});
|
|
61
|
+
}, 5000);
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
// Send started event
|
|
65
|
+
sendEvent(socket, {
|
|
66
|
+
type: "started",
|
|
67
|
+
instanceId,
|
|
68
|
+
timestamp: Date.now(),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Import the workflow module to get the definition
|
|
72
|
+
const module = await import(modulePath);
|
|
73
|
+
const definition = findWorkflowDefinition(module, workflowName, modulePath);
|
|
74
|
+
|
|
75
|
+
// Create proxy objects for plugin/core access via IPC
|
|
76
|
+
const plugins = createPluginsProxy(proxyConnection);
|
|
77
|
+
const coreServices = createCoreServicesProxy(proxyConnection);
|
|
78
|
+
|
|
79
|
+
// Create state machine with IPC event bridge
|
|
80
|
+
const sm = new WorkflowStateMachine({
|
|
81
|
+
adapter,
|
|
82
|
+
core: { ...coreServices, db } as any,
|
|
83
|
+
plugins,
|
|
84
|
+
events: createIpcEventBridge(socket, instanceId),
|
|
85
|
+
pollInterval: 1000,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Run the state machine to completion
|
|
89
|
+
const result = await sm.run(instanceId, definition);
|
|
90
|
+
|
|
91
|
+
// Send completed event
|
|
92
|
+
sendEvent(socket, {
|
|
93
|
+
type: "completed",
|
|
94
|
+
instanceId,
|
|
95
|
+
timestamp: Date.now(),
|
|
96
|
+
output: result,
|
|
97
|
+
});
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// Send failed event
|
|
100
|
+
sendEvent(socket, {
|
|
101
|
+
type: "failed",
|
|
102
|
+
instanceId,
|
|
103
|
+
timestamp: Date.now(),
|
|
104
|
+
error: error instanceof Error ? error.message : String(error),
|
|
105
|
+
});
|
|
106
|
+
process.exit(1);
|
|
107
|
+
} finally {
|
|
108
|
+
clearInterval(heartbeatInterval);
|
|
109
|
+
proxyConnection.close();
|
|
110
|
+
socket.end();
|
|
111
|
+
adapter.stop();
|
|
112
|
+
await db.destroy();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
process.exit(0);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ============================================
|
|
119
|
+
// IPC Event Bridge
|
|
120
|
+
// ============================================
|
|
121
|
+
|
|
122
|
+
function createIpcEventBridge(socket: Socket, instanceId: string): StateMachineEvents {
|
|
123
|
+
return {
|
|
124
|
+
onStepStarted: (id, stepName, stepType) => {
|
|
125
|
+
sendEvent(socket, {
|
|
126
|
+
type: "step.started",
|
|
127
|
+
instanceId: id,
|
|
128
|
+
timestamp: Date.now(),
|
|
129
|
+
stepName,
|
|
130
|
+
stepType,
|
|
131
|
+
});
|
|
132
|
+
},
|
|
133
|
+
onStepCompleted: (id, stepName, output, nextStep) => {
|
|
134
|
+
sendEvent(socket, {
|
|
135
|
+
type: "step.completed",
|
|
136
|
+
instanceId: id,
|
|
137
|
+
timestamp: Date.now(),
|
|
138
|
+
stepName,
|
|
139
|
+
output,
|
|
140
|
+
nextStep,
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
onStepFailed: (id, stepName, error, attempts) => {
|
|
144
|
+
sendEvent(socket, {
|
|
145
|
+
type: "step.failed",
|
|
146
|
+
instanceId: id,
|
|
147
|
+
timestamp: Date.now(),
|
|
148
|
+
stepName,
|
|
149
|
+
error,
|
|
150
|
+
});
|
|
151
|
+
},
|
|
152
|
+
onStepRetry: () => {
|
|
153
|
+
// Retry is internal to the state machine - no IPC event needed
|
|
154
|
+
},
|
|
155
|
+
onProgress: (id, progress, currentStep, completed, total) => {
|
|
156
|
+
sendEvent(socket, {
|
|
157
|
+
type: "progress",
|
|
158
|
+
instanceId: id,
|
|
159
|
+
timestamp: Date.now(),
|
|
160
|
+
progress,
|
|
161
|
+
completedSteps: completed,
|
|
162
|
+
totalSteps: total,
|
|
163
|
+
});
|
|
164
|
+
},
|
|
165
|
+
onCompleted: () => {
|
|
166
|
+
// Handled by the main try/catch after sm.run() returns
|
|
167
|
+
},
|
|
168
|
+
onFailed: () => {
|
|
169
|
+
// Handled by the main try/catch after sm.run() throws
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ============================================
|
|
175
|
+
// Socket Connection
|
|
176
|
+
// ============================================
|
|
177
|
+
|
|
178
|
+
function connectToSocket(socketPath?: string, tcpPort?: number): Promise<Socket> {
|
|
179
|
+
return new Promise((resolve, reject) => {
|
|
180
|
+
let socket: Socket;
|
|
181
|
+
|
|
182
|
+
if (socketPath) {
|
|
183
|
+
socket = connect(socketPath);
|
|
184
|
+
} else if (tcpPort) {
|
|
185
|
+
socket = connect(tcpPort, "127.0.0.1");
|
|
186
|
+
} else {
|
|
187
|
+
reject(new Error("No socket path or TCP port provided"));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
socket.once("connect", () => resolve(socket));
|
|
192
|
+
socket.once("error", (err) => reject(err));
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ============================================
|
|
197
|
+
// Helpers
|
|
198
|
+
// ============================================
|
|
199
|
+
|
|
200
|
+
function sendEvent(socket: Socket, event: WorkflowEvent): void {
|
|
201
|
+
socket.write(JSON.stringify(event) + "\n");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function findWorkflowDefinition(
|
|
205
|
+
module: any,
|
|
206
|
+
workflowName: string,
|
|
207
|
+
modulePath: string,
|
|
208
|
+
): WorkflowDefinition {
|
|
209
|
+
// Try named exports
|
|
210
|
+
for (const key of Object.keys(module)) {
|
|
211
|
+
const exported = module[key];
|
|
212
|
+
if (isWorkflowDefinition(exported) && exported.name === workflowName) {
|
|
213
|
+
return exported;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Try default export
|
|
218
|
+
if (module.default && isWorkflowDefinition(module.default) && module.default.name === workflowName) {
|
|
219
|
+
return module.default;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
throw new Error(`Workflow "${workflowName}" not found in module ${modulePath}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function isWorkflowDefinition(obj: any): obj is WorkflowDefinition {
|
|
226
|
+
return (
|
|
227
|
+
obj &&
|
|
228
|
+
typeof obj === "object" &&
|
|
229
|
+
typeof obj.name === "string" &&
|
|
230
|
+
obj.steps instanceof Map &&
|
|
231
|
+
typeof obj.startAt === "string"
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Run main
|
|
236
|
+
main().catch((err) => {
|
|
237
|
+
console.error("[WorkflowExecutor] Fatal error:", err);
|
|
238
|
+
process.exit(1);
|
|
239
|
+
});
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// Workflow Proxy Utilities
|
|
2
|
+
// Provides transparent access to plugins and core services from isolated workflow subprocess via IPC
|
|
3
|
+
|
|
4
|
+
import type { Socket } from "node:net";
|
|
5
|
+
import type { ProxyRequest, ProxyResponse } from "./workflow-socket";
|
|
6
|
+
|
|
7
|
+
// ============================================
|
|
8
|
+
// Types
|
|
9
|
+
// ============================================
|
|
10
|
+
|
|
11
|
+
export interface ProxyConnection {
|
|
12
|
+
/** Send a proxy request and wait for response */
|
|
13
|
+
call(target: "plugin" | "core", service: string, method: string, args: any[]): Promise<any>;
|
|
14
|
+
/** Close the connection */
|
|
15
|
+
close(): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface PendingRequest {
|
|
19
|
+
resolve: (value: any) => void;
|
|
20
|
+
reject: (error: Error) => void;
|
|
21
|
+
timeout: ReturnType<typeof setTimeout>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ============================================
|
|
25
|
+
// Proxy Connection Implementation
|
|
26
|
+
// ============================================
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Creates a proxy connection that sends requests over a socket and handles responses.
|
|
30
|
+
* Used by the workflow executor subprocess to communicate with the main process.
|
|
31
|
+
*/
|
|
32
|
+
export class WorkflowProxyConnection implements ProxyConnection {
|
|
33
|
+
private socket: Socket;
|
|
34
|
+
private pendingRequests = new Map<string, PendingRequest>();
|
|
35
|
+
private requestCounter = 0;
|
|
36
|
+
private timeoutMs: number;
|
|
37
|
+
private buffer = "";
|
|
38
|
+
|
|
39
|
+
constructor(socket: Socket, timeoutMs = 30000) {
|
|
40
|
+
this.socket = socket;
|
|
41
|
+
this.timeoutMs = timeoutMs;
|
|
42
|
+
|
|
43
|
+
// Handle incoming data (proxy responses)
|
|
44
|
+
socket.on("data", (data) => {
|
|
45
|
+
this.buffer += data.toString();
|
|
46
|
+
this.processBuffer();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
socket.on("error", (err) => {
|
|
50
|
+
// Reject all pending requests on socket error
|
|
51
|
+
for (const [requestId, pending] of this.pendingRequests) {
|
|
52
|
+
clearTimeout(pending.timeout);
|
|
53
|
+
pending.reject(new Error(`Socket error: ${err.message}`));
|
|
54
|
+
}
|
|
55
|
+
this.pendingRequests.clear();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
socket.on("close", () => {
|
|
59
|
+
// Reject all pending requests on socket close
|
|
60
|
+
for (const [requestId, pending] of this.pendingRequests) {
|
|
61
|
+
clearTimeout(pending.timeout);
|
|
62
|
+
pending.reject(new Error("Socket closed"));
|
|
63
|
+
}
|
|
64
|
+
this.pendingRequests.clear();
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private processBuffer(): void {
|
|
69
|
+
const lines = this.buffer.split("\n");
|
|
70
|
+
this.buffer = lines.pop() || "";
|
|
71
|
+
|
|
72
|
+
for (const line of lines) {
|
|
73
|
+
if (!line.trim()) continue;
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const response = JSON.parse(line) as ProxyResponse;
|
|
77
|
+
this.handleResponse(response);
|
|
78
|
+
} catch {
|
|
79
|
+
// Ignore invalid JSON - might be other message types
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private handleResponse(response: ProxyResponse): void {
|
|
85
|
+
const pending = this.pendingRequests.get(response.requestId);
|
|
86
|
+
if (!pending) return;
|
|
87
|
+
|
|
88
|
+
clearTimeout(pending.timeout);
|
|
89
|
+
this.pendingRequests.delete(response.requestId);
|
|
90
|
+
|
|
91
|
+
if (response.type === "proxy.result") {
|
|
92
|
+
pending.resolve(response.result);
|
|
93
|
+
} else {
|
|
94
|
+
pending.reject(new Error(response.error ?? "Proxy call failed"));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async call(target: "plugin" | "core", service: string, method: string, args: any[]): Promise<any> {
|
|
99
|
+
const requestId = `req_${++this.requestCounter}_${Date.now()}`;
|
|
100
|
+
|
|
101
|
+
const request: ProxyRequest = {
|
|
102
|
+
type: "proxy.call",
|
|
103
|
+
requestId,
|
|
104
|
+
target,
|
|
105
|
+
service,
|
|
106
|
+
method,
|
|
107
|
+
args,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
return new Promise((resolve, reject) => {
|
|
111
|
+
const timeout = setTimeout(() => {
|
|
112
|
+
this.pendingRequests.delete(requestId);
|
|
113
|
+
reject(new Error(`Proxy call timed out: ${target}.${service}.${method}`));
|
|
114
|
+
}, this.timeoutMs);
|
|
115
|
+
|
|
116
|
+
this.pendingRequests.set(requestId, { resolve, reject, timeout });
|
|
117
|
+
|
|
118
|
+
// Send request
|
|
119
|
+
this.socket.write(JSON.stringify(request) + "\n", (err) => {
|
|
120
|
+
if (err) {
|
|
121
|
+
clearTimeout(timeout);
|
|
122
|
+
this.pendingRequests.delete(requestId);
|
|
123
|
+
reject(new Error(`Failed to send proxy request: ${err.message}`));
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
close(): void {
|
|
130
|
+
// Cancel all pending requests
|
|
131
|
+
for (const [requestId, pending] of this.pendingRequests) {
|
|
132
|
+
clearTimeout(pending.timeout);
|
|
133
|
+
pending.reject(new Error("Connection closed"));
|
|
134
|
+
}
|
|
135
|
+
this.pendingRequests.clear();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ============================================
|
|
140
|
+
// Proxy Factories
|
|
141
|
+
// ============================================
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Creates a proxy object for accessing a plugin service.
|
|
145
|
+
* Method calls are intercepted and forwarded via IPC to the main process.
|
|
146
|
+
*
|
|
147
|
+
* @example
|
|
148
|
+
* const usersProxy = createPluginProxy(connection, "users");
|
|
149
|
+
* const user = await usersProxy.getById("user_123"); // Calls main process
|
|
150
|
+
*/
|
|
151
|
+
export function createPluginProxy<T = Record<string, any>>(
|
|
152
|
+
connection: ProxyConnection,
|
|
153
|
+
pluginName: string
|
|
154
|
+
): T {
|
|
155
|
+
return new Proxy({}, {
|
|
156
|
+
get(_target, prop) {
|
|
157
|
+
if (typeof prop === "symbol") return undefined;
|
|
158
|
+
|
|
159
|
+
// Return a function that calls the method via IPC
|
|
160
|
+
return async (...args: any[]) => {
|
|
161
|
+
return connection.call("plugin", pluginName, prop as string, args);
|
|
162
|
+
};
|
|
163
|
+
},
|
|
164
|
+
}) as T;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Creates a proxy object for accessing a core service.
|
|
169
|
+
* Method calls are intercepted and forwarded via IPC to the main process.
|
|
170
|
+
*
|
|
171
|
+
* @example
|
|
172
|
+
* const cacheProxy = createCoreProxy(connection, "cache");
|
|
173
|
+
* await cacheProxy.set("key", "value"); // Calls main process
|
|
174
|
+
*/
|
|
175
|
+
export function createCoreProxy<T = Record<string, any>>(
|
|
176
|
+
connection: ProxyConnection,
|
|
177
|
+
serviceName: string
|
|
178
|
+
): T {
|
|
179
|
+
return new Proxy({}, {
|
|
180
|
+
get(_target, prop) {
|
|
181
|
+
if (typeof prop === "symbol") return undefined;
|
|
182
|
+
|
|
183
|
+
// Return a function that calls the method via IPC
|
|
184
|
+
return async (...args: any[]) => {
|
|
185
|
+
return connection.call("core", serviceName, prop as string, args);
|
|
186
|
+
};
|
|
187
|
+
},
|
|
188
|
+
}) as T;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Creates a full plugins proxy that lazily creates plugin proxies on access.
|
|
193
|
+
*
|
|
194
|
+
* @example
|
|
195
|
+
* const plugins = createPluginsProxy(connection);
|
|
196
|
+
* const user = await plugins.users.getById("user_123");
|
|
197
|
+
* const order = await plugins.orders.create({ ... });
|
|
198
|
+
*/
|
|
199
|
+
export function createPluginsProxy(connection: ProxyConnection): Record<string, any> {
|
|
200
|
+
const cache = new Map<string, any>();
|
|
201
|
+
|
|
202
|
+
return new Proxy({}, {
|
|
203
|
+
get(_target, prop) {
|
|
204
|
+
if (typeof prop === "symbol") return undefined;
|
|
205
|
+
|
|
206
|
+
const pluginName = prop as string;
|
|
207
|
+
if (!cache.has(pluginName)) {
|
|
208
|
+
cache.set(pluginName, createPluginProxy(connection, pluginName));
|
|
209
|
+
}
|
|
210
|
+
return cache.get(pluginName);
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Creates a full core services proxy that lazily creates service proxies on access.
|
|
217
|
+
* Note: Some services like db require special handling as they can't be fully proxied.
|
|
218
|
+
*
|
|
219
|
+
* @example
|
|
220
|
+
* const core = createCoreServicesProxy(connection);
|
|
221
|
+
* await core.cache.set("key", "value");
|
|
222
|
+
* await core.events.emit("user.created", { userId: "123" });
|
|
223
|
+
*/
|
|
224
|
+
export function createCoreServicesProxy(connection: ProxyConnection): Record<string, any> {
|
|
225
|
+
const cache = new Map<string, any>();
|
|
226
|
+
|
|
227
|
+
return new Proxy({}, {
|
|
228
|
+
get(_target, prop) {
|
|
229
|
+
if (typeof prop === "symbol") return undefined;
|
|
230
|
+
|
|
231
|
+
const serviceName = prop as string;
|
|
232
|
+
if (!cache.has(serviceName)) {
|
|
233
|
+
cache.set(serviceName, createCoreProxy(connection, serviceName));
|
|
234
|
+
}
|
|
235
|
+
return cache.get(serviceName);
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
}
|