@codemation/cli 0.0.4 → 0.0.7

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-900C8Din.js → CliBin-Bx1lFBi5.js} +1125 -340
  3. package/dist/bin.js +1 -1
  4. package/dist/index.d.ts +669 -197
  5. package/dist/index.js +1 -1
  6. package/package.json +9 -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 +302 -158
  11. package/src/commands/ServeWebCommand.ts +26 -1
  12. package/src/commands/ServeWorkerCommand.ts +46 -30
  13. package/src/commands/devCommandLifecycle.types.ts +7 -9
  14. package/src/database/ConsumerDatabaseConnectionResolver.ts +55 -9
  15. package/src/database/DatabaseMigrationsApplyService.ts +2 -2
  16. package/src/dev/Builder.ts +3 -14
  17. package/src/dev/CliDevProxyServer.ts +447 -0
  18. package/src/dev/CliDevProxyServerFactory.ts +7 -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 +8 -4
  26. package/src/dev/DevNextHostEnvironmentBuilder.ts +65 -3
  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 +2 -4
  31. package/src/dev/DevSourceChangeClassifier.ts +59 -0
  32. package/src/dev/WatchRootsResolver.ts +6 -4
  33. package/src/runtime/NextHostConsumerServerCommandFactory.ts +11 -2
  34. package/src/runtime/TypeScriptRuntimeConfigurator.ts +7 -0
  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
@@ -1,25 +1,27 @@
1
1
  import type { Logger } from "@codemation/host/next/server";
2
- import { CodemationPluginDiscovery } from "@codemation/host/server";
3
2
  import { spawn, type ChildProcess } from "node:child_process";
4
3
  import { createRequire } from "node:module";
5
4
  import path from "node:path";
6
5
  import process from "node:process";
7
6
 
8
7
  import type { DatabaseMigrationsApplyService } from "../database/DatabaseMigrationsApplyService";
8
+ import type { DevApiRuntimeFactory, DevApiRuntimeServerHandle } from "../dev/DevApiRuntimeFactory";
9
9
  import type { DevBootstrapSummaryFetcher } from "../dev/DevBootstrapSummaryFetcher";
10
+ import type { CliDevProxyServer } from "../dev/CliDevProxyServer";
11
+ import type { CliDevProxyServerFactory } from "../dev/CliDevProxyServerFactory";
10
12
  import type { DevCliBannerRenderer } from "../dev/DevCliBannerRenderer";
11
13
  import type { DevConsumerPublishBootstrap } from "../dev/DevConsumerPublishBootstrap";
12
14
  import { ConsumerEnvDotenvFilePredicate } from "../dev/ConsumerEnvDotenvFilePredicate";
15
+ import type { DevRebuildQueueFactory } from "../dev/DevRebuildQueueFactory";
13
16
  import type { DevSourceWatcher } from "../dev/DevSourceWatcher";
14
17
  import { DevSessionServices } from "../dev/DevSessionServices";
15
18
  import { DevLockFactory } from "../dev/Factory";
16
19
  import { DevTrackedProcessTreeKiller } from "../dev/DevTrackedProcessTreeKiller";
17
20
  import { DevSourceWatcherFactory } from "../dev/Runner";
21
+ import type { DevResolvedAuthSettings } from "../dev/DevAuthSettingsLoader";
18
22
  import { CliPathResolver, type CliPaths } from "../path/CliPathResolver";
19
- import { TypeScriptRuntimeConfigurator } from "../runtime/TypeScriptRuntimeConfigurator";
20
23
  import { NextHostConsumerServerCommandFactory } from "../runtime/NextHostConsumerServerCommandFactory";
21
-
22
- import type { DevResolvedAuthSettings } from "../dev/DevAuthSettingsLoader";
24
+ import { TypeScriptRuntimeConfigurator } from "../runtime/TypeScriptRuntimeConfigurator";
23
25
 
24
26
  import type { DevMode, DevMutableProcessState, DevPreparedRuntime } from "./devCommandLifecycle.types";
25
27
 
