@axlsdk/studio 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.cjs CHANGED
@@ -24,13 +24,15 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  ));
25
25
 
26
26
  // src/cli.ts
27
- var import_node_path2 = require("path");
28
- var import_node_fs2 = require("fs");
27
+ var import_node_path3 = require("path");
28
+ var import_node_fs3 = require("fs");
29
29
  var import_node_url = require("url");
30
30
  var import_node_server = require("@hono/node-server");
31
31
  var import_node_ws = require("@hono/node-ws");
32
32
 
33
33
  // src/server/index.ts
34
+ var import_node_fs = require("fs");
35
+ var import_node_path = require("path");
34
36
  var import_hono12 = require("hono");
35
37
  var import_cors = require("hono/cors");
36
38
  var import_serve_static = require("@hono/node-server/serve-static");
@@ -67,8 +69,13 @@ var ConnectionManager = class {
67
69
  channels = /* @__PURE__ */ new Map();
68
70
  /** ws -> set of subscribed channels (for cleanup) */
69
71
  connections = /* @__PURE__ */ new Map();
72
+ maxConnections = 100;
70
73
  /** Register a new WS connection. */
71
74
  add(ws) {
75
+ if (this.connections.size >= this.maxConnections) {
76
+ ws.close?.();
77
+ return;
78
+ }
72
79
  this.connections.set(ws, /* @__PURE__ */ new Set());
73
80
  }
74
81
  /** Remove a WS connection and all its subscriptions. */
@@ -84,15 +91,16 @@ var ConnectionManager = class {
84
91
  }
85
92
  this.connections.delete(ws);
86
93
  }
87
- /** Subscribe a connection to a channel. */
94
+ /** Subscribe a connection to a channel. No-op if the connection was not added. */
88
95
  subscribe(ws, channel) {
96
+ if (!this.connections.has(ws)) return;
89
97
  let subs = this.channels.get(channel);
90
98
  if (!subs) {
91
99
  subs = /* @__PURE__ */ new Set();
92
100
  this.channels.set(channel, subs);
93
101
  }
94
102
  subs.add(ws);
95
- this.connections.get(ws)?.add(channel);
103
+ this.connections.get(ws).add(channel);
96
104
  }
97
105
  /** Unsubscribe a connection from a channel. */
98
106
  unsubscribe(ws, channel) {
@@ -133,6 +141,14 @@ var ConnectionManager = class {
133
141
  }
134
142
  }
135
143
  }
144
+ /** Close all connections and clear all state. Used during shutdown. */
145
+ closeAll() {
146
+ for (const ws of this.connections.keys()) {
147
+ ws.close?.();
148
+ }
149
+ this.connections.clear();
150
+ this.channels.clear();
151
+ }
136
152
  /** Get the number of active connections. */
137
153
  get connectionCount() {
138
154
  return this.connections.size;
@@ -143,6 +159,52 @@ var ConnectionManager = class {
143
159
  }
144
160
  };
145
161
 
162
+ // src/server/ws/protocol.ts
163
+ var VALID_CHANNEL_PREFIXES = ["execution:", "trace:"];
164
+ var VALID_EXACT_CHANNELS = ["costs", "decisions"];
165
+ var MAX_CHANNEL_LENGTH = 256;
166
+ function handleWsMessage(raw, socket, connMgr) {
167
+ if (raw.length > 65536) {
168
+ return JSON.stringify({ type: "error", message: "Message too large" });
169
+ }
170
+ let msg;
171
+ try {
172
+ msg = JSON.parse(raw);
173
+ } catch {
174
+ return JSON.stringify({ type: "error", message: "Invalid JSON" });
175
+ }
176
+ switch (msg.type) {
177
+ case "subscribe": {
178
+ const error = validateChannel(msg.channel);
179
+ if (error) return JSON.stringify({ type: "error", message: error });
180
+ connMgr.subscribe(socket, msg.channel);
181
+ return JSON.stringify({ type: "subscribed", channel: msg.channel });
182
+ }
183
+ case "unsubscribe": {
184
+ const error = validateChannel(msg.channel);
185
+ if (error) return JSON.stringify({ type: "error", message: error });
186
+ connMgr.unsubscribe(socket, msg.channel);
187
+ return JSON.stringify({ type: "unsubscribed", channel: msg.channel });
188
+ }
189
+ case "ping":
190
+ return JSON.stringify({ type: "pong" });
191
+ default:
192
+ return JSON.stringify({ type: "error", message: "Unknown message type" });
193
+ }
194
+ }
195
+ function validateChannel(channel) {
196
+ if (typeof channel !== "string" || !channel) {
197
+ return "Missing or invalid channel";
198
+ }
199
+ if (channel.length > MAX_CHANNEL_LENGTH) {
200
+ return `Channel name exceeds ${MAX_CHANNEL_LENGTH} characters`;
201
+ }
202
+ if (!VALID_EXACT_CHANNELS.includes(channel) && !VALID_CHANNEL_PREFIXES.some((p) => channel.startsWith(p))) {
203
+ return `Invalid channel: ${channel}`;
204
+ }
205
+ return null;
206
+ }
207
+
146
208
  // src/server/ws/handler.ts
