cdk-local 0.79.0 → 0.81.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 +74 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +2 -2
- package/dist/{local-studio-CSW1raG0.js → local-studio-KUaxIkpx.js} +786 -41
- package/dist/local-studio-KUaxIkpx.js.map +1 -0
- package/package.json +1 -1
- package/dist/local-studio-CSW1raG0.js.map +0 -1
|
@@ -5,7 +5,8 @@ import { tmpdir } from "node:os";
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { Command, Option } from "commander";
|
|
7
7
|
import { spawn } from "node:child_process";
|
|
8
|
-
import {
|
|
8
|
+
import { connect } from "node:net";
|
|
9
|
+
import { createServer as createServer$1, request } from "node:http";
|
|
9
10
|
import { EventEmitter } from "node:events";
|
|
10
11
|
|
|
11
12
|
//#region src/local/studio-events.ts
|
|
@@ -53,14 +54,17 @@ var StudioEventBus = class {
|
|
|
53
54
|
* the studio HTTP server (`startStudioServer`) at `GET /`.
|
|
54
55
|
*
|
|
55
56
|
* 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
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
57
|
+
* - left = target list (from `GET /api/targets`); each Lambda has an
|
|
58
|
+
* [Invoke] button, each API a [Start] / [Stop] serve control with a
|
|
59
|
+
* `running ● :port` indicator (slice C1), plus a selected-highlight.
|
|
60
|
+
* - center = the WORKSPACE for the selected target: for a Lambda, an
|
|
61
|
+
* event composer (textarea + Invoke button) with the latest run's
|
|
62
|
+
* Request / Response / Logs shown below; for an API, a Start/Stop
|
|
63
|
+
* control with the served endpoints + streaming logs.
|
|
64
|
+
* - right = the timeline (history) of every invocation AND every
|
|
65
|
+
* captured serve request (slice C2); clicking a Lambda row reloads
|
|
66
|
+
* it into the composer, clicking a captured request row opens a
|
|
67
|
+
* read-only Request / Response detail.
|
|
64
68
|
*
|
|
65
69
|
* The center workspace is deliberately adjacent to the left target list
|
|
66
70
|
* (short eye-travel: pick a target -> compose right next to it), and is
|
|
@@ -106,6 +110,13 @@ const STUDIO_CSS = `
|
|
|
106
110
|
}
|
|
107
111
|
.target .invoke-btn:hover { background: #6fe097; }
|
|
108
112
|
.target.sel .invoke-btn { background: #6fe097; }
|
|
113
|
+
.target .stop-btn {
|
|
114
|
+
padding: 2px 10px; font: 11px ui-monospace, Menlo, monospace; font-weight: 700;
|
|
115
|
+
color: #2a0d0d; background: #e0707a; border: 0; border-radius: 3px; cursor: pointer;
|
|
116
|
+
}
|
|
117
|
+
.target .stop-btn:hover { background: #ec8a92; }
|
|
118
|
+
.target .run-dot { color: #7bd88f; font-size: 11px; white-space: nowrap; }
|
|
119
|
+
.target .run-dot.starting { color: #e0b54e; }
|
|
109
120
|
.empty { padding: 16px 12px; color: #666; }
|
|
110
121
|
.row {
|
|
111
122
|
padding: 5px 12px; border-bottom: 1px solid #222; cursor: pointer;
|
|
@@ -137,6 +148,8 @@ const STUDIO_CSS = `
|
|
|
137
148
|
.section h3 .ok { color: #7bd88f; }
|
|
138
149
|
.section h3 .bad { color: #e0707a; }
|
|
139
150
|
.section pre { margin: 0; white-space: pre-wrap; word-break: break-word; color: #cfcfcf; }
|
|
151
|
+
.endpoint { display: block; color: #6aa9ff; text-decoration: none; padding: 2px 0; }
|
|
152
|
+
.endpoint:hover { text-decoration: underline; }
|
|
140
153
|
#conn { font-size: 11px; }
|
|
141
154
|
#conn.up { color: #7bd88f; }
|
|
142
155
|
#conn.down { color: #e0707a; }
|
|
@@ -145,10 +158,14 @@ const STUDIO_SCRIPT = `
|
|
|
145
158
|
const KIND_LABEL = { lambda: 'Lambda', api: 'API', alb: 'ALB', ecs: 'ECS', agentcore: 'AgentCore' };
|
|
146
159
|
const rowsById = new Map(); // invocationId -> timeline row element
|
|
147
160
|
const invById = new Map(); // invocationId -> latest invocation event
|
|
148
|
-
const logsById = new Map(); // invocationId -> [log lines]
|
|
161
|
+
const logsById = new Map(); // invocationId / serve targetId -> [log lines]
|
|
149
162
|
const targetEls = new Map(); // targetId -> left-pane element
|
|
163
|
+
const serveMeta = new Map(); // serve targetId -> { dot, btnSlot } row controls
|
|
164
|
+
const serveState = new Map(); // serve targetId -> { status, endpoints }
|
|
150
165
|
let active = null; // { id, kind, ta, btn, msg, result }
|
|
151
|
-
let shownInvId = null; // invocation whose result is in the workspace
|
|
166
|
+
let shownInvId = null; // lambda invocation whose result is in the workspace
|
|
167
|
+
let shownServeId = null; // serve target whose workspace is shown
|
|
168
|
+
let shownDetailId = null; // captured request whose read-only detail is shown
|
|
152
169
|
|
|
153
170
|
function el(tag, cls, text) {
|
|
154
171
|
const e = document.createElement(tag);
|
|
@@ -169,20 +186,32 @@ const STUDIO_SCRIPT = `
|
|
|
169
186
|
pane.appendChild(el('div', 'group-title', group.title));
|
|
170
187
|
for (const entry of group.entries) {
|
|
171
188
|
total += 1;
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
189
|
+
// Lambda targets are single-shot invokes; API targets are
|
|
190
|
+
// long-running serves (slice C1). Other kinds list but are not
|
|
191
|
+
// yet runnable.
|
|
192
|
+
const runnable = group.kind === 'lambda' || group.kind === 'api';
|
|
175
193
|
const t = el('div', runnable ? 'target runnable' : 'target');
|
|
176
194
|
const name = el('span', 'name', entry.id);
|
|
177
195
|
name.title = entry.id; // full path on hover even when truncated
|
|
178
196
|
t.appendChild(name);
|
|
179
197
|
t.appendChild(el('span', 'kind', '(' + (KIND_LABEL[group.kind] || group.kind) + ')'));
|
|
180
|
-
if (
|
|
198
|
+
if (group.kind === 'lambda') {
|
|
181
199
|
const btn = el('button', 'invoke-btn', 'Invoke');
|
|
182
|
-
btn.onclick = (e) => { e.stopPropagation(); selectTarget(entry.id,
|
|
200
|
+
btn.onclick = (e) => { e.stopPropagation(); selectTarget(entry.id, 'lambda'); };
|
|
183
201
|
t.appendChild(btn);
|
|
184
|
-
t.onclick = () => selectTarget(entry.id,
|
|
202
|
+
t.onclick = () => selectTarget(entry.id, 'lambda');
|
|
185
203
|
targetEls.set(entry.id, t);
|
|
204
|
+
} else if (group.kind === 'api') {
|
|
205
|
+
// A serve target: a running-state dot + a Start/Stop button
|
|
206
|
+
// slot, both refreshed by updateServeRow on serve events.
|
|
207
|
+
const dot = el('span', 'run-dot');
|
|
208
|
+
const btnSlot = el('span', 'btn-slot');
|
|
209
|
+
t.appendChild(dot);
|
|
210
|
+
t.appendChild(btnSlot);
|
|
211
|
+
t.onclick = () => selectTarget(entry.id, 'api');
|
|
212
|
+
targetEls.set(entry.id, t);
|
|
213
|
+
serveMeta.set(entry.id, { dot, btnSlot });
|
|
214
|
+
updateServeRow(entry.id);
|
|
186
215
|
}
|
|
187
216
|
pane.appendChild(t);
|
|
188
217
|
}
|
|
@@ -193,6 +222,50 @@ const STUDIO_SCRIPT = `
|
|
|
193
222
|
}
|
|
194
223
|
}
|
|
195
224
|
|
|
225
|
+
// Pull any already-running serves (e.g. after a UI reload) so the rows
|
|
226
|
+
// and workspace reflect them without waiting for a fresh serve event.
|
|
227
|
+
async function loadRunning() {
|
|
228
|
+
try {
|
|
229
|
+
const res = await fetch('/api/running');
|
|
230
|
+
const data = await res.json();
|
|
231
|
+
for (const s of (data.running || [])) {
|
|
232
|
+
serveState.set(s.targetId, { status: s.status, endpoints: s.endpoints || [] });
|
|
233
|
+
updateServeRow(s.targetId);
|
|
234
|
+
}
|
|
235
|
+
} catch (err) {
|
|
236
|
+
/* best-effort; the serve SSE stream still drives live updates */
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function firstPort(endpoints) {
|
|
241
|
+
const u = (endpoints || [])[0];
|
|
242
|
+
if (!u) return '';
|
|
243
|
+
const m = /:(\\d+)/.exec(u);
|
|
244
|
+
return m ? ':' + m[1] : '';
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function updateServeRow(id) {
|
|
248
|
+
const meta = serveMeta.get(id);
|
|
249
|
+
if (!meta) return;
|
|
250
|
+
const st = serveState.get(id);
|
|
251
|
+
const status = st ? st.status : 'stopped';
|
|
252
|
+
const running = status === 'running';
|
|
253
|
+
const starting = status === 'starting';
|
|
254
|
+
meta.dot.textContent = running ? '● ' + firstPort(st.endpoints) : starting ? '○ starting' : '';
|
|
255
|
+
meta.dot.className = 'run-dot' + (starting ? ' starting' : '');
|
|
256
|
+
meta.btnSlot.innerHTML = '';
|
|
257
|
+
const btn = running || starting
|
|
258
|
+
? el('button', 'stop-btn', 'Stop')
|
|
259
|
+
: el('button', 'invoke-btn', 'Start');
|
|
260
|
+
btn.onclick = (e) => {
|
|
261
|
+
e.stopPropagation();
|
|
262
|
+
if (running || starting) stopServe(id); else startServe(id);
|
|
263
|
+
};
|
|
264
|
+
meta.btnSlot.appendChild(btn);
|
|
265
|
+
// Refresh the workspace if it is showing this serve.
|
|
266
|
+
if (shownServeId === id) renderServeWorkspace(id);
|
|
267
|
+
}
|
|
268
|
+
|
|
196
269
|
function highlightTarget(id) {
|
|
197
270
|
document.querySelectorAll('.target.sel').forEach((n) => n.classList.remove('sel'));
|
|
198
271
|
const t = targetEls.get(id);
|
|
@@ -201,7 +274,105 @@ const STUDIO_SCRIPT = `
|
|
|
201
274
|
|
|
202
275
|
function selectTarget(id, kind) {
|
|
203
276
|
highlightTarget(id);
|
|
204
|
-
|
|
277
|
+
shownDetailId = null;
|
|
278
|
+
if (kind === 'api') {
|
|
279
|
+
shownServeId = id;
|
|
280
|
+
shownInvId = null;
|
|
281
|
+
active = null;
|
|
282
|
+
renderServeWorkspace(id);
|
|
283
|
+
} else {
|
|
284
|
+
shownServeId = null;
|
|
285
|
+
renderComposer(id, kind, '{}');
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function startServe(id) {
|
|
290
|
+
serveState.set(id, { status: 'starting', endpoints: [] });
|
|
291
|
+
updateServeRow(id);
|
|
292
|
+
try {
|
|
293
|
+
const res = await fetch('/api/run', {
|
|
294
|
+
method: 'POST',
|
|
295
|
+
headers: { 'content-type': 'application/json' },
|
|
296
|
+
body: JSON.stringify({ targetId: id, kind: 'api' }),
|
|
297
|
+
});
|
|
298
|
+
const data = await res.json();
|
|
299
|
+
if (!res.ok) {
|
|
300
|
+
// Roll back the optimistic 'starting' on a rejected start.
|
|
301
|
+
serveState.set(id, { status: 'error', endpoints: [] });
|
|
302
|
+
updateServeRow(id);
|
|
303
|
+
if (shownServeId === id) renderServeWorkspace(id, data.error || ('HTTP ' + res.status));
|
|
304
|
+
}
|
|
305
|
+
// On success the serve SSE 'running' event fills in the endpoints.
|
|
306
|
+
} catch (err) {
|
|
307
|
+
serveState.set(id, { status: 'error', endpoints: [] });
|
|
308
|
+
updateServeRow(id);
|
|
309
|
+
if (shownServeId === id) renderServeWorkspace(id, String(err));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function stopServe(id) {
|
|
314
|
+
try {
|
|
315
|
+
await fetch('/api/stop', {
|
|
316
|
+
method: 'POST',
|
|
317
|
+
headers: { 'content-type': 'application/json' },
|
|
318
|
+
body: JSON.stringify({ targetId: id }),
|
|
319
|
+
});
|
|
320
|
+
// The serve SSE 'stopped' event clears the running state.
|
|
321
|
+
} catch (err) {
|
|
322
|
+
/* the stop SSE event (or a later refresh) reconciles state */
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function renderServeWorkspace(id, errMsg) {
|
|
327
|
+
const ws = document.getElementById('workspace');
|
|
328
|
+
ws.innerHTML = '';
|
|
329
|
+
const st = serveState.get(id) || { status: 'stopped', endpoints: [] };
|
|
330
|
+
const running = st.status === 'running';
|
|
331
|
+
const starting = st.status === 'starting';
|
|
332
|
+
|
|
333
|
+
const head = el('div', 'composer');
|
|
334
|
+
head.appendChild(el('div', 'target-name', 'Serve ' + id));
|
|
335
|
+
const btn = running || starting
|
|
336
|
+
? el('button', null, 'Stop')
|
|
337
|
+
: el('button', null, starting ? 'Starting…' : 'Start');
|
|
338
|
+
btn.onclick = () => { if (running || starting) stopServe(id); else startServe(id); };
|
|
339
|
+
head.appendChild(btn);
|
|
340
|
+
if (errMsg) {
|
|
341
|
+
const m = el('div', 'err', errMsg);
|
|
342
|
+
head.appendChild(m);
|
|
343
|
+
}
|
|
344
|
+
ws.appendChild(head);
|
|
345
|
+
|
|
346
|
+
const epSec = el('div', 'section');
|
|
347
|
+
epSec.appendChild(el('h3', null, 'Endpoints'));
|
|
348
|
+
if (running && st.endpoints.length) {
|
|
349
|
+
for (const url of st.endpoints) {
|
|
350
|
+
const link = href(url);
|
|
351
|
+
epSec.appendChild(link);
|
|
352
|
+
}
|
|
353
|
+
} else {
|
|
354
|
+
epSec.appendChild(el('pre', null, starting ? '(starting…)' : '(not running)'));
|
|
355
|
+
}
|
|
356
|
+
ws.appendChild(epSec);
|
|
357
|
+
|
|
358
|
+
const logs = logsById.get(id) || [];
|
|
359
|
+
const logSec = el('div', 'section');
|
|
360
|
+
logSec.appendChild(el('h3', null, 'Logs'));
|
|
361
|
+
logSec.appendChild(el('pre', null, logs.length ? logs.join('\\n') : '(none)'));
|
|
362
|
+
ws.appendChild(logSec);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Build an <a> that opens an http(s) endpoint in a new tab; ws:// URLs
|
|
366
|
+
// are shown as plain text (not navigable in a browser tab).
|
|
367
|
+
function href(url) {
|
|
368
|
+
if (/^https?:/.test(url)) {
|
|
369
|
+
const a = el('a', 'endpoint', url);
|
|
370
|
+
a.href = url;
|
|
371
|
+
a.target = '_blank';
|
|
372
|
+
a.rel = 'noopener';
|
|
373
|
+
return a;
|
|
374
|
+
}
|
|
375
|
+
return el('div', 'endpoint', url);
|
|
205
376
|
}
|
|
206
377
|
|
|
207
378
|
function renderComposer(id, kind, eventText) {
|
|
@@ -228,6 +399,7 @@ const STUDIO_SCRIPT = `
|
|
|
228
399
|
active = { id, kind, ta, btn, msg, result };
|
|
229
400
|
btn.onclick = () => runInvoke();
|
|
230
401
|
shownInvId = null;
|
|
402
|
+
shownDetailId = null;
|
|
231
403
|
ta.focus();
|
|
232
404
|
}
|
|
233
405
|
|
|
@@ -328,8 +500,9 @@ const STUDIO_SCRIPT = `
|
|
|
328
500
|
: '…';
|
|
329
501
|
statusEl.className = 'status' + (merged.status != null && (merged.status < 200 || merged.status >= 300) ? ' err' : '');
|
|
330
502
|
|
|
331
|
-
// Live-refresh the workspace
|
|
503
|
+
// Live-refresh the workspace if it is showing this invocation.
|
|
332
504
|
if (shownInvId === ev.id) renderResult(ev.id);
|
|
505
|
+
if (shownDetailId === ev.id) renderCapturedDetail(ev.id);
|
|
333
506
|
}
|
|
334
507
|
|
|
335
508
|
function loadInvocation(id) {
|
|
@@ -339,9 +512,49 @@ const STUDIO_SCRIPT = `
|
|
|
339
512
|
const row = rowsById.get(id);
|
|
340
513
|
if (row) row.classList.add('sel');
|
|
341
514
|
highlightTarget(ev.target);
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
515
|
+
if (ev.kind === 'lambda') {
|
|
516
|
+
// A Lambda invocation row reloads into the re-invokable composer.
|
|
517
|
+
shownDetailId = null;
|
|
518
|
+
shownServeId = null;
|
|
519
|
+
renderComposer(ev.target, ev.kind, ev.request != null ? fmt(ev.request) : '{}');
|
|
520
|
+
shownInvId = id;
|
|
521
|
+
renderResult(id);
|
|
522
|
+
} else {
|
|
523
|
+
// A captured serve request (slice C2) opens a READ-ONLY detail —
|
|
524
|
+
// re-invoking a captured request is Phase 3.
|
|
525
|
+
shownInvId = null;
|
|
526
|
+
shownServeId = null;
|
|
527
|
+
active = null;
|
|
528
|
+
renderCapturedDetail(id);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Read-only Request / Response detail for a captured serve request.
|
|
533
|
+
function renderCapturedDetail(id) {
|
|
534
|
+
shownDetailId = id;
|
|
535
|
+
const ev = invById.get(id);
|
|
536
|
+
const ws = document.getElementById('workspace');
|
|
537
|
+
ws.innerHTML = '';
|
|
538
|
+
if (!ev) return;
|
|
539
|
+
|
|
540
|
+
const head = el('div', 'composer');
|
|
541
|
+
head.appendChild(el('div', 'target-name', (ev.label || 'request') + ' — ' + (ev.target || '')));
|
|
542
|
+
ws.appendChild(head);
|
|
543
|
+
|
|
544
|
+
const reqSec = el('div', 'section');
|
|
545
|
+
reqSec.appendChild(el('h3', null, 'Request'));
|
|
546
|
+
reqSec.appendChild(el('pre', null, ev.request != null ? fmt(ev.request) : '(none)'));
|
|
547
|
+
ws.appendChild(reqSec);
|
|
548
|
+
|
|
549
|
+
const respSec = el('div', 'section');
|
|
550
|
+
const h = el('h3', null, 'Response');
|
|
551
|
+
if (ev.status != null) {
|
|
552
|
+
const cls = ev.status >= 200 && ev.status < 300 ? 'ok' : 'bad';
|
|
553
|
+
h.appendChild(el('span', cls, ' ' + ev.status + (ev.durationMs != null ? ' · ' + ev.durationMs + 'ms' : '')));
|
|
554
|
+
}
|
|
555
|
+
respSec.appendChild(h);
|
|
556
|
+
respSec.appendChild(el('pre', null, ev.response != null ? fmt(ev.response) : '(pending…)'));
|
|
557
|
+
ws.appendChild(respSec);
|
|
345
558
|
}
|
|
346
559
|
|
|
347
560
|
function connect() {
|
|
@@ -350,15 +563,28 @@ const STUDIO_SCRIPT = `
|
|
|
350
563
|
es.addEventListener('open', () => { conn.textContent = '● live'; conn.className = 'up'; });
|
|
351
564
|
es.addEventListener('error', () => { conn.textContent = '● disconnected'; conn.className = 'down'; });
|
|
352
565
|
es.addEventListener('invocation', (e) => addInvocation(JSON.parse(e.data)));
|
|
566
|
+
es.addEventListener('serve', (e) => onServeEvent(JSON.parse(e.data)));
|
|
353
567
|
es.addEventListener('log', (e) => {
|
|
354
568
|
const ev = JSON.parse(e.data);
|
|
355
569
|
const arr = logsById.get(ev.containerId) || [];
|
|
356
570
|
arr.push(ev.line);
|
|
357
571
|
logsById.set(ev.containerId, arr);
|
|
358
572
|
if (shownInvId === ev.containerId) renderResult(ev.containerId);
|
|
573
|
+
if (shownServeId === ev.containerId) renderServeWorkspace(ev.containerId);
|
|
359
574
|
});
|
|
360
575
|
}
|
|
361
576
|
|
|
577
|
+
function onServeEvent(ev) {
|
|
578
|
+
// A 'stopped' / 'error' transition clears the running state; otherwise
|
|
579
|
+
// record the latest status + endpoints for the row + workspace.
|
|
580
|
+
if (ev.status === 'stopped' || ev.status === 'error') {
|
|
581
|
+
serveState.set(ev.target, { status: ev.status, endpoints: [] });
|
|
582
|
+
} else {
|
|
583
|
+
serveState.set(ev.target, { status: ev.status, endpoints: ev.endpoints || [] });
|
|
584
|
+
}
|
|
585
|
+
updateServeRow(ev.target);
|
|
586
|
+
}
|
|
587
|
+
|
|
362
588
|
function initSplitters() {
|
|
363
589
|
const main = document.querySelector('main');
|
|
364
590
|
let left = 280, right = 320;
|
|
@@ -389,7 +615,7 @@ const STUDIO_SCRIPT = `
|
|
|
389
615
|
wire('split-right', (dx, l0, r0) => { right = clamp(r0 - dx); });
|
|
390
616
|
}
|
|
391
617
|
|
|
392
|
-
loadTargets();
|
|
618
|
+
loadTargets().then(loadRunning);
|
|
393
619
|
connect();
|
|
394
620
|
initSplitters();
|
|
395
621
|
`;
|
|
@@ -418,7 +644,7 @@ function renderStudioHtml(appLabel, cliName) {
|
|
|
418
644
|
<main>
|
|
419
645
|
<section class="pane" id="targets"><h2>Targets</h2></section>
|
|
420
646
|
<div class="splitter" id="split-left"></div>
|
|
421
|
-
<section class="pane" id="workspace"><div class="empty">Pick a Lambda
|
|
647
|
+
<section class="pane" id="workspace"><div class="empty">Pick a Lambda to invoke, or an API to serve, on the left.</div></section>
|
|
422
648
|
<div class="splitter" id="split-right"></div>
|
|
423
649
|
<section class="pane" id="timeline"><h2>Timeline</h2><div class="empty">No requests yet.</div></section>
|
|
424
650
|
</main>
|
|
@@ -489,7 +715,7 @@ async function startStudioServer(options) {
|
|
|
489
715
|
const maxBump = options.maxPortBump ?? 20;
|
|
490
716
|
const html = renderStudioHtml(options.appLabel, options.cliName);
|
|
491
717
|
const targetsJson = JSON.stringify({ groups: options.targetGroups });
|
|
492
|
-
const server = createServer((req, res) => handleRequest(req, res, options.bus, html, targetsJson, options
|
|
718
|
+
const server = createServer$1((req, res) => handleRequest(req, res, options.bus, html, targetsJson, options));
|
|
493
719
|
const boundPort = await listenWithBump(server, host, options.port, maxBump);
|
|
494
720
|
return {
|
|
495
721
|
url: `http://${host}:${boundPort}`,
|
|
@@ -500,7 +726,7 @@ async function startStudioServer(options) {
|
|
|
500
726
|
})
|
|
501
727
|
};
|
|
502
728
|
}
|
|
503
|
-
function handleRequest(req, res, bus, html, targetsJson,
|
|
729
|
+
function handleRequest(req, res, bus, html, targetsJson, options) {
|
|
504
730
|
const path = (req.url ?? "/").split("?")[0];
|
|
505
731
|
if (req.method === "GET" && (path === "/" || path === "/index.html")) {
|
|
506
732
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
@@ -512,25 +738,40 @@ function handleRequest(req, res, bus, html, targetsJson, onRun) {
|
|
|
512
738
|
res.end(targetsJson);
|
|
513
739
|
return;
|
|
514
740
|
}
|
|
741
|
+
if (req.method === "GET" && path === "/api/running") {
|
|
742
|
+
const running = options.getRunning ? options.getRunning() : { running: [] };
|
|
743
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
744
|
+
res.end(JSON.stringify(running));
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
515
747
|
if (req.method === "GET" && path === "/api/events") {
|
|
516
748
|
serveSse(req, res, bus);
|
|
517
749
|
return;
|
|
518
750
|
}
|
|
519
751
|
if (req.method === "POST" && path === "/api/run") {
|
|
520
|
-
|
|
752
|
+
handleDispatch(req, res, options.onRun);
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
if (req.method === "POST" && path === "/api/stop") {
|
|
756
|
+
handleDispatch(req, res, options.onStop);
|
|
521
757
|
return;
|
|
522
758
|
}
|
|
523
759
|
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
524
760
|
res.end("Not found");
|
|
525
761
|
}
|
|
526
762
|
const MAX_RUN_BODY_BYTES = 5 * 1024 * 1024;
|
|
527
|
-
/**
|
|
528
|
-
|
|
763
|
+
/**
|
|
764
|
+
* Reply to a JSON POST endpoint (`/api/run` / `/api/stop`): parse the
|
|
765
|
+
* bounded JSON body and dispatch via `handler`. 501 when no handler is
|
|
766
|
+
* wired (the observe-only shell), 400 on a malformed body, 500 when the
|
|
767
|
+
* handler throws.
|
|
768
|
+
*/
|
|
769
|
+
async function handleDispatch(req, res, handler) {
|
|
529
770
|
const sendJson = (statusCode, payload) => {
|
|
530
771
|
res.writeHead(statusCode, { "content-type": "application/json; charset=utf-8" });
|
|
531
772
|
res.end(JSON.stringify(payload));
|
|
532
773
|
};
|
|
533
|
-
if (!
|
|
774
|
+
if (!handler) {
|
|
534
775
|
sendJson(501, { error: "Running targets is not supported by this studio server." });
|
|
535
776
|
return;
|
|
536
777
|
}
|
|
@@ -542,7 +783,7 @@ async function handleRun(req, res, onRun) {
|
|
|
542
783
|
return;
|
|
543
784
|
}
|
|
544
785
|
try {
|
|
545
|
-
sendJson(200, await
|
|
786
|
+
sendJson(200, await handler(body));
|
|
546
787
|
} catch (err) {
|
|
547
788
|
sendJson(500, { error: err instanceof Error ? err.message : String(err) });
|
|
548
789
|
}
|
|
@@ -600,12 +841,16 @@ function serveSse(req, res, bus) {
|
|
|
600
841
|
const onLog = (ev) => {
|
|
601
842
|
safeWrite(`event: log\ndata: ${JSON.stringify(ev)}\n\n`);
|
|
602
843
|
};
|
|
844
|
+
const onServe = (ev) => {
|
|
845
|
+
safeWrite(`event: serve\ndata: ${JSON.stringify(ev)}\n\n`);
|
|
846
|
+
};
|
|
603
847
|
function cleanup() {
|
|
604
848
|
if (closed) return;
|
|
605
849
|
closed = true;
|
|
606
850
|
clearInterval(heartbeat);
|
|
607
851
|
bus.off("invocation", onInvocation);
|
|
608
852
|
bus.off("log", onLog);
|
|
853
|
+
bus.off("serve", onServe);
|
|
609
854
|
}
|
|
610
855
|
function safeWrite(chunk) {
|
|
611
856
|
if (closed || res.writableEnded || res.destroyed) {
|
|
@@ -616,6 +861,7 @@ function serveSse(req, res, bus) {
|
|
|
616
861
|
}
|
|
617
862
|
bus.on("invocation", onInvocation);
|
|
618
863
|
bus.on("log", onLog);
|
|
864
|
+
bus.on("serve", onServe);
|
|
619
865
|
req.on("close", cleanup);
|
|
620
866
|
res.on("close", cleanup);
|
|
621
867
|
res.on("error", cleanup);
|
|
@@ -857,6 +1103,480 @@ function tryParseJson(raw) {
|
|
|
857
1103
|
}
|
|
858
1104
|
}
|
|
859
1105
|
|
|
1106
|
+
//#endregion
|
|
1107
|
+
//#region src/local/studio-proxy.ts
|
|
1108
|
+
let proxyIdCounter = 0;
|
|
1109
|
+
/**
|
|
1110
|
+
* Start a capturing reverse proxy in front of a studio serve target
|
|
1111
|
+
* (decision D4a: because every request to the served port flows through
|
|
1112
|
+
* `cdkl studio`, the timeline observes them regardless of source —
|
|
1113
|
+
* browser, curl, or the in-UI pad alike).
|
|
1114
|
+
*
|
|
1115
|
+
* Each HTTP request is forwarded to `upstream` and, in parallel,
|
|
1116
|
+
* captured (method / path / headers / bounded body) and emitted as an
|
|
1117
|
+
* `invocation` start event; when the upstream response completes, an end
|
|
1118
|
+
* event carries the status / headers / bounded body / duration. The full
|
|
1119
|
+
* bodies stream through untouched — only the captured copies are bounded.
|
|
1120
|
+
* `Upgrade` (WebSocket) requests are bridged raw to the upstream without
|
|
1121
|
+
* capture so they keep working.
|
|
1122
|
+
*
|
|
1123
|
+
* Studio is a control plane over the CLI, so this proxy sits in front of
|
|
1124
|
+
* the long-running `cdkl start-api` child the serve manager spawned; it
|
|
1125
|
+
* does NOT re-implement any routing — it forwards verbatim.
|
|
1126
|
+
*/
|
|
1127
|
+
function startStudioProxy(config) {
|
|
1128
|
+
const host = config.host ?? "127.0.0.1";
|
|
1129
|
+
const clock = config.clock ?? Date.now;
|
|
1130
|
+
const maxCapture = config.maxCaptureBytes ?? 64 * 1024;
|
|
1131
|
+
const idFactory = config.idFactory ?? (() => {
|
|
1132
|
+
proxyIdCounter += 1;
|
|
1133
|
+
return `req-${clock()}-${proxyIdCounter}`;
|
|
1134
|
+
});
|
|
1135
|
+
const upstreamUrl = new URL(config.upstream);
|
|
1136
|
+
const upstreamHost = upstreamUrl.hostname;
|
|
1137
|
+
const upstreamPort = Number(upstreamUrl.port) || 80;
|
|
1138
|
+
const server = createServer$1((clientReq, clientRes) => {
|
|
1139
|
+
const id = idFactory();
|
|
1140
|
+
const startedAt = clock();
|
|
1141
|
+
const path = clientReq.url ?? "/";
|
|
1142
|
+
const method = clientReq.method ?? "GET";
|
|
1143
|
+
const label = `${method} ${path.split("?")[0]}`;
|
|
1144
|
+
const reqBody = boundedCollector(maxCapture);
|
|
1145
|
+
config.bus.emit("invocation", {
|
|
1146
|
+
id,
|
|
1147
|
+
ts: startedAt,
|
|
1148
|
+
target: config.target,
|
|
1149
|
+
kind: config.kind,
|
|
1150
|
+
label,
|
|
1151
|
+
request: {
|
|
1152
|
+
method,
|
|
1153
|
+
path,
|
|
1154
|
+
headers: { ...clientReq.headers }
|
|
1155
|
+
}
|
|
1156
|
+
});
|
|
1157
|
+
let ended = false;
|
|
1158
|
+
const emitEnd = (status, response) => {
|
|
1159
|
+
if (ended) return;
|
|
1160
|
+
ended = true;
|
|
1161
|
+
config.bus.emit("invocation", {
|
|
1162
|
+
id,
|
|
1163
|
+
ts: startedAt,
|
|
1164
|
+
target: config.target,
|
|
1165
|
+
kind: config.kind,
|
|
1166
|
+
label,
|
|
1167
|
+
request: {
|
|
1168
|
+
method,
|
|
1169
|
+
path,
|
|
1170
|
+
headers: { ...clientReq.headers },
|
|
1171
|
+
body: reqBody.text()
|
|
1172
|
+
},
|
|
1173
|
+
response,
|
|
1174
|
+
status,
|
|
1175
|
+
durationMs: clock() - startedAt
|
|
1176
|
+
});
|
|
1177
|
+
};
|
|
1178
|
+
const upstreamReq = request({
|
|
1179
|
+
host: upstreamHost,
|
|
1180
|
+
port: upstreamPort,
|
|
1181
|
+
method,
|
|
1182
|
+
path,
|
|
1183
|
+
headers: clientReq.headers
|
|
1184
|
+
}, (upstreamRes) => {
|
|
1185
|
+
const respBody = boundedCollector(maxCapture);
|
|
1186
|
+
clientRes.writeHead(upstreamRes.statusCode ?? 502, stripHopByHop(upstreamRes.headers));
|
|
1187
|
+
upstreamRes.on("data", (chunk) => respBody.push(chunk));
|
|
1188
|
+
upstreamRes.pipe(clientRes);
|
|
1189
|
+
upstreamRes.on("end", () => emitEnd(upstreamRes.statusCode ?? 502, {
|
|
1190
|
+
status: upstreamRes.statusCode,
|
|
1191
|
+
headers: { ...upstreamRes.headers },
|
|
1192
|
+
body: respBody.text()
|
|
1193
|
+
}));
|
|
1194
|
+
upstreamRes.on("error", () => emitEnd(502, "upstream response stream error"));
|
|
1195
|
+
});
|
|
1196
|
+
upstreamReq.on("error", (err) => {
|
|
1197
|
+
if (!clientRes.headersSent) clientRes.writeHead(502, { "content-type": "text/plain" });
|
|
1198
|
+
clientRes.end(`studio proxy: upstream error: ${err.message}`);
|
|
1199
|
+
emitEnd(502, `upstream error: ${err.message}`);
|
|
1200
|
+
});
|
|
1201
|
+
clientReq.on("data", (chunk) => reqBody.push(chunk));
|
|
1202
|
+
clientReq.on("error", () => {
|
|
1203
|
+
upstreamReq.destroy();
|
|
1204
|
+
emitEnd(499, "client aborted the request");
|
|
1205
|
+
});
|
|
1206
|
+
clientReq.pipe(upstreamReq);
|
|
1207
|
+
});
|
|
1208
|
+
const upgradeSockets = /* @__PURE__ */ new Set();
|
|
1209
|
+
server.on("upgrade", (req, clientSocket, head) => {
|
|
1210
|
+
const sock = clientSocket;
|
|
1211
|
+
upgradeSockets.add(sock);
|
|
1212
|
+
sock.on("close", () => upgradeSockets.delete(sock));
|
|
1213
|
+
bridgeUpgrade(req, sock, head, upstreamHost, upstreamPort);
|
|
1214
|
+
});
|
|
1215
|
+
return new Promise((resolve, reject) => {
|
|
1216
|
+
server.once("error", reject);
|
|
1217
|
+
server.listen(0, host, () => {
|
|
1218
|
+
server.removeListener("error", reject);
|
|
1219
|
+
const port = server.address().port;
|
|
1220
|
+
resolve({
|
|
1221
|
+
url: `http://${host}:${port}`,
|
|
1222
|
+
port,
|
|
1223
|
+
close: () => new Promise((resolveClose, rejectClose) => {
|
|
1224
|
+
for (const sock of upgradeSockets) sock.destroy();
|
|
1225
|
+
upgradeSockets.clear();
|
|
1226
|
+
server.close((err) => err ? rejectClose(err) : resolveClose());
|
|
1227
|
+
server.closeAllConnections?.();
|
|
1228
|
+
})
|
|
1229
|
+
});
|
|
1230
|
+
});
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
/** RFC 7230 hop-by-hop headers — never forwarded across a proxy. */
|
|
1234
|
+
const HOP_BY_HOP = new Set([
|
|
1235
|
+
"connection",
|
|
1236
|
+
"keep-alive",
|
|
1237
|
+
"proxy-authenticate",
|
|
1238
|
+
"proxy-authorization",
|
|
1239
|
+
"te",
|
|
1240
|
+
"trailer",
|
|
1241
|
+
"transfer-encoding",
|
|
1242
|
+
"upgrade"
|
|
1243
|
+
]);
|
|
1244
|
+
/** Copy `headers` without the hop-by-hop ones (which describe one connection). */
|
|
1245
|
+
function stripHopByHop(headers) {
|
|
1246
|
+
const out = {};
|
|
1247
|
+
for (const [k, v] of Object.entries(headers)) if (!HOP_BY_HOP.has(k.toLowerCase())) out[k] = v;
|
|
1248
|
+
return out;
|
|
1249
|
+
}
|
|
1250
|
+
/** A bounded byte collector that decodes to a (possibly truncated) utf8 string. */
|
|
1251
|
+
function boundedCollector(maxBytes) {
|
|
1252
|
+
const chunks = [];
|
|
1253
|
+
let size = 0;
|
|
1254
|
+
let truncated = false;
|
|
1255
|
+
return {
|
|
1256
|
+
push: (c) => {
|
|
1257
|
+
if (size >= maxBytes) {
|
|
1258
|
+
truncated = true;
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
const room = maxBytes - size;
|
|
1262
|
+
if (c.length > room) {
|
|
1263
|
+
chunks.push(c.subarray(0, room));
|
|
1264
|
+
size = maxBytes;
|
|
1265
|
+
truncated = true;
|
|
1266
|
+
} else {
|
|
1267
|
+
chunks.push(c);
|
|
1268
|
+
size += c.length;
|
|
1269
|
+
}
|
|
1270
|
+
},
|
|
1271
|
+
text: () => {
|
|
1272
|
+
const s = Buffer.concat(chunks).toString("utf8");
|
|
1273
|
+
return truncated ? `${s}… (truncated)` : s;
|
|
1274
|
+
}
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
/** Raw-bridge an `Upgrade` request (e.g. WebSocket) to the upstream. */
|
|
1278
|
+
function bridgeUpgrade(req, clientSocket, head, upstreamHost, upstreamPort) {
|
|
1279
|
+
const upstream = connect(upstreamPort, upstreamHost, () => {
|
|
1280
|
+
let raw = `${req.method} ${req.url} HTTP/1.1\r\n`;
|
|
1281
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2) raw += `${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r\n`;
|
|
1282
|
+
raw += "\r\n";
|
|
1283
|
+
upstream.write(raw);
|
|
1284
|
+
if (head && head.length > 0) upstream.write(head);
|
|
1285
|
+
clientSocket.pipe(upstream);
|
|
1286
|
+
upstream.pipe(clientSocket);
|
|
1287
|
+
});
|
|
1288
|
+
const teardown = () => {
|
|
1289
|
+
clientSocket.destroy();
|
|
1290
|
+
upstream.destroy();
|
|
1291
|
+
};
|
|
1292
|
+
upstream.on("error", teardown);
|
|
1293
|
+
upstream.on("close", teardown);
|
|
1294
|
+
clientSocket.on("error", teardown);
|
|
1295
|
+
clientSocket.on("close", teardown);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
//#endregion
|
|
1299
|
+
//#region src/local/studio-serve-manager.ts
|
|
1300
|
+
/** Kinds the serve manager can start in this build. */
|
|
1301
|
+
const SERVE_SUPPORTED = ["api"];
|
|
1302
|
+
/** `Server listening on <url>` is the stable ready marker `start-api` prints. */
|
|
1303
|
+
const LISTENING_RE = /Server listening on (\S+)/;
|
|
1304
|
+
/**
|
|
1305
|
+
* Build the studio serve manager. Slice C1 drives a long-running
|
|
1306
|
+
* `cdkl start-api <target>` child — studio is a control plane over the
|
|
1307
|
+
* CLI (the same pattern as the single-shot invoke dispatcher), so it
|
|
1308
|
+
* spawns the SAME headless serve command rather than re-wiring its
|
|
1309
|
+
* internals. This preserves byte-for-byte parity and isolates the
|
|
1310
|
+
* server's process-global behavior in a child.
|
|
1311
|
+
*
|
|
1312
|
+
* `start()` spawns the child with `--port 0` (OS-assigned, collision
|
|
1313
|
+
* free), streams its stdout/stderr onto the bus as `log` events keyed by
|
|
1314
|
+
* the target id, and resolves once the child prints its first
|
|
1315
|
+
* `Server listening on <url>` line — emitting a `serve` `running` event
|
|
1316
|
+
* with the discovered endpoints. `stop()` SIGTERMs the child (SIGKILL
|
|
1317
|
+
* after a grace window) and emits `stopped`.
|
|
1318
|
+
*
|
|
1319
|
+
* Slice C2 fronts each HTTP serve endpoint with a capture proxy
|
|
1320
|
+
* ({@link startStudioProxy}) so every request to the served port lands
|
|
1321
|
+
* on the studio timeline (decision D4a); the `endpoints` the UI is
|
|
1322
|
+
* handed are the proxy URLs. Full-text log search and alb / ecs serve
|
|
1323
|
+
* kinds are still to come.
|
|
1324
|
+
*/
|
|
1325
|
+
function createStudioServeManager(config) {
|
|
1326
|
+
const spawnFn = config.spawnFn ?? spawn;
|
|
1327
|
+
const nodeBin = config.nodeBin ?? process.execPath;
|
|
1328
|
+
const clock = config.clock ?? Date.now;
|
|
1329
|
+
const readyTimeoutMs = config.readyTimeoutMs ?? 12e4;
|
|
1330
|
+
const stopGraceMs = config.stopGraceMs ?? 1e4;
|
|
1331
|
+
const setTimeoutFn = config.setTimeoutFn ?? setTimeout;
|
|
1332
|
+
const clearTimeoutFn = config.clearTimeoutFn ?? clearTimeout;
|
|
1333
|
+
const cwd = config.cwd ?? process.cwd();
|
|
1334
|
+
const proxyFactory = config.proxyFactory ?? startStudioProxy;
|
|
1335
|
+
const captureRequests = config.captureRequests ?? true;
|
|
1336
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1337
|
+
/** Close every capture proxy fronting `e` (best-effort; idempotent). */
|
|
1338
|
+
async function closeProxies(e) {
|
|
1339
|
+
const proxies = e.proxies.splice(0);
|
|
1340
|
+
await Promise.all(proxies.map((p) => p.close().catch(() => void 0)));
|
|
1341
|
+
}
|
|
1342
|
+
function publicState(e) {
|
|
1343
|
+
const s = {
|
|
1344
|
+
targetId: e.targetId,
|
|
1345
|
+
kind: e.kind,
|
|
1346
|
+
status: e.status,
|
|
1347
|
+
endpoints: [...e.endpoints],
|
|
1348
|
+
startedAt: e.startedAt
|
|
1349
|
+
};
|
|
1350
|
+
if (e.pid !== void 0) s.pid = e.pid;
|
|
1351
|
+
return s;
|
|
1352
|
+
}
|
|
1353
|
+
function emitServe(e, message) {
|
|
1354
|
+
const ev = {
|
|
1355
|
+
ts: clock(),
|
|
1356
|
+
target: e.targetId,
|
|
1357
|
+
kind: e.kind,
|
|
1358
|
+
status: e.status,
|
|
1359
|
+
endpoints: [...e.endpoints]
|
|
1360
|
+
};
|
|
1361
|
+
if (e.pid !== void 0) ev.pid = e.pid;
|
|
1362
|
+
if (message !== void 0) ev.message = message;
|
|
1363
|
+
config.bus.emit("serve", ev);
|
|
1364
|
+
}
|
|
1365
|
+
function buildArgs(targetId) {
|
|
1366
|
+
const args = [
|
|
1367
|
+
"start-api",
|
|
1368
|
+
targetId,
|
|
1369
|
+
"--port",
|
|
1370
|
+
"0",
|
|
1371
|
+
"--host",
|
|
1372
|
+
"127.0.0.1"
|
|
1373
|
+
];
|
|
1374
|
+
if (config.app) args.push("--app", config.app);
|
|
1375
|
+
if (config.profile) args.push("--profile", config.profile);
|
|
1376
|
+
if (config.region) args.push("--region", config.region);
|
|
1377
|
+
for (const [k, v] of Object.entries(config.context ?? {})) args.push("-c", `${k}=${v}`);
|
|
1378
|
+
return args;
|
|
1379
|
+
}
|
|
1380
|
+
async function start(req) {
|
|
1381
|
+
if (!SERVE_SUPPORTED.includes(req.kind)) throw new Error(`Serving '${req.kind}' targets from studio is not supported yet (API only in this build).`);
|
|
1382
|
+
const existing = entries.get(req.targetId);
|
|
1383
|
+
if (existing && existing.status !== "stopped" && existing.status !== "error") throw new Error(`'${req.targetId}' is already running.`);
|
|
1384
|
+
const startedAt = clock();
|
|
1385
|
+
let child;
|
|
1386
|
+
try {
|
|
1387
|
+
child = spawnFn(nodeBin, [config.cliEntry, ...buildArgs(req.targetId)], { cwd });
|
|
1388
|
+
} catch (err) {
|
|
1389
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
1390
|
+
}
|
|
1391
|
+
const entry = {
|
|
1392
|
+
targetId: req.targetId,
|
|
1393
|
+
kind: req.kind,
|
|
1394
|
+
status: "starting",
|
|
1395
|
+
endpoints: [],
|
|
1396
|
+
startedAt,
|
|
1397
|
+
child,
|
|
1398
|
+
proxies: []
|
|
1399
|
+
};
|
|
1400
|
+
if (child.pid !== void 0) entry.pid = child.pid;
|
|
1401
|
+
entries.set(req.targetId, entry);
|
|
1402
|
+
emitServe(entry);
|
|
1403
|
+
return new Promise((resolve, reject) => {
|
|
1404
|
+
let settled = false;
|
|
1405
|
+
child.stdout.setEncoding("utf8");
|
|
1406
|
+
child.stderr.setEncoding("utf8");
|
|
1407
|
+
const timer = setTimeoutFn(() => {
|
|
1408
|
+
if (settled) return;
|
|
1409
|
+
settled = true;
|
|
1410
|
+
entry.status = "error";
|
|
1411
|
+
emitServe(entry, `Timed out after ${readyTimeoutMs}ms waiting for the server to listen.`);
|
|
1412
|
+
stopChild(child, stopGraceMs, setTimeoutFn, clearTimeoutFn);
|
|
1413
|
+
closeProxies(entry);
|
|
1414
|
+
entries.delete(req.targetId);
|
|
1415
|
+
reject(/* @__PURE__ */ new Error(`'${req.targetId}' did not start within ${readyTimeoutMs}ms.`));
|
|
1416
|
+
}, readyTimeoutMs);
|
|
1417
|
+
timer.unref?.();
|
|
1418
|
+
const becomeRunning = () => {
|
|
1419
|
+
if (settled) return;
|
|
1420
|
+
settled = true;
|
|
1421
|
+
clearTimeoutFn(timer);
|
|
1422
|
+
entry.status = "running";
|
|
1423
|
+
emitServe(entry);
|
|
1424
|
+
resolve(publicState(entry));
|
|
1425
|
+
};
|
|
1426
|
+
const onListening = async (childUrl) => {
|
|
1427
|
+
let endpoint = childUrl;
|
|
1428
|
+
if (captureRequests && /^https?:/i.test(childUrl)) try {
|
|
1429
|
+
const proxy = await proxyFactory({
|
|
1430
|
+
bus: config.bus,
|
|
1431
|
+
target: req.targetId,
|
|
1432
|
+
kind: req.kind,
|
|
1433
|
+
upstream: childUrl
|
|
1434
|
+
});
|
|
1435
|
+
entry.proxies.push(proxy);
|
|
1436
|
+
endpoint = proxy.url;
|
|
1437
|
+
} catch {
|
|
1438
|
+
endpoint = childUrl;
|
|
1439
|
+
}
|
|
1440
|
+
if (entry.stopping || settled && !entries.has(req.targetId)) {
|
|
1441
|
+
await closeProxies(entry);
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
if (!entry.endpoints.includes(endpoint)) entry.endpoints.push(endpoint);
|
|
1445
|
+
if (settled) emitServe(entry);
|
|
1446
|
+
else becomeRunning();
|
|
1447
|
+
};
|
|
1448
|
+
streamLines(child.stdout, (line) => {
|
|
1449
|
+
const m = LISTENING_RE.exec(line);
|
|
1450
|
+
if (m?.[1]) onListening(m[1]);
|
|
1451
|
+
emitLog(config.bus, clock, req.targetId, line, "stdout");
|
|
1452
|
+
});
|
|
1453
|
+
streamLines(child.stderr, (line) => {
|
|
1454
|
+
emitLog(config.bus, clock, req.targetId, line, "stderr");
|
|
1455
|
+
});
|
|
1456
|
+
child.on("error", (err) => {
|
|
1457
|
+
if (settled) {
|
|
1458
|
+
entry.status = "error";
|
|
1459
|
+
emitServe(entry, err.message);
|
|
1460
|
+
closeProxies(entry);
|
|
1461
|
+
entries.delete(req.targetId);
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
settled = true;
|
|
1465
|
+
clearTimeoutFn(timer);
|
|
1466
|
+
entry.status = "error";
|
|
1467
|
+
emitServe(entry, err.message);
|
|
1468
|
+
closeProxies(entry);
|
|
1469
|
+
entries.delete(req.targetId);
|
|
1470
|
+
reject(err);
|
|
1471
|
+
});
|
|
1472
|
+
child.on("close", (code) => {
|
|
1473
|
+
if (!settled) {
|
|
1474
|
+
settled = true;
|
|
1475
|
+
clearTimeoutFn(timer);
|
|
1476
|
+
if (entry.stopping) {
|
|
1477
|
+
reject(/* @__PURE__ */ new Error(`'${req.targetId}' was stopped before it finished starting.`));
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
entry.status = "error";
|
|
1481
|
+
const msg = `Server exited before listening (code ${code ?? "null"}).`;
|
|
1482
|
+
emitServe(entry, msg);
|
|
1483
|
+
closeProxies(entry);
|
|
1484
|
+
entries.delete(req.targetId);
|
|
1485
|
+
reject(new Error(msg));
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
if (entries.get(req.targetId) === entry && entry.status === "running") {
|
|
1489
|
+
entry.status = "stopped";
|
|
1490
|
+
emitServe(entry, `Server process exited (code ${code ?? "null"}).`);
|
|
1491
|
+
closeProxies(entry);
|
|
1492
|
+
entries.delete(req.targetId);
|
|
1493
|
+
}
|
|
1494
|
+
});
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
async function stop(req) {
|
|
1498
|
+
const entry = entries.get(req.targetId);
|
|
1499
|
+
if (!entry) throw new Error(`'${req.targetId}' is not running.`);
|
|
1500
|
+
entry.stopping = true;
|
|
1501
|
+
entries.delete(req.targetId);
|
|
1502
|
+
await Promise.all([closeProxies(entry), stopChild(entry.child, stopGraceMs, setTimeoutFn, clearTimeoutFn)]);
|
|
1503
|
+
entry.status = "stopped";
|
|
1504
|
+
emitServe(entry);
|
|
1505
|
+
}
|
|
1506
|
+
function list() {
|
|
1507
|
+
return [...entries.values()].map(publicState);
|
|
1508
|
+
}
|
|
1509
|
+
async function stopAll() {
|
|
1510
|
+
const targets = [...entries.keys()];
|
|
1511
|
+
await Promise.all(targets.map((targetId) => stop({ targetId }).catch(() => void 0)));
|
|
1512
|
+
}
|
|
1513
|
+
return {
|
|
1514
|
+
start,
|
|
1515
|
+
stop,
|
|
1516
|
+
list,
|
|
1517
|
+
stopAll
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
/** Emit one container log line onto the bus, keyed by the serve target id. */
|
|
1521
|
+
function emitLog(bus, clock, target, line, stream) {
|
|
1522
|
+
bus.emit("log", {
|
|
1523
|
+
ts: clock(),
|
|
1524
|
+
containerId: target,
|
|
1525
|
+
target,
|
|
1526
|
+
line,
|
|
1527
|
+
stream
|
|
1528
|
+
});
|
|
1529
|
+
}
|
|
1530
|
+
/**
|
|
1531
|
+
* Line-buffer a child stream and invoke `onLine` per complete line
|
|
1532
|
+
* (trailing newline stripped, blank lines dropped). Flushes any partial
|
|
1533
|
+
* final line on stream end.
|
|
1534
|
+
*/
|
|
1535
|
+
function streamLines(stream, onLine) {
|
|
1536
|
+
let buf = "";
|
|
1537
|
+
stream.on("data", (chunk) => {
|
|
1538
|
+
buf += chunk;
|
|
1539
|
+
let nl = buf.indexOf("\n");
|
|
1540
|
+
while (nl !== -1) {
|
|
1541
|
+
const line = buf.slice(0, nl).replace(/\r$/, "");
|
|
1542
|
+
buf = buf.slice(nl + 1);
|
|
1543
|
+
if (line.length > 0) onLine(line);
|
|
1544
|
+
nl = buf.indexOf("\n");
|
|
1545
|
+
}
|
|
1546
|
+
});
|
|
1547
|
+
stream.on("end", () => {
|
|
1548
|
+
const line = buf.replace(/\r$/, "");
|
|
1549
|
+
if (line.length > 0) onLine(line);
|
|
1550
|
+
buf = "";
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1553
|
+
/**
|
|
1554
|
+
* SIGTERM a child and resolve once it exits, escalating to SIGKILL after
|
|
1555
|
+
* `graceMs`. Resolves immediately if the child has already exited.
|
|
1556
|
+
*/
|
|
1557
|
+
function stopChild(child, graceMs, setTimeoutFn, clearTimeoutFn) {
|
|
1558
|
+
return new Promise((resolve) => {
|
|
1559
|
+
let done = false;
|
|
1560
|
+
let kill;
|
|
1561
|
+
const finish = () => {
|
|
1562
|
+
if (done) return;
|
|
1563
|
+
done = true;
|
|
1564
|
+
if (kill) clearTimeoutFn(kill);
|
|
1565
|
+
resolve();
|
|
1566
|
+
};
|
|
1567
|
+
child.once("close", finish);
|
|
1568
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
1569
|
+
finish();
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
kill = setTimeoutFn(() => {
|
|
1573
|
+
if (!done) child.kill("SIGKILL");
|
|
1574
|
+
}, graceMs);
|
|
1575
|
+
kill.unref?.();
|
|
1576
|
+
child.kill("SIGTERM");
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
|
|
860
1580
|
//#endregion
|
|
861
1581
|
//#region src/cli/commands/local-studio.ts
|
|
862
1582
|
const STUDIO_TARGET_KINDS = [
|
|
@@ -883,6 +1603,17 @@ function coerceRunRequest(body) {
|
|
|
883
1603
|
event
|
|
884
1604
|
};
|
|
885
1605
|
}
|
|
1606
|
+
/**
|
|
1607
|
+
* Validate + narrow the untyped `POST /api/stop` body into a
|
|
1608
|
+
* {@link StudioStopRequest}. Throws (→ 400 from the server) on a missing
|
|
1609
|
+
* / empty target id.
|
|
1610
|
+
*/
|
|
1611
|
+
function coerceStopRequest(body) {
|
|
1612
|
+
if (typeof body !== "object" || body === null) throw new Error("Request body must be a JSON object.");
|
|
1613
|
+
const { targetId } = body;
|
|
1614
|
+
if (typeof targetId !== "string" || targetId.trim() === "") throw new Error("Request body must include a non-empty \"targetId\" string.");
|
|
1615
|
+
return { targetId };
|
|
1616
|
+
}
|
|
886
1617
|
const DEFAULT_STUDIO_PORT = 9999;
|
|
887
1618
|
/**
|
|
888
1619
|
* Parse + validate the `--studio-port` value. Accepts `0` (OS-assigned)
|
|
@@ -918,7 +1649,7 @@ async function localStudioCommand(options) {
|
|
|
918
1649
|
const targetGroups = toStudioTargetGroups(listTargets(stacks));
|
|
919
1650
|
const appLabel = stacks.map((s) => s.stackName).join(", ") || appCmd;
|
|
920
1651
|
const bus = new StudioEventBus();
|
|
921
|
-
const
|
|
1652
|
+
const childConfig = {
|
|
922
1653
|
cliEntry: process.argv[1] ?? "",
|
|
923
1654
|
bus,
|
|
924
1655
|
cwd: process.cwd(),
|
|
@@ -926,20 +1657,31 @@ async function localStudioCommand(options) {
|
|
|
926
1657
|
...options.profile ? { profile: options.profile } : {},
|
|
927
1658
|
...options.region ? { region: options.region } : {},
|
|
928
1659
|
...Object.keys(context).length > 0 ? { context } : {}
|
|
929
|
-
}
|
|
1660
|
+
};
|
|
1661
|
+
const dispatcher = createStudioDispatcher(childConfig);
|
|
1662
|
+
const serveManager = createStudioServeManager(childConfig);
|
|
930
1663
|
const server = await startStudioServer({
|
|
931
1664
|
port,
|
|
932
1665
|
bus,
|
|
933
1666
|
targetGroups,
|
|
934
1667
|
appLabel,
|
|
935
1668
|
cliName: getEmbedConfig().cliName,
|
|
936
|
-
onRun: (body) =>
|
|
1669
|
+
onRun: (body) => {
|
|
1670
|
+
const req = coerceRunRequest(body);
|
|
1671
|
+
return req.kind === "lambda" ? dispatcher.run(req) : serveManager.start(req);
|
|
1672
|
+
},
|
|
1673
|
+
onStop: async (body) => {
|
|
1674
|
+
const req = coerceStopRequest(body);
|
|
1675
|
+
await serveManager.stop(req);
|
|
1676
|
+
return { stopped: req.targetId };
|
|
1677
|
+
},
|
|
1678
|
+
getRunning: () => ({ running: serveManager.list() })
|
|
937
1679
|
});
|
|
938
1680
|
const cliName = getEmbedConfig().cliName;
|
|
939
1681
|
logger.info(`${cliName} studio is running at ${server.url}`);
|
|
940
1682
|
logger.info("Press Ctrl-C to stop.");
|
|
941
1683
|
if (options.open && process.stdout.isTTY) openBrowser(server.url);
|
|
942
|
-
await blockUntilShutdown(server, cliName);
|
|
1684
|
+
await blockUntilShutdown(server, serveManager, cliName);
|
|
943
1685
|
}
|
|
944
1686
|
/** Best-effort cross-platform browser open. Failures are non-fatal. */
|
|
945
1687
|
function openBrowser(url) {
|
|
@@ -955,17 +1697,20 @@ function openBrowser(url) {
|
|
|
955
1697
|
} catch {}
|
|
956
1698
|
}
|
|
957
1699
|
/**
|
|
958
|
-
* Block until SIGINT / SIGTERM, then
|
|
959
|
-
* Mirrors the long-running serve
|
|
1700
|
+
* Block until SIGINT / SIGTERM, then stop every running serve child,
|
|
1701
|
+
* close the studio server, and resolve. Mirrors the long-running serve
|
|
1702
|
+
* commands' graceful-shutdown contract — the serve children are killed
|
|
1703
|
+
* BEFORE the server closes so their RIE containers are torn down rather
|
|
1704
|
+
* than orphaned.
|
|
960
1705
|
*/
|
|
961
|
-
function blockUntilShutdown(server, cliName) {
|
|
1706
|
+
function blockUntilShutdown(server, serveManager, cliName) {
|
|
962
1707
|
return new Promise((resolveShutdown) => {
|
|
963
1708
|
let shuttingDown = false;
|
|
964
1709
|
const shutdown = (signal) => {
|
|
965
1710
|
if (shuttingDown) return;
|
|
966
1711
|
shuttingDown = true;
|
|
967
1712
|
getLogger().info(`Received ${signal}; stopping ${cliName} studio...`);
|
|
968
|
-
server.close().catch((err) => getLogger().warn(`Error stopping studio server: ${String(err)}`)).finally(() => resolveShutdown());
|
|
1713
|
+
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
1714
|
};
|
|
970
1715
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
971
1716
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
@@ -1000,5 +1745,5 @@ function addStudioSpecificOptions(cmd) {
|
|
|
1000
1745
|
}
|
|
1001
1746
|
|
|
1002
1747
|
//#endregion
|
|
1003
|
-
export {
|
|
1004
|
-
//# sourceMappingURL=local-studio-
|
|
1748
|
+
export { createStudioServeManager as a, startStudioServer as c, StudioEventBus as d, createLocalStudioCommand as i, toStudioTargetGroups as l, coerceRunRequest as n, startStudioProxy as o, coerceStopRequest as r, createStudioDispatcher as s, addStudioSpecificOptions as t, renderStudioHtml as u };
|
|
1749
|
+
//# sourceMappingURL=local-studio-KUaxIkpx.js.map
|