@codemation/cli 0.0.5 → 0.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.
Files changed (40) hide show
  1. package/README.md +20 -26
  2. package/dist/{CliBin-C3ar49fj.js → CliBin-BAnFX1wL.js} +1105 -366
  3. package/dist/bin.js +1 -1
  4. package/dist/index.d.ts +655 -207
  5. package/dist/index.js +1 -1
  6. package/package.json +14 -6
  7. package/src/CliProgramFactory.ts +23 -8
  8. package/src/Program.ts +7 -3
  9. package/src/bootstrap/CodemationCliApplicationSession.ts +17 -19
  10. package/src/commands/DevCommand.ts +203 -171
  11. package/src/commands/ServeWebCommand.ts +26 -1
  12. package/src/commands/ServeWorkerCommand.ts +46 -30
  13. package/src/commands/devCommandLifecycle.types.ts +7 -11
  14. package/src/database/ConsumerDatabaseConnectionResolver.ts +55 -9
  15. package/src/database/DatabaseMigrationsApplyService.ts +2 -2
  16. package/src/dev/Builder.ts +1 -14
  17. package/src/dev/CliDevProxyServer.ts +457 -0
  18. package/src/dev/CliDevProxyServerFactory.ts +10 -0
  19. package/src/dev/DevApiRuntimeFactory.ts +44 -0
  20. package/src/dev/DevApiRuntimeHost.ts +130 -0
  21. package/src/dev/DevApiRuntimeServer.ts +107 -0
  22. package/src/dev/DevApiRuntimeTypes.ts +24 -0
  23. package/src/dev/DevAuthSettingsLoader.ts +9 -3
  24. package/src/dev/DevBootstrapSummaryFetcher.ts +1 -1
  25. package/src/dev/DevHttpProbe.ts +2 -2
  26. package/src/dev/DevNextHostEnvironmentBuilder.ts +35 -5
  27. package/src/dev/DevRebuildQueue.ts +54 -0
  28. package/src/dev/DevRebuildQueueFactory.ts +7 -0
  29. package/src/dev/DevSessionPortsResolver.ts +2 -2
  30. package/src/dev/DevSessionServices.ts +0 -4
  31. package/src/dev/DevSourceChangeClassifier.ts +33 -13
  32. package/src/dev/ListenPortConflictDescriber.ts +83 -0
  33. package/src/dev/WatchRootsResolver.ts +6 -4
  34. package/src/runtime/NextHostConsumerServerCommandFactory.ts +11 -2
  35. package/src/user/CliDatabaseUrlDescriptor.ts +2 -2
  36. package/src/user/UserAdminCliBootstrap.ts +9 -21
  37. package/codemation-cli-0.0.3.tgz +0 -0
  38. package/src/dev/DevSourceRestartCoordinator.ts +0 -48
  39. package/src/dev/DevelopmentGatewayNotifier.ts +0 -35
  40. package/src/dev/RuntimeToolEntrypointResolver.ts +0 -47
