cdk-local 0.79.0 → 0.80.0

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.
@@ -53,12 +53,13 @@ var StudioEventBus = class {
53
53
  * the studio HTTP server (`startStudioServer`) at `GET /`.
54
54
  *
55
55
  * 3-pane shell (decision D6), framework-free vanilla JS (decision D7):
56
- * - left = target list (from `GET /api/targets`); each runnable
57
- * Lambda has an [Invoke] button and a selected-highlight.
58
- * - center = the WORKSPACE for the selected target: an event composer
59
- * (textarea + Invoke button) with the latest run's Request /
60
- * Response / Logs shown BELOW it, so you can edit and re-invoke
61
- * repeatedly without losing the composer.
56
+ * - left = target list (from `GET /api/targets`); each Lambda has an
57
+ * [Invoke] button, each API a [Start] / [Stop] serve control with a
58
+ * `running :port` indicator (slice C1), plus a selected-highlight.
59
+ * - center = the WORKSPACE for the selected target: for a Lambda, an
60
+ * event composer (textarea + Invoke button) with the latest run's
61
+ * Request / Response / Logs shown below; for an API, a Start/Stop
62
+ * control with the served endpoints + streaming logs.
62
63
  * - right = the timeline (history) of every invocation; clicking a
63
64
  * row loads it back into the workspace.
64
65
  *
@@ -106,6 +107,13 @@ const STUDIO_CSS = `
106
107
  }
107
108
  .target .invoke-btn:hover { background: #6fe097; }
108
109
  .target.sel .invoke-btn { background: #6fe097; }
110
+ .target .stop-btn {
111
+ padding: 2px 10px; font: 11px ui-monospace, Menlo, monospace; font-weight: 700;
112
+ color: #2a0d0d; background: #e0707a; border: 0; border-radius: 3px; cursor: pointer;
113
+ }
114
+ .target .stop-btn:hover { background: #ec8a92; }
115
+ .target .run-dot { color: #7bd88f; font-size: 11px; white-space: nowrap; }
116
+ .target .run-dot.starting { color: #e0b54e; }
109
117
  .empty { padding: 16px 12px; color: #666; }
110
118
  .row {
111
119
  padding: 5px 12px; border-bottom: 1px solid #222; cursor: pointer;
@@ -137,6 +145,8 @@ const STUDIO_CSS = `
137
145
  .section h3 .ok { color: #7bd88f; }
138
146
  .section h3 .bad { color: #e0707a; }
139
147
  .section pre { margin: 0; white-space: pre-wrap; word-break: break-word; color: #cfcfcf; }
148
+ .endpoint { display: block; color: #6aa9ff; text-decoration: none; padding: 2px 0; }
149
+ .endpoint:hover { text-decoration: underline; }
140
150
  #conn { font-size: 11px; }
141
151
  #conn.up { color: #7bd88f; }
142
152
  #conn.down { color: #e0707a; }
@@ -145,10 +155,13 @@ const STUDIO_SCRIPT = `
145
155
  const KIND_LABEL = { lambda: 'Lambda', api: 'API', alb: 'ALB', ecs: 'ECS', agentcore: 'AgentCore' };
146
156
  const rowsById = new Map(); // invocationId -> timeline row element
147
157
  const invById = new Map(); // invocationId -> latest invocation event
148
- const logsById = new Map(); // invocationId -> [log lines]
158
+ const logsById = new Map(); // invocationId / serve targetId -> [log lines]
149
159
  const targetEls = new Map(); // targetId -> left-pane element
160
+ const serveMeta = new Map(); // serve targetId -> { dot, btnSlot } row controls
161
+ const serveState = new Map(); // serve targetId -> { status, endpoints }
150
162
  let active = null; // { id, kind, ta, btn, msg, result }
151
163
  let shownInvId = null; // invocation whose result is in the workspace
164
+ let shownServeId = null; // serve target whose workspace is shown
152
165
 
153
166
  function el(tag, cls, text) {
154
167
  const e = document.createElement(tag);
@@ -169,20 +182,32 @@ const STUDIO_SCRIPT = `
169
182
  pane.appendChild(el('div', 'group-title', group.title));
170
183
  for (const entry of group.entries) {
171
184
  total += 1;
172
- // Slice B: only Lambda targets are runnable from the UI (single-shot
173
- // invoke). Other kinds list but are not yet selectable.
174
- const runnable = group.kind === 'lambda';
185
+ // Lambda targets are single-shot invokes; API targets are
186
+ // long-running serves (slice C1). Other kinds list but are not
187
+ // yet runnable.
188
+ const runnable = group.kind === 'lambda' || group.kind === 'api';
175
189
  const t = el('div', runnable ? 'target runnable' : 'target');
176
190
  const name = el('span', 'name', entry.id);
177
191
  name.title = entry.id; // full path on hover even when truncated
178
192
  t.appendChild(name);
179
193
  t.appendChild(el('span', 'kind', '(' + (KIND_LABEL[group.kind] || group.kind) + ')'));
180
- if (runnable) {
194
+ if (group.kind === 'lambda') {
181
195
  const btn = el('button', 'invoke-btn', 'Invoke');
182
- btn.onclick = (e) => { e.stopPropagation(); selectTarget(entry.id, group.kind); };
196
+ btn.onclick = (e) => { e.stopPropagation(); selectTarget(entry.id, 'lambda'); };
183
197
  t.appendChild(btn);
184
- t.onclick = () => selectTarget(entry.id, group.kind);
198
+ t.onclick = () => selectTarget(entry.id, 'lambda');
199
+ targetEls.set(entry.id, t);
200
+ } else if (group.kind === 'api') {
201
+ // A serve target: a running-state dot + a Start/Stop button
202
+ // slot, both refreshed by updateServeRow on serve events.
203
+ const dot = el('span', 'run-dot');
204
+ const btnSlot = el('span', 'btn-slot');
205
+ t.appendChild(dot);
206
+ t.appendChild(btnSlot);
207
+ t.onclick = () => selectTarget(entry.id, 'api');
185
208
  targetEls.set(entry.id, t);
209
+ serveMeta.set(entry.id, { dot, btnSlot });
210
+ updateServeRow(entry.id);
186
211
  }
187
212
  pane.appendChild(t);
188
213
  }
@@ -193,6 +218,50 @@ const STUDIO_SCRIPT = `
193
218
  }
194
219
  }
195
220
 
221
+ // Pull any already-running serves (e.g. after a UI reload) so the rows
222
+ // and workspace reflect them without waiting for a fresh serve event.
223
+ async function loadRunning() {
224
+ try {
225
+ const res = await fetch('/api/running');
226
+ const data = await res.json();
227
+ for (const s of (data.running || [])) {
228
+ serveState.set(s.targetId, { status: s.status, endpoints: s.endpoints || [] });
229
+ updateServeRow(s.targetId);
230
+ }
231
+ } catch (err) {
232
+ /* best-effort; the serve SSE stream still drives live updates */
233
+ }
234
+ }
235
+
236
+ function firstPort(endpoints) {
237
+ const u = (endpoints || [])[0];
238
+ if (!u) return '';
239
+ const m = /:(\\d+)/.exec(u);
240
+ return m ? ':' + m[1] : '';
241
+ }
242
+
243
+ function updateServeRow(id) {
244
+ const meta = serveMeta.get(id);
245
+ if (!meta) return;
246
+ const st = serveState.get(id);
247
+ const status = st ? st.status : 'stopped';
248
+ const running = status === 'running';
249
+ const starting = status === 'starting';
250
+ meta.dot.textContent = running ? '● ' + firstPort(st.endpoints) : starting ? '○ starting' : '';
251
+ meta.dot.className = 'run-dot' + (starting ? ' starting' : '');
252
+ meta.btnSlot.innerHTML = '';
253
+ const btn = running || starting
254
+ ? el('button', 'stop-btn', 'Stop')
255
+ : el('button', 'invoke-btn', 'Start');
256
+ btn.onclick = (e) => {
257
+ e.stopPropagation();
258
+ if (running || starting) stopServe(id); else startServe(id);
259
+ };
260
+ meta.btnSlot.appendChild(btn);
261
+ // Refresh the workspace if it is showing this serve.
262
+ if (shownServeId === id) renderServeWorkspace(id);
263
+ }
264
+
196
265
  function highlightTarget(id) {
197
266
  document.querySelectorAll('.target.sel').forEach((n) => n.classList.remove('sel'));
198
267
  const t = targetEls.get(id);
@@ -201,7 +270,104 @@ const STUDIO_SCRIPT = `
201
270
 
202
271
  function selectTarget(id, kind) {
203
272
  highlightTarget(id);
204
- renderComposer(id, kind, '{}');
273
+ if (kind === 'api') {
274
+ shownServeId = id;
275
+ shownInvId = null;
276
+ active = null;
277
+ renderServeWorkspace(id);
278
+ } else {
279
+ shownServeId = null;
280
+ renderComposer(id, kind, '{}');
281
+ }
282
+ }
283
+
284
+ async function startServe(id) {
285
+ serveState.set(id, { status: 'starting', endpoints: [] });
286
+ updateServeRow(id);
287
+ try {
288
+ const res = await fetch('/api/run', {
289
+ method: 'POST',
290
+ headers: { 'content-type': 'application/json' },
291
+ body: JSON.stringify({ targetId: id, kind: 'api' }),
292
+ });
293
+ const data = await res.json();
294
+ if (!res.ok) {
295
+ // Roll back the optimistic 'starting' on a rejected start.
296
+ serveState.set(id, { status: 'error', endpoints: [] });
297
+ updateServeRow(id);
298
+ if (shownServeId === id) renderServeWorkspace(id, data.error || ('HTTP ' + res.status));
299
+ }
300
+ // On success the serve SSE 'running' event fills in the endpoints.
301
+ } catch (err) {
302
+ serveState.set(id, { status: 'error', endpoints: [] });
303
+ updateServeRow(id);
304
+ if (shownServeId === id) renderServeWorkspace(id, String(err));
305
+ }
306
+ }
307
+
308
+ async function stopServe(id) {
309
+ try {
310
+ await fetch('/api/stop', {
311
+ method: 'POST',
312
+ headers: { 'content-type': 'application/json' },
313
+ body: JSON.stringify({ targetId: id }),
314
+ });
315
+ // The serve SSE 'stopped' event clears the running state.
316
+ } catch (err) {
317
+ /* the stop SSE event (or a later refresh) reconciles state */
318
+ }
319
+ }
320
+
321
+ function renderServeWorkspace(id, errMsg) {
322
+ const ws = document.getElementById('workspace');
323
+ ws.innerHTML = '';
324
+ const st = serveState.get(id) || { status: 'stopped', endpoints: [] };
325
+ const running = st.status === 'running';
326
+ const starting = st.status === 'starting';
327
+
328
+ const head = el('div', 'composer');
329
+ head.appendChild(el('div', 'target-name', 'Serve ' + id));
330
+ const btn = running || starting
331
+ ? el('button', null, 'Stop')
332
+ : el('button', null, starting ? 'Starting…' : 'Start');
333
+ btn.onclick = () => { if (running || starting) stopServe(id); else startServe(id); };
334
+ head.appendChild(btn);
335
+ if (errMsg) {
336
+ const m = el('div', 'err', errMsg);
337
+ head.appendChild(m);
338
+ }
339
+ ws.appendChild(head);
340
+
341
+ const epSec = el('div', 'section');
342
+ epSec.appendChild(el('h3', null, 'Endpoints'));
343
+ if (running && st.endpoints.length) {
344
+ for (const url of st.endpoints) {
345
+ const link = href(url);
346
+ epSec.appendChild(link);
347
+ }
348
+ } else {
349
+ epSec.appendChild(el('pre', null, starting ? '(starting…)' : '(not running)'));
350
+ }
351
+ ws.appendChild(epSec);
352
+
353
+ const logs = logsById.get(id) || [];
354
+ const logSec = el('div', 'section');
355
+ logSec.appendChild(el('h3', null, 'Logs'));
356
+ logSec.appendChild(el('pre', null, logs.length ? logs.join('\\n') : '(none)'));
357
+ ws.appendChild(logSec);
358
+ }
359
+
360
+ // Build an <a> that opens an http(s) endpoint in a new tab; ws:// URLs
361
+ // are shown as plain text (not navigable in a browser tab).
362
+ function href(url) {
363
+ if (/^https?:/.test(url)) {
364
+ const a = el('a', 'endpoint', url);
365
+ a.href = url;
366
+ a.target = '_blank';
367
+ a.rel = 'noopener';
368
+ return a;
369
+ }
370
+ return el('div', 'endpoint', url);
205
371
  }
206
372
 
207
373
  function renderComposer(id, kind, eventText) {
@@ -350,15 +516,28 @@ const STUDIO_SCRIPT = `
350
516
  es.addEventListener('open', () => { conn.textContent = '● live'; conn.className = 'up'; });
351
517
  es.addEventListener('error', () => { conn.textContent = '● disconnected'; conn.className = 'down'; });
352
518
  es.addEventListener('invocation', (e) => addInvocation(JSON.parse(e.data)));
519
+ es.addEventListener('serve', (e) => onServeEvent(JSON.parse(e.data)));
353
520
  es.addEventListener('log', (e) => {
354
521
  const ev = JSON.parse(e.data);
355
522
  const arr = logsById.get(ev.containerId) || [];
356
523
  arr.push(ev.line);
357
524
  logsById.set(ev.containerId, arr);
358
525
  if (shownInvId === ev.containerId) renderResult(ev.containerId);
526
+ if (shownServeId === ev.containerId) renderServeWorkspace(ev.containerId);
359
527
  });
360
528
  }
361
529
 
530
+ function onServeEvent(ev) {
531
+ // A 'stopped' / 'error' transition clears the running state; otherwise
532
+ // record the latest status + endpoints for the row + workspace.
533
+ if (ev.status === 'stopped' || ev.status === 'error') {
534
+ serveState.set(ev.target, { status: ev.status, endpoints: [] });
535
+ } else {
536
+ serveState.set(ev.target, { status: ev.status, endpoints: ev.endpoints || [] });
537
+ }
538
+ updateServeRow(ev.target);
539
+ }
540
+
362
541
  function initSplitters() {
363
542
  const main = document.querySelector('main');
364
543
  let left = 280, right = 320;
@@ -389,7 +568,7 @@ const STUDIO_SCRIPT = `
389
568
  wire('split-right', (dx, l0, r0) => { right = clamp(r0 - dx); });
390
569
  }
391
570
 
392
- loadTargets();
571
+ loadTargets().then(loadRunning);
393
572
  connect();
394
573
  initSplitters();
395
574
  `;
@@ -418,7 +597,7 @@ function renderStudioHtml(appLabel, cliName) {
418
597
  <main>
419
598
  <section class="pane" id="targets"><h2>Targets</h2></section>
420
599
  <div class="splitter" id="split-left"></div>
421
- <section class="pane" id="workspace"><div class="empty">Pick a Lambda on the left to invoke it.</div></section>
600
+ <section class="pane" id="workspace"><div class="empty">Pick a Lambda to invoke, or an API to serve, on the left.</div></section>
422
601
  <div class="splitter" id="split-right"></div>
423
602
  <section class="pane" id="timeline"><h2>Timeline</h2><div class="empty">No requests yet.</div></section>
424
603
  </main>
@@ -489,7 +668,7 @@ async function startStudioServer(options) {
489
668
  const maxBump = options.maxPortBump ?? 20;
490
669
  const html = renderStudioHtml(options.appLabel, options.cliName);
491
670
  const targetsJson = JSON.stringify({ groups: options.targetGroups });
492
- const server = createServer((req, res) => handleRequest(req, res, options.bus, html, targetsJson, options.onRun));
671
+ const server = createServer((req, res) => handleRequest(req, res, options.bus, html, targetsJson, options));
493
672
  const boundPort = await listenWithBump(server, host, options.port, maxBump);
494
673
  return {
495
674
  url: `http://${host}:${boundPort}`,
@@ -500,7 +679,7 @@ async function startStudioServer(options) {
500
679
  })
501
680
  };
502
681
  }
503
- function handleRequest(req, res, bus, html, targetsJson, onRun) {
682
+ function handleRequest(req, res, bus, html, targetsJson, options) {
504
683
  const path = (req.url ?? "/").split("?")[0];
505
684
  if (req.method === "GET" && (path === "/" || path === "/index.html")) {
506
685
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
@@ -512,25 +691,40 @@ function handleRequest(req, res, bus, html, targetsJson, onRun) {
512
691
  res.end(targetsJson);
513
692
  return;
514
693
  }
694
+ if (req.method === "GET" && path === "/api/running") {
695
+ const running = options.getRunning ? options.getRunning() : { running: [] };
696
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
697
+ res.end(JSON.stringify(running));
698
+ return;
699
+ }
515
700
  if (req.method === "GET" && path === "/api/events") {
516
701
  serveSse(req, res, bus);
517
702
  return;
518
703
  }
519
704
  if (req.method === "POST" && path === "/api/run") {
520
- handleRun(req, res, onRun);
705
+ handleDispatch(req, res, options.onRun);
706
+ return;
707
+ }
708
+ if (req.method === "POST" && path === "/api/stop") {
709
+ handleDispatch(req, res, options.onStop);
521
710
  return;
522
711
  }
523
712
  res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
524
713
  res.end("Not found");
525
714
  }
526
715
  const MAX_RUN_BODY_BYTES = 5 * 1024 * 1024;
527
- /** Reply to `POST /api/run`: parse the JSON body, dispatch via `onRun`. */
528
- async function handleRun(req, res, onRun) {
716
+ /**
717
+ * Reply to a JSON POST endpoint (`/api/run` / `/api/stop`): parse the
718
+ * bounded JSON body and dispatch via `handler`. 501 when no handler is
719
+ * wired (the observe-only shell), 400 on a malformed body, 500 when the
720
+ * handler throws.
721
+ */
722
+ async function handleDispatch(req, res, handler) {
529
723
  const sendJson = (statusCode, payload) => {
530
724
  res.writeHead(statusCode, { "content-type": "application/json; charset=utf-8" });
531
725
  res.end(JSON.stringify(payload));
532
726
  };
533
- if (!onRun) {
727
+ if (!handler) {
534
728
  sendJson(501, { error: "Running targets is not supported by this studio server." });
535
729
  return;
536
730
  }
@@ -542,7 +736,7 @@ async function handleRun(req, res, onRun) {
542
736
  return;
543
737
  }
544
738
  try {
545
- sendJson(200, await onRun(body));
739
+ sendJson(200, await handler(body));
546
740
  } catch (err) {
547
741
  sendJson(500, { error: err instanceof Error ? err.message : String(err) });
548
742
  }
@@ -600,12 +794,16 @@ function serveSse(req, res, bus) {
600
794
  const onLog = (ev) => {
601
795
  safeWrite(`event: log\ndata: ${JSON.stringify(ev)}\n\n`);
602
796
  };
797
+ const onServe = (ev) => {
798
+ safeWrite(`event: serve\ndata: ${JSON.stringify(ev)}\n\n`);
799
+ };
603
800
  function cleanup() {
604
801
  if (closed) return;
605
802
  closed = true;
606
803
  clearInterval(heartbeat);
607
804
  bus.off("invocation", onInvocation);
608
805
  bus.off("log", onLog);
806
+ bus.off("serve", onServe);
609
807
  }
610
808
  function safeWrite(chunk) {
611
809
  if (closed || res.writableEnded || res.destroyed) {
@@ -616,6 +814,7 @@ function serveSse(req, res, bus) {
616
814
  }
617
815
  bus.on("invocation", onInvocation);
618
816
  bus.on("log", onLog);
817
+ bus.on("serve", onServe);
619
818
  req.on("close", cleanup);
620
819
  res.on("close", cleanup);
621
820
  res.on("error", cleanup);
@@ -857,6 +1056,255 @@ function tryParseJson(raw) {
857
1056
  }
858
1057
  }
859
1058
 
1059
+ //#endregion
1060
+ //#region src/local/studio-serve-manager.ts
1061
+ /** Kinds the serve manager can start in this build. */
1062
+ const SERVE_SUPPORTED = ["api"];
1063
+ /** `Server listening on <url>` is the stable ready marker `start-api` prints. */
1064
+ const LISTENING_RE = /Server listening on (\S+)/;
1065
+ /**
1066
+ * Build the studio serve manager. Slice C1 drives a long-running
1067
+ * `cdkl start-api <target>` child — studio is a control plane over the
1068
+ * CLI (the same pattern as the single-shot invoke dispatcher), so it
1069
+ * spawns the SAME headless serve command rather than re-wiring its
1070
+ * internals. This preserves byte-for-byte parity and isolates the
1071
+ * server's process-global behavior in a child.
1072
+ *
1073
+ * `start()` spawns the child with `--port 0` (OS-assigned, collision
1074
+ * free), streams its stdout/stderr onto the bus as `log` events keyed by
1075
+ * the target id, and resolves once the child prints its first
1076
+ * `Server listening on <url>` line — emitting a `serve` `running` event
1077
+ * with the discovered endpoints. `stop()` SIGTERMs the child (SIGKILL
1078
+ * after a grace window) and emits `stopped`.
1079
+ *
1080
+ * Request capture for traffic to the served port (decision D4a / D5) and
1081
+ * full-text log search arrive in slice C2; C1 is lifecycle + log
1082
+ * streaming only.
1083
+ */
1084
+ function createStudioServeManager(config) {
1085
+ const spawnFn = config.spawnFn ?? spawn;
1086
+ const nodeBin = config.nodeBin ?? process.execPath;
1087
+ const clock = config.clock ?? Date.now;
1088
+ const readyTimeoutMs = config.readyTimeoutMs ?? 12e4;
1089
+ const stopGraceMs = config.stopGraceMs ?? 1e4;
1090
+ const setTimeoutFn = config.setTimeoutFn ?? setTimeout;
1091
+ const clearTimeoutFn = config.clearTimeoutFn ?? clearTimeout;
1092
+ const cwd = config.cwd ?? process.cwd();
1093
+ const entries = /* @__PURE__ */ new Map();
1094
+ function publicState(e) {
1095
+ const s = {
1096
+ targetId: e.targetId,
1097
+ kind: e.kind,
1098
+ status: e.status,
1099
+ endpoints: [...e.endpoints],
1100
+ startedAt: e.startedAt
1101
+ };
1102
+ if (e.pid !== void 0) s.pid = e.pid;
1103
+ return s;
1104
+ }
1105
+ function emitServe(e, message) {
1106
+ const ev = {
1107
+ ts: clock(),
1108
+ target: e.targetId,
1109
+ kind: e.kind,
1110
+ status: e.status,
1111
+ endpoints: [...e.endpoints]
1112
+ };
1113
+ if (e.pid !== void 0) ev.pid = e.pid;
1114
+ if (message !== void 0) ev.message = message;
1115
+ config.bus.emit("serve", ev);
1116
+ }
1117
+ function buildArgs(targetId) {
1118
+ const args = [
1119
+ "start-api",
1120
+ targetId,
1121
+ "--port",
1122
+ "0",
1123
+ "--host",
1124
+ "127.0.0.1"
1125
+ ];
1126
+ if (config.app) args.push("--app", config.app);
1127
+ if (config.profile) args.push("--profile", config.profile);
1128
+ if (config.region) args.push("--region", config.region);
1129
+ for (const [k, v] of Object.entries(config.context ?? {})) args.push("-c", `${k}=${v}`);
1130
+ return args;
1131
+ }
1132
+ async function start(req) {
1133
+ if (!SERVE_SUPPORTED.includes(req.kind)) throw new Error(`Serving '${req.kind}' targets from studio is not supported yet (API only in this build).`);
1134
+ const existing = entries.get(req.targetId);
1135
+ if (existing && existing.status !== "stopped" && existing.status !== "error") throw new Error(`'${req.targetId}' is already running.`);
1136
+ const startedAt = clock();
1137
+ let child;
1138
+ try {
1139
+ child = spawnFn(nodeBin, [config.cliEntry, ...buildArgs(req.targetId)], { cwd });
1140
+ } catch (err) {
1141
+ throw err instanceof Error ? err : new Error(String(err));
1142
+ }
1143
+ const entry = {
1144
+ targetId: req.targetId,
1145
+ kind: req.kind,
1146
+ status: "starting",
1147
+ endpoints: [],
1148
+ startedAt,
1149
+ child
1150
+ };
1151
+ if (child.pid !== void 0) entry.pid = child.pid;
1152
+ entries.set(req.targetId, entry);
1153
+ emitServe(entry);
1154
+ return new Promise((resolve, reject) => {
1155
+ let settled = false;
1156
+ child.stdout.setEncoding("utf8");
1157
+ child.stderr.setEncoding("utf8");
1158
+ const timer = setTimeoutFn(() => {
1159
+ if (settled) return;
1160
+ settled = true;
1161
+ entry.status = "error";
1162
+ emitServe(entry, `Timed out after ${readyTimeoutMs}ms waiting for the server to listen.`);
1163
+ stopChild(child, stopGraceMs, setTimeoutFn, clearTimeoutFn);
1164
+ entries.delete(req.targetId);
1165
+ reject(/* @__PURE__ */ new Error(`'${req.targetId}' did not start within ${readyTimeoutMs}ms.`));
1166
+ }, readyTimeoutMs);
1167
+ timer.unref?.();
1168
+ const becomeRunning = () => {
1169
+ if (settled) return;
1170
+ settled = true;
1171
+ clearTimeoutFn(timer);
1172
+ entry.status = "running";
1173
+ emitServe(entry);
1174
+ resolve(publicState(entry));
1175
+ };
1176
+ streamLines(child.stdout, (line) => {
1177
+ const m = LISTENING_RE.exec(line);
1178
+ if (m?.[1]) {
1179
+ if (!entry.endpoints.includes(m[1])) entry.endpoints.push(m[1]);
1180
+ if (settled) emitServe(entry);
1181
+ else becomeRunning();
1182
+ }
1183
+ emitLog(config.bus, clock, req.targetId, line, "stdout");
1184
+ });
1185
+ streamLines(child.stderr, (line) => {
1186
+ emitLog(config.bus, clock, req.targetId, line, "stderr");
1187
+ });
1188
+ child.on("error", (err) => {
1189
+ if (settled) {
1190
+ entry.status = "error";
1191
+ emitServe(entry, err.message);
1192
+ entries.delete(req.targetId);
1193
+ return;
1194
+ }
1195
+ settled = true;
1196
+ clearTimeoutFn(timer);
1197
+ entry.status = "error";
1198
+ emitServe(entry, err.message);
1199
+ entries.delete(req.targetId);
1200
+ reject(err);
1201
+ });
1202
+ child.on("close", (code) => {
1203
+ if (!settled) {
1204
+ settled = true;
1205
+ clearTimeoutFn(timer);
1206
+ if (entry.stopping) {
1207
+ reject(/* @__PURE__ */ new Error(`'${req.targetId}' was stopped before it finished starting.`));
1208
+ return;
1209
+ }
1210
+ entry.status = "error";
1211
+ const msg = `Server exited before listening (code ${code ?? "null"}).`;
1212
+ emitServe(entry, msg);
1213
+ entries.delete(req.targetId);
1214
+ reject(new Error(msg));
1215
+ return;
1216
+ }
1217
+ if (entries.get(req.targetId) === entry && entry.status === "running") {
1218
+ entry.status = "stopped";
1219
+ emitServe(entry, `Server process exited (code ${code ?? "null"}).`);
1220
+ entries.delete(req.targetId);
1221
+ }
1222
+ });
1223
+ });
1224
+ }
1225
+ async function stop(req) {
1226
+ const entry = entries.get(req.targetId);
1227
+ if (!entry) throw new Error(`'${req.targetId}' is not running.`);
1228
+ entry.stopping = true;
1229
+ entries.delete(req.targetId);
1230
+ await stopChild(entry.child, stopGraceMs, setTimeoutFn, clearTimeoutFn);
1231
+ entry.status = "stopped";
1232
+ emitServe(entry);
1233
+ }
1234
+ function list() {
1235
+ return [...entries.values()].map(publicState);
1236
+ }
1237
+ async function stopAll() {
1238
+ const targets = [...entries.keys()];
1239
+ await Promise.all(targets.map((targetId) => stop({ targetId }).catch(() => void 0)));
1240
+ }
1241
+ return {
1242
+ start,
1243
+ stop,
1244
+ list,
1245
+ stopAll
1246
+ };
1247
+ }
1248
+ /** Emit one container log line onto the bus, keyed by the serve target id. */
1249
+ function emitLog(bus, clock, target, line, stream) {
1250
+ bus.emit("log", {
1251
+ ts: clock(),
1252
+ containerId: target,
1253
+ target,
1254
+ line,
1255
+ stream
1256
+ });
1257
+ }
1258
+ /**
1259
+ * Line-buffer a child stream and invoke `onLine` per complete line
1260
+ * (trailing newline stripped, blank lines dropped). Flushes any partial
1261
+ * final line on stream end.
1262
+ */
1263
+ function streamLines(stream, onLine) {
1264
+ let buf = "";
1265
+ stream.on("data", (chunk) => {
1266
+ buf += chunk;
1267
+ let nl = buf.indexOf("\n");
1268
+ while (nl !== -1) {
1269
+ const line = buf.slice(0, nl).replace(/\r$/, "");
1270
+ buf = buf.slice(nl + 1);
1271
+ if (line.length > 0) onLine(line);
1272
+ nl = buf.indexOf("\n");
1273
+ }
1274
+ });
1275
+ stream.on("end", () => {
1276
+ const line = buf.replace(/\r$/, "");
1277
+ if (line.length > 0) onLine(line);
1278
+ buf = "";
1279
+ });
1280
+ }
1281
+ /**
1282
+ * SIGTERM a child and resolve once it exits, escalating to SIGKILL after
1283
+ * `graceMs`. Resolves immediately if the child has already exited.
1284
+ */
1285
+ function stopChild(child, graceMs, setTimeoutFn, clearTimeoutFn) {
1286
+ return new Promise((resolve) => {
1287
+ let done = false;
1288
+ let kill;
1289
+ const finish = () => {
1290
+ if (done) return;
1291
+ done = true;
1292
+ if (kill) clearTimeoutFn(kill);
1293
+ resolve();
1294
+ };
1295
+ child.once("close", finish);
1296
+ if (child.exitCode !== null || child.signalCode !== null) {
1297
+ finish();
1298
+ return;
1299
+ }
1300
+ kill = setTimeoutFn(() => {
1301
+ if (!done) child.kill("SIGKILL");
1302
+ }, graceMs);
1303
+ kill.unref?.();
1304
+ child.kill("SIGTERM");
1305
+ });
1306
+ }
1307
+
860
1308
  //#endregion
861
1309
  //#region src/cli/commands/local-studio.ts
862
1310
  const STUDIO_TARGET_KINDS = [
@@ -883,6 +1331,17 @@ function coerceRunRequest(body) {
883
1331
  event
884
1332
  };
885
1333
  }
1334
+ /**
1335
+ * Validate + narrow the untyped `POST /api/stop` body into a
1336
+ * {@link StudioStopRequest}. Throws (→ 400 from the server) on a missing
1337
+ * / empty target id.
1338
+ */
1339
+ function coerceStopRequest(body) {
1340
+ if (typeof body !== "object" || body === null) throw new Error("Request body must be a JSON object.");
1341
+ const { targetId } = body;
1342
+ if (typeof targetId !== "string" || targetId.trim() === "") throw new Error("Request body must include a non-empty \"targetId\" string.");
1343
+ return { targetId };
1344
+ }
886
1345
  const DEFAULT_STUDIO_PORT = 9999;
887
1346
  /**
888
1347
  * Parse + validate the `--studio-port` value. Accepts `0` (OS-assigned)
@@ -918,7 +1377,7 @@ async function localStudioCommand(options) {
918
1377
  const targetGroups = toStudioTargetGroups(listTargets(stacks));
919
1378
  const appLabel = stacks.map((s) => s.stackName).join(", ") || appCmd;
920
1379
  const bus = new StudioEventBus();
921
- const dispatcher = createStudioDispatcher({
1380
+ const childConfig = {
922
1381
  cliEntry: process.argv[1] ?? "",
923
1382
  bus,
924
1383
  cwd: process.cwd(),
@@ -926,20 +1385,31 @@ async function localStudioCommand(options) {
926
1385
  ...options.profile ? { profile: options.profile } : {},
927
1386
  ...options.region ? { region: options.region } : {},
928
1387
  ...Object.keys(context).length > 0 ? { context } : {}
929
- });
1388
+ };
1389
+ const dispatcher = createStudioDispatcher(childConfig);
1390
+ const serveManager = createStudioServeManager(childConfig);
930
1391
  const server = await startStudioServer({
931
1392
  port,
932
1393
  bus,
933
1394
  targetGroups,
934
1395
  appLabel,
935
1396
  cliName: getEmbedConfig().cliName,
936
- onRun: (body) => dispatcher.run(coerceRunRequest(body))
1397
+ onRun: (body) => {
1398
+ const req = coerceRunRequest(body);
1399
+ return req.kind === "lambda" ? dispatcher.run(req) : serveManager.start(req);
1400
+ },
1401
+ onStop: async (body) => {
1402
+ const req = coerceStopRequest(body);
1403
+ await serveManager.stop(req);
1404
+ return { stopped: req.targetId };
1405
+ },
1406
+ getRunning: () => ({ running: serveManager.list() })
937
1407
  });
938
1408
  const cliName = getEmbedConfig().cliName;
939
1409
  logger.info(`${cliName} studio is running at ${server.url}`);
940
1410
  logger.info("Press Ctrl-C to stop.");
941
1411
  if (options.open && process.stdout.isTTY) openBrowser(server.url);
942
- await blockUntilShutdown(server, cliName);
1412
+ await blockUntilShutdown(server, serveManager, cliName);
943
1413
  }
944
1414
  /** Best-effort cross-platform browser open. Failures are non-fatal. */
945
1415
  function openBrowser(url) {
@@ -955,17 +1425,20 @@ function openBrowser(url) {
955
1425
  } catch {}
956
1426
  }
957
1427
  /**
958
- * Block until SIGINT / SIGTERM, then close the studio server and resolve.
959
- * Mirrors the long-running serve commands' graceful-shutdown contract.
1428
+ * Block until SIGINT / SIGTERM, then stop every running serve child,
1429
+ * close the studio server, and resolve. Mirrors the long-running serve
1430
+ * commands' graceful-shutdown contract — the serve children are killed
1431
+ * BEFORE the server closes so their RIE containers are torn down rather
1432
+ * than orphaned.
960
1433
  */
961
- function blockUntilShutdown(server, cliName) {
1434
+ function blockUntilShutdown(server, serveManager, cliName) {
962
1435
  return new Promise((resolveShutdown) => {
963
1436
  let shuttingDown = false;
964
1437
  const shutdown = (signal) => {
965
1438
  if (shuttingDown) return;
966
1439
  shuttingDown = true;
967
1440
  getLogger().info(`Received ${signal}; stopping ${cliName} studio...`);
968
- server.close().catch((err) => getLogger().warn(`Error stopping studio server: ${String(err)}`)).finally(() => resolveShutdown());
1441
+ serveManager.stopAll().catch((err) => getLogger().warn(`Error stopping serve targets: ${String(err)}`)).then(() => server.close()).catch((err) => getLogger().warn(`Error stopping studio server: ${String(err)}`)).finally(() => resolveShutdown());
969
1442
  };
970
1443
  process.on("SIGINT", () => shutdown("SIGINT"));
971
1444
  process.on("SIGTERM", () => shutdown("SIGTERM"));
@@ -1000,5 +1473,5 @@ function addStudioSpecificOptions(cmd) {
1000
1473
  }
1001
1474
 
1002
1475
  //#endregion
1003
- export { startStudioServer as a, StudioEventBus as c, createStudioDispatcher as i, coerceRunRequest as n, toStudioTargetGroups as o, createLocalStudioCommand as r, renderStudioHtml as s, addStudioSpecificOptions as t };
1004
- //# sourceMappingURL=local-studio-CSW1raG0.js.map
1476
+ export { createStudioServeManager as a, toStudioTargetGroups as c, createLocalStudioCommand as i, renderStudioHtml as l, coerceRunRequest as n, createStudioDispatcher as o, coerceStopRequest as r, startStudioServer as s, addStudioSpecificOptions as t, StudioEventBus as u };
1477
+ //# sourceMappingURL=local-studio-MXuSiMqC.js.map