@@ -28,7 +30,6 @@ export class DevCommand {
28
30
 
29
31
  constructor(
30
32
  private readonly pathResolver: CliPathResolver,
31
- private readonly pluginDiscovery: CodemationPluginDiscovery,
32
33
  private readonly tsRuntime: TypeScriptRuntimeConfigurator,
33
34
  private readonly devLockFactory: DevLockFactory,
34
35
  private readonly devSourceWatcherFactory: DevSourceWatcherFactory,
@@ -41,15 +42,18 @@ export class DevCommand {
41
42
  private readonly consumerEnvDotenvFilePredicate: ConsumerEnvDotenvFilePredicate,
42
43
  private readonly devTrackedProcessTreeKiller: DevTrackedProcessTreeKiller,
43
44
  private readonly nextHostConsumerServerCommandFactory: NextHostConsumerServerCommandFactory,
45
+ private readonly devApiRuntimeFactory: DevApiRuntimeFactory,
46
+ private readonly cliDevProxyServerFactory: CliDevProxyServerFactory,
47
+ private readonly devRebuildQueueFactory: DevRebuildQueueFactory,
44
48
  ) {}
45
49
 
46
- async execute(consumerRoot: string): Promise<void> {
47
- const paths = await this.pathResolver.resolve(consumerRoot);
50
+ async execute(args: Readonly<{ consumerRoot: string; watchFramework?: boolean }>): Promise<void> {
51
+ const paths = await this.pathResolver.resolve(args.consumerRoot);
48
52
  this.devCliBannerRenderer.renderBrandHeader();
49
53
  this.tsRuntime.configure(paths.repoRoot);
50
54
  await this.databaseMigrationsApplyService.applyForConsumer(paths.consumerRoot);
51
55
  await this.devConsumerPublishBootstrap.ensurePublished(paths);
52
- const devMode = this.resolveDevModeFromEnv();
56
+ const devMode = this.resolveDevMode(args);
53
57
  const { nextPort, gatewayPort } = await this.session.sessionPorts.resolve({
54
58
  devMode,
55
59
  portEnv: process.env.PORT,
@@ -58,35 +62,42 @@ export class DevCommand {
58
62
  const devLock = this.devLockFactory.create();
59
63
  await devLock.acquire({
60
64
  consumerRoot: paths.consumerRoot,
61
- nextPort: devMode === "framework" ? nextPort : gatewayPort,
65
+ nextPort: devMode === "watch-framework" ? nextPort : gatewayPort,
62
66
  });
63
67
  const authSettings = await this.session.devAuthLoader.loadForConsumer(paths.consumerRoot);
64
68
  const watcher = this.devSourceWatcherFactory.create();
69
+ const processState = this.createInitialProcessState();
70
+ let proxyServer: CliDevProxyServer | null = null;
65
71
  try {
66
72
  const prepared = await this.prepareDevRuntime(paths, devMode, nextPort, gatewayPort, authSettings);
67
- const processState = this.createInitialProcessState();
68
73
  const stopPromise = this.wireStopPromise(processState);
69
- const uiProxyBase = await this.startConsumerUiProxyWhenNeeded(prepared, processState);
74
+ const uiProxyBase = await this.startPackagedUiWhenNeeded(prepared, processState);
75
+ proxyServer = await this.startProxyServer(prepared.gatewayPort, uiProxyBase);
70
76
  const gatewayBaseUrl = this.gatewayBaseHttpUrl(gatewayPort);
71
- await this.spawnGatewayChildAndWaitForHealth(prepared, processState, gatewayBaseUrl, uiProxyBase);
77
+ await this.bootInitialRuntime(prepared, processState, proxyServer);
72
78
  await this.session.devHttpProbe.waitUntilBootstrapSummaryReady(gatewayBaseUrl);
73
79
  const initialSummary = await this.devBootstrapSummaryFetcher.fetch(gatewayBaseUrl);
74
80
  if (initialSummary) {
75
81
  this.devCliBannerRenderer.renderRuntimeSummary(initialSummary);
76
82
  }
77
- this.bindShutdownSignalsToChildProcesses(processState);
78
- this.spawnFrameworkNextHostWhenNeeded(prepared, processState, gatewayBaseUrl);
79
- await this.startWatcherForSourceRestart(prepared, watcher, devMode, gatewayBaseUrl);
80
- this.logConsumerDevHintWhenNeeded(devMode, gatewayPort);
83
+ this.bindShutdownSignalsToChildProcesses(processState, proxyServer);
84
+ await this.spawnDevUiWhenNeeded(prepared, processState, gatewayBaseUrl);
85
+ await this.startWatcherForSourceRestart(prepared, processState, watcher, devMode, gatewayBaseUrl, proxyServer);
86
+ this.logPackagedUiDevHintWhenNeeded(devMode, gatewayPort);
81
87
  await stopPromise;
82
88
  } finally {
89
+ processState.stopRequested = true;
90
+ await this.stopLiveProcesses(processState, proxyServer);
83
91
  await watcher.stop();
84
92
  await devLock.release();
85
93
  }
86
94
  }
87
95
 
88
- private resolveDevModeFromEnv(): DevMode {
89
- return process.env.CODEMATION_DEV_MODE === "framework" ? "framework" : "consumer";
96
+ private resolveDevMode(args: Readonly<{ watchFramework?: boolean }>): DevMode {
97
+ if (args.watchFramework === true || process.env.CODEMATION_DEV_MODE === "framework") {
98
+ return "watch-framework";
99
+ }
100
+ return "packaged-ui";
90
101
  }
91
102
 
92
103
  private async prepareDevRuntime(
@@ -99,19 +110,7 @@ export class DevCommand {
99
110
  const developmentServerToken = this.session.devAuthLoader.resolveDevelopmentServerToken(
100
111
  process.env.CODEMATION_DEV_SERVER_TOKEN,
101
112
  );
102
- const gatewayEntrypoint = await this.session.runtimeEntrypointResolver.resolve({
103
- packageName: "@codemation/dev-gateway",
104
- repoRoot: paths.repoRoot,
105
- sourceEntrypoint: "packages/dev-gateway/src/bin.ts",
106
- });
107
- const runtimeEntrypoint = await this.session.runtimeEntrypointResolver.resolve({
108
- packageName: "@codemation/runtime-dev",
109
- repoRoot: paths.repoRoot,
110
- sourceEntrypoint: "packages/runtime-dev/src/bin.ts",
111
- });
112
- const runtimeWorkingDirectory = paths.repoRoot ?? paths.consumerRoot;
113
113
  const consumerEnv = this.session.consumerEnvLoader.load(paths.consumerRoot);
114
- const discoveredPluginPackagesJson = JSON.stringify(await this.pluginDiscovery.discover(paths.consumerRoot));
115
114
  return {
116
115
  paths,
117
116
  devMode,
@@ -119,19 +118,17 @@ export class DevCommand {
119
118
  gatewayPort,
120
119
  authSettings,
121
120
  developmentServerToken,
122
- gatewayEntrypoint,
123
- runtimeEntrypoint,
124
- runtimeWorkingDirectory,
125
- discoveredPluginPackagesJson,
126
121
  consumerEnv,
127
122
  };
128
123
  }
129
124
 
130
125
  private createInitialProcessState(): DevMutableProcessState {
131
126
  return {
132
- currentGateway: null,
133
- currentNextHost: null,
134
- currentUiNext: null,
127
+ currentDevUi: null,
128
+ currentPackagedUi: null,
129
+ currentPackagedUiBaseUrl: null,
130
+ currentRuntime: null,
131
+ isRestartingUi: false,
135
132
  stopRequested: false,
136
133
  stopResolve: null,
137
134
  stopReject: null,
@@ -149,20 +146,28 @@ export class DevCommand {
149
146
  return `http://127.0.0.1:${gatewayPort}`;
150
147
  }
151
148
 
152
- /**
153
- * Consumer mode: run `next start` for the host UI and wait until it responds, so the gateway can proxy to it.
154
- * Framework mode: no separate UI child (Next runs in dev later).
155
- */
156
- private async startConsumerUiProxyWhenNeeded(
149
+ private async startPackagedUiWhenNeeded(
157
150
  prepared: DevPreparedRuntime,
158
151
  state: DevMutableProcessState,
159
152
  ): Promise<string> {
160
- if (prepared.devMode !== "consumer") {
153
+ if (prepared.devMode !== "packaged-ui") {
161
154
  return "";
162
155
  }
163
156
  const websocketPort = prepared.gatewayPort;
164
- const uiPort = await this.session.loopbackPortAllocator.allocate();
165
- const uiProxyBase = `http://127.0.0.1:${uiPort}`;
157
+ const uiProxyBase =
158
+ state.currentPackagedUiBaseUrl ?? `http://127.0.0.1:${await this.session.loopbackPortAllocator.allocate()}`;
159
+ state.currentPackagedUiBaseUrl = uiProxyBase;
160
+ await this.spawnPackagedUi(prepared, state, prepared.authSettings, websocketPort, uiProxyBase);
161
+ return uiProxyBase;
162
+ }
163
+
164
+ private async spawnPackagedUi(
165
+ prepared: DevPreparedRuntime,
166
+ state: DevMutableProcessState,
167
+ authSettings: DevResolvedAuthSettings,
168
+ websocketPort: number,
169
+ uiProxyBase: string,
170
+ ): Promise<void> {
166
171
  const nextHostPackageJsonPath = this.require.resolve("@codemation/next-host/package.json");
167
172
  const nextHostRoot = path.dirname(nextHostPackageJsonPath);
168
173
  const nextHostCommand = await this.nextHostConsumerServerCommandFactory.create({ nextHostRoot });
@@ -172,94 +177,61 @@ export class DevCommand {
172
177
  "output",
173
178
  "current.json",
174
179
  );
175
- state.currentUiNext = spawn(nextHostCommand.command, nextHostCommand.args, {
180
+ const uiPort = Number(new URL(uiProxyBase).port);
181
+ const nextHostEnvironment = this.session.nextHostEnvBuilder.buildConsumerUiProxy({
182
+ authConfigJson: authSettings.authConfigJson,
183
+ authSecret: authSettings.authSecret,
184
+ consumerRoot: prepared.paths.consumerRoot,
185
+ consumerOutputManifestPath,
186
+ developmentServerToken: prepared.developmentServerToken,
187
+ nextPort: uiPort,
188
+ publicBaseUrl: this.gatewayBaseHttpUrl(prepared.gatewayPort),
189
+ runtimeDevUrl: this.gatewayBaseHttpUrl(prepared.gatewayPort),
190
+ skipUiAuth: authSettings.skipUiAuth,
191
+ websocketPort,
192
+ });
193
+ state.currentPackagedUi = spawn(nextHostCommand.command, nextHostCommand.args, {
176
194
  cwd: nextHostCommand.cwd,
177
195
  ...this.devDetachedChildSpawnOptions(),
178
- env: {
179
- ...process.env,
180
- ...prepared.consumerEnv,
181
- PORT: String(uiPort),
182
- CODEMATION_AUTH_CONFIG_JSON: prepared.authSettings.authConfigJson,
183
- CODEMATION_CONSUMER_ROOT: prepared.paths.consumerRoot,
184
- CODEMATION_CONSUMER_OUTPUT_MANIFEST_PATH: consumerOutputManifestPath,
185
- CODEMATION_SKIP_UI_AUTH: prepared.authSettings.skipUiAuth ? "true" : "false",
186
- AUTH_SECRET: prepared.authSettings.authSecret,
187
- NEXTAUTH_SECRET: prepared.authSettings.authSecret,
188
- NEXT_PUBLIC_CODEMATION_SKIP_UI_AUTH: prepared.authSettings.skipUiAuth ? "true" : "false",
189
- CODEMATION_WS_PORT: String(websocketPort),
190
- NEXT_PUBLIC_CODEMATION_WS_PORT: String(websocketPort),
191
- CODEMATION_DEV_SERVER_TOKEN: prepared.developmentServerToken,
192
- NODE_OPTIONS: this.session.sourceMapNodeOptions.appendToNodeOptions(process.env.NODE_OPTIONS),
193
- WS_NO_BUFFER_UTIL: "1",
194
- WS_NO_UTF_8_VALIDATE: "1",
195
- },
196
+ env: nextHostEnvironment,
196
197
  });
197
- state.currentUiNext.on("error", (error) => {
198
- if (!state.stopRequested) {
199
- state.stopRequested = true;
200
- state.stopReject?.(error instanceof Error ? error : new Error(String(error)));
198
+ state.currentPackagedUi.on("error", (error) => {
199
+ if (state.stopRequested || state.isRestartingUi) {
200
+ return;
201
201
  }
202
+ state.stopRequested = true;
203
+ state.stopReject?.(error instanceof Error ? error : new Error(String(error)));
202
204
  });
203
- state.currentUiNext.on("exit", (code) => {
204
- if (state.stopRequested) {
205
+ state.currentPackagedUi.on("exit", (code) => {
206
+ if (state.stopRequested || state.isRestartingUi) {
205
207
  return;
206
208
  }
207
209
  state.stopRequested = true;
208
- if (state.currentGateway?.exitCode === null) {
209
- void this.devTrackedProcessTreeKiller.killProcessTreeRootedAt(state.currentGateway);
210
- }
211
- state.stopReject?.(new Error(`next start (consumer UI) exited unexpectedly with code ${code ?? 0}.`));
210
+ state.stopReject?.(new Error(`next start (packaged UI) exited unexpectedly with code ${code ?? 0}.`));
212
211
  });
213
212
  await this.session.devHttpProbe.waitUntilUrlRespondsOk(`${uiProxyBase}/`);
214
- return uiProxyBase;
215
213
  }
216
214
 
217
- private async spawnGatewayChildAndWaitForHealth(
215
+ private async startProxyServer(gatewayPort: number, uiProxyBase: string): Promise<CliDevProxyServer> {
216
+ const proxyServer = this.cliDevProxyServerFactory.create(gatewayPort);
217
+ proxyServer.setUiProxyTarget(uiProxyBase.length > 0 ? uiProxyBase : null);
218
+ await proxyServer.start();
219
+ await this.session.devHttpProbe.waitUntilGatewayHealthy(this.gatewayBaseHttpUrl(gatewayPort));
220
+ return proxyServer;
221
+ }
222
+
223
+ private async bootInitialRuntime(
218
224
  prepared: DevPreparedRuntime,
219
225
  state: DevMutableProcessState,
220
- gatewayBaseUrl: string,
221
- uiProxyBase: string,
226
+ proxyServer: CliDevProxyServer,
222
227
  ): Promise<void> {
223
- const gatewayProcessEnv = this.session.consumerEnvLoader.mergeIntoProcessEnvironment(
224
- process.env,
225
- prepared.consumerEnv,
226
- );
227
- state.currentGateway = spawn(prepared.gatewayEntrypoint.command, prepared.gatewayEntrypoint.args, {
228
- cwd: prepared.runtimeWorkingDirectory,
229
- ...this.devDetachedChildSpawnOptions(),
230
- env: {
231
- ...gatewayProcessEnv,
232
- ...prepared.gatewayEntrypoint.env,
233
- CODEMATION_DEV_GATEWAY_HTTP_PORT: String(prepared.gatewayPort),
234
- CODEMATION_RUNTIME_CHILD_BIN: prepared.runtimeEntrypoint.command,
235
- CODEMATION_RUNTIME_CHILD_ARGS_JSON: JSON.stringify(prepared.runtimeEntrypoint.args),
236
- CODEMATION_RUNTIME_CHILD_ENV_JSON: JSON.stringify(prepared.runtimeEntrypoint.env),
237
- CODEMATION_RUNTIME_CHILD_CWD: prepared.runtimeWorkingDirectory,
238
- CODEMATION_CONSUMER_ROOT: prepared.paths.consumerRoot,
239
- CODEMATION_DISCOVERED_PLUGIN_PACKAGES_JSON: prepared.discoveredPluginPackagesJson,
240
- CODEMATION_PREFER_PLUGIN_SOURCE_ENTRY: "true",
241
- CODEMATION_DEV_SERVER_TOKEN: prepared.developmentServerToken,
242
- CODEMATION_SKIP_STARTUP_MIGRATIONS: "true",
243
- NODE_OPTIONS: this.session.sourceMapNodeOptions.appendToNodeOptions(process.env.NODE_OPTIONS),
244
- WS_NO_BUFFER_UTIL: "1",
245
- WS_NO_UTF_8_VALIDATE: "1",
246
- ...(uiProxyBase.length > 0 ? { CODEMATION_DEV_UI_PROXY_TARGET: uiProxyBase } : {}),
247
- },
228
+ const runtime = await this.createRuntime(prepared);
229
+ state.currentRuntime = runtime;
230
+ await proxyServer.activateRuntime({
231
+ httpPort: runtime.httpPort,
232
+ workflowWebSocketPort: runtime.workflowWebSocketPort,
248
233
  });
249
- state.currentGateway.on("error", (error) => {
250
- if (!state.stopRequested) {
251
- state.stopRequested = true;
252
- state.stopReject?.(error instanceof Error ? error : new Error(String(error)));
253
- }
254
- });
255
- state.currentGateway.on("exit", (code) => {
256
- if (state.stopRequested) {
257
- return;
258
- }
259
- state.stopRequested = true;
260
- state.stopReject?.(new Error(`codemation dev-gateway exited unexpectedly with code ${code ?? 0}.`));
261
- });
262
- await this.session.devHttpProbe.waitUntilGatewayHealthy(gatewayBaseUrl);
234
+ proxyServer.setBuildStatus("idle");
263
235
  }
264
236
 
265
237
  private devDetachedChildSpawnOptions(): Readonly<{
@@ -272,7 +244,10 @@ export class DevCommand {
272
244
  : { stdio: "inherit", detached: true };
273
245
  }
274
246
 
275
- private bindShutdownSignalsToChildProcesses(state: DevMutableProcessState): void {
247
+ private bindShutdownSignalsToChildProcesses(
248
+ state: DevMutableProcessState,
249
+ proxyServer: CliDevProxyServer | null,
250
+ ): void {
276
251
  let shutdownInProgress = false;
277
252
  const runShutdown = async (): Promise<void> => {
278
253
  if (shutdownInProgress) {
@@ -281,13 +256,7 @@ export class DevCommand {
281
256
  shutdownInProgress = true;
282
257
  state.stopRequested = true;
283
258
  process.stdout.write("\n[codemation] Stopping..\n");
284
- const children: ChildProcess[] = [];
285
- for (const child of [state.currentUiNext, state.currentNextHost, state.currentGateway]) {
286
- if (child && child.exitCode === null && child.signalCode === null) {
287
- children.push(child);
288
- }
289
- }
290
- await Promise.all(children.map((child) => this.devTrackedProcessTreeKiller.killProcessTreeRootedAt(child)));
259
+ await this.stopLiveProcesses(state, proxyServer);
291
260
  process.stdout.write("[codemation] Stopped.\n");
292
261
  state.stopResolve?.();
293
262
  };
@@ -298,64 +267,76 @@ export class DevCommand {
298
267
  }
299
268
  }
300
269
 
301
- /**
302
- * Framework mode: run `next dev` for the Next host with HMR, pointed at the dev gateway runtime URL.
303
- */
304
- private spawnFrameworkNextHostWhenNeeded(
270
+ private async spawnDevUiWhenNeeded(
305
271
  prepared: DevPreparedRuntime,
306
272
  state: DevMutableProcessState,
307
273
  gatewayBaseUrl: string,
308
- ): void {
309
- if (prepared.devMode !== "framework") {
274
+ ): Promise<void> {
275
+ if (prepared.devMode !== "watch-framework") {
310
276
  return;
311
277
  }
278
+ await this.spawnDevUi(prepared, state, gatewayBaseUrl, prepared.authSettings);
279
+ }
280
+
281
+ private async spawnDevUi(
282
+ prepared: DevPreparedRuntime,
283
+ state: DevMutableProcessState,
284
+ gatewayBaseUrl: string,
285
+ authSettings: DevResolvedAuthSettings,
286
+ ): Promise<void> {
312
287
  const websocketPort = prepared.gatewayPort;
313
288
  const nextHostPackageJsonPath = this.require.resolve("@codemation/next-host/package.json");
314
289
  const nextHostRoot = path.dirname(nextHostPackageJsonPath);
315
290
  const nextHostEnvironment = this.session.nextHostEnvBuilder.build({
316
- authConfigJson: prepared.authSettings.authConfigJson,
291
+ authConfigJson: authSettings.authConfigJson,
317
292
  consumerRoot: prepared.paths.consumerRoot,
318
293
  developmentServerToken: prepared.developmentServerToken,
319
294
  nextPort: prepared.nextPort,
320
- skipUiAuth: prepared.authSettings.skipUiAuth,
295
+ skipUiAuth: authSettings.skipUiAuth,
321
296
  websocketPort,
322
297
  runtimeDevUrl: gatewayBaseUrl,
323
298
  });
324
- state.currentNextHost = spawn("pnpm", ["exec", "next", "dev"], {
299
+ state.currentDevUi = spawn("pnpm", ["exec", "next", "dev"], {
325
300
  cwd: nextHostRoot,
326
301
  ...this.devDetachedChildSpawnOptions(),
327
302
  env: nextHostEnvironment,
328
303
  });
329
- state.currentNextHost.on("exit", (code) => {
304
+ state.currentDevUi.on("exit", (code) => {
330
305
  const normalizedCode = code ?? 0;
331
- if (state.stopRequested) {
306
+ if (state.stopRequested || state.isRestartingUi) {
332
307
  return;
333
308
  }
334
309
  if (normalizedCode === 0) {
335
310
  state.stopRequested = true;
336
- if (state.currentGateway?.exitCode === null) {
337
- void this.devTrackedProcessTreeKiller.killProcessTreeRootedAt(state.currentGateway);
338
- }
339
311
  state.stopResolve?.();
340
312
  return;
341
313
  }
342
314
  state.stopRequested = true;
343
315
  state.stopReject?.(new Error(`next host exited with code ${normalizedCode}.`));
344
316
  });
345
- state.currentNextHost.on("error", (error) => {
346
- if (!state.stopRequested) {
347
- state.stopRequested = true;
348
- state.stopReject?.(error instanceof Error ? error : new Error(String(error)));
317
+ state.currentDevUi.on("error", (error) => {
318
+ if (state.stopRequested || state.isRestartingUi) {
319
+ return;
349
320
  }
321
+ state.stopRequested = true;
322
+ state.stopReject?.(error instanceof Error ? error : new Error(String(error)));
350
323
  });
324
+ await this.session.devHttpProbe.waitUntilUrlRespondsOk(`http://127.0.0.1:${prepared.nextPort}/`);
351
325
  }
352
326
 
353
327
  private async startWatcherForSourceRestart(
354
328
  prepared: DevPreparedRuntime,
329
+ state: DevMutableProcessState,
355
330
  watcher: DevSourceWatcher,
356
331
  devMode: DevMode,
357
332
  gatewayBaseUrl: string,
333
+ proxyServer: CliDevProxyServer,
358
334
  ): Promise<void> {
335
+ const rebuildQueue = this.devRebuildQueueFactory.create({
336
+ run: async (request) => {
337
+ await this.runQueuedRebuild(prepared, state, gatewayBaseUrl, proxyServer, request);
338
+ },
339
+ });
359
340
  await watcher.start({
360
341
  roots: this.session.watchRootsResolver.resolve({
361
342
  consumerRoot: prepared.paths.consumerRoot,
@@ -365,32 +346,195 @@ export class DevCommand {
365
346
  onChange: async ({ changedPaths }) => {
366
347
  if (changedPaths.length > 0 && changedPaths.every((p) => this.consumerEnvDotenvFilePredicate.matches(p))) {
367
348
  process.stdout.write(
368
- "\n[codemation] Consumer environment file changed (e.g. .env). Restart the `codemation dev` process so the gateway and runtime pick up updated variables (host `process.env` does not hot-reload).\n",
349
+ "\n[codemation] Consumer environment file changed (e.g. .env). Restart the `codemation dev` process so the runtime picks up updated variables (host `process.env` does not hot-reload).\n",
369
350
  );
370
351
  return;
371
352
  }
372
- process.stdout.write("\n[codemation] Source change detected — rebuilding consumer…\n");
373
- await this.session.sourceRestartCoordinator.runHandshakeAfterSourceChange(
374
- gatewayBaseUrl,
375
- prepared.developmentServerToken,
376
- );
377
- process.stdout.write("[codemation] Waiting for runtime to accept traffic…\n");
378
- await this.session.devHttpProbe.waitUntilBootstrapSummaryReady(gatewayBaseUrl);
379
- const json = await this.devBootstrapSummaryFetcher.fetch(gatewayBaseUrl);
380
- if (json) {
381
- this.devCliBannerRenderer.renderCompact(json);
353
+ try {
354
+ const shouldRepublishConsumerOutput = this.session.sourceChangeClassifier.shouldRepublishConsumerOutput({
355
+ changedPaths,
356
+ consumerRoot: prepared.paths.consumerRoot,
357
+ });
358
+ const shouldRestartUi = this.session.sourceChangeClassifier.requiresUiRestart({
359
+ changedPaths,
360
+ consumerRoot: prepared.paths.consumerRoot,
361
+ });
362
+ process.stdout.write(
363
+ shouldRestartUi
364
+ ? "\n[codemation] Source change detected — rebuilding consumer, restarting the runtime, and restarting the UI…\n"
365
+ : "\n[codemation] Source change detected — rebuilding consumer and restarting the runtime…\n",
366
+ );
367
+ await rebuildQueue.enqueue({
368
+ changedPaths,
369
+ shouldRepublishConsumerOutput,
370
+ shouldRestartUi,
371
+ });
372
+ } catch (error) {
373
+ await this.failDevSessionAfterIrrecoverableSourceError(state, proxyServer, error);
382
374
  }
383
- process.stdout.write("[codemation] Runtime ready.\n");
384
375
  },
385
376
  });
386
377
  }
387
378
 
388
- private logConsumerDevHintWhenNeeded(devMode: DevMode, gatewayPort: number): void {
389
- if (devMode !== "consumer") {
379
+ private async runQueuedRebuild(
380
+ prepared: DevPreparedRuntime,
381
+ state: DevMutableProcessState,
382
+ gatewayBaseUrl: string,
383
+ proxyServer: CliDevProxyServer,
384
+ request: Readonly<{
385
+ changedPaths: ReadonlyArray<string>;
386
+ shouldRepublishConsumerOutput: boolean;
387
+ shouldRestartUi: boolean;
388
+ }>,
389
+ ): Promise<void> {
390
+ proxyServer.setBuildStatus("building");
391
+ proxyServer.broadcastBuildStarted();
392
+ try {
393
+ if (request.shouldRepublishConsumerOutput) {
394
+ await this.devConsumerPublishBootstrap.ensurePublished(prepared.paths);
395
+ }
396
+ await this.stopCurrentRuntime(state, proxyServer);
397
+ process.stdout.write("[codemation] Waiting for runtime to accept traffic…\n");
398
+ const runtime = await this.createRuntime(prepared);
399
+ state.currentRuntime = runtime;
400
+ await proxyServer.activateRuntime({
401
+ httpPort: runtime.httpPort,
402
+ workflowWebSocketPort: runtime.workflowWebSocketPort,
403
+ });
404
+ if (request.shouldRestartUi) {
405
+ await this.restartUiAfterSourceChange(prepared, state, gatewayBaseUrl);
406
+ }
407
+ await this.session.devHttpProbe.waitUntilBootstrapSummaryReady(gatewayBaseUrl);
408
+ const json = await this.devBootstrapSummaryFetcher.fetch(gatewayBaseUrl);
409
+ if (json) {
410
+ this.devCliBannerRenderer.renderCompact(json);
411
+ }
412
+ proxyServer.setBuildStatus("idle");
413
+ proxyServer.broadcastBuildCompleted(runtime.buildVersion);
414
+ process.stdout.write("[codemation] Runtime ready.\n");
415
+ } catch (error) {
416
+ proxyServer.setBuildStatus("idle");
417
+ proxyServer.broadcastBuildFailed(error instanceof Error ? error.message : String(error));
418
+ throw error;
419
+ }
420
+ }
421
+
422
+ private async restartUiAfterSourceChange(
423
+ prepared: DevPreparedRuntime,
424
+ state: DevMutableProcessState,
425
+ gatewayBaseUrl: string,
426
+ ): Promise<void> {
427
+ const refreshedAuthSettings = await this.session.devAuthLoader.loadForConsumer(prepared.paths.consumerRoot);
428
+ process.stdout.write("[codemation] Restarting the UI process to apply source changes…\n");
429
+ state.isRestartingUi = true;
430
+ try {
431
+ if (prepared.devMode === "packaged-ui") {
432
+ await this.restartPackagedUi(prepared, state, refreshedAuthSettings);
433
+ return;
434
+ }
435
+ await this.restartDevUi(prepared, state, gatewayBaseUrl, refreshedAuthSettings);
436
+ } finally {
437
+ state.isRestartingUi = false;
438
+ }
439
+ }
440
+
441
+ private async restartPackagedUi(
442
+ prepared: DevPreparedRuntime,
443
+ state: DevMutableProcessState,
444
+ authSettings: DevResolvedAuthSettings,
445
+ ): Promise<void> {
446
+ if (
447
+ state.currentPackagedUi &&
448
+ state.currentPackagedUi.exitCode === null &&
449
+ state.currentPackagedUi.signalCode === null
450
+ ) {
451
+ await this.devTrackedProcessTreeKiller.killProcessTreeRootedAt(state.currentPackagedUi);
452
+ }
453
+ state.currentPackagedUi = null;
454
+ const uiProxyBaseUrl = state.currentPackagedUiBaseUrl;
455
+ if (!uiProxyBaseUrl) {
456
+ throw new Error("Packaged UI proxy base URL is missing during UI restart.");
457
+ }
458
+ await this.spawnPackagedUi(prepared, state, authSettings, prepared.gatewayPort, uiProxyBaseUrl);
459
+ }
460
+
461
+ private async restartDevUi(
462
+ prepared: DevPreparedRuntime,
463
+ state: DevMutableProcessState,
464
+ gatewayBaseUrl: string,
465
+ authSettings: DevResolvedAuthSettings,
466
+ ): Promise<void> {
467
+ if (state.currentDevUi && state.currentDevUi.exitCode === null && state.currentDevUi.signalCode === null) {
468
+ await this.devTrackedProcessTreeKiller.killProcessTreeRootedAt(state.currentDevUi);
469
+ }
470
+ state.currentDevUi = null;
471
+ await this.spawnDevUi(prepared, state, gatewayBaseUrl, authSettings);
472
+ }
473
+
474
+ private async failDevSessionAfterIrrecoverableSourceError(
475
+ state: DevMutableProcessState,
476
+ proxyServer: CliDevProxyServer | null,
477
+ error: unknown,
478
+ ): Promise<void> {
479
+ const exception = error instanceof Error ? error : new Error(String(error));
480
+ state.stopRequested = true;
481
+ await this.stopLiveProcesses(state, proxyServer);
482
+ state.stopReject?.(exception);
483
+ }
484
+
485
+ private async stopLiveProcesses(state: DevMutableProcessState, proxyServer: CliDevProxyServer | null): Promise<void> {
486
+ await this.stopCurrentRuntime(state, proxyServer);
487
+ const children: ChildProcess[] = [];
488
+ for (const child of [state.currentPackagedUi, state.currentDevUi]) {
489
+ if (child && child.exitCode === null && child.signalCode === null) {
490
+ children.push(child);
491
+ }
492
+ }
493
+ await Promise.all(children.map((child) => this.devTrackedProcessTreeKiller.killProcessTreeRootedAt(child)));
494
+ if (proxyServer) {
495
+ await proxyServer.stop();
496
+ }
497
+ }
498
+
499
+ private async stopCurrentRuntime(
500
+ state: DevMutableProcessState,
501
+ proxyServer: CliDevProxyServer | null,
502
+ ): Promise<void> {
503
+ const runtime = state.currentRuntime;
504
+ state.currentRuntime = null;
505
+ if (proxyServer) {
506
+ await proxyServer.activateRuntime(null);
507
+ }
508
+ if (runtime) {
509
+ await runtime.stop();
510
+ }
511
+ }
512
+
513
+ private async createRuntime(prepared: DevPreparedRuntime): Promise<DevApiRuntimeServerHandle> {
514
+ const runtimeEnvironment = this.session.consumerEnvLoader.mergeIntoProcessEnvironment(
515
+ process.env,
516
+ prepared.consumerEnv,
517
+ );
518
+ return await this.devApiRuntimeFactory.create({
519
+ consumerRoot: prepared.paths.consumerRoot,
520
+ runtimeWorkingDirectory: process.cwd(),
521
+ env: {
522
+ ...runtimeEnvironment,
523
+ CODEMATION_DEV_SERVER_TOKEN: prepared.developmentServerToken,
524
+ CODEMATION_SKIP_STARTUP_MIGRATIONS: "true",
525
+ NODE_OPTIONS: this.session.sourceMapNodeOptions.appendToNodeOptions(process.env.NODE_OPTIONS),
526
+ WS_NO_BUFFER_UTIL: "1",
527
+ WS_NO_UTF_8_VALIDATE: "1",
528
+ },
529
+ });
530
+ }
531
+
532
+ private logPackagedUiDevHintWhenNeeded(devMode: DevMode, gatewayPort: number): void {
533
+ if (devMode !== "packaged-ui") {
390
534
  return;
391
535
  }
392
536
  this.cliLogger.info(
393
- `codemation dev (consumer): open http://127.0.0.1:${gatewayPort} — uses the packaged @codemation/next-host UI. For framework UI HMR, set CODEMATION_DEV_MODE=framework.`,
537
+ `codemation dev: open http://127.0.0.1:${gatewayPort} — this uses the packaged @codemation/next-host UI. Use \`codemation dev --watch-framework\` only when working on the framework UI itself.`,
394
538
  );
395
539
  }
396
540
  }