@@ -0,0 +1,457 @@
1
+ import { createServer, type IncomingMessage, type Server as HttpServer, type ServerResponse } from "node:http";
2
+ import { ApiPaths } from "@codemation/host";
3
+ import httpProxy from "http-proxy";
4
+ import type { Duplex } from "node:stream";
5
+ import { WebSocket, WebSocketServer } from "ws";
6
+ import type { ListenPortConflictDescriber } from "./ListenPortConflictDescriber";
7
+
8
+ type WorkflowClientMessage =
9
+ | Readonly<{ kind: "subscribe"; roomId: string }>
10
+ | Readonly<{ kind: "unsubscribe"; roomId: string }>;
11
+
12
+ type ProxyRuntimeTarget = Readonly<{
13
+ httpPort: number;
14
+ workflowWebSocketPort: number;
15
+ }>;
16
+
17
+ type BuildStatus = "idle" | "building";
18
+
19
+ export class CliDevProxyServer {
20
+ private readonly proxy = httpProxy.createProxyServer({ ws: true, xfwd: true });
21
+ private readonly devClients = new Set<WebSocket>();
22
+ private readonly devWss = new WebSocketServer({ noServer: true });
23
+ private readonly workflowClients = new Set<WebSocket>();
24
+ private readonly workflowWss = new WebSocketServer({ noServer: true });
25
+ private readonly roomIdsByWorkflowClient = new Map<WebSocket, Set<string>>();
26
+ private readonly workflowClientCountByRoomId = new Map<string, number>();
27
+ private activeRuntime: ProxyRuntimeTarget | null = null;
28
+ private activeBuildStatus: BuildStatus = "idle";
29
+ private childWorkflowSocket: WebSocket | null = null;
30
+ private server: HttpServer | null = null;
31
+ private uiProxyTarget: string | null = null;
32
+
33
+ constructor(
34
+ private readonly listenPort: number,
35
+ private readonly listenPortConflictDescriber: ListenPortConflictDescriber,
36
+ ) {}
37
+
38
+ async start(): Promise<void> {
39
+ if (this.server) {
40
+ return;
41
+ }
42
+ this.bindDevWebSocket();
43
+ this.bindWorkflowWebSocket();
44
+ this.proxy.on("error", (error, _req, res) => {
45
+ if (res && "writeHead" in res && typeof res.writeHead === "function") {
46
+ const serverResponse = res as ServerResponse;
47
+ if (!serverResponse.headersSent) {
48
+ serverResponse.writeHead(502, { "content-type": "text/plain" });
49
+ serverResponse.end(`Bad gateway: ${error.message}`);
50
+ }
51
+ }
52
+ });
53
+ const server = createServer((req, res) => {
54
+ void this.handleHttpRequest(req, res);
55
+ });
56
+ server.on("upgrade", (request, socket, head) => {
57
+ this.handleUpgrade(request, socket, head);
58
+ });
59
+ await new Promise<void>((resolve, reject) => {
60
+ server.once("error", (error) => {
61
+ void this.rejectListenError(error, reject);
62
+ });
63
+ server.listen(this.listenPort, "127.0.0.1", () => {
64
+ resolve();
65
+ });
66
+ });
67
+ this.server = server;
68
+ }
69
+
70
+ async stop(): Promise<void> {
71
+ await this.disconnectChildWorkflowSocket();
72
+ this.activeRuntime = null;
73
+ const server = this.server;
74
+ this.server = null;
75
+ for (const client of this.devClients) {
76
+ client.terminate();
77
+ }
78
+ this.devClients.clear();
79
+ for (const client of this.workflowClients) {
80
+ client.terminate();
81
+ }
82
+ this.workflowClients.clear();
83
+ this.roomIdsByWorkflowClient.clear();
84
+ this.workflowClientCountByRoomId.clear();
85
+ if (!server) {
86
+ return;
87
+ }
88
+ await new Promise<void>((resolve, reject) => {
89
+ server.close((error) => {
90
+ if (error) {
91
+ reject(error);
92
+ return;
93
+ }
94
+ resolve();
95
+ });
96
+ });
97
+ }
98
+
99
+ setUiProxyTarget(target: string | null): void {
100
+ this.uiProxyTarget = target?.trim() ? target.trim() : null;
101
+ }
102
+
103
+ async activateRuntime(target: ProxyRuntimeTarget | null): Promise<void> {
104
+ this.activeRuntime = target;
105
+ await this.connectChildWorkflowSocket();
106
+ }
107
+
108
+ setBuildStatus(status: BuildStatus): void {
109
+ this.activeBuildStatus = status;
110
+ }
111
+
112
+ broadcastBuildStarted(): void {
113
+ this.broadcastDev({ kind: "devBuildStarted" });
114
+ this.broadcastWorkflowLifecycleToSubscribedRooms((roomId: string) => ({
115
+ kind: "devBuildStarted",
116
+ workflowId: roomId,
117
+ }));
118
+ }
119
+
120
+ broadcastBuildCompleted(buildVersion: string): void {
121
+ this.broadcastDev({ kind: "devBuildCompleted", buildVersion });
122
+ this.broadcastWorkflowLifecycleToSubscribedRooms((roomId: string) => ({
123
+ kind: "devBuildCompleted",
124
+ workflowId: roomId,
125
+ buildVersion,
126
+ }));
127
+ }
128
+
129
+ broadcastBuildFailed(message: string): void {
130
+ this.broadcastDev({ kind: "devBuildFailed", message });
131
+ this.broadcastWorkflowLifecycleToSubscribedRooms((roomId: string) => ({
132
+ kind: "devBuildFailed",
133
+ workflowId: roomId,
134
+ message,
135
+ }));
136
+ }
137
+
138
+ private bindDevWebSocket(): void {
139
+ this.devWss.on("connection", (socket) => {
140
+ this.devClients.add(socket);
141
+ socket.on("close", () => {
142
+ this.devClients.delete(socket);
143
+ });
144
+ });
145
+ }
146
+
147
+ private bindWorkflowWebSocket(): void {
148
+ this.workflowWss.on("connection", (socket) => {
149
+ void this.connectWorkflowClient(socket);
150
+ });
151
+ }
152
+
153
+ private async handleHttpRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
154
+ const pathname = this.safePathname(req.url ?? "");
155
+ const uiProxyTarget = this.uiProxyTarget;
156
+ if (pathname === "/api/dev/health" && req.method === "GET") {
157
+ res.writeHead(200, { "content-type": "application/json" });
158
+ res.end(
159
+ JSON.stringify({
160
+ ok: true,
161
+ runtime: {
162
+ status: this.activeRuntime ? (this.activeBuildStatus === "building" ? "building" : "ready") : "stopped",
163
+ },
164
+ }),
165
+ );
166
+ return;
167
+ }
168
+ if (uiProxyTarget && pathname.startsWith("/api/auth/")) {
169
+ this.proxy.web(req, res, {
170
+ target: uiProxyTarget.replace(/\/$/, ""),
171
+ });
172
+ return;
173
+ }
174
+ if (pathname.startsWith("/api/")) {
175
+ if (this.activeBuildStatus === "building" || !this.activeRuntime) {
176
+ res.writeHead(503, { "content-type": "text/plain" });
177
+ res.end("Runtime is rebuilding.");
178
+ return;
179
+ }
180
+ this.proxy.web(req, res, {
181
+ target: `http://127.0.0.1:${this.activeRuntime.httpPort}`,
182
+ });
183
+ return;
184
+ }
185
+ if (uiProxyTarget) {
186
+ this.proxy.web(req, res, {
187
+ target: uiProxyTarget.replace(/\/$/, ""),
188
+ });
189
+ return;
190
+ }
191
+ res.writeHead(404, { "content-type": "text/plain" });
192
+ res.end("Not found.");
193
+ }
194
+
195
+ private handleUpgrade(request: IncomingMessage, socket: Duplex, head: Buffer): void {
196
+ const pathname = this.safePathname(request.url ?? "");
197
+ if (pathname === ApiPaths.devGatewaySocket()) {
198
+ this.devWss.handleUpgrade(request, socket, head, (ws) => {
199
+ this.devWss.emit("connection", ws, request);
200
+ });
201
+ return;
202
+ }
203
+ if (pathname === ApiPaths.workflowWebsocket()) {
204
+ this.workflowWss.handleUpgrade(request, socket, head, (ws) => {
205
+ this.workflowWss.emit("connection", ws, request);
206
+ });
207
+ return;
208
+ }
209
+ const uiProxyTarget = this.uiProxyTarget;
210
+ if (uiProxyTarget && !pathname.startsWith("/api/")) {
211
+ this.proxy.ws(request, socket, head, {
212
+ target: uiProxyTarget.replace(/\/$/, ""),
213
+ });
214
+ return;
215
+ }
216
+ socket.destroy();
217
+ }
218
+
219
+ private safePathname(url: string): string {
220
+ try {
221
+ return new URL(url, "http://127.0.0.1").pathname;
222
+ } catch {
223
+ return url.split("?")[0] ?? url;
224
+ }
225
+ }
226
+
227
+ private async rejectListenError(error: unknown, reject: (reason?: unknown) => void): Promise<void> {
228
+ const errorWithCode = error as Error & Readonly<{ code?: unknown }>;
229
+ if (errorWithCode.code !== "EADDRINUSE") {
230
+ reject(error);
231
+ return;
232
+ }
233
+
234
+ const description = await this.listenPortConflictDescriber.describeLoopbackPort(this.listenPort);
235
+ const baseMessage = `Dev gateway port ${this.listenPort} is already in use on 127.0.0.1.`;
236
+ const suffix =
237
+ description === null
238
+ ? " Stop the process using that port or change the configured Codemation dev port."
239
+ : ` Listener: ${description}. Stop that process or change the configured Codemation dev port.`;
240
+ reject(new Error(`${baseMessage}${suffix}`, { cause: error instanceof Error ? error : undefined }));
241
+ }
242
+
243
+ private broadcastDev(message: Readonly<Record<string, unknown>>): void {
244
+ const text = JSON.stringify(message);
245
+ for (const client of this.devClients) {
246
+ if (client.readyState === WebSocket.OPEN) {
247
+ client.send(text);
248
+ }
249
+ }
250
+ }
251
+
252
+ private broadcastWorkflowLifecycleToSubscribedRooms(
253
+ createMessage: (roomId: string) => Readonly<Record<string, unknown>>,
254
+ ): void {
255
+ for (const roomId of this.workflowClientCountByRoomId.keys()) {
256
+ this.broadcastWorkflowTextToRoom(roomId, JSON.stringify(createMessage(roomId)));
257
+ }
258
+ }
259
+
260
+ private async connectWorkflowClient(socket: WebSocket): Promise<void> {
261
+ this.workflowClients.add(socket);
262
+ this.roomIdsByWorkflowClient.set(socket, new Set());
263
+ socket.send(JSON.stringify({ kind: "ready" }));
264
+ socket.on("message", (rawData) => {
265
+ void this.handleWorkflowClientMessage(socket, rawData);
266
+ });
267
+ socket.on("close", () => {
268
+ this.disconnectWorkflowClient(socket);
269
+ });
270
+ socket.on("error", () => {
271
+ this.disconnectWorkflowClient(socket);
272
+ });
273
+ }
274
+
275
+ private disconnectWorkflowClient(socket: WebSocket): void {
276
+ const roomIds = this.roomIdsByWorkflowClient.get(socket);
277
+ if (roomIds) {
278
+ for (const roomId of roomIds) {
279
+ this.releaseWorkflowRoom(roomId);
280
+ }
281
+ }
282
+ this.roomIdsByWorkflowClient.delete(socket);
283
+ this.workflowClients.delete(socket);
284
+ }
285
+
286
+ private async handleWorkflowClientMessage(socket: WebSocket, rawData: unknown): Promise<void> {
287
+ try {
288
+ const message = this.parseWorkflowClientMessage(rawData);
289
+ if (message.kind === "subscribe") {
290
+ const roomIds = this.roomIdsByWorkflowClient.get(socket);
291
+ if (!roomIds) {
292
+ return;
293
+ }
294
+ if (!roomIds.has(message.roomId)) {
295
+ roomIds.add(message.roomId);
296
+ this.retainWorkflowRoom(message.roomId);
297
+ }
298
+ socket.send(JSON.stringify({ kind: "subscribed", roomId: message.roomId }));
299
+ return;
300
+ }
301
+ const roomIds = this.roomIdsByWorkflowClient.get(socket);
302
+ if (!roomIds) {
303
+ return;
304
+ }
305
+ if (roomIds.delete(message.roomId)) {
306
+ this.releaseWorkflowRoom(message.roomId);
307
+ }
308
+ socket.send(JSON.stringify({ kind: "unsubscribed", roomId: message.roomId }));
309
+ } catch (error) {
310
+ const exception = error instanceof Error ? error : new Error(String(error));
311
+ if (socket.readyState === WebSocket.OPEN) {
312
+ socket.send(JSON.stringify({ kind: "error", message: exception.message }));
313
+ }
314
+ }
315
+ }
316
+
317
+ private parseWorkflowClientMessage(rawData: unknown): WorkflowClientMessage {
318
+ const value = typeof rawData === "string" ? rawData : Buffer.isBuffer(rawData) ? rawData.toString("utf8") : "";
319
+ const message = JSON.parse(value) as Readonly<{ kind?: unknown; roomId?: unknown }>;
320
+ if (message.kind === "subscribe" && typeof message.roomId === "string") {
321
+ return { kind: "subscribe", roomId: message.roomId };
322
+ }
323
+ if (message.kind === "unsubscribe" && typeof message.roomId === "string") {
324
+ return { kind: "unsubscribe", roomId: message.roomId };
325
+ }
326
+ throw new Error("Unsupported websocket client message.");
327
+ }
328
+
329
+ private retainWorkflowRoom(roomId: string): void {
330
+ const nextCount = (this.workflowClientCountByRoomId.get(roomId) ?? 0) + 1;
331
+ this.workflowClientCountByRoomId.set(roomId, nextCount);
332
+ if (nextCount === 1) {
333
+ this.sendToChildWorkflowSocket({ kind: "subscribe", roomId });
334
+ }
335
+ }
336
+
337
+ private releaseWorkflowRoom(roomId: string): void {
338
+ const currentCount = this.workflowClientCountByRoomId.get(roomId) ?? 0;
339
+ if (currentCount <= 1) {
340
+ this.workflowClientCountByRoomId.delete(roomId);
341
+ this.sendToChildWorkflowSocket({ kind: "unsubscribe", roomId });
342
+ return;
343
+ }
344
+ this.workflowClientCountByRoomId.set(roomId, currentCount - 1);
345
+ }
346
+
347
+ private sendToChildWorkflowSocket(message: WorkflowClientMessage): void {
348
+ if (!this.childWorkflowSocket || this.childWorkflowSocket.readyState !== WebSocket.OPEN) {
349
+ return;
350
+ }
351
+ this.childWorkflowSocket.send(JSON.stringify(message));
352
+ }
353
+
354
+ private async connectChildWorkflowSocket(): Promise<void> {
355
+ await this.disconnectChildWorkflowSocket();
356
+ if (!this.activeRuntime || this.activeBuildStatus === "building") {
357
+ return;
358
+ }
359
+ const childWorkflowSocket = await this.openChildWorkflowSocket(this.activeRuntime.workflowWebSocketPort);
360
+ this.childWorkflowSocket = childWorkflowSocket;
361
+ childWorkflowSocket.on("message", (rawData) => {
362
+ this.handleChildWorkflowSocketMessage(rawData);
363
+ });
364
+ childWorkflowSocket.on("close", () => {
365
+ if (this.childWorkflowSocket === childWorkflowSocket) {
366
+ this.childWorkflowSocket = null;
367
+ }
368
+ });
369
+ childWorkflowSocket.on("error", () => {
370
+ if (this.childWorkflowSocket === childWorkflowSocket) {
371
+ this.childWorkflowSocket = null;
372
+ }
373
+ });
374
+ for (const roomId of this.workflowClientCountByRoomId.keys()) {
375
+ this.sendToChildWorkflowSocket({ kind: "subscribe", roomId });
376
+ }
377
+ }
378
+
379
+ private openChildWorkflowSocket(workflowWebSocketPort: number): Promise<WebSocket> {
380
+ return new Promise<WebSocket>((resolve, reject) => {
381
+ const childWorkflowUrl = `ws://127.0.0.1:${workflowWebSocketPort}${ApiPaths.workflowWebsocket()}`;
382
+ const socket = new WebSocket(childWorkflowUrl);
383
+ socket.once("open", () => {
384
+ resolve(socket);
385
+ });
386
+ socket.once("error", (error) => {
387
+ socket.close();
388
+ reject(error);
389
+ });
390
+ });
391
+ }
392
+
393
+ private async disconnectChildWorkflowSocket(): Promise<void> {
394
+ if (!this.childWorkflowSocket) {
395
+ return;
396
+ }
397
+ const socket = this.childWorkflowSocket;
398
+ this.childWorkflowSocket = null;
399
+ await new Promise<void>((resolve) => {
400
+ socket.once("close", () => {
401
+ resolve();
402
+ });
403
+ socket.close();
404
+ });
405
+ }
406
+
407
+ private handleChildWorkflowSocketMessage(rawData: unknown): void {
408
+ const text = typeof rawData === "string" ? rawData : Buffer.isBuffer(rawData) ? rawData.toString("utf8") : "";
409
+ if (text.trim().length === 0) {
410
+ return;
411
+ }
412
+ try {
413
+ const message = JSON.parse(text) as Readonly<{
414
+ event?: Readonly<{ workflowId?: unknown }>;
415
+ kind?: unknown;
416
+ message?: unknown;
417
+ workflowId?: unknown;
418
+ }>;
419
+ if (message.kind === "event" && typeof message.event?.workflowId === "string") {
420
+ this.broadcastWorkflowTextToRoom(message.event.workflowId, text);
421
+ return;
422
+ }
423
+ if (
424
+ (message.kind === "workflowChanged" ||
425
+ message.kind === "devBuildStarted" ||
426
+ message.kind === "devBuildCompleted" ||
427
+ message.kind === "devBuildFailed") &&
428
+ typeof message.workflowId === "string"
429
+ ) {
430
+ this.broadcastWorkflowTextToRoom(message.workflowId, text);
431
+ return;
432
+ }
433
+ if (message.kind === "error" && typeof message.message === "string") {
434
+ this.broadcastWorkflowTextToAll(text);
435
+ }
436
+ } catch {
437
+ // Ignore malformed runtime workflow websocket messages.
438
+ }
439
+ }
440
+
441
+ private broadcastWorkflowTextToRoom(roomId: string, text: string): void {
442
+ for (const [client, roomIds] of this.roomIdsByWorkflowClient) {
443
+ if (client.readyState !== WebSocket.OPEN || !roomIds.has(roomId)) {
444
+ continue;
445
+ }
446
+ client.send(text);
447
+ }
448
+ }
449
+
450
+ private broadcastWorkflowTextToAll(text: string): void {
451
+ for (const client of this.workflowClients) {
452
+ if (client.readyState === WebSocket.OPEN) {
453
+ client.send(text);
454
+ }
455
+ }
456
+ }
457
+ }
@@ -0,0 +1,10 @@
1
+ import { CliDevProxyServer } from "./CliDevProxyServer";
2
+ import { ListenPortConflictDescriber } from "./ListenPortConflictDescriber";
3
+
4
+ export class CliDevProxyServerFactory {
5
+ private readonly listenPortConflictDescriber = new ListenPortConflictDescriber();
6
+
7
+ create(gatewayPort: number): CliDevProxyServer {
8
+ return new CliDevProxyServer(gatewayPort, this.listenPortConflictDescriber);
9
+ }
10
+ }
@@ -0,0 +1,44 @@
1
+ import { CodemationPluginDiscovery, AppConfigLoader } from "@codemation/host/server";
2
+
3
+ import { LoopbackPortAllocator } from "./LoopbackPortAllocator";
4
+ import { DevApiRuntimeHost } from "./DevApiRuntimeHost";
5
+ import { DevApiRuntimeServer } from "./DevApiRuntimeServer";
6
+ import type { DevApiRuntimeFactoryArgs, DevApiRuntimeServerHandle } from "./DevApiRuntimeTypes";
7
+
8
+ export type { DevApiRuntimeContext, DevApiRuntimeFactoryArgs, DevApiRuntimeServerHandle } from "./DevApiRuntimeTypes";
9
+
10
+ export class DevApiRuntimeFactory {
11
+ constructor(
12
+ private readonly portAllocator: LoopbackPortAllocator,
13
+ private readonly configLoader: AppConfigLoader,
14
+ private readonly pluginDiscovery: CodemationPluginDiscovery,
15
+ ) {}
16
+
17
+ async create(args: DevApiRuntimeFactoryArgs): Promise<DevApiRuntimeServerHandle> {
18
+ const httpPort = await this.portAllocator.allocate();
19
+ const workflowWebSocketPort = await this.portAllocator.allocate();
20
+ const runtime = new DevApiRuntimeServer(
21
+ httpPort,
22
+ workflowWebSocketPort,
23
+ new DevApiRuntimeHost(this.configLoader, this.pluginDiscovery, {
24
+ consumerRoot: args.consumerRoot,
25
+ env: {
26
+ ...args.env,
27
+ CODEMATION_WS_PORT: String(workflowWebSocketPort),
28
+ NEXT_PUBLIC_CODEMATION_WS_PORT: String(workflowWebSocketPort),
29
+ },
30
+ runtimeWorkingDirectory: args.runtimeWorkingDirectory,
31
+ }),
32
+ );
33
+ const context = await runtime.start();
34
+ return {
35
+ buildVersion: context.buildVersion,
36
+ httpPort,
37
+ stop: async () => {
38
+ await runtime.stop();
39
+ },
40
+ workflowIds: context.workflowIds,
41
+ workflowWebSocketPort,
42
+ };
43
+ }
44
+ }
@@ -0,0 +1,130 @@
1
+ import type { CodemationPlugin } from "@codemation/host";
2
+ import {
3
+ AppContainerFactory,
4
+ AppContainerLifecycle,
5
+ CodemationPluginListMerger,
6
+ FrontendRuntime,
7
+ } from "@codemation/host/next/server";
8
+ import {
9
+ AppConfigLoader,
10
+ CodemationPluginDiscovery,
11
+ type CodemationResolvedPluginPackage,
12
+ } from "@codemation/host/server";
13
+ import { access } from "node:fs/promises";
14
+ import path from "node:path";
15
+ import process from "node:process";
16
+ import { CodemationTsyringeTypeInfoRegistrar } from "@codemation/host-src/presentation/server/CodemationTsyringeTypeInfoRegistrar";
17
+
18
+ import type { DevApiRuntimeContext } from "./DevApiRuntimeTypes";
19
+
20
+ export class DevApiRuntimeHost {
21
+ private readonly pluginListMerger = new CodemationPluginListMerger();
22
+ private contextPromise: Promise<DevApiRuntimeContext> | null = null;
23
+
24
+ constructor(
25
+ private readonly configLoader: AppConfigLoader,
26
+ private readonly pluginDiscovery: CodemationPluginDiscovery,
27
+ private readonly args: Readonly<{
28
+ consumerRoot: string;
29
+ env: NodeJS.ProcessEnv;
30
+ runtimeWorkingDirectory: string;
31
+ }>,
32
+ ) {}
33
+
34
+ async prepare(): Promise<DevApiRuntimeContext> {
35
+ if (!this.contextPromise) {
36
+ this.contextPromise = this.createContext();
37
+ }
38
+ return await this.contextPromise;
39
+ }
40
+
41
+ async stop(): Promise<void> {
42
+ const contextPromise = this.contextPromise;
43
+ this.contextPromise = null;
44
+ if (!contextPromise) {
45
+ return;
46
+ }
47
+ const context = await contextPromise;
48
+ await context.container.resolve(AppContainerLifecycle).stop();
49
+ }
50
+
51
+ private async createContext(): Promise<DevApiRuntimeContext> {
52
+ const consumerRoot = path.resolve(this.args.consumerRoot);
53
+ const repoRoot = await this.detectWorkspaceRoot(consumerRoot);
54
+ const prismaCliOverride = await this.resolvePrismaCliOverride();
55
+ const hostPackageRoot = path.resolve(repoRoot, "packages", "host");
56
+ const env = { ...this.args.env };
57
+ if (prismaCliOverride) {
58
+ env.CODEMATION_PRISMA_CLI_PATH = prismaCliOverride;
59
+ }
60
+ env.CODEMATION_HOST_PACKAGE_ROOT = hostPackageRoot;
61
+ env.CODEMATION_PRISMA_CONFIG_PATH = path.resolve(hostPackageRoot, "prisma.config.ts");
62
+ env.CODEMATION_CONSUMER_ROOT = consumerRoot;
63
+ const configResolution = await this.configLoader.load({
64
+ consumerRoot,
65
+ repoRoot,
66
+ env,
67
+ });
68
+ const discoveredPlugins = await this.loadDiscoveredPlugins(consumerRoot);
69
+ const appConfig = {
70
+ ...configResolution.appConfig,
71
+ env,
72
+ plugins:
73
+ discoveredPlugins.length > 0
74
+ ? this.pluginListMerger.merge(configResolution.appConfig.plugins, discoveredPlugins)
75
+ : configResolution.appConfig.plugins,
76
+ };
77
+ const container = await new AppContainerFactory().create({
78
+ appConfig,
79
+ sharedWorkflowWebsocketServer: null,
80
+ });
81
+ const typeInfoRegistrar = new CodemationTsyringeTypeInfoRegistrar(container);
82
+ typeInfoRegistrar.registerWorkflowDefinitions(appConfig.workflows ?? []);
83
+ await container.resolve(FrontendRuntime).start();
84
+ return {
85
+ buildVersion: this.createBuildVersion(),
86
+ container,
87
+ consumerRoot,
88
+ repoRoot,
89
+ workflowIds: appConfig.workflows.map((workflow) => workflow.id),
90
+ workflowSources: appConfig.workflowSources,
91
+ };
92
+ }
93
+
94
+ private async loadDiscoveredPlugins(consumerRoot: string): Promise<ReadonlyArray<CodemationPlugin>> {
95
+ const resolvedPackages = await this.pluginDiscovery.resolvePlugins(consumerRoot);
96
+ return resolvedPackages.map((resolvedPackage: CodemationResolvedPluginPackage) => resolvedPackage.plugin);
97
+ }
98
+
99
+ private async detectWorkspaceRoot(startDirectory: string): Promise<string> {
100
+ let currentDirectory = path.resolve(startDirectory);
101
+ while (true) {
102
+ if (await this.exists(path.resolve(currentDirectory, "pnpm-workspace.yaml"))) {
103
+ return currentDirectory;
104
+ }
105
+ const parentDirectory = path.dirname(currentDirectory);
106
+ if (parentDirectory === currentDirectory) {
107
+ return startDirectory;
108
+ }
109
+ currentDirectory = parentDirectory;
110
+ }
111
+ }
112
+
113
+ private async resolvePrismaCliOverride(): Promise<string | null> {
114
+ const candidate = path.resolve(this.args.runtimeWorkingDirectory, "node_modules", "prisma", "build", "index.js");
115
+ return (await this.exists(candidate)) ? candidate : null;
116
+ }
117
+
118
+ private createBuildVersion(): string {
119
+ return `${Date.now()}-${process.pid}`;
120
+ }
121
+
122
+ private async exists(filePath: string): Promise<boolean> {
123
+ try {
124
+ await access(filePath);
125
+ return true;
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+ }