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.
- package/dist/cli.js +2 -2
- package/dist/internal.d.ts +54 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +2 -2
- package/dist/{local-studio-CSW1raG0.js → local-studio-MXuSiMqC.js} +506 -33
- package/dist/local-studio-MXuSiMqC.js.map +1 -0
- package/package.json +1 -1
- package/dist/local-studio-CSW1raG0.js.map +0 -1
|
@@ -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
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
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 (
|
|
194
|
+
if (group.kind === 'lambda') {
|
|
181
195
|
const btn = el('button', 'invoke-btn', 'Invoke');
|
|
182
|
-
btn.onclick = (e) => { e.stopPropagation(); selectTarget(entry.id,
|
|
196
|
+
btn.onclick = (e) => { e.stopPropagation(); selectTarget(entry.id, 'lambda'); };
|
|
183
197
|
t.appendChild(btn);
|
|
184
|
-
t.onclick = () => selectTarget(entry.id,
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
/**
|
|
528
|
-
|
|
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 (!
|
|
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
|
|
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
|
|
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) =>
|
|
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
|
|
959
|
-
* Mirrors the long-running serve
|
|
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 {
|
|
1004
|
-
//# sourceMappingURL=local-studio-
|
|
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
|