147
209
  function createWsHandlers(connMgr) {
148
210
  return {
@@ -150,37 +212,8 @@ function createWsHandlers(connMgr) {
150
212
  connMgr.add(ws);
151
213
  },
152
214
  onMessage(event, ws) {
153
- let msg;
154
- try {
155
- msg = JSON.parse(String(event.data));
156
- } catch {
157
- const err = { type: "error", message: "Invalid JSON" };
158
- ws.send(JSON.stringify(err));
159
- return;
160
- }
161
- switch (msg.type) {
162
- case "subscribe": {
163
- connMgr.subscribe(ws, msg.channel);
164
- const reply = { type: "subscribed", channel: msg.channel };
165
- ws.send(JSON.stringify(reply));
166
- break;
167
- }
168
- case "unsubscribe": {
169
- connMgr.unsubscribe(ws, msg.channel);
170
- const reply = { type: "unsubscribed", channel: msg.channel };
171
- ws.send(JSON.stringify(reply));
172
- break;
173
- }
174
- case "ping": {
175
- const reply = { type: "pong" };
176
- ws.send(JSON.stringify(reply));
177
- break;
178
- }
179
- default: {
180
- const err = { type: "error", message: `Unknown message type` };
181
- ws.send(JSON.stringify(err));
182
- }
183
- }
215
+ const reply = handleWsMessage(String(event.data), ws, connMgr);
216
+ if (reply) ws.send(reply);
184
217
  },
185
218
  onClose(_event, ws) {
186
219
  connMgr.remove(ws);
@@ -751,16 +784,48 @@ function createPlaygroundRoutes(connMgr) {
751
784
 
752
785
  // src/server/index.ts
753
786
  function createServer(options) {
754
- const { runtime, staticRoot } = options;
787
+ const { runtime, staticRoot, basePath = "", readOnly = false } = options;
755
788
  const app8 = new import_hono12.Hono();
756
789
  const connMgr = new ConnectionManager();
757
790
  const costAggregator = new CostAggregator(connMgr);
758
- app8.use("*", (0, import_cors.cors)());
791
+ if (options.cors !== false) {
792
+ app8.use("*", (0, import_cors.cors)());
793
+ }
759
794
  app8.use("*", errorHandler);
760
795
  app8.use("*", async (c, next) => {
761
796
  c.set("runtime", runtime);
762
797
  await next();
763
798
  });
799
+ if (readOnly) {
800
+ const blocked = [
801
+ "POST /api/workflows",
802
+ "POST /api/executions",
803
+ "POST /api/sessions",
804
+ "DELETE /api/sessions",
805
+ "PUT /api/memory",
806
+ "DELETE /api/memory",
807
+ "POST /api/decisions",
808
+ "POST /api/costs",
809
+ "POST /api/tools",
810
+ "POST /api/evals",
811
+ "POST /api/playground"
812
+ ];
813
+ app8.use("/api/*", async (c, next) => {
814
+ const apiIdx = c.req.path.indexOf("/api/");
815
+ const apiPath = apiIdx >= 0 ? c.req.path.slice(apiIdx) : c.req.path;
816
+ const key = `${c.req.method} ${apiPath}`;
817
+ if (blocked.some((b) => key.startsWith(b))) {
818
+ return c.json(
819
+ {
820
+ ok: false,
821
+ error: { code: "READ_ONLY", message: "Studio is mounted in read-only mode" }
822
+ },
823
+ 405
824
+ );
825
+ }
826
+ await next();
827
+ });
828
+ }
764
829
  const api = new import_hono12.Hono();
765
830
  api.route("/", health_default);
766
831
  api.route("/", createWorkflowRoutes(connMgr));
@@ -774,7 +839,7 @@ function createServer(options) {
774
839
  api.route("/", evals_default);
775
840
  api.route("/", createPlaygroundRoutes(connMgr));
776
841
  app8.route("/api", api);
777
- runtime.on("trace", (event) => {
842
+ const traceListener = (event) => {
778
843
  const traceEvent = event;
779
844
  if (traceEvent.executionId) {
780
845
  connMgr.broadcastWithWildcard(`trace:${traceEvent.executionId}`, traceEvent);
@@ -783,12 +848,47 @@ function createServer(options) {
783
848
  if (traceEvent.type === "await_human") {
784
849
  connMgr.broadcast("decisions", traceEvent);
785
850
  }
786
- });
851
+ };
852
+ runtime.on("trace", traceListener);
787
853
  if (staticRoot) {
788
- app8.use("/*", (0, import_serve_static.serveStatic)({ root: staticRoot }));
789
- app8.get("*", (0, import_serve_static.serveStatic)({ root: staticRoot, path: "/index.html" }));
854
+ app8.use(
855
+ "/*",
856
+ (0, import_serve_static.serveStatic)({
857
+ root: staticRoot,
858
+ rewriteRequestPath: basePath ? (path) => path.startsWith(basePath) ? path.slice(basePath.length) || "/" : path : void 0
859
+ })
860
+ );
861
+ if (basePath) {
862
+ const indexPath = (0, import_node_path.resolve)(staticRoot, "index.html");
863
+ if (!(0, import_node_fs.existsSync)(indexPath)) {
864
+ console.warn(`[axl-studio] index.html not found at ${indexPath}`);
865
+ } else {
866
+ const indexHtml = (0, import_node_fs.readFileSync)(indexPath, "utf-8");
867
+ const safeBasePath = JSON.stringify(basePath).replace(/</g, "\\u003c");
868
+ const injectedHtml = indexHtml.replace(
869
+ "</head>",
870
+ `<base href="${basePath}/">
871
+ <script>window.__AXL_STUDIO_BASE__=${safeBasePath}</script>
872
+ </head>`
873
+ );
874
+ if (injectedHtml === indexHtml) {
875
+ console.warn(
876
+ "[axl-studio] Could not inject basePath into index.html \u2014 </head> tag not found. The SPA may not route correctly."
877
+ );
878
+ }
879
+ app8.get("*", (c) => c.html(injectedHtml));
880
+ }
881
+ } else {
882
+ app8.get("*", (0, import_serve_static.serveStatic)({ root: staticRoot, path: "/index.html" }));
883
+ }
790
884
  }
791
- return { app: app8, connMgr, costAggregator, createWsHandlers: () => createWsHandlers(connMgr) };
885
+ return {
886
+ app: app8,
887
+ connMgr,
888
+ costAggregator,
889
+ createWsHandlers: () => createWsHandlers(connMgr),
890
+ traceListener
891
+ };
792
892
  }
793
893
 
794
894
  // src/resolve-runtime.ts
@@ -798,8 +898,8 @@ function resolveRuntime(mod) {
798
898
  }
799
899
 
800
900
  // src/cli-utils.ts
801
- var import_node_path = require("path");
802
- var import_node_fs = require("fs");
901
+ var import_node_path2 = require("path");
902
+ var import_node_fs2 = require("fs");
803
903
  var CONFIG_CANDIDATES = [
804
904
  "axl.config.mts",
805
905
  "axl.config.ts",
@@ -808,8 +908,8 @@ var CONFIG_CANDIDATES = [
808
908
  ];
809
909
  function findConfig(cwd) {
810
910
  for (const name of CONFIG_CANDIDATES) {
811
- const p = (0, import_node_path.resolve)(cwd, name);
812
- if ((0, import_node_fs.existsSync)(p)) return p;
911
+ const p = (0, import_node_path2.resolve)(cwd, name);
912
+ if ((0, import_node_fs2.existsSync)(p)) return p;
813
913
  }
814
914
  return void 0;
815
915
  }
@@ -843,7 +943,7 @@ function parseArgs(argv) {
843
943
  return result;
844
944
  }
845
945
  function needsEsmForcing(configPath) {
846
- const ext = (0, import_node_path.extname)(configPath);
946
+ const ext = (0, import_node_path2.extname)(configPath);
847
947
  return ext === ".ts" || ext === ".tsx";
848
948
  }
849
949
  function needsTsxLoader(configPath) {
@@ -881,8 +981,8 @@ Tip: Use .mts for configs with top-level await or in projects without "type": "m
881
981
  }
882
982
  let configPath;
883
983
  if (args.config) {
884
- configPath = (0, import_node_path2.resolve)(process.cwd(), args.config);
885
- if (!(0, import_node_fs2.existsSync)(configPath)) {
984
+ configPath = (0, import_node_path3.resolve)(process.cwd(), args.config);
985
+ if (!(0, import_node_fs3.existsSync)(configPath)) {
886
986
  console.error(`Config file not found: ${configPath}`);
887
987
  process.exit(1);
888
988
  }
@@ -950,7 +1050,7 @@ Tip: Use .mts for configs with top-level await or in projects without "type": "m
950
1050
  }
951
1051
  console.log(`[axl-studio] Loading config from ${configPath}`);
952
1052
  let runtime;
953
- const ext = (0, import_node_path2.extname)(configPath);
1053
+ const ext = (0, import_node_path3.extname)(configPath);
954
1054
  try {
955
1055
  const mod = await import((0, import_node_url.pathToFileURL)(configPath).href);
956
1056
  runtime = resolveRuntime(mod);
@@ -987,8 +1087,8 @@ Tip: Use .mts for configs with top-level await or in projects without "type": "m
987
1087
  console.error(`Failed to load config:`, err);
988
1088
  process.exit(1);
989
1089
  }
990
- const staticRoot = (0, import_node_path2.resolve)(import_meta.dirname ?? __dirname, "client");
991
- const hasStaticAssets = (0, import_node_fs2.existsSync)((0, import_node_path2.resolve)(staticRoot, "index.html"));
1090
+ const staticRoot = (0, import_node_path3.resolve)(import_meta.dirname ?? __dirname, "client");
1091
+ const hasStaticAssets = (0, import_node_fs3.existsSync)((0, import_node_path3.resolve)(staticRoot, "index.html"));
992
1092
  const { app: app8, createWsHandlers: createWsHandlers2 } = createServer({
993
1093
  runtime,
994
1094
  staticRoot: hasStaticAssets ? staticRoot : void 0