cdk-local 0.83.0 → 0.85.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/README.md +15 -0
- package/dist/cli.js +3 -12
- package/dist/cli.js.map +1 -1
- package/dist/{docker-cmd-Dqx2YENO.js → docker-cmd-GcI_cqY5.js} +19 -3
- package/dist/docker-cmd-GcI_cqY5.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +3 -3
- package/dist/internal.d.ts +1 -145
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +1 -2
- package/dist/{local-list-HwEDWCdf.d.ts → local-studio-C5bVzVS4.d.ts} +151 -2
- package/dist/local-studio-C5bVzVS4.d.ts.map +1 -0
- package/dist/{local-list-DBlBRfA-.js → local-studio-DaJ3D7co.js} +2031 -14
- package/dist/local-studio-DaJ3D7co.js.map +1 -0
- package/package.json +1 -1
- package/dist/docker-cmd-Dqx2YENO.js.map +0 -1
- package/dist/local-list-DBlBRfA-.js.map +0 -1
- package/dist/local-list-HwEDWCdf.d.ts.map +0 -1
- package/dist/local-studio-8r3jr9nj.js +0 -1993
- package/dist/local-studio-8r3jr9nj.js.map +0 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as runDockerStreaming, c as
|
|
1
|
+
import { a as runDockerStreaming, c as resolveConfiguredLogLevel, d as setEmbedConfig, i as runDockerForeground, l as getEmbedConfig, n as formatDockerLoginError, o as spawnStreaming, r as getDockerCmd, s as getLogger } from "./docker-cmd-GcI_cqY5.js";
|
|
2
2
|
import { cpSync, createWriteStream, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir, tmpdir } from "node:os";
|
|
4
4
|
import * as path$1 from "node:path";
|
|
@@ -33,6 +33,7 @@ import { Sha256 } from "@aws-crypto/sha256-js";
|
|
|
33
33
|
import { SignatureV4 } from "@smithy/signature-v4";
|
|
34
34
|
import graphlib from "graphlib";
|
|
35
35
|
import { GetSecretValueCommand, SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
|
|
36
|
+
import { EventEmitter } from "node:events";
|
|
36
37
|
|
|
37
38
|
//#region src/cli/options.ts
|
|
38
39
|
/**
|
|
@@ -3560,14 +3561,28 @@ async function resolveMultiTarget(provided, params) {
|
|
|
3560
3561
|
* command additionally writes its own `Synthesizing...` status to
|
|
3561
3562
|
* stderr so its stdout is exactly the list. These are status lines no
|
|
3562
3563
|
* caller parses from cdk-local's stdout.
|
|
3564
|
+
*
|
|
3565
|
+
* When `CDKL_LOG_LEVEL` resolves to `warn` / `error` (set by `cdkl studio`
|
|
3566
|
+
* on its single-shot `cdkl invoke` child), the synth-progress notifications
|
|
3567
|
+
* above — and any other non-`warn`/`error` toolkit message — are dropped
|
|
3568
|
+
* entirely, so cdk-local's own synth chatter never reaches the child's
|
|
3569
|
+
* stderr. This keeps the studio LOGS panel free of "Successfully
|
|
3570
|
+
* synthesized to ..." / asset-bundling noise; real synth failures still
|
|
3571
|
+
* surface (toolkit raises `AssemblyError` on non-zero subprocess exit, and
|
|
3572
|
+
* `warn` / `error` messages still pass through).
|
|
3563
3573
|
*/
|
|
3564
3574
|
var CdklIoHost = class extends NonInteractiveIoHost {
|
|
3575
|
+
suppressNonWarnings = (() => {
|
|
3576
|
+
const level = resolveConfiguredLogLevel();
|
|
3577
|
+
return level === "warn" || level === "error";
|
|
3578
|
+
})();
|
|
3565
3579
|
async notify(msg) {
|
|
3566
|
-
|
|
3580
|
+
const reclassified = msg.code === "CDK_ASSEMBLY_E1002" || msg.code === "CDK_TOOLKIT_I1901" || msg.code === "CDK_TOOLKIT_I1902" ? {
|
|
3567
3581
|
...msg,
|
|
3568
3582
|
level: "info"
|
|
3569
|
-
}
|
|
3570
|
-
|
|
3583
|
+
} : msg;
|
|
3584
|
+
if (this.suppressNonWarnings && reclassified.level !== "warn" && reclassified.level !== "error") return;
|
|
3585
|
+
return super.notify(reclassified);
|
|
3571
3586
|
}
|
|
3572
3587
|
};
|
|
3573
3588
|
|
|
@@ -13488,7 +13503,7 @@ async function startApiServer(opts) {
|
|
|
13488
13503
|
const logger = getLogger().child("start-api");
|
|
13489
13504
|
let currentState = opts.state;
|
|
13490
13505
|
const requestHandler = (req, res) => {
|
|
13491
|
-
handleRequest(req, res, currentState, opts).catch((err) => {
|
|
13506
|
+
handleRequest$1(req, res, currentState, opts).catch((err) => {
|
|
13492
13507
|
logger.error(`Unhandled request error: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
13493
13508
|
if (!res.headersSent) writeError$1(res, 502);
|
|
13494
13509
|
});
|
|
@@ -13557,7 +13572,7 @@ async function startApiServer(opts) {
|
|
|
13557
13572
|
* route handler.
|
|
13558
13573
|
* 4. Build event, acquire container, invoke RIE, translate response.
|
|
13559
13574
|
*/
|
|
13560
|
-
async function handleRequest(req, res, state, opts) {
|
|
13575
|
+
async function handleRequest$1(req, res, state, opts) {
|
|
13561
13576
|
const logger = getLogger().child("start-api");
|
|
13562
13577
|
if (opts.preDispatch) try {
|
|
13563
13578
|
if (await opts.preDispatch(req, res)) return;
|
|
@@ -22207,7 +22222,7 @@ async function softReloadReplica(args) {
|
|
|
22207
22222
|
const defaultDockerInspectWorkdirImpl = async (containerId) => {
|
|
22208
22223
|
const { execFile } = await import("node:child_process");
|
|
22209
22224
|
const { promisify } = await import("node:util");
|
|
22210
|
-
const { getDockerCmd } = await import("./docker-cmd-
|
|
22225
|
+
const { getDockerCmd } = await import("./docker-cmd-GcI_cqY5.js").then((n) => n.t);
|
|
22211
22226
|
const { stdout } = await promisify(execFile)(getDockerCmd(), [
|
|
22212
22227
|
"inspect",
|
|
22213
22228
|
"--format",
|
|
@@ -22226,7 +22241,7 @@ let dockerInspectWorkdirImpl = defaultDockerInspectWorkdirImpl;
|
|
|
22226
22241
|
const defaultDockerCpImpl = async (src, dst) => {
|
|
22227
22242
|
const { execFile } = await import("node:child_process");
|
|
22228
22243
|
const { promisify } = await import("node:util");
|
|
22229
|
-
const { getDockerCmd } = await import("./docker-cmd-
|
|
22244
|
+
const { getDockerCmd } = await import("./docker-cmd-GcI_cqY5.js").then((n) => n.t);
|
|
22230
22245
|
await promisify(execFile)(getDockerCmd(), [
|
|
22231
22246
|
"cp",
|
|
22232
22247
|
src,
|
|
@@ -22241,7 +22256,7 @@ let dockerCpImpl = defaultDockerCpImpl;
|
|
|
22241
22256
|
const defaultDockerRestartImpl = async (containerId) => {
|
|
22242
22257
|
const { execFile } = await import("node:child_process");
|
|
22243
22258
|
const { promisify } = await import("node:util");
|
|
22244
|
-
const { getDockerCmd } = await import("./docker-cmd-
|
|
22259
|
+
const { getDockerCmd } = await import("./docker-cmd-GcI_cqY5.js").then((n) => n.t);
|
|
22245
22260
|
await promisify(execFile)(getDockerCmd(), ["restart", containerId]);
|
|
22246
22261
|
};
|
|
22247
22262
|
let dockerRestartImpl = defaultDockerRestartImpl;
|
|
@@ -22281,7 +22296,7 @@ async function disconnectOldFromSharedNetwork(oldInstance) {
|
|
|
22281
22296
|
const defaultDockerNetworkDisconnectImpl = async (networkName, containerId) => {
|
|
22282
22297
|
const { execFile } = await import("node:child_process");
|
|
22283
22298
|
const { promisify } = await import("node:util");
|
|
22284
|
-
const { getDockerCmd } = await import("./docker-cmd-
|
|
22299
|
+
const { getDockerCmd } = await import("./docker-cmd-GcI_cqY5.js").then((n) => n.t);
|
|
22285
22300
|
await promisify(execFile)(getDockerCmd(), [
|
|
22286
22301
|
"network",
|
|
22287
22302
|
"disconnect",
|
|
@@ -22458,7 +22473,7 @@ function pickEssentialContainerId(instance, service) {
|
|
|
22458
22473
|
const defaultWaitForExitImpl = async (containerId) => {
|
|
22459
22474
|
const { execFile } = await import("node:child_process");
|
|
22460
22475
|
const { promisify } = await import("node:util");
|
|
22461
|
-
const { getDockerCmd } = await import("./docker-cmd-
|
|
22476
|
+
const { getDockerCmd } = await import("./docker-cmd-GcI_cqY5.js").then((n) => n.t);
|
|
22462
22477
|
const { stdout } = await promisify(execFile)(getDockerCmd(), ["wait", containerId], { maxBuffer: 1024 * 1024 });
|
|
22463
22478
|
const code = parseInt(stdout.trim(), 10);
|
|
22464
22479
|
return Number.isFinite(code) ? code : -1;
|
|
@@ -22478,7 +22493,7 @@ const EXIT_LOG_TAIL_LINES = 50;
|
|
|
22478
22493
|
const defaultReadContainerLogsImpl = async (containerId) => {
|
|
22479
22494
|
const { execFile } = await import("node:child_process");
|
|
22480
22495
|
const { promisify } = await import("node:util");
|
|
22481
|
-
const { getDockerCmd } = await import("./docker-cmd-
|
|
22496
|
+
const { getDockerCmd } = await import("./docker-cmd-GcI_cqY5.js").then((n) => n.t);
|
|
22482
22497
|
const { stdout, stderr } = await promisify(execFile)(getDockerCmd(), [
|
|
22483
22498
|
"logs",
|
|
22484
22499
|
"--tail",
|
|
@@ -27855,5 +27870,2007 @@ function addListSpecificOptions(cmd) {
|
|
|
27855
27870
|
}
|
|
27856
27871
|
|
|
27857
27872
|
//#endregion
|
|
27858
|
-
|
|
27859
|
-
|
|
27873
|
+
//#region src/local/studio-events.ts
|
|
27874
|
+
/**
|
|
27875
|
+
* In-process event bus that every studio observation flows through. The
|
|
27876
|
+
* studio HTTP server subscribes and forwards events to the browser over
|
|
27877
|
+
* SSE; the dispatch / log-streaming layers emit onto it.
|
|
27878
|
+
*
|
|
27879
|
+
* A thin typed wrapper over {@link EventEmitter} so producers and the
|
|
27880
|
+
* server agree on the event shapes without `any`. Re-exported from
|
|
27881
|
+
* `cdk-local/internal` so a host CLI embedding studio can subscribe.
|
|
27882
|
+
*/
|
|
27883
|
+
var StudioEventBus = class {
|
|
27884
|
+
emitter = new EventEmitter();
|
|
27885
|
+
constructor() {
|
|
27886
|
+
this.emitter.setMaxListeners(0);
|
|
27887
|
+
}
|
|
27888
|
+
emit(event, ...args) {
|
|
27889
|
+
this.emitter.emit(event, ...args);
|
|
27890
|
+
}
|
|
27891
|
+
on(event, listener) {
|
|
27892
|
+
this.emitter.on(event, listener);
|
|
27893
|
+
return this;
|
|
27894
|
+
}
|
|
27895
|
+
off(event, listener) {
|
|
27896
|
+
this.emitter.off(event, listener);
|
|
27897
|
+
return this;
|
|
27898
|
+
}
|
|
27899
|
+
/**
|
|
27900
|
+
* Number of listeners currently subscribed to `event`. Exposed so the
|
|
27901
|
+
* SSE server's subscribe / unsubscribe symmetry can be asserted (a
|
|
27902
|
+
* dropped client must not leak a listener).
|
|
27903
|
+
*/
|
|
27904
|
+
listenerCount(event) {
|
|
27905
|
+
return this.emitter.listenerCount(event);
|
|
27906
|
+
}
|
|
27907
|
+
};
|
|
27908
|
+
|
|
27909
|
+
//#endregion
|
|
27910
|
+
//#region src/local/studio-store.ts
|
|
27911
|
+
/**
|
|
27912
|
+
* Build the studio store and subscribe it to `bus`. The store merges the
|
|
27913
|
+
* start/end pair of each invocation (keyed by id) and keeps a ring of log
|
|
27914
|
+
* lines; both windows evict oldest-first past their caps.
|
|
27915
|
+
*/
|
|
27916
|
+
function createStudioStore(bus, options = {}) {
|
|
27917
|
+
const maxInvocations = options.maxInvocations ?? 200;
|
|
27918
|
+
const maxLogs = options.maxLogs ?? 5e3;
|
|
27919
|
+
const bindGraceMs = options.bindGraceMs ?? 250;
|
|
27920
|
+
const invocations = /* @__PURE__ */ new Map();
|
|
27921
|
+
const logs = [];
|
|
27922
|
+
const onInvocation = (ev) => {
|
|
27923
|
+
invocations.set(ev.id, {
|
|
27924
|
+
...invocations.get(ev.id),
|
|
27925
|
+
...ev
|
|
27926
|
+
});
|
|
27927
|
+
if (invocations.size > maxInvocations) {
|
|
27928
|
+
const oldest = invocations.keys().next().value;
|
|
27929
|
+
if (oldest !== void 0) invocations.delete(oldest);
|
|
27930
|
+
}
|
|
27931
|
+
};
|
|
27932
|
+
const onLog = (ev) => {
|
|
27933
|
+
logs.push(ev);
|
|
27934
|
+
if (logs.length > maxLogs) logs.shift();
|
|
27935
|
+
};
|
|
27936
|
+
bus.on("invocation", onInvocation);
|
|
27937
|
+
bus.on("log", onLog);
|
|
27938
|
+
let disposed = false;
|
|
27939
|
+
return {
|
|
27940
|
+
history: () => ({
|
|
27941
|
+
invocations: [...invocations.values()],
|
|
27942
|
+
logs: [...logs]
|
|
27943
|
+
}),
|
|
27944
|
+
searchLogs: (query, opts = {}) => {
|
|
27945
|
+
const needle = query.toLowerCase();
|
|
27946
|
+
const limit = opts.limit ?? 200;
|
|
27947
|
+
const matches = [];
|
|
27948
|
+
for (let i = logs.length - 1; i >= 0 && matches.length < limit; i -= 1) {
|
|
27949
|
+
const log = logs[i];
|
|
27950
|
+
if (!log) continue;
|
|
27951
|
+
if (opts.target !== void 0 && log.target !== opts.target) continue;
|
|
27952
|
+
if (needle === "" || log.line.toLowerCase().includes(needle)) matches.push(log);
|
|
27953
|
+
}
|
|
27954
|
+
return matches;
|
|
27955
|
+
},
|
|
27956
|
+
logsForInvocation: (id) => {
|
|
27957
|
+
const inv = invocations.get(id);
|
|
27958
|
+
if (!inv) return [];
|
|
27959
|
+
if (inv.kind === "lambda") return logs.filter((l) => l.containerId === id);
|
|
27960
|
+
const from = inv.ts;
|
|
27961
|
+
const to = inv.ts + (inv.durationMs ?? 0) + bindGraceMs;
|
|
27962
|
+
return logs.filter((l) => l.target === inv.target && l.ts >= from && l.ts <= to);
|
|
27963
|
+
},
|
|
27964
|
+
invocation: (id) => invocations.get(id),
|
|
27965
|
+
dispose: () => {
|
|
27966
|
+
if (disposed) return;
|
|
27967
|
+
disposed = true;
|
|
27968
|
+
bus.off("invocation", onInvocation);
|
|
27969
|
+
bus.off("log", onLog);
|
|
27970
|
+
}
|
|
27971
|
+
};
|
|
27972
|
+
}
|
|
27973
|
+
|
|
27974
|
+
//#endregion
|
|
27975
|
+
//#region src/local/studio-ui.ts
|
|
27976
|
+
/**
|
|
27977
|
+
* The studio web UI, embedded as a string so it ships inside the
|
|
27978
|
+
* `cdk-local` npm package (decision D9) with no asset-copy build step —
|
|
27979
|
+
* `tsdown` bundles this module like any other source file. Served by
|
|
27980
|
+
* the studio HTTP server (`startStudioServer`) at `GET /`.
|
|
27981
|
+
*
|
|
27982
|
+
* 3-pane shell (decision D6), framework-free vanilla JS (decision D7):
|
|
27983
|
+
* - left = target list (from `GET /api/targets`); each Lambda has an
|
|
27984
|
+
* [Invoke] button, each API a [Start] / [Stop] serve control with a
|
|
27985
|
+
* `running ● :port` indicator (slice C1), plus a selected-highlight.
|
|
27986
|
+
* - center = the WORKSPACE for the selected target: for a Lambda, an
|
|
27987
|
+
* event composer (textarea + Invoke button) with the latest run's
|
|
27988
|
+
* Request / Response / Logs shown below; for an API, a Start/Stop
|
|
27989
|
+
* control with the served endpoints + streaming logs.
|
|
27990
|
+
* - right = the timeline (history) of every invocation AND every
|
|
27991
|
+
* captured serve request (slice C2); clicking a Lambda row reloads
|
|
27992
|
+
* it into the composer, clicking a captured request row opens a
|
|
27993
|
+
* read-only Request / Response detail.
|
|
27994
|
+
*
|
|
27995
|
+
* The center workspace is deliberately adjacent to the left target list
|
|
27996
|
+
* (short eye-travel: pick a target -> compose right next to it), and is
|
|
27997
|
+
* the primary surface — the timeline is secondary history.
|
|
27998
|
+
*/
|
|
27999
|
+
const STUDIO_CSS = `
|
|
28000
|
+
* { box-sizing: border-box; }
|
|
28001
|
+
body {
|
|
28002
|
+
margin: 0; font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
28003
|
+
color: #e6e6e6; background: #1a1a1a; height: 100vh; overflow: hidden;
|
|
28004
|
+
}
|
|
28005
|
+
header {
|
|
28006
|
+
padding: 8px 14px; background: #111; border-bottom: 1px solid #333;
|
|
28007
|
+
display: flex; align-items: center; gap: 10px;
|
|
28008
|
+
}
|
|
28009
|
+
header .brand { font-weight: 700; color: #fff; }
|
|
28010
|
+
header .meta { color: #888; font-size: 12px; }
|
|
28011
|
+
main {
|
|
28012
|
+
display: grid; grid-template-columns: 280px 5px 1fr 5px 320px;
|
|
28013
|
+
height: calc(100vh - 38px);
|
|
28014
|
+
}
|
|
28015
|
+
.pane { overflow: auto; }
|
|
28016
|
+
.splitter { background: #2a2a2a; cursor: col-resize; }
|
|
28017
|
+
.splitter:hover, .splitter.dragging { background: #4ec97a; }
|
|
28018
|
+
.pane h2 {
|
|
28019
|
+
margin: 0; padding: 8px 12px; font-size: 11px; text-transform: uppercase;
|
|
28020
|
+
letter-spacing: 0.5px; color: #888; background: #151515;
|
|
28021
|
+
position: sticky; top: 0; border-bottom: 1px solid #2a2a2a; z-index: 1;
|
|
28022
|
+
}
|
|
28023
|
+
.group-title { padding: 8px 12px 2px; color: #6aa9ff; font-size: 11px; }
|
|
28024
|
+
.target {
|
|
28025
|
+
padding: 6px 12px; border-bottom: 1px solid #222; display: flex;
|
|
28026
|
+
align-items: center; gap: 8px;
|
|
28027
|
+
}
|
|
28028
|
+
.target.runnable { cursor: pointer; }
|
|
28029
|
+
.target.runnable:hover { background: #202020; }
|
|
28030
|
+
.target.sel { background: #2a3550; }
|
|
28031
|
+
.target .name { color: #ddd; flex: 1; overflow: hidden; text-overflow: ellipsis; }
|
|
28032
|
+
.target .kind { color: #777; font-size: 11px; }
|
|
28033
|
+
.target .invoke-btn {
|
|
28034
|
+
padding: 2px 10px; font: 11px ui-monospace, Menlo, monospace; font-weight: 700;
|
|
28035
|
+
color: #0d1f12; background: #4ec97a; border: 0; border-radius: 3px; cursor: pointer;
|
|
28036
|
+
}
|
|
28037
|
+
.target .invoke-btn:hover { background: #6fe097; }
|
|
28038
|
+
.target.sel .invoke-btn { background: #6fe097; }
|
|
28039
|
+
.target .stop-btn {
|
|
28040
|
+
padding: 2px 10px; font: 11px ui-monospace, Menlo, monospace; font-weight: 700;
|
|
28041
|
+
color: #2a0d0d; background: #e0707a; border: 0; border-radius: 3px; cursor: pointer;
|
|
28042
|
+
}
|
|
28043
|
+
.target .stop-btn:hover { background: #ec8a92; }
|
|
28044
|
+
.target .run-dot { color: #7bd88f; font-size: 11px; white-space: nowrap; }
|
|
28045
|
+
.target .run-dot.starting { color: #e0b54e; }
|
|
28046
|
+
.empty { padding: 16px 12px; color: #666; }
|
|
28047
|
+
.row {
|
|
28048
|
+
padding: 5px 12px; border-bottom: 1px solid #222; cursor: pointer;
|
|
28049
|
+
display: flex; gap: 8px; white-space: nowrap;
|
|
28050
|
+
}
|
|
28051
|
+
.row:hover { background: #222; }
|
|
28052
|
+
.row.sel { background: #2a3550; }
|
|
28053
|
+
.row .ts { color: #777; }
|
|
28054
|
+
.row .label { color: #ddd; flex: 1; overflow: hidden; text-overflow: ellipsis; }
|
|
28055
|
+
.row .status { color: #7bd88f; }
|
|
28056
|
+
.row .status.err { color: #e0707a; }
|
|
28057
|
+
#workspace { padding: 0 0 24px; }
|
|
28058
|
+
.composer { padding: 10px 12px; border-bottom: 1px solid #2a2a2a; }
|
|
28059
|
+
.composer .target-name { color: #fff; font-weight: 700; margin-bottom: 6px; }
|
|
28060
|
+
.composer textarea {
|
|
28061
|
+
width: 100%; min-height: 130px; resize: vertical; background: #111; color: #ddd;
|
|
28062
|
+
border: 1px solid #333; border-radius: 3px; padding: 6px;
|
|
28063
|
+
font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
28064
|
+
}
|
|
28065
|
+
.composer button {
|
|
28066
|
+
margin-top: 8px; padding: 6px 18px; background: #2a7d46; color: #fff;
|
|
28067
|
+
border: 0; border-radius: 3px; cursor: pointer; font: inherit; font-weight: 700;
|
|
28068
|
+
}
|
|
28069
|
+
.composer button:hover { background: #339152; }
|
|
28070
|
+
.composer button:disabled { background: #333; color: #888; cursor: default; }
|
|
28071
|
+
.composer .err { color: #e0707a; margin-top: 6px; min-height: 18px; }
|
|
28072
|
+
.section { padding: 8px 12px; border-bottom: 1px solid #222; }
|
|
28073
|
+
.section h3 { margin: 0 0 6px; font-size: 11px; color: #888; text-transform: uppercase; }
|
|
28074
|
+
.section h3 .ok { color: #7bd88f; }
|
|
28075
|
+
.section h3 .bad { color: #e0707a; }
|
|
28076
|
+
.section pre { margin: 0; white-space: pre-wrap; word-break: break-word; color: #cfcfcf; }
|
|
28077
|
+
.endpoint { display: block; color: #6aa9ff; text-decoration: none; padding: 2px 0; }
|
|
28078
|
+
.endpoint:hover { text-decoration: underline; }
|
|
28079
|
+
.searchbar { padding: 6px 10px; border-bottom: 1px solid #2a2a2a; background: #151515;
|
|
28080
|
+
position: sticky; top: 28px; z-index: 1; }
|
|
28081
|
+
.searchbar input {
|
|
28082
|
+
width: 100%; background: #111; color: #ddd; border: 1px solid #333; border-radius: 3px;
|
|
28083
|
+
padding: 5px 8px; font: 12px ui-monospace, Menlo, monospace;
|
|
28084
|
+
}
|
|
28085
|
+
.searchbar input:focus { outline: none; border-color: #4ec97a; }
|
|
28086
|
+
#log-results { display: none; }
|
|
28087
|
+
#log-results.active { display: block; }
|
|
28088
|
+
.log-hit { padding: 4px 12px; border-bottom: 1px solid #222; white-space: pre-wrap;
|
|
28089
|
+
word-break: break-word; }
|
|
28090
|
+
.log-hit .lt { color: #777; }
|
|
28091
|
+
.log-hit .lg { color: #6aa9ff; }
|
|
28092
|
+
.log-hits-meta { padding: 6px 12px; color: #888; font-size: 11px; }
|
|
28093
|
+
#conn { font-size: 11px; }
|
|
28094
|
+
#conn.up { color: #7bd88f; }
|
|
28095
|
+
#conn.down { color: #e0707a; }
|
|
28096
|
+
`;
|
|
28097
|
+
const STUDIO_SCRIPT = `
|
|
28098
|
+
const KIND_LABEL = { lambda: 'Lambda', api: 'API', alb: 'ALB', ecs: 'ECS', agentcore: 'AgentCore' };
|
|
28099
|
+
const SERVE_KINDS = ['api', 'alb', 'ecs']; // long-running serve targets
|
|
28100
|
+
const rowsById = new Map(); // invocationId -> timeline row element
|
|
28101
|
+
const invById = new Map(); // invocationId -> latest invocation event
|
|
28102
|
+
const logsById = new Map(); // invocationId / serve targetId -> [log lines]
|
|
28103
|
+
const targetEls = new Map(); // targetId -> left-pane element
|
|
28104
|
+
const serveMeta = new Map(); // serve targetId -> { dot, btnSlot } row controls
|
|
28105
|
+
const serveState = new Map(); // serve targetId -> { status, endpoints }
|
|
28106
|
+
let active = null; // { id, kind, ta, btn, msg, result }
|
|
28107
|
+
let shownInvId = null; // lambda invocation whose result is in the workspace
|
|
28108
|
+
let shownServeId = null; // serve target whose workspace is shown
|
|
28109
|
+
let shownDetailId = null; // captured request whose read-only detail is shown
|
|
28110
|
+
|
|
28111
|
+
function el(tag, cls, text) {
|
|
28112
|
+
const e = document.createElement(tag);
|
|
28113
|
+
if (cls) e.className = cls;
|
|
28114
|
+
if (text != null) e.textContent = text;
|
|
28115
|
+
return e;
|
|
28116
|
+
}
|
|
28117
|
+
|
|
28118
|
+
async function loadTargets() {
|
|
28119
|
+
const pane = document.getElementById('targets');
|
|
28120
|
+
try {
|
|
28121
|
+
const res = await fetch('/api/targets');
|
|
28122
|
+
const data = await res.json();
|
|
28123
|
+
pane.querySelectorAll('.group-title,.target,.empty').forEach((n) => n.remove());
|
|
28124
|
+
let total = 0;
|
|
28125
|
+
for (const group of data.groups) {
|
|
28126
|
+
if (!group.entries.length) continue;
|
|
28127
|
+
pane.appendChild(el('div', 'group-title', group.title));
|
|
28128
|
+
for (const entry of group.entries) {
|
|
28129
|
+
total += 1;
|
|
28130
|
+
// Lambda targets are single-shot invokes; api / alb / ecs are
|
|
28131
|
+
// long-running serves. Other kinds list but are not yet runnable.
|
|
28132
|
+
// Within ecs, only services are servable (task defs are run-task).
|
|
28133
|
+
const isServe = SERVE_KINDS.includes(group.kind) && (group.kind !== 'ecs' || entry.servable === true);
|
|
28134
|
+
const runnable = group.kind === 'lambda' || isServe;
|
|
28135
|
+
const t = el('div', runnable ? 'target runnable' : 'target');
|
|
28136
|
+
const name = el('span', 'name', entry.id);
|
|
28137
|
+
name.title = entry.id; // full path on hover even when truncated
|
|
28138
|
+
t.appendChild(name);
|
|
28139
|
+
t.appendChild(el('span', 'kind', '(' + (KIND_LABEL[group.kind] || group.kind) + ')'));
|
|
28140
|
+
if (group.kind === 'lambda') {
|
|
28141
|
+
const btn = el('button', 'invoke-btn', 'Invoke');
|
|
28142
|
+
btn.onclick = (e) => { e.stopPropagation(); selectTarget(entry.id, 'lambda'); };
|
|
28143
|
+
t.appendChild(btn);
|
|
28144
|
+
t.onclick = () => selectTarget(entry.id, 'lambda');
|
|
28145
|
+
targetEls.set(entry.id, t);
|
|
28146
|
+
} else if (isServe) {
|
|
28147
|
+
// A serve target: a running-state dot + a Start/Stop button
|
|
28148
|
+
// slot, both refreshed by updateServeRow on serve events.
|
|
28149
|
+
const dot = el('span', 'run-dot');
|
|
28150
|
+
const btnSlot = el('span', 'btn-slot');
|
|
28151
|
+
t.appendChild(dot);
|
|
28152
|
+
t.appendChild(btnSlot);
|
|
28153
|
+
t.onclick = () => selectTarget(entry.id, group.kind);
|
|
28154
|
+
targetEls.set(entry.id, t);
|
|
28155
|
+
serveMeta.set(entry.id, { dot, btnSlot, kind: group.kind });
|
|
28156
|
+
updateServeRow(entry.id);
|
|
28157
|
+
}
|
|
28158
|
+
pane.appendChild(t);
|
|
28159
|
+
}
|
|
28160
|
+
}
|
|
28161
|
+
if (!total) pane.appendChild(el('div', 'empty', 'No runnable targets found.'));
|
|
28162
|
+
} catch (err) {
|
|
28163
|
+
pane.appendChild(el('div', 'empty', 'Failed to load targets: ' + err));
|
|
28164
|
+
}
|
|
28165
|
+
}
|
|
28166
|
+
|
|
28167
|
+
// Pull any already-running serves (e.g. after a UI reload) so the rows
|
|
28168
|
+
// and workspace reflect them without waiting for a fresh serve event.
|
|
28169
|
+
async function loadRunning() {
|
|
28170
|
+
try {
|
|
28171
|
+
const res = await fetch('/api/running');
|
|
28172
|
+
const data = await res.json();
|
|
28173
|
+
for (const s of (data.running || [])) {
|
|
28174
|
+
serveState.set(s.targetId, { status: s.status, endpoints: s.endpoints || [] });
|
|
28175
|
+
updateServeRow(s.targetId);
|
|
28176
|
+
}
|
|
28177
|
+
} catch (err) {
|
|
28178
|
+
/* best-effort; the serve SSE stream still drives live updates */
|
|
28179
|
+
}
|
|
28180
|
+
}
|
|
28181
|
+
|
|
28182
|
+
function firstPort(endpoints) {
|
|
28183
|
+
const u = (endpoints || [])[0];
|
|
28184
|
+
if (!u) return '';
|
|
28185
|
+
const m = /:(\\d+)/.exec(u);
|
|
28186
|
+
return m ? ':' + m[1] : '';
|
|
28187
|
+
}
|
|
28188
|
+
|
|
28189
|
+
function updateServeRow(id) {
|
|
28190
|
+
const meta = serveMeta.get(id);
|
|
28191
|
+
if (!meta) return;
|
|
28192
|
+
const st = serveState.get(id);
|
|
28193
|
+
const status = st ? st.status : 'stopped';
|
|
28194
|
+
const running = status === 'running';
|
|
28195
|
+
const starting = status === 'starting';
|
|
28196
|
+
// A serve with a host endpoint shows the dot + :port; a pure-compute
|
|
28197
|
+
// ecs service has no endpoint, so just the dot + running.
|
|
28198
|
+
const port = running ? firstPort(st.endpoints) : '';
|
|
28199
|
+
meta.dot.textContent = running ? (port ? '● ' + port : '● running') : starting ? '○ starting' : '';
|
|
28200
|
+
meta.dot.className = 'run-dot' + (starting ? ' starting' : '');
|
|
28201
|
+
meta.btnSlot.innerHTML = '';
|
|
28202
|
+
const btn = running || starting
|
|
28203
|
+
? el('button', 'stop-btn', 'Stop')
|
|
28204
|
+
: el('button', 'invoke-btn', 'Start');
|
|
28205
|
+
btn.onclick = (e) => {
|
|
28206
|
+
e.stopPropagation();
|
|
28207
|
+
if (running || starting) stopServe(id); else startServe(id);
|
|
28208
|
+
};
|
|
28209
|
+
meta.btnSlot.appendChild(btn);
|
|
28210
|
+
// Refresh the workspace if it is showing this serve.
|
|
28211
|
+
if (shownServeId === id) renderServeWorkspace(id);
|
|
28212
|
+
}
|
|
28213
|
+
|
|
28214
|
+
function highlightTarget(id) {
|
|
28215
|
+
document.querySelectorAll('.target.sel').forEach((n) => n.classList.remove('sel'));
|
|
28216
|
+
const t = targetEls.get(id);
|
|
28217
|
+
if (t) t.classList.add('sel');
|
|
28218
|
+
}
|
|
28219
|
+
|
|
28220
|
+
function selectTarget(id, kind) {
|
|
28221
|
+
highlightTarget(id);
|
|
28222
|
+
shownDetailId = null;
|
|
28223
|
+
if (SERVE_KINDS.includes(kind)) {
|
|
28224
|
+
shownServeId = id;
|
|
28225
|
+
shownInvId = null;
|
|
28226
|
+
active = null;
|
|
28227
|
+
renderServeWorkspace(id);
|
|
28228
|
+
} else {
|
|
28229
|
+
shownServeId = null;
|
|
28230
|
+
renderComposer(id, kind, '{}');
|
|
28231
|
+
}
|
|
28232
|
+
}
|
|
28233
|
+
|
|
28234
|
+
async function startServe(id) {
|
|
28235
|
+
// The serve kind (api / alb / ecs) drives which headless command the
|
|
28236
|
+
// server spawns; it is recorded on the row when the target list loads.
|
|
28237
|
+
const meta = serveMeta.get(id);
|
|
28238
|
+
const kind = meta ? meta.kind : 'api';
|
|
28239
|
+
serveState.set(id, { status: 'starting', endpoints: [] });
|
|
28240
|
+
updateServeRow(id);
|
|
28241
|
+
try {
|
|
28242
|
+
const res = await fetch('/api/run', {
|
|
28243
|
+
method: 'POST',
|
|
28244
|
+
headers: { 'content-type': 'application/json' },
|
|
28245
|
+
body: JSON.stringify({ targetId: id, kind }),
|
|
28246
|
+
});
|
|
28247
|
+
const data = await res.json();
|
|
28248
|
+
if (!res.ok) {
|
|
28249
|
+
// Roll back the optimistic 'starting' on a rejected start.
|
|
28250
|
+
serveState.set(id, { status: 'error', endpoints: [] });
|
|
28251
|
+
updateServeRow(id);
|
|
28252
|
+
if (shownServeId === id) renderServeWorkspace(id, data.error || ('HTTP ' + res.status));
|
|
28253
|
+
}
|
|
28254
|
+
// On success the serve SSE 'running' event fills in the endpoints.
|
|
28255
|
+
} catch (err) {
|
|
28256
|
+
serveState.set(id, { status: 'error', endpoints: [] });
|
|
28257
|
+
updateServeRow(id);
|
|
28258
|
+
if (shownServeId === id) renderServeWorkspace(id, String(err));
|
|
28259
|
+
}
|
|
28260
|
+
}
|
|
28261
|
+
|
|
28262
|
+
async function stopServe(id) {
|
|
28263
|
+
try {
|
|
28264
|
+
await fetch('/api/stop', {
|
|
28265
|
+
method: 'POST',
|
|
28266
|
+
headers: { 'content-type': 'application/json' },
|
|
28267
|
+
body: JSON.stringify({ targetId: id }),
|
|
28268
|
+
});
|
|
28269
|
+
// The serve SSE 'stopped' event clears the running state.
|
|
28270
|
+
} catch (err) {
|
|
28271
|
+
/* the stop SSE event (or a later refresh) reconciles state */
|
|
28272
|
+
}
|
|
28273
|
+
}
|
|
28274
|
+
|
|
28275
|
+
function renderServeWorkspace(id, errMsg) {
|
|
28276
|
+
const ws = document.getElementById('workspace');
|
|
28277
|
+
ws.innerHTML = '';
|
|
28278
|
+
const st = serveState.get(id) || { status: 'stopped', endpoints: [] };
|
|
28279
|
+
const running = st.status === 'running';
|
|
28280
|
+
const starting = st.status === 'starting';
|
|
28281
|
+
|
|
28282
|
+
const head = el('div', 'composer');
|
|
28283
|
+
head.appendChild(el('div', 'target-name', 'Serve ' + id));
|
|
28284
|
+
const btn = running || starting
|
|
28285
|
+
? el('button', null, 'Stop')
|
|
28286
|
+
: el('button', null, starting ? 'Starting…' : 'Start');
|
|
28287
|
+
btn.onclick = () => { if (running || starting) stopServe(id); else startServe(id); };
|
|
28288
|
+
head.appendChild(btn);
|
|
28289
|
+
if (errMsg) {
|
|
28290
|
+
const m = el('div', 'err', errMsg);
|
|
28291
|
+
head.appendChild(m);
|
|
28292
|
+
}
|
|
28293
|
+
ws.appendChild(head);
|
|
28294
|
+
|
|
28295
|
+
const meta = serveMeta.get(id);
|
|
28296
|
+
const isEcs = meta && meta.kind === 'ecs';
|
|
28297
|
+
const epSec = el('div', 'section');
|
|
28298
|
+
epSec.appendChild(el('h3', null, 'Endpoints'));
|
|
28299
|
+
if (running && st.endpoints.length) {
|
|
28300
|
+
for (const url of st.endpoints) {
|
|
28301
|
+
const link = href(url);
|
|
28302
|
+
epSec.appendChild(link);
|
|
28303
|
+
}
|
|
28304
|
+
} else if (running && isEcs) {
|
|
28305
|
+
// A pure-compute ECS service has no host endpoint — it just runs the
|
|
28306
|
+
// replicas (reach them container-to-container via Cloud Map).
|
|
28307
|
+
epSec.appendChild(el('pre', null, '(running — pure compute service, no host endpoint)'));
|
|
28308
|
+
} else {
|
|
28309
|
+
epSec.appendChild(el('pre', null, starting ? '(starting…)' : '(not running)'));
|
|
28310
|
+
}
|
|
28311
|
+
ws.appendChild(epSec);
|
|
28312
|
+
|
|
28313
|
+
const logs = logsById.get(id) || [];
|
|
28314
|
+
const logSec = el('div', 'section');
|
|
28315
|
+
logSec.appendChild(el('h3', null, 'Logs'));
|
|
28316
|
+
logSec.appendChild(el('pre', null, logs.length ? logs.join('\\n') : '(none)'));
|
|
28317
|
+
ws.appendChild(logSec);
|
|
28318
|
+
}
|
|
28319
|
+
|
|
28320
|
+
// Build an <a> that opens an http(s) endpoint in a new tab; ws:// URLs
|
|
28321
|
+
// are shown as plain text (not navigable in a browser tab).
|
|
28322
|
+
function href(url) {
|
|
28323
|
+
if (/^https?:/.test(url)) {
|
|
28324
|
+
const a = el('a', 'endpoint', url);
|
|
28325
|
+
a.href = url;
|
|
28326
|
+
a.target = '_blank';
|
|
28327
|
+
a.rel = 'noopener';
|
|
28328
|
+
return a;
|
|
28329
|
+
}
|
|
28330
|
+
return el('div', 'endpoint', url);
|
|
28331
|
+
}
|
|
28332
|
+
|
|
28333
|
+
function renderComposer(id, kind, eventText) {
|
|
28334
|
+
const ws = document.getElementById('workspace');
|
|
28335
|
+
ws.innerHTML = '';
|
|
28336
|
+
|
|
28337
|
+
const composer = el('div', 'composer');
|
|
28338
|
+
composer.appendChild(el('div', 'target-name', 'Invoke ' + id));
|
|
28339
|
+
const ta = el('textarea');
|
|
28340
|
+
ta.value = eventText;
|
|
28341
|
+
ta.spellcheck = false;
|
|
28342
|
+
composer.appendChild(ta);
|
|
28343
|
+
composer.appendChild(document.createElement('br'));
|
|
28344
|
+
const btn = el('button', null, 'Invoke');
|
|
28345
|
+
const msg = el('div', 'err');
|
|
28346
|
+
composer.appendChild(btn);
|
|
28347
|
+
composer.appendChild(msg);
|
|
28348
|
+
|
|
28349
|
+
const result = el('div', 'result');
|
|
28350
|
+
|
|
28351
|
+
ws.appendChild(composer);
|
|
28352
|
+
ws.appendChild(result);
|
|
28353
|
+
|
|
28354
|
+
active = { id, kind, ta, btn, msg, result };
|
|
28355
|
+
btn.onclick = () => runInvoke();
|
|
28356
|
+
shownInvId = null;
|
|
28357
|
+
shownDetailId = null;
|
|
28358
|
+
ta.focus();
|
|
28359
|
+
}
|
|
28360
|
+
|
|
28361
|
+
async function runInvoke() {
|
|
28362
|
+
if (!active) return;
|
|
28363
|
+
const { id, kind, ta, btn, msg, result } = active;
|
|
28364
|
+
let event;
|
|
28365
|
+
try {
|
|
28366
|
+
event = ta.value.trim() === '' ? {} : JSON.parse(ta.value);
|
|
28367
|
+
} catch (err) {
|
|
28368
|
+
msg.textContent = 'Invalid JSON: ' + err.message;
|
|
28369
|
+
return;
|
|
28370
|
+
}
|
|
28371
|
+
msg.textContent = '';
|
|
28372
|
+
btn.disabled = true;
|
|
28373
|
+
btn.textContent = 'Invoking...';
|
|
28374
|
+
result.innerHTML = '';
|
|
28375
|
+
try {
|
|
28376
|
+
const res = await fetch('/api/run', {
|
|
28377
|
+
method: 'POST',
|
|
28378
|
+
headers: { 'content-type': 'application/json' },
|
|
28379
|
+
body: JSON.stringify({ targetId: id, kind, event }),
|
|
28380
|
+
});
|
|
28381
|
+
const data = await res.json();
|
|
28382
|
+
if (data.invocationId) {
|
|
28383
|
+
shownInvId = data.invocationId;
|
|
28384
|
+
renderResult(shownInvId);
|
|
28385
|
+
}
|
|
28386
|
+
if (!res.ok || data.ok === false) {
|
|
28387
|
+
msg.textContent = 'Invoke failed: ' + (data.error || ('HTTP ' + res.status));
|
|
28388
|
+
}
|
|
28389
|
+
} catch (err) {
|
|
28390
|
+
msg.textContent = 'Request failed: ' + err;
|
|
28391
|
+
} finally {
|
|
28392
|
+
btn.disabled = false;
|
|
28393
|
+
btn.textContent = 'Invoke';
|
|
28394
|
+
}
|
|
28395
|
+
}
|
|
28396
|
+
|
|
28397
|
+
function fmt(body) {
|
|
28398
|
+
return typeof body === 'string' ? body : JSON.stringify(body, null, 2);
|
|
28399
|
+
}
|
|
28400
|
+
|
|
28401
|
+
function renderResult(invId) {
|
|
28402
|
+
if (!active) return;
|
|
28403
|
+
const result = active.result;
|
|
28404
|
+
result.innerHTML = '';
|
|
28405
|
+
const ev = invById.get(invId);
|
|
28406
|
+
if (!ev) return;
|
|
28407
|
+
|
|
28408
|
+
const reqSec = el('div', 'section');
|
|
28409
|
+
reqSec.appendChild(el('h3', null, 'Request'));
|
|
28410
|
+
reqSec.appendChild(el('pre', null, ev.request != null ? fmt(ev.request) : '(none)'));
|
|
28411
|
+
result.appendChild(reqSec);
|
|
28412
|
+
|
|
28413
|
+
const respSec = el('div', 'section');
|
|
28414
|
+
const h = el('h3', null, 'Response');
|
|
28415
|
+
if (ev.status != null) {
|
|
28416
|
+
const cls = ev.status >= 200 && ev.status < 300 ? 'ok' : 'bad';
|
|
28417
|
+
const meta = el('span', cls, ' ' + ev.status + (ev.durationMs != null ? ' · ' + ev.durationMs + 'ms' : ''));
|
|
28418
|
+
h.appendChild(meta);
|
|
28419
|
+
}
|
|
28420
|
+
respSec.appendChild(h);
|
|
28421
|
+
respSec.appendChild(el('pre', null, ev.response != null ? fmt(ev.response) : '(pending…)'));
|
|
28422
|
+
result.appendChild(respSec);
|
|
28423
|
+
|
|
28424
|
+
const logs = logsById.get(invId) || [];
|
|
28425
|
+
const logSec = el('div', 'section');
|
|
28426
|
+
logSec.appendChild(el('h3', null, 'Logs'));
|
|
28427
|
+
logSec.appendChild(el('pre', null, logs.length ? logs.join('\\n') : '(none)'));
|
|
28428
|
+
result.appendChild(logSec);
|
|
28429
|
+
}
|
|
28430
|
+
|
|
28431
|
+
function addInvocation(ev) {
|
|
28432
|
+
invById.set(ev.id, Object.assign(invById.get(ev.id) || {}, ev));
|
|
28433
|
+
const timeline = document.getElementById('timeline-rows');
|
|
28434
|
+
const placeholder = timeline.querySelector('.empty');
|
|
28435
|
+
if (placeholder) placeholder.remove();
|
|
28436
|
+
|
|
28437
|
+
let row = rowsById.get(ev.id);
|
|
28438
|
+
if (!row) {
|
|
28439
|
+
row = el('div', 'row');
|
|
28440
|
+
row.appendChild(el('span', 'ts'));
|
|
28441
|
+
row.appendChild(el('span', 'label'));
|
|
28442
|
+
row.appendChild(el('span', 'status'));
|
|
28443
|
+
row.onclick = () => loadInvocation(ev.id);
|
|
28444
|
+
rowsById.set(ev.id, row);
|
|
28445
|
+
timeline.insertBefore(row, timeline.querySelector('.row')); // newest on top
|
|
28446
|
+
}
|
|
28447
|
+
const merged = invById.get(ev.id);
|
|
28448
|
+
const d = new Date(merged.ts);
|
|
28449
|
+
row.querySelector('.ts').textContent = d.toLocaleTimeString();
|
|
28450
|
+
row.querySelector('.label').textContent = (merged.target || '') + ' ' + (merged.label || '');
|
|
28451
|
+
const statusEl = row.querySelector('.status');
|
|
28452
|
+
statusEl.textContent =
|
|
28453
|
+
merged.status != null
|
|
28454
|
+
? merged.status + (merged.durationMs != null ? ' ' + merged.durationMs + 'ms' : '')
|
|
28455
|
+
: '…';
|
|
28456
|
+
statusEl.className = 'status' + (merged.status != null && (merged.status < 200 || merged.status >= 300) ? ' err' : '');
|
|
28457
|
+
|
|
28458
|
+
// Live-refresh the workspace if it is showing this invocation.
|
|
28459
|
+
if (shownInvId === ev.id) renderResult(ev.id);
|
|
28460
|
+
if (shownDetailId === ev.id) renderCapturedDetail(ev.id);
|
|
28461
|
+
}
|
|
28462
|
+
|
|
28463
|
+
function loadInvocation(id) {
|
|
28464
|
+
const ev = invById.get(id);
|
|
28465
|
+
if (!ev) return;
|
|
28466
|
+
document.querySelectorAll('.row.sel').forEach((n) => n.classList.remove('sel'));
|
|
28467
|
+
const row = rowsById.get(id);
|
|
28468
|
+
if (row) row.classList.add('sel');
|
|
28469
|
+
highlightTarget(ev.target);
|
|
28470
|
+
if (ev.kind === 'lambda') {
|
|
28471
|
+
// A Lambda invocation row reloads into the re-invokable composer.
|
|
28472
|
+
shownDetailId = null;
|
|
28473
|
+
shownServeId = null;
|
|
28474
|
+
renderComposer(ev.target, ev.kind, ev.request != null ? fmt(ev.request) : '{}');
|
|
28475
|
+
shownInvId = id;
|
|
28476
|
+
renderResult(id);
|
|
28477
|
+
} else {
|
|
28478
|
+
// A captured serve request (slice C2) opens a READ-ONLY detail —
|
|
28479
|
+
// re-invoking a captured request is Phase 3.
|
|
28480
|
+
shownInvId = null;
|
|
28481
|
+
shownServeId = null;
|
|
28482
|
+
active = null;
|
|
28483
|
+
renderCapturedDetail(id);
|
|
28484
|
+
}
|
|
28485
|
+
}
|
|
28486
|
+
|
|
28487
|
+
// Read-only Request / Response detail for a captured serve request.
|
|
28488
|
+
function renderCapturedDetail(id) {
|
|
28489
|
+
shownDetailId = id;
|
|
28490
|
+
const ev = invById.get(id);
|
|
28491
|
+
const ws = document.getElementById('workspace');
|
|
28492
|
+
ws.innerHTML = '';
|
|
28493
|
+
if (!ev) return;
|
|
28494
|
+
|
|
28495
|
+
const head = el('div', 'composer');
|
|
28496
|
+
head.appendChild(el('div', 'target-name', (ev.label || 'request') + ' — ' + (ev.target || '')));
|
|
28497
|
+
ws.appendChild(head);
|
|
28498
|
+
|
|
28499
|
+
const reqSec = el('div', 'section');
|
|
28500
|
+
reqSec.appendChild(el('h3', null, 'Request'));
|
|
28501
|
+
reqSec.appendChild(el('pre', null, ev.request != null ? fmt(ev.request) : '(none)'));
|
|
28502
|
+
ws.appendChild(reqSec);
|
|
28503
|
+
|
|
28504
|
+
const respSec = el('div', 'section');
|
|
28505
|
+
const h = el('h3', null, 'Response');
|
|
28506
|
+
if (ev.status != null) {
|
|
28507
|
+
const cls = ev.status >= 200 && ev.status < 300 ? 'ok' : 'bad';
|
|
28508
|
+
h.appendChild(el('span', cls, ' ' + ev.status + (ev.durationMs != null ? ' · ' + ev.durationMs + 'ms' : '')));
|
|
28509
|
+
}
|
|
28510
|
+
respSec.appendChild(h);
|
|
28511
|
+
respSec.appendChild(el('pre', null, ev.response != null ? fmt(ev.response) : '(pending…)'));
|
|
28512
|
+
ws.appendChild(respSec);
|
|
28513
|
+
|
|
28514
|
+
// Logs bound to THIS request at CloudWatch granularity (D5), fetched
|
|
28515
|
+
// from the server store.
|
|
28516
|
+
const logSec = el('div', 'section');
|
|
28517
|
+
logSec.appendChild(el('h3', null, 'Logs'));
|
|
28518
|
+
const logPre = el('pre', null, '(loading…)');
|
|
28519
|
+
logSec.appendChild(logPre);
|
|
28520
|
+
ws.appendChild(logSec);
|
|
28521
|
+
fetchInvocationLogs(id, logPre);
|
|
28522
|
+
}
|
|
28523
|
+
|
|
28524
|
+
async function fetchInvocationLogs(id, pre) {
|
|
28525
|
+
try {
|
|
28526
|
+
const res = await fetch('/api/invocations/' + encodeURIComponent(id) + '/logs');
|
|
28527
|
+
const data = await res.json();
|
|
28528
|
+
const lines = (data.logs || []).map((l) => l.line);
|
|
28529
|
+
if (shownDetailId === id) pre.textContent = lines.length ? lines.join('\\n') : '(none)';
|
|
28530
|
+
} catch (err) {
|
|
28531
|
+
if (shownDetailId === id) pre.textContent = '(failed to load logs)';
|
|
28532
|
+
}
|
|
28533
|
+
}
|
|
28534
|
+
|
|
28535
|
+
// Full-text log search over the server store. A non-empty query shows
|
|
28536
|
+
// matching log lines INSTEAD of the timeline rows; clearing restores them.
|
|
28537
|
+
let searchTimer = null;
|
|
28538
|
+
function wireLogSearch() {
|
|
28539
|
+
const input = document.getElementById('log-search');
|
|
28540
|
+
const rows = document.getElementById('timeline-rows');
|
|
28541
|
+
const results = document.getElementById('log-results');
|
|
28542
|
+
input.addEventListener('input', () => {
|
|
28543
|
+
if (searchTimer) clearTimeout(searchTimer);
|
|
28544
|
+
searchTimer = setTimeout(() => runLogSearch(input.value.trim(), rows, results), 180);
|
|
28545
|
+
});
|
|
28546
|
+
}
|
|
28547
|
+
|
|
28548
|
+
async function runLogSearch(query, rows, results) {
|
|
28549
|
+
if (query === '') {
|
|
28550
|
+
results.classList.remove('active');
|
|
28551
|
+
rows.style.display = '';
|
|
28552
|
+
results.innerHTML = '';
|
|
28553
|
+
return;
|
|
28554
|
+
}
|
|
28555
|
+
rows.style.display = 'none';
|
|
28556
|
+
results.classList.add('active');
|
|
28557
|
+
try {
|
|
28558
|
+
const res = await fetch('/api/logs?q=' + encodeURIComponent(query));
|
|
28559
|
+
const data = await res.json();
|
|
28560
|
+
const hits = data.logs || [];
|
|
28561
|
+
results.innerHTML = '';
|
|
28562
|
+
results.appendChild(el('div', 'log-hits-meta', hits.length + ' match' + (hits.length === 1 ? '' : 'es')));
|
|
28563
|
+
for (const h of hits) {
|
|
28564
|
+
const row = el('div', 'log-hit');
|
|
28565
|
+
row.appendChild(el('span', 'lt', new Date(h.ts).toLocaleTimeString() + ' '));
|
|
28566
|
+
row.appendChild(el('span', 'lg', (h.target || '') + ' '));
|
|
28567
|
+
row.appendChild(document.createTextNode(h.line));
|
|
28568
|
+
results.appendChild(row);
|
|
28569
|
+
}
|
|
28570
|
+
} catch (err) {
|
|
28571
|
+
results.innerHTML = '';
|
|
28572
|
+
results.appendChild(el('div', 'log-hits-meta', 'Search failed: ' + err));
|
|
28573
|
+
}
|
|
28574
|
+
}
|
|
28575
|
+
|
|
28576
|
+
// Pull retained history on (re)connect so the timeline + logs reflect the
|
|
28577
|
+
// whole session, not just events since this page loaded.
|
|
28578
|
+
async function loadHistory() {
|
|
28579
|
+
try {
|
|
28580
|
+
const res = await fetch('/api/history');
|
|
28581
|
+
const data = await res.json();
|
|
28582
|
+
for (const log of (data.logs || [])) {
|
|
28583
|
+
const arr = logsById.get(log.containerId) || [];
|
|
28584
|
+
arr.push(log.line);
|
|
28585
|
+
logsById.set(log.containerId, arr);
|
|
28586
|
+
}
|
|
28587
|
+
for (const inv of (data.invocations || [])) addInvocation(inv);
|
|
28588
|
+
} catch (err) {
|
|
28589
|
+
/* live SSE still drives the timeline; history is best-effort */
|
|
28590
|
+
}
|
|
28591
|
+
}
|
|
28592
|
+
|
|
28593
|
+
function connect() {
|
|
28594
|
+
const conn = document.getElementById('conn');
|
|
28595
|
+
const es = new EventSource('/api/events');
|
|
28596
|
+
es.addEventListener('open', () => { conn.textContent = '● live'; conn.className = 'up'; });
|
|
28597
|
+
es.addEventListener('error', () => { conn.textContent = '● disconnected'; conn.className = 'down'; });
|
|
28598
|
+
es.addEventListener('invocation', (e) => addInvocation(JSON.parse(e.data)));
|
|
28599
|
+
es.addEventListener('serve', (e) => onServeEvent(JSON.parse(e.data)));
|
|
28600
|
+
es.addEventListener('log', (e) => {
|
|
28601
|
+
const ev = JSON.parse(e.data);
|
|
28602
|
+
const arr = logsById.get(ev.containerId) || [];
|
|
28603
|
+
arr.push(ev.line);
|
|
28604
|
+
logsById.set(ev.containerId, arr);
|
|
28605
|
+
if (shownInvId === ev.containerId) renderResult(ev.containerId);
|
|
28606
|
+
if (shownServeId === ev.containerId) renderServeWorkspace(ev.containerId);
|
|
28607
|
+
});
|
|
28608
|
+
}
|
|
28609
|
+
|
|
28610
|
+
function onServeEvent(ev) {
|
|
28611
|
+
// A 'stopped' / 'error' transition clears the running state; otherwise
|
|
28612
|
+
// record the latest status + endpoints for the row + workspace.
|
|
28613
|
+
if (ev.status === 'stopped' || ev.status === 'error') {
|
|
28614
|
+
serveState.set(ev.target, { status: ev.status, endpoints: [] });
|
|
28615
|
+
} else {
|
|
28616
|
+
serveState.set(ev.target, { status: ev.status, endpoints: ev.endpoints || [] });
|
|
28617
|
+
}
|
|
28618
|
+
updateServeRow(ev.target);
|
|
28619
|
+
}
|
|
28620
|
+
|
|
28621
|
+
function initSplitters() {
|
|
28622
|
+
const main = document.querySelector('main');
|
|
28623
|
+
let left = 280, right = 320;
|
|
28624
|
+
const apply = () => {
|
|
28625
|
+
main.style.gridTemplateColumns = left + 'px 5px 1fr 5px ' + right + 'px';
|
|
28626
|
+
};
|
|
28627
|
+
const wire = (splitterId, onMove) => {
|
|
28628
|
+
const s = document.getElementById(splitterId);
|
|
28629
|
+
s.addEventListener('mousedown', (e) => {
|
|
28630
|
+
e.preventDefault();
|
|
28631
|
+
const startX = e.clientX;
|
|
28632
|
+
const l0 = left, r0 = right;
|
|
28633
|
+
s.classList.add('dragging');
|
|
28634
|
+
document.body.style.userSelect = 'none';
|
|
28635
|
+
const move = (ev) => { onMove(ev.clientX - startX, l0, r0); apply(); };
|
|
28636
|
+
const up = () => {
|
|
28637
|
+
s.classList.remove('dragging');
|
|
28638
|
+
document.body.style.userSelect = '';
|
|
28639
|
+
document.removeEventListener('mousemove', move);
|
|
28640
|
+
document.removeEventListener('mouseup', up);
|
|
28641
|
+
};
|
|
28642
|
+
document.addEventListener('mousemove', move);
|
|
28643
|
+
document.addEventListener('mouseup', up);
|
|
28644
|
+
});
|
|
28645
|
+
};
|
|
28646
|
+
const clamp = (v) => Math.max(160, Math.min(760, v));
|
|
28647
|
+
wire('split-left', (dx, l0) => { left = clamp(l0 + dx); });
|
|
28648
|
+
wire('split-right', (dx, l0, r0) => { right = clamp(r0 - dx); });
|
|
28649
|
+
}
|
|
28650
|
+
|
|
28651
|
+
loadTargets().then(loadRunning);
|
|
28652
|
+
loadHistory();
|
|
28653
|
+
connect();
|
|
28654
|
+
initSplitters();
|
|
28655
|
+
wireLogSearch();
|
|
28656
|
+
`;
|
|
28657
|
+
/**
|
|
28658
|
+
* Render the full studio HTML document. `appLabel` is shown in the
|
|
28659
|
+
* header (the CDK app / stack context); `cliName` brands the title for
|
|
28660
|
+
* host CLIs that rebrand `cdkl`.
|
|
28661
|
+
*/
|
|
28662
|
+
function renderStudioHtml(appLabel, cliName) {
|
|
28663
|
+
const safeApp = escapeHtml(appLabel);
|
|
28664
|
+
const safeCli = escapeHtml(cliName);
|
|
28665
|
+
return `<!doctype html>
|
|
28666
|
+
<html lang="en">
|
|
28667
|
+
<head>
|
|
28668
|
+
<meta charset="utf-8" />
|
|
28669
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
28670
|
+
<title>${safeCli} studio</title>
|
|
28671
|
+
<style>${STUDIO_CSS}</style>
|
|
28672
|
+
</head>
|
|
28673
|
+
<body>
|
|
28674
|
+
<header>
|
|
28675
|
+
<span class="brand">${safeCli} studio</span>
|
|
28676
|
+
<span class="meta">${safeApp}</span>
|
|
28677
|
+
<span id="conn" class="down">● connecting</span>
|
|
28678
|
+
</header>
|
|
28679
|
+
<main>
|
|
28680
|
+
<section class="pane" id="targets"><h2>Targets</h2></section>
|
|
28681
|
+
<div class="splitter" id="split-left"></div>
|
|
28682
|
+
<section class="pane" id="workspace"><div class="empty">Pick a Lambda to invoke, or an API to serve, on the left.</div></section>
|
|
28683
|
+
<div class="splitter" id="split-right"></div>
|
|
28684
|
+
<section class="pane" id="timeline">
|
|
28685
|
+
<h2>Timeline</h2>
|
|
28686
|
+
<div class="searchbar"><input id="log-search" type="search" placeholder="Search logs…" autocomplete="off" spellcheck="false" /></div>
|
|
28687
|
+
<div id="timeline-rows"><div class="empty">No requests yet.</div></div>
|
|
28688
|
+
<div id="log-results"></div>
|
|
28689
|
+
</section>
|
|
28690
|
+
</main>
|
|
28691
|
+
<script>${STUDIO_SCRIPT}<\/script>
|
|
28692
|
+
</body>
|
|
28693
|
+
</html>`;
|
|
28694
|
+
}
|
|
28695
|
+
/** Minimal HTML-escape for the few interpolated text values. */
|
|
28696
|
+
function escapeHtml(s) {
|
|
28697
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
28698
|
+
}
|
|
28699
|
+
|
|
28700
|
+
//#endregion
|
|
28701
|
+
//#region src/local/studio-server.ts
|
|
28702
|
+
/**
|
|
28703
|
+
* Project a {@link TargetListing} (the same enumeration `cdkl list`
|
|
28704
|
+
* prints) into the grouped shape the studio UI renders. ECS services and
|
|
28705
|
+
* task definitions are folded into one `ecs` group; everything else maps
|
|
28706
|
+
* one category to one group. Exported so a unit test can assert the
|
|
28707
|
+
* projection without booting the server.
|
|
28708
|
+
*/
|
|
28709
|
+
function toStudioTargetGroups(listing) {
|
|
28710
|
+
const map = (entries, opts = {}) => entries.map((e) => {
|
|
28711
|
+
const t = {
|
|
28712
|
+
id: e.displayPath ?? e.qualifiedId,
|
|
28713
|
+
qualifiedId: e.qualifiedId
|
|
28714
|
+
};
|
|
28715
|
+
if (e.kind) t.surface = e.kind;
|
|
28716
|
+
if (opts.servable !== void 0) t.servable = opts.servable;
|
|
28717
|
+
return t;
|
|
28718
|
+
});
|
|
28719
|
+
return [
|
|
28720
|
+
{
|
|
28721
|
+
kind: "lambda",
|
|
28722
|
+
title: "Lambda Functions",
|
|
28723
|
+
entries: map(listing.lambdas)
|
|
28724
|
+
},
|
|
28725
|
+
{
|
|
28726
|
+
kind: "api",
|
|
28727
|
+
title: "APIs",
|
|
28728
|
+
entries: map(listing.apis)
|
|
28729
|
+
},
|
|
28730
|
+
{
|
|
28731
|
+
kind: "ecs",
|
|
28732
|
+
title: "ECS Services / Tasks",
|
|
28733
|
+
entries: [...map(listing.ecsServices, { servable: true }), ...map(listing.ecsTaskDefinitions, { servable: false })]
|
|
28734
|
+
},
|
|
28735
|
+
{
|
|
28736
|
+
kind: "agentcore",
|
|
28737
|
+
title: "AgentCore Runtimes",
|
|
28738
|
+
entries: map(listing.agentCoreRuntimes)
|
|
28739
|
+
},
|
|
28740
|
+
{
|
|
28741
|
+
kind: "alb",
|
|
28742
|
+
title: "Load Balancers",
|
|
28743
|
+
entries: map(listing.loadBalancers)
|
|
28744
|
+
}
|
|
28745
|
+
];
|
|
28746
|
+
}
|
|
28747
|
+
const SSE_HEARTBEAT_MS = 15e3;
|
|
28748
|
+
/**
|
|
28749
|
+
* Boot the studio HTTP server: serves the embedded UI at `/`, the target
|
|
28750
|
+
* list at `/api/targets`, and a Server-Sent-Events stream of the bus's
|
|
28751
|
+
* `invocation` / `log` events at `/api/events`. Localhost-only by
|
|
28752
|
+
* default. Resolves once the socket is listening.
|
|
28753
|
+
*/
|
|
28754
|
+
async function startStudioServer(options) {
|
|
28755
|
+
const host = options.host ?? "127.0.0.1";
|
|
28756
|
+
const maxBump = options.maxPortBump ?? 20;
|
|
28757
|
+
const html = renderStudioHtml(options.appLabel, options.cliName);
|
|
28758
|
+
const targetsJson = JSON.stringify({ groups: options.targetGroups });
|
|
28759
|
+
const server = createServer$1((req, res) => handleRequest(req, res, options.bus, html, targetsJson, options));
|
|
28760
|
+
const boundPort = await listenWithBump(server, host, options.port, maxBump);
|
|
28761
|
+
return {
|
|
28762
|
+
url: `http://${host}:${boundPort}`,
|
|
28763
|
+
port: boundPort,
|
|
28764
|
+
close: () => new Promise((resolveClose, reject) => {
|
|
28765
|
+
server.close((err) => err ? reject(err) : resolveClose());
|
|
28766
|
+
server.closeAllConnections?.();
|
|
28767
|
+
})
|
|
28768
|
+
};
|
|
28769
|
+
}
|
|
28770
|
+
function handleRequest(req, res, bus, html, targetsJson, options) {
|
|
28771
|
+
const url = req.url ?? "/";
|
|
28772
|
+
const path = url.split("?")[0];
|
|
28773
|
+
if (req.method === "GET" && (path === "/" || path === "/index.html")) {
|
|
28774
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
28775
|
+
res.end(html);
|
|
28776
|
+
return;
|
|
28777
|
+
}
|
|
28778
|
+
if (req.method === "GET" && path === "/api/targets") {
|
|
28779
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
28780
|
+
res.end(targetsJson);
|
|
28781
|
+
return;
|
|
28782
|
+
}
|
|
28783
|
+
if (req.method === "GET" && path === "/api/running") {
|
|
28784
|
+
const running = options.getRunning ? options.getRunning() : { running: [] };
|
|
28785
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
28786
|
+
res.end(JSON.stringify(running));
|
|
28787
|
+
return;
|
|
28788
|
+
}
|
|
28789
|
+
if (req.method === "GET" && path === "/api/history") {
|
|
28790
|
+
const history = options.store ? options.store.history() : {
|
|
28791
|
+
invocations: [],
|
|
28792
|
+
logs: []
|
|
28793
|
+
};
|
|
28794
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
28795
|
+
res.end(JSON.stringify(history));
|
|
28796
|
+
return;
|
|
28797
|
+
}
|
|
28798
|
+
if (req.method === "GET" && path === "/api/logs") {
|
|
28799
|
+
const params = new URLSearchParams(url.split("?")[1] ?? "");
|
|
28800
|
+
const query = params.get("q") ?? "";
|
|
28801
|
+
const target = params.get("target") || void 0;
|
|
28802
|
+
const logs = options.store ? options.store.searchLogs(query, target !== void 0 ? { target } : {}) : [];
|
|
28803
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
28804
|
+
res.end(JSON.stringify({ logs }));
|
|
28805
|
+
return;
|
|
28806
|
+
}
|
|
28807
|
+
const invLogsMatch = /^\/api\/invocations\/([^/]+)\/logs$/.exec(path ?? "");
|
|
28808
|
+
if (req.method === "GET" && invLogsMatch) {
|
|
28809
|
+
const id = decodeURIComponent(invLogsMatch[1] ?? "");
|
|
28810
|
+
const logs = options.store ? options.store.logsForInvocation(id) : [];
|
|
28811
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
28812
|
+
res.end(JSON.stringify({ logs }));
|
|
28813
|
+
return;
|
|
28814
|
+
}
|
|
28815
|
+
if (req.method === "GET" && path === "/api/events") {
|
|
28816
|
+
serveSse(req, res, bus);
|
|
28817
|
+
return;
|
|
28818
|
+
}
|
|
28819
|
+
if (req.method === "POST" && path === "/api/run") {
|
|
28820
|
+
handleDispatch(req, res, options.onRun);
|
|
28821
|
+
return;
|
|
28822
|
+
}
|
|
28823
|
+
if (req.method === "POST" && path === "/api/stop") {
|
|
28824
|
+
handleDispatch(req, res, options.onStop);
|
|
28825
|
+
return;
|
|
28826
|
+
}
|
|
28827
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
28828
|
+
res.end("Not found");
|
|
28829
|
+
}
|
|
28830
|
+
const MAX_RUN_BODY_BYTES = 5 * 1024 * 1024;
|
|
28831
|
+
/**
|
|
28832
|
+
* Reply to a JSON POST endpoint (`/api/run` / `/api/stop`): parse the
|
|
28833
|
+
* bounded JSON body and dispatch via `handler`. 501 when no handler is
|
|
28834
|
+
* wired (the observe-only shell), 400 on a malformed body, 500 when the
|
|
28835
|
+
* handler throws.
|
|
28836
|
+
*/
|
|
28837
|
+
async function handleDispatch(req, res, handler) {
|
|
28838
|
+
const sendJson = (statusCode, payload) => {
|
|
28839
|
+
res.writeHead(statusCode, { "content-type": "application/json; charset=utf-8" });
|
|
28840
|
+
res.end(JSON.stringify(payload));
|
|
28841
|
+
};
|
|
28842
|
+
if (!handler) {
|
|
28843
|
+
sendJson(501, { error: "Running targets is not supported by this studio server." });
|
|
28844
|
+
return;
|
|
28845
|
+
}
|
|
28846
|
+
let body;
|
|
28847
|
+
try {
|
|
28848
|
+
body = await readJsonBody(req);
|
|
28849
|
+
} catch (err) {
|
|
28850
|
+
sendJson(400, { error: err instanceof Error ? err.message : String(err) });
|
|
28851
|
+
return;
|
|
28852
|
+
}
|
|
28853
|
+
try {
|
|
28854
|
+
sendJson(200, await handler(body));
|
|
28855
|
+
} catch (err) {
|
|
28856
|
+
sendJson(500, { error: err instanceof Error ? err.message : String(err) });
|
|
28857
|
+
}
|
|
28858
|
+
}
|
|
28859
|
+
/** Read + JSON-parse a request body, bounded to {@link MAX_RUN_BODY_BYTES}. */
|
|
28860
|
+
function readJsonBody(req) {
|
|
28861
|
+
return new Promise((resolveBody, reject) => {
|
|
28862
|
+
let raw = "";
|
|
28863
|
+
let bytes = 0;
|
|
28864
|
+
let done = false;
|
|
28865
|
+
const settle = (fn) => {
|
|
28866
|
+
if (done) return;
|
|
28867
|
+
done = true;
|
|
28868
|
+
fn();
|
|
28869
|
+
};
|
|
28870
|
+
req.setEncoding("utf8");
|
|
28871
|
+
req.on("data", (chunk) => {
|
|
28872
|
+
if (done) return;
|
|
28873
|
+
bytes += Buffer.byteLength(chunk);
|
|
28874
|
+
if (bytes > MAX_RUN_BODY_BYTES) {
|
|
28875
|
+
settle(() => reject(/* @__PURE__ */ new Error("Request body too large.")));
|
|
28876
|
+
req.destroy();
|
|
28877
|
+
return;
|
|
28878
|
+
}
|
|
28879
|
+
raw += chunk;
|
|
28880
|
+
});
|
|
28881
|
+
req.on("end", () => {
|
|
28882
|
+
settle(() => {
|
|
28883
|
+
if (raw.trim() === "") {
|
|
28884
|
+
resolveBody(void 0);
|
|
28885
|
+
return;
|
|
28886
|
+
}
|
|
28887
|
+
try {
|
|
28888
|
+
resolveBody(JSON.parse(raw));
|
|
28889
|
+
} catch {
|
|
28890
|
+
reject(/* @__PURE__ */ new Error("Invalid JSON body."));
|
|
28891
|
+
}
|
|
28892
|
+
});
|
|
28893
|
+
});
|
|
28894
|
+
req.on("error", (err) => settle(() => reject(err)));
|
|
28895
|
+
});
|
|
28896
|
+
}
|
|
28897
|
+
function serveSse(req, res, bus) {
|
|
28898
|
+
res.writeHead(200, {
|
|
28899
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
28900
|
+
"cache-control": "no-cache, no-transform",
|
|
28901
|
+
connection: "keep-alive"
|
|
28902
|
+
});
|
|
28903
|
+
let closed = false;
|
|
28904
|
+
const heartbeat = setInterval(() => safeWrite(":hb\n\n"), SSE_HEARTBEAT_MS);
|
|
28905
|
+
heartbeat.unref?.();
|
|
28906
|
+
const onInvocation = (ev) => {
|
|
28907
|
+
safeWrite(`event: invocation\ndata: ${JSON.stringify(ev)}\n\n`);
|
|
28908
|
+
};
|
|
28909
|
+
const onLog = (ev) => {
|
|
28910
|
+
safeWrite(`event: log\ndata: ${JSON.stringify(ev)}\n\n`);
|
|
28911
|
+
};
|
|
28912
|
+
const onServe = (ev) => {
|
|
28913
|
+
safeWrite(`event: serve\ndata: ${JSON.stringify(ev)}\n\n`);
|
|
28914
|
+
};
|
|
28915
|
+
function cleanup() {
|
|
28916
|
+
if (closed) return;
|
|
28917
|
+
closed = true;
|
|
28918
|
+
clearInterval(heartbeat);
|
|
28919
|
+
bus.off("invocation", onInvocation);
|
|
28920
|
+
bus.off("log", onLog);
|
|
28921
|
+
bus.off("serve", onServe);
|
|
28922
|
+
}
|
|
28923
|
+
function safeWrite(chunk) {
|
|
28924
|
+
if (closed || res.writableEnded || res.destroyed) {
|
|
28925
|
+
cleanup();
|
|
28926
|
+
return;
|
|
28927
|
+
}
|
|
28928
|
+
res.write(chunk);
|
|
28929
|
+
}
|
|
28930
|
+
bus.on("invocation", onInvocation);
|
|
28931
|
+
bus.on("log", onLog);
|
|
28932
|
+
bus.on("serve", onServe);
|
|
28933
|
+
req.on("close", cleanup);
|
|
28934
|
+
res.on("close", cleanup);
|
|
28935
|
+
res.on("error", cleanup);
|
|
28936
|
+
safeWrite(":ok\n\n");
|
|
28937
|
+
}
|
|
28938
|
+
/**
|
|
28939
|
+
* Listen on `port`, retrying `port+1`, `port+2`, ... on `EADDRINUSE` up
|
|
28940
|
+
* to `maxBump` extra attempts. Resolves with the bound port.
|
|
28941
|
+
*/
|
|
28942
|
+
function listenWithBump(server, host, port, maxBump) {
|
|
28943
|
+
return new Promise((resolveListen, reject) => {
|
|
28944
|
+
let attempt = 0;
|
|
28945
|
+
const tryListen = (p) => {
|
|
28946
|
+
const onError = (err) => {
|
|
28947
|
+
if (err.code === "EADDRINUSE" && attempt < maxBump) {
|
|
28948
|
+
attempt += 1;
|
|
28949
|
+
server.removeListener("error", onError);
|
|
28950
|
+
tryListen(p + 1);
|
|
28951
|
+
return;
|
|
28952
|
+
}
|
|
28953
|
+
reject(err);
|
|
28954
|
+
};
|
|
28955
|
+
server.once("error", onError);
|
|
28956
|
+
server.listen(p, host, () => {
|
|
28957
|
+
server.removeListener("error", onError);
|
|
28958
|
+
resolveListen(server.address().port);
|
|
28959
|
+
});
|
|
28960
|
+
};
|
|
28961
|
+
tryListen(port);
|
|
28962
|
+
});
|
|
28963
|
+
}
|
|
28964
|
+
|
|
28965
|
+
//#endregion
|
|
28966
|
+
//#region src/local/studio-child-args.ts
|
|
28967
|
+
/**
|
|
28968
|
+
* Build the shared `--app` / `--profile` / `--region` / `-c` /
|
|
28969
|
+
* `--from-cfn-stack` / `--assume-role` args for a studio child command.
|
|
28970
|
+
* Returns a flat argv fragment to spread after the subcommand + target.
|
|
28971
|
+
*/
|
|
28972
|
+
function buildSharedChildArgs(config) {
|
|
28973
|
+
const args = [];
|
|
28974
|
+
if (config.app) args.push("--app", config.app);
|
|
28975
|
+
if (config.profile) args.push("--profile", config.profile);
|
|
28976
|
+
if (config.region) args.push("--region", config.region);
|
|
28977
|
+
for (const [k, v] of Object.entries(config.context ?? {})) args.push("-c", `${k}=${v}`);
|
|
28978
|
+
if (config.fromCfnStack === true) args.push("--from-cfn-stack");
|
|
28979
|
+
else if (typeof config.fromCfnStack === "string" && config.fromCfnStack !== "") args.push("--from-cfn-stack", config.fromCfnStack);
|
|
28980
|
+
if (typeof config.assumeRole === "string" && config.assumeRole !== "") args.push("--assume-role", config.assumeRole);
|
|
28981
|
+
return args;
|
|
28982
|
+
}
|
|
28983
|
+
|
|
28984
|
+
//#endregion
|
|
28985
|
+
//#region src/local/studio-dispatch.ts
|
|
28986
|
+
let idCounter = 0;
|
|
28987
|
+
/**
|
|
28988
|
+
* Build the studio run dispatcher. Slice B drives a single-shot Lambda
|
|
28989
|
+
* invoke by spawning the SAME `cdkl invoke` the headless command runs —
|
|
28990
|
+
* studio is a control plane over the CLI, so re-using the whole command
|
|
28991
|
+
* (rather than re-wiring its internals) guarantees byte-for-byte parity
|
|
28992
|
+
* and keeps all of `cdkl invoke`'s process-global behavior
|
|
28993
|
+
* (`process.exit`, env mutation, stdin) isolated in a child process.
|
|
28994
|
+
*
|
|
28995
|
+
* The child's stdout is the Lambda response payload; its stderr (status
|
|
28996
|
+
* + container logs) is streamed line-by-line onto the bus as `log`
|
|
28997
|
+
* events. An `invocation` start event is emitted before spawn and an end
|
|
28998
|
+
* event (with response + status + duration) after exit, both keyed by
|
|
28999
|
+
* the same correlation id so the UI threads them into one timeline row.
|
|
29000
|
+
*/
|
|
29001
|
+
function createStudioDispatcher(config) {
|
|
29002
|
+
const spawnFn = config.spawnFn ?? spawn;
|
|
29003
|
+
const nodeBin = config.nodeBin ?? process.execPath;
|
|
29004
|
+
const clock = config.clock ?? Date.now;
|
|
29005
|
+
const idFactory = config.idFactory ?? (() => {
|
|
29006
|
+
idCounter += 1;
|
|
29007
|
+
return `inv-${clock()}-${idCounter}`;
|
|
29008
|
+
});
|
|
29009
|
+
async function run(req) {
|
|
29010
|
+
const invocationId = idFactory();
|
|
29011
|
+
const startedAt = clock();
|
|
29012
|
+
if (req.kind !== "lambda") {
|
|
29013
|
+
const error = `Running '${req.kind}' targets from studio is not supported yet (Lambda only).`;
|
|
29014
|
+
config.bus.emit("invocation", {
|
|
29015
|
+
id: invocationId,
|
|
29016
|
+
ts: startedAt,
|
|
29017
|
+
target: req.targetId,
|
|
29018
|
+
kind: req.kind,
|
|
29019
|
+
label: "invoke",
|
|
29020
|
+
request: req.event,
|
|
29021
|
+
response: error,
|
|
29022
|
+
status: 501,
|
|
29023
|
+
durationMs: clock() - startedAt
|
|
29024
|
+
});
|
|
29025
|
+
return {
|
|
29026
|
+
invocationId,
|
|
29027
|
+
ok: false,
|
|
29028
|
+
status: 501,
|
|
29029
|
+
error,
|
|
29030
|
+
durationMs: clock() - startedAt
|
|
29031
|
+
};
|
|
29032
|
+
}
|
|
29033
|
+
config.bus.emit("invocation", {
|
|
29034
|
+
id: invocationId,
|
|
29035
|
+
ts: startedAt,
|
|
29036
|
+
target: req.targetId,
|
|
29037
|
+
kind: req.kind,
|
|
29038
|
+
label: "invoke",
|
|
29039
|
+
request: req.event
|
|
29040
|
+
});
|
|
29041
|
+
let dir;
|
|
29042
|
+
try {
|
|
29043
|
+
dir = mkdtempSync(join(tmpdir(), "cdkl-studio-run-"));
|
|
29044
|
+
const eventFile = join(dir, "event.json");
|
|
29045
|
+
writeFileSync(eventFile, JSON.stringify(req.event ?? {}));
|
|
29046
|
+
const args = [
|
|
29047
|
+
"invoke",
|
|
29048
|
+
req.targetId,
|
|
29049
|
+
"--event",
|
|
29050
|
+
eventFile,
|
|
29051
|
+
...buildSharedChildArgs(config)
|
|
29052
|
+
];
|
|
29053
|
+
const { code, stdout, stderr } = await runChild(spawnFn, nodeBin, [config.cliEntry, ...args], config.cwd ?? process.cwd(), invocationId, req.targetId, config.bus, clock);
|
|
29054
|
+
const durationMs = clock() - startedAt;
|
|
29055
|
+
const ok = code === 0;
|
|
29056
|
+
const failure = stderr.trim() || `cdkl invoke exited ${code}`;
|
|
29057
|
+
const stdoutLines = stdout.split("\n").map((l) => l.replace(/\r$/, "").trimEnd()).filter((l) => l.trim().length > 0);
|
|
29058
|
+
let responseIdx = -1;
|
|
29059
|
+
let response;
|
|
29060
|
+
if (ok) {
|
|
29061
|
+
for (let i = stdoutLines.length - 1; i >= 0; i -= 1) {
|
|
29062
|
+
const parsed = tryParseJson(stdoutLines[i] ?? "");
|
|
29063
|
+
if (parsed.ok) {
|
|
29064
|
+
responseIdx = i;
|
|
29065
|
+
response = parsed.value;
|
|
29066
|
+
break;
|
|
29067
|
+
}
|
|
29068
|
+
}
|
|
29069
|
+
if (responseIdx === -1 && stdoutLines.length > 0) {
|
|
29070
|
+
responseIdx = stdoutLines.length - 1;
|
|
29071
|
+
response = stdoutLines[responseIdx];
|
|
29072
|
+
}
|
|
29073
|
+
} else response = failure;
|
|
29074
|
+
stdoutLines.forEach((line, i) => {
|
|
29075
|
+
if (i === responseIdx) return;
|
|
29076
|
+
config.bus.emit("log", {
|
|
29077
|
+
ts: clock(),
|
|
29078
|
+
containerId: invocationId,
|
|
29079
|
+
target: req.targetId,
|
|
29080
|
+
line,
|
|
29081
|
+
stream: "stdout"
|
|
29082
|
+
});
|
|
29083
|
+
});
|
|
29084
|
+
const raw = responseIdx >= 0 ? stdoutLines[responseIdx] ?? "" : "";
|
|
29085
|
+
const status = ok ? 200 : 500;
|
|
29086
|
+
config.bus.emit("invocation", {
|
|
29087
|
+
id: invocationId,
|
|
29088
|
+
ts: startedAt,
|
|
29089
|
+
target: req.targetId,
|
|
29090
|
+
kind: req.kind,
|
|
29091
|
+
label: "invoke",
|
|
29092
|
+
request: req.event,
|
|
29093
|
+
response,
|
|
29094
|
+
status,
|
|
29095
|
+
durationMs
|
|
29096
|
+
});
|
|
29097
|
+
const result = {
|
|
29098
|
+
invocationId,
|
|
29099
|
+
ok,
|
|
29100
|
+
status,
|
|
29101
|
+
durationMs
|
|
29102
|
+
};
|
|
29103
|
+
if (raw) result.raw = raw;
|
|
29104
|
+
if (response !== void 0) result.response = response;
|
|
29105
|
+
if (!ok) result.error = failure;
|
|
29106
|
+
return result;
|
|
29107
|
+
} finally {
|
|
29108
|
+
if (dir) rmSync(dir, {
|
|
29109
|
+
recursive: true,
|
|
29110
|
+
force: true
|
|
29111
|
+
});
|
|
29112
|
+
}
|
|
29113
|
+
}
|
|
29114
|
+
return { run };
|
|
29115
|
+
}
|
|
29116
|
+
/**
|
|
29117
|
+
* Spawn the child invoke, accumulate stdout, and stream stderr to the
|
|
29118
|
+
* bus line-by-line as `log` events. Resolves on process close.
|
|
29119
|
+
*/
|
|
29120
|
+
function runChild(spawnFn, nodeBin, argv, cwd, invocationId, target, bus, clock) {
|
|
29121
|
+
return new Promise((resolve, reject) => {
|
|
29122
|
+
let child;
|
|
29123
|
+
try {
|
|
29124
|
+
child = spawnFn(nodeBin, argv, {
|
|
29125
|
+
cwd,
|
|
29126
|
+
env: {
|
|
29127
|
+
...process.env,
|
|
29128
|
+
CDKL_LOG_LEVEL: "warn"
|
|
29129
|
+
}
|
|
29130
|
+
});
|
|
29131
|
+
} catch (err) {
|
|
29132
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
29133
|
+
return;
|
|
29134
|
+
}
|
|
29135
|
+
let stdout = "";
|
|
29136
|
+
let stderr = "";
|
|
29137
|
+
let lineBuf = "";
|
|
29138
|
+
child.stdout.setEncoding("utf8");
|
|
29139
|
+
child.stderr.setEncoding("utf8");
|
|
29140
|
+
child.stdout.on("data", (chunk) => {
|
|
29141
|
+
stdout += chunk;
|
|
29142
|
+
});
|
|
29143
|
+
child.stderr.on("data", (chunk) => {
|
|
29144
|
+
stderr += chunk;
|
|
29145
|
+
lineBuf += chunk;
|
|
29146
|
+
let nl = lineBuf.indexOf("\n");
|
|
29147
|
+
while (nl !== -1) {
|
|
29148
|
+
const line = lineBuf.slice(0, nl);
|
|
29149
|
+
lineBuf = lineBuf.slice(nl + 1);
|
|
29150
|
+
if (line.length > 0) bus.emit("log", {
|
|
29151
|
+
ts: clock(),
|
|
29152
|
+
containerId: invocationId,
|
|
29153
|
+
target,
|
|
29154
|
+
line,
|
|
29155
|
+
stream: "stderr"
|
|
29156
|
+
});
|
|
29157
|
+
nl = lineBuf.indexOf("\n");
|
|
29158
|
+
}
|
|
29159
|
+
});
|
|
29160
|
+
child.on("error", (err) => reject(err));
|
|
29161
|
+
child.on("close", (code) => {
|
|
29162
|
+
if (lineBuf.length > 0) bus.emit("log", {
|
|
29163
|
+
ts: clock(),
|
|
29164
|
+
containerId: invocationId,
|
|
29165
|
+
target,
|
|
29166
|
+
line: lineBuf,
|
|
29167
|
+
stream: "stderr"
|
|
29168
|
+
});
|
|
29169
|
+
resolve({
|
|
29170
|
+
code,
|
|
29171
|
+
stdout,
|
|
29172
|
+
stderr
|
|
29173
|
+
});
|
|
29174
|
+
});
|
|
29175
|
+
});
|
|
29176
|
+
}
|
|
29177
|
+
/** Try to JSON-parse `raw`; `ok` distinguishes a parsed value from a failure. */
|
|
29178
|
+
function tryParseJson(raw) {
|
|
29179
|
+
if (raw === "") return {
|
|
29180
|
+
ok: false,
|
|
29181
|
+
value: void 0
|
|
29182
|
+
};
|
|
29183
|
+
try {
|
|
29184
|
+
return {
|
|
29185
|
+
ok: true,
|
|
29186
|
+
value: JSON.parse(raw)
|
|
29187
|
+
};
|
|
29188
|
+
} catch {
|
|
29189
|
+
return {
|
|
29190
|
+
ok: false,
|
|
29191
|
+
value: void 0
|
|
29192
|
+
};
|
|
29193
|
+
}
|
|
29194
|
+
}
|
|
29195
|
+
|
|
29196
|
+
//#endregion
|
|
29197
|
+
//#region src/local/studio-proxy.ts
|
|
29198
|
+
let proxyIdCounter = 0;
|
|
29199
|
+
/**
|
|
29200
|
+
* Start a capturing reverse proxy in front of a studio serve target
|
|
29201
|
+
* (decision D4a: because every request to the served port flows through
|
|
29202
|
+
* `cdkl studio`, the timeline observes them regardless of source —
|
|
29203
|
+
* browser, curl, or the in-UI pad alike).
|
|
29204
|
+
*
|
|
29205
|
+
* Each HTTP request is forwarded to `upstream` and, in parallel,
|
|
29206
|
+
* captured (method / path / headers / bounded body) and emitted as an
|
|
29207
|
+
* `invocation` start event; when the upstream response completes, an end
|
|
29208
|
+
* event carries the status / headers / bounded body / duration. The full
|
|
29209
|
+
* bodies stream through untouched — only the captured copies are bounded.
|
|
29210
|
+
* `Upgrade` (WebSocket) requests are bridged raw to the upstream without
|
|
29211
|
+
* capture so they keep working.
|
|
29212
|
+
*
|
|
29213
|
+
* Studio is a control plane over the CLI, so this proxy sits in front of
|
|
29214
|
+
* the long-running `cdkl start-api` child the serve manager spawned; it
|
|
29215
|
+
* does NOT re-implement any routing — it forwards verbatim.
|
|
29216
|
+
*/
|
|
29217
|
+
function startStudioProxy(config) {
|
|
29218
|
+
const host = config.host ?? "127.0.0.1";
|
|
29219
|
+
const clock = config.clock ?? Date.now;
|
|
29220
|
+
const maxCapture = config.maxCaptureBytes ?? 64 * 1024;
|
|
29221
|
+
const idFactory = config.idFactory ?? (() => {
|
|
29222
|
+
proxyIdCounter += 1;
|
|
29223
|
+
return `req-${clock()}-${proxyIdCounter}`;
|
|
29224
|
+
});
|
|
29225
|
+
const upstreamUrl = new URL(config.upstream);
|
|
29226
|
+
const upstreamHost = upstreamUrl.hostname;
|
|
29227
|
+
const upstreamPort = Number(upstreamUrl.port) || 80;
|
|
29228
|
+
const server = createServer$1((clientReq, clientRes) => {
|
|
29229
|
+
const id = idFactory();
|
|
29230
|
+
const startedAt = clock();
|
|
29231
|
+
const path = clientReq.url ?? "/";
|
|
29232
|
+
const method = clientReq.method ?? "GET";
|
|
29233
|
+
const label = `${method} ${path.split("?")[0]}`;
|
|
29234
|
+
const reqBody = boundedCollector(maxCapture);
|
|
29235
|
+
config.bus.emit("invocation", {
|
|
29236
|
+
id,
|
|
29237
|
+
ts: startedAt,
|
|
29238
|
+
target: config.target,
|
|
29239
|
+
kind: config.kind,
|
|
29240
|
+
label,
|
|
29241
|
+
request: {
|
|
29242
|
+
method,
|
|
29243
|
+
path,
|
|
29244
|
+
headers: { ...clientReq.headers }
|
|
29245
|
+
}
|
|
29246
|
+
});
|
|
29247
|
+
let ended = false;
|
|
29248
|
+
const emitEnd = (status, response) => {
|
|
29249
|
+
if (ended) return;
|
|
29250
|
+
ended = true;
|
|
29251
|
+
config.bus.emit("invocation", {
|
|
29252
|
+
id,
|
|
29253
|
+
ts: startedAt,
|
|
29254
|
+
target: config.target,
|
|
29255
|
+
kind: config.kind,
|
|
29256
|
+
label,
|
|
29257
|
+
request: {
|
|
29258
|
+
method,
|
|
29259
|
+
path,
|
|
29260
|
+
headers: { ...clientReq.headers },
|
|
29261
|
+
body: reqBody.text()
|
|
29262
|
+
},
|
|
29263
|
+
response,
|
|
29264
|
+
status,
|
|
29265
|
+
durationMs: clock() - startedAt
|
|
29266
|
+
});
|
|
29267
|
+
};
|
|
29268
|
+
const upstreamReq = request({
|
|
29269
|
+
host: upstreamHost,
|
|
29270
|
+
port: upstreamPort,
|
|
29271
|
+
method,
|
|
29272
|
+
path,
|
|
29273
|
+
headers: clientReq.headers
|
|
29274
|
+
}, (upstreamRes) => {
|
|
29275
|
+
const respBody = boundedCollector(maxCapture);
|
|
29276
|
+
clientRes.writeHead(upstreamRes.statusCode ?? 502, stripHopByHop(upstreamRes.headers));
|
|
29277
|
+
upstreamRes.on("data", (chunk) => respBody.push(chunk));
|
|
29278
|
+
upstreamRes.pipe(clientRes);
|
|
29279
|
+
upstreamRes.on("end", () => emitEnd(upstreamRes.statusCode ?? 502, {
|
|
29280
|
+
status: upstreamRes.statusCode,
|
|
29281
|
+
headers: { ...upstreamRes.headers },
|
|
29282
|
+
body: respBody.text()
|
|
29283
|
+
}));
|
|
29284
|
+
upstreamRes.on("error", () => {
|
|
29285
|
+
if (!clientRes.writableEnded) clientRes.destroy();
|
|
29286
|
+
emitEnd(502, "upstream response stream error");
|
|
29287
|
+
});
|
|
29288
|
+
});
|
|
29289
|
+
upstreamReq.on("error", (err) => {
|
|
29290
|
+
if (!clientRes.headersSent) clientRes.writeHead(502, { "content-type": "text/plain" });
|
|
29291
|
+
clientRes.end(`studio proxy: upstream error: ${err.message}`);
|
|
29292
|
+
emitEnd(502, `upstream error: ${err.message}`);
|
|
29293
|
+
});
|
|
29294
|
+
clientReq.on("data", (chunk) => reqBody.push(chunk));
|
|
29295
|
+
clientReq.on("error", () => {
|
|
29296
|
+
upstreamReq.destroy();
|
|
29297
|
+
emitEnd(499, "client aborted the request");
|
|
29298
|
+
});
|
|
29299
|
+
clientReq.pipe(upstreamReq);
|
|
29300
|
+
});
|
|
29301
|
+
const upgradeSockets = /* @__PURE__ */ new Set();
|
|
29302
|
+
server.on("upgrade", (req, clientSocket, head) => {
|
|
29303
|
+
const sock = clientSocket;
|
|
29304
|
+
upgradeSockets.add(sock);
|
|
29305
|
+
sock.on("close", () => upgradeSockets.delete(sock));
|
|
29306
|
+
bridgeUpgrade(req, sock, head, upstreamHost, upstreamPort);
|
|
29307
|
+
});
|
|
29308
|
+
return new Promise((resolve, reject) => {
|
|
29309
|
+
server.once("error", reject);
|
|
29310
|
+
server.listen(0, host, () => {
|
|
29311
|
+
server.removeListener("error", reject);
|
|
29312
|
+
const port = server.address().port;
|
|
29313
|
+
resolve({
|
|
29314
|
+
url: `http://${host}:${port}`,
|
|
29315
|
+
port,
|
|
29316
|
+
close: () => new Promise((resolveClose, rejectClose) => {
|
|
29317
|
+
for (const sock of upgradeSockets) sock.destroy();
|
|
29318
|
+
upgradeSockets.clear();
|
|
29319
|
+
server.close((err) => err ? rejectClose(err) : resolveClose());
|
|
29320
|
+
server.closeAllConnections?.();
|
|
29321
|
+
})
|
|
29322
|
+
});
|
|
29323
|
+
});
|
|
29324
|
+
});
|
|
29325
|
+
}
|
|
29326
|
+
/** RFC 7230 hop-by-hop headers — never forwarded across a proxy. */
|
|
29327
|
+
const HOP_BY_HOP = new Set([
|
|
29328
|
+
"connection",
|
|
29329
|
+
"keep-alive",
|
|
29330
|
+
"proxy-authenticate",
|
|
29331
|
+
"proxy-authorization",
|
|
29332
|
+
"te",
|
|
29333
|
+
"trailer",
|
|
29334
|
+
"transfer-encoding",
|
|
29335
|
+
"upgrade"
|
|
29336
|
+
]);
|
|
29337
|
+
/** Copy `headers` without the hop-by-hop ones (which describe one connection). */
|
|
29338
|
+
function stripHopByHop(headers) {
|
|
29339
|
+
const out = {};
|
|
29340
|
+
for (const [k, v] of Object.entries(headers)) if (!HOP_BY_HOP.has(k.toLowerCase())) out[k] = v;
|
|
29341
|
+
return out;
|
|
29342
|
+
}
|
|
29343
|
+
/** A bounded byte collector that decodes to a (possibly truncated) utf8 string. */
|
|
29344
|
+
function boundedCollector(maxBytes) {
|
|
29345
|
+
const chunks = [];
|
|
29346
|
+
let size = 0;
|
|
29347
|
+
let truncated = false;
|
|
29348
|
+
return {
|
|
29349
|
+
push: (c) => {
|
|
29350
|
+
if (size >= maxBytes) {
|
|
29351
|
+
truncated = true;
|
|
29352
|
+
return;
|
|
29353
|
+
}
|
|
29354
|
+
const room = maxBytes - size;
|
|
29355
|
+
if (c.length > room) {
|
|
29356
|
+
chunks.push(c.subarray(0, room));
|
|
29357
|
+
size = maxBytes;
|
|
29358
|
+
truncated = true;
|
|
29359
|
+
} else {
|
|
29360
|
+
chunks.push(c);
|
|
29361
|
+
size += c.length;
|
|
29362
|
+
}
|
|
29363
|
+
},
|
|
29364
|
+
text: () => {
|
|
29365
|
+
const s = Buffer.concat(chunks).toString("utf8");
|
|
29366
|
+
return truncated ? `${s}… (truncated)` : s;
|
|
29367
|
+
}
|
|
29368
|
+
};
|
|
29369
|
+
}
|
|
29370
|
+
/** Raw-bridge an `Upgrade` request (e.g. WebSocket) to the upstream. */
|
|
29371
|
+
function bridgeUpgrade(req, clientSocket, head, upstreamHost, upstreamPort) {
|
|
29372
|
+
const upstream = connect(upstreamPort, upstreamHost, () => {
|
|
29373
|
+
let raw = `${req.method} ${req.url} HTTP/1.1\r\n`;
|
|
29374
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2) raw += `${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r\n`;
|
|
29375
|
+
raw += "\r\n";
|
|
29376
|
+
upstream.write(raw);
|
|
29377
|
+
if (head && head.length > 0) upstream.write(head);
|
|
29378
|
+
clientSocket.pipe(upstream);
|
|
29379
|
+
upstream.pipe(clientSocket);
|
|
29380
|
+
});
|
|
29381
|
+
const teardown = () => {
|
|
29382
|
+
clientSocket.destroy();
|
|
29383
|
+
upstream.destroy();
|
|
29384
|
+
};
|
|
29385
|
+
upstream.on("error", teardown);
|
|
29386
|
+
upstream.on("close", teardown);
|
|
29387
|
+
clientSocket.on("error", teardown);
|
|
29388
|
+
clientSocket.on("close", teardown);
|
|
29389
|
+
}
|
|
29390
|
+
|
|
29391
|
+
//#endregion
|
|
29392
|
+
//#region src/local/studio-serve-manager.ts
|
|
29393
|
+
/**
|
|
29394
|
+
* The serve lifecycle per kind. `api` + `alb` expose host HTTP endpoints
|
|
29395
|
+
* the studio capture proxy fronts; `ecs` (start-service) is pure compute
|
|
29396
|
+
* with no host port, so it has no endpoint and no capture — studio just
|
|
29397
|
+
* runs the replicas + streams their logs.
|
|
29398
|
+
*/
|
|
29399
|
+
const SERVE_SPECS = {
|
|
29400
|
+
api: {
|
|
29401
|
+
command: "start-api",
|
|
29402
|
+
portArgs: [
|
|
29403
|
+
"--port",
|
|
29404
|
+
"0",
|
|
29405
|
+
"--host",
|
|
29406
|
+
"127.0.0.1"
|
|
29407
|
+
],
|
|
29408
|
+
readyRe: /Server listening on (\S+)/,
|
|
29409
|
+
capturesHttp: true
|
|
29410
|
+
},
|
|
29411
|
+
alb: {
|
|
29412
|
+
command: "start-alb",
|
|
29413
|
+
portArgs: [],
|
|
29414
|
+
readyRe: /ALB front-door: (https?:\/\/\S+)/,
|
|
29415
|
+
capturesHttp: true
|
|
29416
|
+
},
|
|
29417
|
+
ecs: {
|
|
29418
|
+
command: "start-service",
|
|
29419
|
+
portArgs: [],
|
|
29420
|
+
readyRe: /Service\(s\) running:/,
|
|
29421
|
+
capturesHttp: false
|
|
29422
|
+
}
|
|
29423
|
+
};
|
|
29424
|
+
/**
|
|
29425
|
+
* Build the studio serve manager. Slice C1 drives a long-running
|
|
29426
|
+
* `cdkl start-api <target>` child — studio is a control plane over the
|
|
29427
|
+
* CLI (the same pattern as the single-shot invoke dispatcher), so it
|
|
29428
|
+
* spawns the SAME headless serve command rather than re-wiring its
|
|
29429
|
+
* internals. This preserves byte-for-byte parity and isolates the
|
|
29430
|
+
* server's process-global behavior in a child.
|
|
29431
|
+
*
|
|
29432
|
+
* `start()` spawns the child with `--port 0` (OS-assigned, collision
|
|
29433
|
+
* free), streams its stdout/stderr onto the bus as `log` events keyed by
|
|
29434
|
+
* the target id, and resolves once the child prints its first
|
|
29435
|
+
* `Server listening on <url>` line — emitting a `serve` `running` event
|
|
29436
|
+
* with the discovered endpoints. `stop()` SIGTERMs the child (SIGKILL
|
|
29437
|
+
* after a grace window) and emits `stopped`.
|
|
29438
|
+
*
|
|
29439
|
+
* Slice C2 fronts each HTTP serve endpoint with a capture proxy
|
|
29440
|
+
* ({@link startStudioProxy}) so every request to the served port lands
|
|
29441
|
+
* on the studio timeline (decision D4a); the `endpoints` the UI is
|
|
29442
|
+
* handed are the proxy URLs. The serve-kinds slice generalized this to a
|
|
29443
|
+
* per-kind {@link ServeKindSpec}: `api` (`start-api`) + `alb`
|
|
29444
|
+
* (`start-alb`) expose host HTTP endpoints the proxy captures, while
|
|
29445
|
+
* `ecs` (`start-service`) is pure compute — no host port, no capture,
|
|
29446
|
+
* just the running replicas + their streamed logs.
|
|
29447
|
+
*/
|
|
29448
|
+
function createStudioServeManager(config) {
|
|
29449
|
+
const spawnFn = config.spawnFn ?? spawn;
|
|
29450
|
+
const nodeBin = config.nodeBin ?? process.execPath;
|
|
29451
|
+
const clock = config.clock ?? Date.now;
|
|
29452
|
+
const readyTimeoutMs = config.readyTimeoutMs ?? 12e4;
|
|
29453
|
+
const stopGraceMs = config.stopGraceMs ?? 45e3;
|
|
29454
|
+
const setTimeoutFn = config.setTimeoutFn ?? setTimeout;
|
|
29455
|
+
const clearTimeoutFn = config.clearTimeoutFn ?? clearTimeout;
|
|
29456
|
+
const cwd = config.cwd ?? process.cwd();
|
|
29457
|
+
const proxyFactory = config.proxyFactory ?? startStudioProxy;
|
|
29458
|
+
const captureRequests = config.captureRequests ?? true;
|
|
29459
|
+
const entries = /* @__PURE__ */ new Map();
|
|
29460
|
+
/** Close every capture proxy fronting `e` (best-effort; idempotent). */
|
|
29461
|
+
async function closeProxies(e) {
|
|
29462
|
+
const proxies = e.proxies.splice(0);
|
|
29463
|
+
await Promise.all(proxies.map((p) => p.close().catch(() => void 0)));
|
|
29464
|
+
}
|
|
29465
|
+
function publicState(e) {
|
|
29466
|
+
const s = {
|
|
29467
|
+
targetId: e.targetId,
|
|
29468
|
+
kind: e.kind,
|
|
29469
|
+
status: e.status,
|
|
29470
|
+
endpoints: [...e.endpoints],
|
|
29471
|
+
startedAt: e.startedAt
|
|
29472
|
+
};
|
|
29473
|
+
if (e.pid !== void 0) s.pid = e.pid;
|
|
29474
|
+
return s;
|
|
29475
|
+
}
|
|
29476
|
+
function emitServe(e, message) {
|
|
29477
|
+
const ev = {
|
|
29478
|
+
ts: clock(),
|
|
29479
|
+
target: e.targetId,
|
|
29480
|
+
kind: e.kind,
|
|
29481
|
+
status: e.status,
|
|
29482
|
+
endpoints: [...e.endpoints]
|
|
29483
|
+
};
|
|
29484
|
+
if (e.pid !== void 0) ev.pid = e.pid;
|
|
29485
|
+
if (message !== void 0) ev.message = message;
|
|
29486
|
+
config.bus.emit("serve", ev);
|
|
29487
|
+
}
|
|
29488
|
+
function buildArgs(targetId, spec) {
|
|
29489
|
+
return [
|
|
29490
|
+
spec.command,
|
|
29491
|
+
targetId,
|
|
29492
|
+
...spec.portArgs,
|
|
29493
|
+
...buildSharedChildArgs(config)
|
|
29494
|
+
];
|
|
29495
|
+
}
|
|
29496
|
+
async function start(req) {
|
|
29497
|
+
const spec = SERVE_SPECS[req.kind];
|
|
29498
|
+
if (!spec) throw new Error(`Serving '${req.kind}' targets from studio is not supported (serve kinds: ${Object.keys(SERVE_SPECS).join(", ")}).`);
|
|
29499
|
+
const existing = entries.get(req.targetId);
|
|
29500
|
+
if (existing && existing.status !== "stopped" && existing.status !== "error") throw new Error(`'${req.targetId}' is already running.`);
|
|
29501
|
+
const startedAt = clock();
|
|
29502
|
+
let child;
|
|
29503
|
+
try {
|
|
29504
|
+
child = spawnFn(nodeBin, [config.cliEntry, ...buildArgs(req.targetId, spec)], { cwd });
|
|
29505
|
+
} catch (err) {
|
|
29506
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
29507
|
+
}
|
|
29508
|
+
const entry = {
|
|
29509
|
+
targetId: req.targetId,
|
|
29510
|
+
kind: req.kind,
|
|
29511
|
+
status: "starting",
|
|
29512
|
+
endpoints: [],
|
|
29513
|
+
startedAt,
|
|
29514
|
+
child,
|
|
29515
|
+
proxies: []
|
|
29516
|
+
};
|
|
29517
|
+
if (child.pid !== void 0) entry.pid = child.pid;
|
|
29518
|
+
entries.set(req.targetId, entry);
|
|
29519
|
+
emitServe(entry);
|
|
29520
|
+
return new Promise((resolve, reject) => {
|
|
29521
|
+
let settled = false;
|
|
29522
|
+
child.stdout.setEncoding("utf8");
|
|
29523
|
+
child.stderr.setEncoding("utf8");
|
|
29524
|
+
const timer = setTimeoutFn(() => {
|
|
29525
|
+
if (settled) return;
|
|
29526
|
+
settled = true;
|
|
29527
|
+
entry.status = "error";
|
|
29528
|
+
emitServe(entry, `Timed out after ${readyTimeoutMs}ms waiting for the serve to be ready.`);
|
|
29529
|
+
stopChild(child, stopGraceMs, setTimeoutFn, clearTimeoutFn);
|
|
29530
|
+
closeProxies(entry);
|
|
29531
|
+
entries.delete(req.targetId);
|
|
29532
|
+
reject(/* @__PURE__ */ new Error(`'${req.targetId}' did not start within ${readyTimeoutMs}ms.`));
|
|
29533
|
+
}, readyTimeoutMs);
|
|
29534
|
+
timer.unref?.();
|
|
29535
|
+
const becomeRunning = () => {
|
|
29536
|
+
if (settled) return;
|
|
29537
|
+
settled = true;
|
|
29538
|
+
clearTimeoutFn(timer);
|
|
29539
|
+
entry.status = "running";
|
|
29540
|
+
emitServe(entry);
|
|
29541
|
+
resolve(publicState(entry));
|
|
29542
|
+
};
|
|
29543
|
+
const onReady = async (childUrl) => {
|
|
29544
|
+
let endpoint = childUrl;
|
|
29545
|
+
if (childUrl && spec.capturesHttp && captureRequests && /^https?:/i.test(childUrl)) try {
|
|
29546
|
+
const proxy = await proxyFactory({
|
|
29547
|
+
bus: config.bus,
|
|
29548
|
+
target: req.targetId,
|
|
29549
|
+
kind: req.kind,
|
|
29550
|
+
upstream: childUrl
|
|
29551
|
+
});
|
|
29552
|
+
entry.proxies.push(proxy);
|
|
29553
|
+
endpoint = proxy.url;
|
|
29554
|
+
} catch {
|
|
29555
|
+
endpoint = childUrl;
|
|
29556
|
+
}
|
|
29557
|
+
if (entry.stopping || settled && !entries.has(req.targetId)) {
|
|
29558
|
+
await closeProxies(entry);
|
|
29559
|
+
return;
|
|
29560
|
+
}
|
|
29561
|
+
if (endpoint && !entry.endpoints.includes(endpoint)) entry.endpoints.push(endpoint);
|
|
29562
|
+
if (settled) emitServe(entry);
|
|
29563
|
+
else becomeRunning();
|
|
29564
|
+
};
|
|
29565
|
+
streamLines(child.stdout, (line) => {
|
|
29566
|
+
const m = spec.readyRe.exec(line);
|
|
29567
|
+
if (m) onReady(m[1]);
|
|
29568
|
+
emitLog(config.bus, clock, req.targetId, line, "stdout");
|
|
29569
|
+
});
|
|
29570
|
+
streamLines(child.stderr, (line) => {
|
|
29571
|
+
emitLog(config.bus, clock, req.targetId, line, "stderr");
|
|
29572
|
+
});
|
|
29573
|
+
child.on("error", (err) => {
|
|
29574
|
+
if (settled) {
|
|
29575
|
+
entry.status = "error";
|
|
29576
|
+
emitServe(entry, err.message);
|
|
29577
|
+
closeProxies(entry);
|
|
29578
|
+
entries.delete(req.targetId);
|
|
29579
|
+
return;
|
|
29580
|
+
}
|
|
29581
|
+
settled = true;
|
|
29582
|
+
clearTimeoutFn(timer);
|
|
29583
|
+
entry.status = "error";
|
|
29584
|
+
emitServe(entry, err.message);
|
|
29585
|
+
closeProxies(entry);
|
|
29586
|
+
entries.delete(req.targetId);
|
|
29587
|
+
reject(err);
|
|
29588
|
+
});
|
|
29589
|
+
child.on("close", (code) => {
|
|
29590
|
+
if (!settled) {
|
|
29591
|
+
settled = true;
|
|
29592
|
+
clearTimeoutFn(timer);
|
|
29593
|
+
if (entry.stopping) {
|
|
29594
|
+
reject(/* @__PURE__ */ new Error(`'${req.targetId}' was stopped before it finished starting.`));
|
|
29595
|
+
return;
|
|
29596
|
+
}
|
|
29597
|
+
entry.status = "error";
|
|
29598
|
+
const msg = `Server exited before listening (code ${code ?? "null"}).`;
|
|
29599
|
+
emitServe(entry, msg);
|
|
29600
|
+
closeProxies(entry);
|
|
29601
|
+
entries.delete(req.targetId);
|
|
29602
|
+
reject(new Error(msg));
|
|
29603
|
+
return;
|
|
29604
|
+
}
|
|
29605
|
+
if (entries.get(req.targetId) === entry && entry.status === "running") {
|
|
29606
|
+
entry.status = "stopped";
|
|
29607
|
+
emitServe(entry, `Server process exited (code ${code ?? "null"}).`);
|
|
29608
|
+
closeProxies(entry);
|
|
29609
|
+
entries.delete(req.targetId);
|
|
29610
|
+
}
|
|
29611
|
+
});
|
|
29612
|
+
});
|
|
29613
|
+
}
|
|
29614
|
+
async function stop(req) {
|
|
29615
|
+
const entry = entries.get(req.targetId);
|
|
29616
|
+
if (!entry) throw new Error(`'${req.targetId}' is not running.`);
|
|
29617
|
+
entry.stopping = true;
|
|
29618
|
+
entries.delete(req.targetId);
|
|
29619
|
+
await Promise.all([closeProxies(entry), stopChild(entry.child, stopGraceMs, setTimeoutFn, clearTimeoutFn)]);
|
|
29620
|
+
entry.status = "stopped";
|
|
29621
|
+
emitServe(entry);
|
|
29622
|
+
}
|
|
29623
|
+
function list() {
|
|
29624
|
+
return [...entries.values()].map(publicState);
|
|
29625
|
+
}
|
|
29626
|
+
async function stopAll() {
|
|
29627
|
+
const targets = [...entries.keys()];
|
|
29628
|
+
await Promise.all(targets.map((targetId) => stop({ targetId }).catch(() => void 0)));
|
|
29629
|
+
}
|
|
29630
|
+
return {
|
|
29631
|
+
start,
|
|
29632
|
+
stop,
|
|
29633
|
+
list,
|
|
29634
|
+
stopAll
|
|
29635
|
+
};
|
|
29636
|
+
}
|
|
29637
|
+
/** Emit one container log line onto the bus, keyed by the serve target id. */
|
|
29638
|
+
function emitLog(bus, clock, target, line, stream) {
|
|
29639
|
+
bus.emit("log", {
|
|
29640
|
+
ts: clock(),
|
|
29641
|
+
containerId: target,
|
|
29642
|
+
target,
|
|
29643
|
+
line,
|
|
29644
|
+
stream
|
|
29645
|
+
});
|
|
29646
|
+
}
|
|
29647
|
+
/**
|
|
29648
|
+
* Line-buffer a child stream and invoke `onLine` per complete line
|
|
29649
|
+
* (trailing newline stripped, blank lines dropped). Flushes any partial
|
|
29650
|
+
* final line on stream end.
|
|
29651
|
+
*/
|
|
29652
|
+
function streamLines(stream, onLine) {
|
|
29653
|
+
let buf = "";
|
|
29654
|
+
stream.on("data", (chunk) => {
|
|
29655
|
+
buf += chunk;
|
|
29656
|
+
let nl = buf.indexOf("\n");
|
|
29657
|
+
while (nl !== -1) {
|
|
29658
|
+
const line = buf.slice(0, nl).replace(/\r$/, "");
|
|
29659
|
+
buf = buf.slice(nl + 1);
|
|
29660
|
+
if (line.length > 0) onLine(line);
|
|
29661
|
+
nl = buf.indexOf("\n");
|
|
29662
|
+
}
|
|
29663
|
+
});
|
|
29664
|
+
stream.on("end", () => {
|
|
29665
|
+
const line = buf.replace(/\r$/, "");
|
|
29666
|
+
if (line.length > 0) onLine(line);
|
|
29667
|
+
buf = "";
|
|
29668
|
+
});
|
|
29669
|
+
}
|
|
29670
|
+
/**
|
|
29671
|
+
* SIGTERM a child and resolve once it exits, escalating to SIGKILL after
|
|
29672
|
+
* `graceMs`. Resolves immediately if the child has already exited.
|
|
29673
|
+
*/
|
|
29674
|
+
function stopChild(child, graceMs, setTimeoutFn, clearTimeoutFn) {
|
|
29675
|
+
return new Promise((resolve) => {
|
|
29676
|
+
let done = false;
|
|
29677
|
+
let kill;
|
|
29678
|
+
const finish = () => {
|
|
29679
|
+
if (done) return;
|
|
29680
|
+
done = true;
|
|
29681
|
+
if (kill) clearTimeoutFn(kill);
|
|
29682
|
+
resolve();
|
|
29683
|
+
};
|
|
29684
|
+
child.once("close", finish);
|
|
29685
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
29686
|
+
finish();
|
|
29687
|
+
return;
|
|
29688
|
+
}
|
|
29689
|
+
kill = setTimeoutFn(() => {
|
|
29690
|
+
if (!done) child.kill("SIGKILL");
|
|
29691
|
+
}, graceMs);
|
|
29692
|
+
kill.unref?.();
|
|
29693
|
+
child.kill("SIGTERM");
|
|
29694
|
+
});
|
|
29695
|
+
}
|
|
29696
|
+
|
|
29697
|
+
//#endregion
|
|
29698
|
+
//#region src/cli/commands/local-studio.ts
|
|
29699
|
+
const STUDIO_TARGET_KINDS = [
|
|
29700
|
+
"lambda",
|
|
29701
|
+
"api",
|
|
29702
|
+
"alb",
|
|
29703
|
+
"ecs",
|
|
29704
|
+
"agentcore"
|
|
29705
|
+
];
|
|
29706
|
+
/**
|
|
29707
|
+
* Validate + narrow the untyped `POST /api/run` body into a
|
|
29708
|
+
* {@link StudioRunRequest}. Throws (→ 400 from the server) on a malformed
|
|
29709
|
+
* body so a bad UI / curl payload fails loudly rather than spawning an
|
|
29710
|
+
* `invoke` for an empty target.
|
|
29711
|
+
*/
|
|
29712
|
+
function coerceRunRequest(body) {
|
|
29713
|
+
if (typeof body !== "object" || body === null) throw new Error("Request body must be a JSON object.");
|
|
29714
|
+
const { targetId, kind, event } = body;
|
|
29715
|
+
if (typeof targetId !== "string" || targetId.trim() === "") throw new Error("Request body must include a non-empty \"targetId\" string.");
|
|
29716
|
+
if (typeof kind !== "string" || !STUDIO_TARGET_KINDS.includes(kind)) throw new Error(`Request body "kind" must be one of: ${STUDIO_TARGET_KINDS.join(", ")}.`);
|
|
29717
|
+
return {
|
|
29718
|
+
targetId,
|
|
29719
|
+
kind,
|
|
29720
|
+
event
|
|
29721
|
+
};
|
|
29722
|
+
}
|
|
29723
|
+
/**
|
|
29724
|
+
* Validate + narrow the untyped `POST /api/stop` body into a
|
|
29725
|
+
* {@link StudioStopRequest}. Throws (→ 400 from the server) on a missing
|
|
29726
|
+
* / empty target id.
|
|
29727
|
+
*/
|
|
29728
|
+
function coerceStopRequest(body) {
|
|
29729
|
+
if (typeof body !== "object" || body === null) throw new Error("Request body must be a JSON object.");
|
|
29730
|
+
const { targetId } = body;
|
|
29731
|
+
if (typeof targetId !== "string" || targetId.trim() === "") throw new Error("Request body must include a non-empty \"targetId\" string.");
|
|
29732
|
+
return { targetId };
|
|
29733
|
+
}
|
|
29734
|
+
const DEFAULT_STUDIO_PORT = 9999;
|
|
29735
|
+
/**
|
|
29736
|
+
* Parse + validate the `--studio-port` value. Accepts `0` (OS-assigned)
|
|
29737
|
+
* through `65535`. Exported so a unit test can assert the bounds without
|
|
29738
|
+
* driving the full command. Throws on anything out of range / non-numeric.
|
|
29739
|
+
*/
|
|
29740
|
+
function parseStudioPort(raw) {
|
|
29741
|
+
const port = raw.trim() === "" ? NaN : Number(raw);
|
|
29742
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`--studio-port must be 0..65535 (got ${raw}).`);
|
|
29743
|
+
return port;
|
|
29744
|
+
}
|
|
29745
|
+
async function localStudioCommand(options) {
|
|
29746
|
+
const logger = getLogger();
|
|
29747
|
+
if (options.verbose) logger.setLevel("debug");
|
|
29748
|
+
const port = parseStudioPort(options.studioPort);
|
|
29749
|
+
await applyRoleArnIfSet({
|
|
29750
|
+
roleArn: options.roleArn,
|
|
29751
|
+
region: void 0,
|
|
29752
|
+
profile: options.profile
|
|
29753
|
+
});
|
|
29754
|
+
const appCmd = resolveApp(options.app);
|
|
29755
|
+
if (!appCmd) throw new Error(`No CDK app specified. Pass --app, set ${getEmbedConfig().envPrefix}_APP, or add "app" to cdk.json.`);
|
|
29756
|
+
logger.info("Synthesizing CDK app...");
|
|
29757
|
+
const synthesizer = new Synthesizer();
|
|
29758
|
+
const context = parseContextOptions(options.context);
|
|
29759
|
+
const synthOpts = {
|
|
29760
|
+
app: appCmd,
|
|
29761
|
+
output: options.output,
|
|
29762
|
+
...options.profile && { profile: options.profile },
|
|
29763
|
+
...Object.keys(context).length > 0 && { context }
|
|
29764
|
+
};
|
|
29765
|
+
const { stacks } = await synthesizer.synthesize(synthOpts);
|
|
29766
|
+
const targetGroups = toStudioTargetGroups(listTargets(stacks));
|
|
29767
|
+
const appLabel = stacks.map((s) => s.stackName).join(", ") || appCmd;
|
|
29768
|
+
const servableEcs = new Set(targetGroups.filter((g) => g.kind === "ecs").flatMap((g) => g.entries.filter((e) => e.servable).map((e) => e.id)));
|
|
29769
|
+
const bus = new StudioEventBus();
|
|
29770
|
+
const childConfig = {
|
|
29771
|
+
cliEntry: process.argv[1] ?? "",
|
|
29772
|
+
bus,
|
|
29773
|
+
cwd: process.cwd(),
|
|
29774
|
+
...appCmd ? { app: appCmd } : {},
|
|
29775
|
+
...options.profile ? { profile: options.profile } : {},
|
|
29776
|
+
...options.region ? { region: options.region } : {},
|
|
29777
|
+
...Object.keys(context).length > 0 ? { context } : {},
|
|
29778
|
+
...options.fromCfnStack !== void 0 ? { fromCfnStack: options.fromCfnStack } : {},
|
|
29779
|
+
...options.assumeRole ? { assumeRole: options.assumeRole } : {}
|
|
29780
|
+
};
|
|
29781
|
+
const dispatcher = createStudioDispatcher(childConfig);
|
|
29782
|
+
const serveManager = createStudioServeManager(childConfig);
|
|
29783
|
+
const store = createStudioStore(bus);
|
|
29784
|
+
const server = await startStudioServer({
|
|
29785
|
+
port,
|
|
29786
|
+
bus,
|
|
29787
|
+
targetGroups,
|
|
29788
|
+
appLabel,
|
|
29789
|
+
cliName: getEmbedConfig().cliName,
|
|
29790
|
+
store,
|
|
29791
|
+
onRun: (body) => {
|
|
29792
|
+
const req = coerceRunRequest(body);
|
|
29793
|
+
if (req.kind === "lambda") return dispatcher.run(req);
|
|
29794
|
+
if (req.kind === "ecs" && !servableEcs.has(req.targetId)) return Promise.reject(/* @__PURE__ */ new Error(`'${req.targetId}' is not a servable ECS service (an ECS task definition runs via run-task, not start-service).`));
|
|
29795
|
+
return serveManager.start(req);
|
|
29796
|
+
},
|
|
29797
|
+
onStop: async (body) => {
|
|
29798
|
+
const req = coerceStopRequest(body);
|
|
29799
|
+
await serveManager.stop(req);
|
|
29800
|
+
return { stopped: req.targetId };
|
|
29801
|
+
},
|
|
29802
|
+
getRunning: () => ({ running: serveManager.list() })
|
|
29803
|
+
});
|
|
29804
|
+
const cliName = getEmbedConfig().cliName;
|
|
29805
|
+
logger.info(`${cliName} studio is running at ${server.url}`);
|
|
29806
|
+
logger.info("Press Ctrl-C to stop.");
|
|
29807
|
+
if (options.open && process.stdout.isTTY) openBrowser(server.url);
|
|
29808
|
+
await blockUntilShutdown(server, serveManager, store, cliName);
|
|
29809
|
+
}
|
|
29810
|
+
/** Best-effort cross-platform browser open. Failures are non-fatal. */
|
|
29811
|
+
function openBrowser(url) {
|
|
29812
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
29813
|
+
try {
|
|
29814
|
+
const child = spawn(cmd, [url], {
|
|
29815
|
+
stdio: "ignore",
|
|
29816
|
+
detached: true,
|
|
29817
|
+
shell: process.platform === "win32"
|
|
29818
|
+
});
|
|
29819
|
+
child.on("error", () => void 0);
|
|
29820
|
+
child.unref();
|
|
29821
|
+
} catch {}
|
|
29822
|
+
}
|
|
29823
|
+
/**
|
|
29824
|
+
* Block until SIGINT / SIGTERM, then stop every running serve child,
|
|
29825
|
+
* close the studio server, and resolve. Mirrors the long-running serve
|
|
29826
|
+
* commands' graceful-shutdown contract — the serve children are killed
|
|
29827
|
+
* BEFORE the server closes so their RIE containers are torn down rather
|
|
29828
|
+
* than orphaned.
|
|
29829
|
+
*/
|
|
29830
|
+
function blockUntilShutdown(server, serveManager, store, cliName) {
|
|
29831
|
+
return new Promise((resolveShutdown) => {
|
|
29832
|
+
let shuttingDown = false;
|
|
29833
|
+
const shutdown = (signal) => {
|
|
29834
|
+
if (shuttingDown) return;
|
|
29835
|
+
shuttingDown = true;
|
|
29836
|
+
getLogger().info(`Received ${signal}; stopping ${cliName} studio...`);
|
|
29837
|
+
store.dispose();
|
|
29838
|
+
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());
|
|
29839
|
+
};
|
|
29840
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
29841
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
29842
|
+
});
|
|
29843
|
+
}
|
|
29844
|
+
function createLocalStudioCommand(opts = {}) {
|
|
29845
|
+
setEmbedConfig(opts.embedConfig);
|
|
29846
|
+
const cmd = new Command("studio").description("Open the local studio: a web console that lists the synthesized CDK app's runnable targets and lets you invoke / serve them from the browser while watching all activity in one timeline. The interactive counterpart to the headless invoke / start-* commands.").action(withErrorHandling(async (options) => {
|
|
29847
|
+
await localStudioCommand(options);
|
|
29848
|
+
}));
|
|
29849
|
+
addStudioSpecificOptions(cmd);
|
|
29850
|
+
[
|
|
29851
|
+
...commonOptions(),
|
|
29852
|
+
...appOptions(),
|
|
29853
|
+
...contextOptions
|
|
29854
|
+
].forEach((opt) => cmd.addOption(opt));
|
|
29855
|
+
cmd.addOption(regionOption);
|
|
29856
|
+
return cmd;
|
|
29857
|
+
}
|
|
29858
|
+
/**
|
|
29859
|
+
* Register the option block `cdkl studio` adds on top of the shared
|
|
29860
|
+
* common / app / context option helpers. Kept in a named helper (not
|
|
29861
|
+
* inline in {@link createLocalStudioCommand}) so a host CLI embedding
|
|
29862
|
+
* this factory inherits new studio flags without a duplicate
|
|
29863
|
+
* `.addOption(...)` block, matching every other `add<Cmd>SpecificOptions`
|
|
29864
|
+
* extraction. Chainable: returns `cmd`.
|
|
29865
|
+
*/
|
|
29866
|
+
function addStudioSpecificOptions(cmd) {
|
|
29867
|
+
cmd.addOption(new Option("--studio-port <port>", "Preferred port for the studio web server (bumps to the next free port on collision)").default(String(DEFAULT_STUDIO_PORT)));
|
|
29868
|
+
cmd.addOption(new Option("--no-open", "Do not auto-open the browser when studio starts (TTY only)"));
|
|
29869
|
+
cmd.addOption(new Option("--from-cfn-stack [cfn-stack-name]", "Bind the whole studio session to a deployed CloudFormation stack: every invoke / serve started from the UI runs against the deployed stack real ARNs / Secret values. Bare flag auto-resolves a single-stack app; pass a name to pick the stack. Forwarded to each child command."));
|
|
29870
|
+
cmd.addOption(new Option("--assume-role <arn>", "IAM role ARN to assume for every invoke / serve started from the UI (temp credentials forwarded into the containers). Forwarded to each child command."));
|
|
29871
|
+
return cmd;
|
|
29872
|
+
}
|
|
29873
|
+
|
|
29874
|
+
//#endregion
|
|
29875
|
+
export { setShadowReadyTimeoutMs as $, discoverWebSocketApis as $n, computeRequestIdentityHash as $t, addEcsAssumeRoleOptions as A, buildContainerImage as An, resolveApiTargetSubset as At, buildImageOverrideTag as B, createLocalStateProvider as Bn, groupRoutesByServer as Bt, addStartServiceSpecificOptions as C, buildMgmtEndpointEnvUrl as Cn, toCmdArgv as Ct, createLocalRunTaskCommand as D, buildDisconnectEvent as Dn, addStartApiSpecificOptions as Dt, addRunTaskSpecificOptions as E, buildConnectEvent as En, createLocalInvokeCommand as Et, parseRestartPolicy as F, substituteAgainstState as Fn, materializeLayerFromArn as Ft, runImageOverrideBuilds as G, resolveCfnStackName as Gn, defaultCredentialsLoader as Gt, mergeForService as H, rejectExplicitCfnStackWithMultipleStacks as Hn, startApiServer as Ht, resolveEcsAssumeRoleOption as I, substituteAgainstStateAsync as In, resolveEnvVars as It, listPinnedTargets as J, resolveSsmParameters as Jn, createJwksCache as Jt, describePinnedImageUri as K, CfnLocalStateProvider as Kn, buildCognitoJwksUrl as Kt, resolveSharedSidecarCredentials as L, substituteEnvVarsFromState as Ln, availableApiIdentifiers as Lt, buildEcsImageResolutionContext$1 as M, resolveRuntimeFileExtension as Mn, createFileWatcher as Mt, ecsClusterOption as N, resolveRuntimeImage as Nn, attachStageContext as Nt, MAX_TASKS_SUBNET_RANGE_CAP as O, buildMessageEvent as On, createLocalStartApiCommand as Ot, parseMaxTasks as P, EcsTaskResolutionError as Pn, buildStageMap as Pt, SOFT_RELOAD_COMPLETION_LOG_SUFFIX as Q, listTargets as Qn, buildMethodArn as Qt, runEcsServiceEmulator as R, substituteEnvVarsFromStateAsync as Rn, filterRoutesByApiIdentifier as Rt, resolveAlbFrontDoor as S, ConnectionRegistry as Sn, renderCodeDockerfile as St, serviceStrategy as T, parseConnectionsPath as Tn, addInvokeSpecificOptions as Tt, parseImageOverrideFlags as U, resolveCfnFallbackRegion as Un, resolveSelectionExpression as Ut, enforceImageOverrideOrphans as V, isCfnFlagPresent as Vn, readMtlsMaterialsFromDisk as Vt, resolveImageOverrides as W, resolveCfnRegion as Wn, resolveServiceIntegrationParameters as Wt, CloudMapRegistry as X, resolveSingleTarget as Xn, verifyJwtAuthorizer as Xt, buildCloudMapIndex as Y, resolveWatchConfig as Yn, verifyCognitoJwt as Yt, DEFAULT_SHADOW_READY_TIMEOUT_MS as Z, countTargets as Zn, verifyJwtViaDiscovery as Zt, albStrategy as _, tryParseStatus as _n, LocalInvokeBuildError as _r, waitForAgentCorePing as _t, createStudioServeManager as a, buildCorsConfigByApiId as an, AGENTCORE_A2A_PROTOCOL as ar, A2A_CONTAINER_PORT as at, resolveAlbTarget as b, probeHostGatewaySupport as bn, buildAgentCoreCodeImage as bt, startStudioServer as c, matchPreflight as cn, AGENTCORE_MCP_PROTOCOL as cr, MCP_CONTAINER_PORT as ct, createStudioStore as d, applyAuthorizerOverlay as dn, pickAgentCoreCandidateStack as dr, mcpInvokeOnce as dt, evaluateCachedLambdaPolicy as en, discoverWebSocketApisOrThrow as er, getContainerNetworkIp as et, StudioEventBus as f, buildHttpApiV2Event as fn, resolveAgentCoreTarget as fr, parseSseForJsonRpc as ft, addAlbSpecificOptions as g, selectIntegrationResponse as gn, tryResolveImageFnJoin as gr, invokeAgentCore as gt, formatTargetListing as h, pickResponseTemplate as hn, substituteImagePlaceholders as hr, AGENTCORE_SESSION_ID_HEADER as ht, createLocalStudioCommand as i, applyCorsResponseHeaders as in, resolveLambdaArnIntrinsic as ir, invokeAgentCoreWs as it, addImageOverrideOptions as j, resolveRuntimeCodeMountPath as jn, createAuthorizerCache as jt, addCommonEcsServiceOptions as k, architectureToPlatform as kn, createWatchPredicates as kt, toStudioTargetGroups as l, matchRoute as ln, AGENTCORE_RUNTIME_TYPE as lr, MCP_PATH as lt, createLocalListCommand as m, evaluateResponseParameters as mn, formatStateRemedy as mr, signAgentCoreInvocation as mt, coerceRunRequest as n, invokeTokenAuthorizer as nn, discoverRoutes as nr, addInvokeAgentCoreSpecificOptions as nt, startStudioProxy as o, buildCorsConfigFromCloudFrontChain as on, AGENTCORE_AGUI_PROTOCOL as or, A2A_PATH as ot, addListSpecificOptions as p, buildRestV1Event as pn, derivePseudoParametersFromRegion as pr, AGENTCORE_SIGV4_SERVICE as pt, isLocalCdkAssetImage as q, collectSsmParameterRefs as qn, buildJwksUrlFromIssuer as qt, coerceStopRequest as r, attachAuthorizers as rn, pickRefLogicalId as rr, createLocalInvokeAgentCoreCommand as rt, createStudioDispatcher as s, isFunctionUrlOacFronted as sn, AGENTCORE_HTTP_PROTOCOL as sr, a2aInvokeOnce as st, addStudioSpecificOptions as t, invokeRequestAuthorizer as tn, parseSelectionExpressionPath as tr, attachContainerLogStreamer as tt, renderStudioHtml as u, translateLambdaResponse as un, AgentCoreResolutionError as ur, MCP_PROTOCOL_VERSION as ut, createLocalStartAlbCommand as v, VtlEvaluationError as vn, buildStsClientConfig as vr, downloadAndExtractS3Bundle as vt, createLocalStartServiceCommand as w, handleConnectionsRequest as wn, classifySourceChange as wt, isApplicationLoadBalancer as x, bufferToBody as xn, computeCodeImageTag as xt, parseLbPortOverrides as y, HOST_GATEWAY_MIN_VERSION as yn, resolveProfileCredentials as yr, SUPPORTED_CODE_RUNTIMES as yt, ImageOverrideError as z, LocalStateSourceError as zn, filterRoutesByApiIdentifiers as zt };
|
|
29876
|
+
//# sourceMappingURL=local-studio-DaJ3D7co.js.map
|