@kubb/studio 5.3.5 → 5.3.6

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/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { t as __name } from "./rolldown-runtime-CRm0XQPb.js";
2
- import { createJobId, isCommandMessage, isDisconnectMessage, isStudioPongMessage, isStudioReadyMessage } from "./protocol.js";
2
+ import { generationEventTypes } from "./protocol.js";
3
3
  import { createRequire } from "node:module";
4
4
  import { promisify, styleText } from "node:util";
5
5
  import process$1 from "node:process";
@@ -19,6 +19,7 @@ import { mergeDeep } from "remeda";
19
19
  import { tmpdir } from "node:os";
20
20
  import { gzip } from "node:zlib";
21
21
  import { build } from "tsdown";
22
+ import { RpcTarget, newWebSocketRpcSession } from "capnweb";
22
23
  import WebSocket from "ws";
23
24
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
24
25
  //#region src/constants.ts
@@ -41,6 +42,8 @@ const agentDefaults = {
41
42
  * a slower cadence would make a healthy agent look dead after a single missed ping.
42
43
  */
43
44
  maxHeartbeatIntervalMs: 6e4,
45
+ /** How long a heartbeat ping may take before the session is treated as dead. */
46
+ heartbeatTimeoutMs: 1e4,
44
47
  poolSize: 1
45
48
  };
46
49
  //#endregion
@@ -618,7 +621,7 @@ const INITIAL_POLL_DELAY_MS = 2e3;
618
621
  */
619
622
  const MAX_POLL_INTERVAL_MS = 3e4;
620
623
  /**
621
- * Polls `GET /api/jobs/{id}` until the job reaches `success` or `failed`, waiting
624
+ * Polls `GET /api/jobs/{id}` until the job reaches a terminal status, waiting
622
625
  * {@link INITIAL_POLL_DELAY_MS} first and doubling up to {@link MAX_POLL_INTERVAL_MS} so a long
623
626
  * job stays inside the API key's rate limit.
624
627
  *
@@ -637,7 +640,7 @@ async function waitForJob({ studioUrl, token, id, timeoutMs = 6e4 }) {
637
640
  headers: { "x-api-key": token },
638
641
  retry: false
639
642
  });
640
- if (job.status === "success" || job.status === "failed") return job;
643
+ if (job.status === "success" || job.status === "failed" || job.status === "canceled") return job;
641
644
  } catch (error) {
642
645
  const response = error.response;
643
646
  if (response?.status !== 429) throw error;
@@ -673,7 +676,7 @@ async function createAgent({ studioUrl, token, name, machineToken }) {
673
676
  }
674
677
  //#endregion
675
678
  //#region package.json
676
- var version = "5.3.5";
679
+ var version = "5.3.6";
677
680
  //#endregion
678
681
  //#region src/hooks.ts
679
682
  /**
@@ -684,16 +687,19 @@ var version = "5.3.5";
684
687
  * Returns a remover, so a session that runs one generation after another on the same emitter does
685
688
  * not stack a listener per run.
686
689
  */
687
- function setupHookListener(hooks, root) {
690
+ function setupHookListener(hooks, root, signal) {
688
691
  return hooks.hook("kubb:hook:start", async (ctx) => {
689
692
  const { id, command, args } = ctx;
690
693
  if (!id) return;
691
694
  const commandWithArgs = args?.length ? `${command} ${args.join(" ")}` : command;
692
695
  try {
693
- const proc = x(command, [...args ?? []], { nodeOptions: {
694
- cwd: root,
695
- detached: true
696
- } });
696
+ const proc = x(command, [...args ?? []], {
697
+ signal,
698
+ nodeOptions: {
699
+ cwd: root,
700
+ detached: true
701
+ }
702
+ });
697
703
  for await (const line of proc) await hooks.callHook("kubb:hook:line", {
698
704
  id,
699
705
  line
@@ -742,8 +748,11 @@ function waitForHookEnd(hooks, hookId) {
742
748
  const handleHookEnd = (ctx) => {
743
749
  if (ctx.id !== hookId) return;
744
750
  hooks.removeHook("kubb:hook:end", handleHookEnd);
745
- if (ctx.success) resolve();
746
- else reject(ctx.error);
751
+ if (ctx.success) {
752
+ resolve();
753
+ return;
754
+ }
755
+ reject(ctx.error);
747
756
  };
748
757
  hooks.hook("kubb:hook:end", handleHookEnd);
749
758
  });
@@ -1282,14 +1291,14 @@ function applyRemove(call, path) {
1282
1291
  });
1283
1292
  }
1284
1293
  /**
1285
- * Adds a `pluginX(...)` call to a config's plugins array. Refuses when the plugin is already
1286
- * present, or when its import name collides with an unrelated existing import.
1294
+ * Adds a `pluginX(...)` call to a config's plugins array. Replaying the same add is a no-op, while
1295
+ * an import name collision with an unrelated package remains an error.
1287
1296
  */
1288
1297
  function applyAddPlugin(mod, config, edit) {
1289
1298
  if (!isKubbPluginSpecifier(edit.plugin)) return { reason: `"${edit.plugin}" is not a @kubb/plugin-* package` };
1290
1299
  const importName = edit.importName ?? toExportName(edit.plugin);
1291
1300
  if (!IDENTIFIER.test(importName)) return { reason: `"${importName}" is not a valid import name` };
1292
- if (pluginCalls(mod, config).some((plugin) => plugin.packageName === edit.plugin)) return { reason: `${edit.plugin} is already in the plugins array` };
1301
+ if (pluginCalls(mod, config).some((plugin) => plugin.packageName === edit.plugin)) return { noop: true };
1293
1302
  const taken = importedFrom(mod).get(importName);
1294
1303
  if (taken && taken !== edit.plugin) return { reason: `${importName} is already imported from ${taken}` };
1295
1304
  const options = edit.options ?? {};
@@ -1410,6 +1419,10 @@ function applyConfigEdits(source, edits) {
1410
1419
  applied: false,
1411
1420
  reason: result.reason
1412
1421
  };
1422
+ if ("noop" in result) return {
1423
+ edit,
1424
+ applied: true
1425
+ };
1413
1426
  const afterLine = lastImportEndLine(mod);
1414
1427
  let next = generateCode(mod, { format }).code;
1415
1428
  if (result.addImport) next = insertImportLine({
@@ -1543,14 +1556,19 @@ function formatGenerationFailure(diagnostics) {
1543
1556
  * can forward progress to connected clients. After a successful build, auto-formatting and
1544
1557
  * linting are applied when configured, followed by any user-defined `hooks.done` commands.
1545
1558
  */
1546
- async function generate({ config, hooks }) {
1559
+ async function generate({ config, hooks, signal }) {
1560
+ signal?.throwIfAborted();
1547
1561
  const hrStart = process$1.hrtime();
1548
1562
  await hooks.callHook("kubb:generation:start", { config });
1549
1563
  await hooks.callHook("kubb:info", { message: config.name ? `Setup generation ${config.name}` : "Setup generation" });
1550
- const kubb = createKubb(config, { hooks });
1564
+ const kubb = createKubb(config, {
1565
+ hooks,
1566
+ signal
1567
+ });
1551
1568
  await kubb.setup();
1552
1569
  await hooks.callHook("kubb:info", { message: config.name ? `Build generation ${config.name}` : "Build generation" });
1553
1570
  const { files, diagnostics, storage } = await kubb.safeBuild();
1571
+ signal?.throwIfAborted();
1554
1572
  await hooks.callHook("kubb:info", { message: "Load summary" });
1555
1573
  for (const diagnostic of diagnostics.filter(isProblemErrorDiagnostic)) await hooks.callHook("kubb:error", { error: new Error(diagnostic.plugin ? `${diagnostic.plugin}: ${diagnostic.message}` : diagnostic.message) });
1556
1574
  const status = Diagnostics.hasError(diagnostics) ? "failed" : "success";
@@ -1585,6 +1603,7 @@ async function generate({ config, hooks }) {
1585
1603
  await hooks.callHook("kubb:success", { message: `${step.verbing} with ${tool} successfully` });
1586
1604
  } catch (caughtError) {
1587
1605
  await hooks.callHook("kubb:error", { error: new Error(command.errorMessage, { cause: caughtError }) });
1606
+ signal?.throwIfAborted();
1588
1607
  }
1589
1608
  await hooks.callHook(`kubb:${step.kind}:end`);
1590
1609
  }
@@ -1740,13 +1759,6 @@ async function createSnapshotPackage(files, packageInfo) {
1740
1759
  * takes over.
1741
1760
  */
1742
1761
  const CONNECT_TIMEOUT_MS = 5e3;
1743
- /**
1744
- * Per-socket event counter. Every data message carries the next value so Studio can restore the
1745
- * agent's emission order even when the relay delivers frames out of order. Keyed by the socket so
1746
- * the count stays monotonic across every generation run on one connection, and is dropped
1747
- * automatically once the socket is collected.
1748
- */
1749
- const eventSeqCounters = /* @__PURE__ */ new WeakMap();
1750
1762
  const require = createRequire(import.meta.url);
1751
1763
  function relativeStoragePath(root, filePath) {
1752
1764
  return (isAbsolute(filePath) ? relative(resolve(root), filePath) : filePath).replaceAll("\\", "/");
@@ -1771,19 +1783,17 @@ async function resolvePeerDependencies(names) {
1771
1783
  }));
1772
1784
  for (const [index, name] of uniqueNames.entries()) {
1773
1785
  const version = versions[index];
1774
- if (version) peerDependencies[name] = version;
1775
- else missingDependencies.push(name);
1786
+ if (version) {
1787
+ peerDependencies[name] = version;
1788
+ continue;
1789
+ }
1790
+ missingDependencies.push(name);
1776
1791
  }
1777
1792
  return {
1778
1793
  peerDependencies,
1779
1794
  missingDependencies
1780
1795
  };
1781
1796
  }
1782
- function nextEventSeq(ws) {
1783
- const seq = eventSeqCounters.get(ws) ?? 0;
1784
- eventSeqCounters.set(ws, seq + 1);
1785
- return seq;
1786
- }
1787
1797
  /**
1788
1798
  * Opens a Studio WebSocket connection and closes it when the initial handshake exceeds the configured timeout.
1789
1799
  */
@@ -1796,41 +1806,15 @@ function createWebsocket(url, options) {
1796
1806
  ws.once("close", () => clearTimeout(timer));
1797
1807
  return ws;
1798
1808
  }
1799
- /**
1800
- * Sends a serialized agent message when the Studio socket is ready to accept frames.
1801
- */
1802
- function sendAgentMessage(ws, message) {
1803
- try {
1804
- if (ws.readyState !== WebSocket.OPEN) return;
1805
- ws.send(JSON.stringify(message));
1806
- } catch (error) {
1807
- throw new Error("Failed to send message to Kubb Studio", { cause: error });
1808
- }
1809
- }
1810
- /**
1811
- * Sends a single `kubb:error` payload to Studio, stamped from the same per-socket counter the event stream
1812
- * uses so Studio can still order it against the generation events around it.
1813
- */
1814
- function sendErrorMessage(ws, error, jobId) {
1815
- sendAgentMessage(ws, {
1816
- type: "agent:data",
1817
- jobId,
1818
- payload: {
1819
- type: "kubb:error",
1820
- data: [{
1821
- message: error.message,
1822
- stack: error.stack
1823
- }],
1824
- timestamp: Date.now(),
1825
- seq: nextEventSeq(ws)
1826
- }
1827
- });
1828
- }
1829
- /**
1830
- * Forwards selected Kubb lifecycle events to Studio as data messages for the active session.
1831
- */
1832
- function setupEventsStream(ws, hooks, jobId, options = {}) {
1809
+ /** Forwards selected Kubb lifecycle events to a native Cap'n Web stream. */
1810
+ function createGenerationStream(hooks, jobId, options = {}) {
1833
1811
  const unhooks = [];
1812
+ let root = "";
1813
+ const transform = new TransformStream(void 0, void 0, { highWaterMark: Infinity });
1814
+ const writer = transform.writable.getWriter();
1815
+ let writes = Promise.resolve();
1816
+ let closed = false;
1817
+ let streamError;
1834
1818
  /**
1835
1819
  * Registers a listener and keeps its remover, so one generation's listeners come off the session
1836
1820
  * emitter again when that generation ends.
@@ -1838,98 +1822,73 @@ function setupEventsStream(ws, hooks, jobId, options = {}) {
1838
1822
  function on(name, handler) {
1839
1823
  unhooks.push(hooks.hook(name, handler));
1840
1824
  }
1841
- function sendDataMessage(payload) {
1842
- sendAgentMessage(ws, {
1843
- type: "agent:data",
1825
+ function emitEvent(type, data) {
1826
+ const event = {
1844
1827
  jobId,
1845
- payload: {
1846
- ...payload,
1847
- timestamp: Date.now(),
1848
- seq: nextEventSeq(ws)
1849
- }
1828
+ type,
1829
+ data,
1830
+ version: 1,
1831
+ timestamp: Date.now()
1832
+ };
1833
+ writes = writes.then(() => writer.write(event)).catch((error) => {
1834
+ streamError = error;
1850
1835
  });
1851
1836
  }
1852
1837
  on("kubb:plugin:start", (ctx) => {
1853
- sendDataMessage({
1854
- type: "kubb:plugin:start",
1855
- data: [{ plugin: ctx.plugin }]
1856
- });
1838
+ emitEvent("kubb:plugin:start", [{ plugin: { name: ctx.plugin.name } }]);
1857
1839
  });
1858
1840
  on("kubb:plugin:end", (ctx) => {
1859
- sendDataMessage({
1860
- type: "kubb:plugin:end",
1861
- data: [{
1862
- plugin: ctx.plugin,
1863
- duration: ctx.duration,
1864
- success: ctx.success
1865
- }]
1866
- });
1841
+ emitEvent("kubb:plugin:end", [{
1842
+ plugin: { name: ctx.plugin.name },
1843
+ duration: ctx.duration,
1844
+ success: ctx.success
1845
+ }]);
1867
1846
  });
1868
1847
  on("kubb:build:start", ({ config, adapter }) => {
1869
- sendDataMessage({
1870
- type: "kubb:build:start",
1871
- data: [{
1872
- config: { name: config.name },
1873
- adapter: { name: adapter.name }
1874
- }]
1875
- });
1848
+ root = config.root;
1849
+ emitEvent("kubb:build:start", [{
1850
+ config: { name: config.name },
1851
+ adapter: { name: adapter.name }
1852
+ }]);
1876
1853
  });
1877
1854
  on("kubb:build:end", ({ files, config, outputDir }) => {
1878
- sendDataMessage({
1879
- type: "kubb:build:end",
1880
- data: [{
1881
- files: files.map((file) => ({
1882
- path: relativeStoragePath(config.root, file.path),
1883
- name: file.name
1884
- })),
1885
- outputDir
1886
- }]
1887
- });
1855
+ emitEvent("kubb:build:end", [{
1856
+ files: files.map((file) => ({
1857
+ path: relativeStoragePath(config.root, file.path),
1858
+ name: file.name
1859
+ })),
1860
+ outputDir
1861
+ }]);
1888
1862
  });
1889
1863
  on("kubb:files:processing:start", ({ files }) => {
1890
- sendDataMessage({
1891
- type: "kubb:files:processing:start",
1892
- data: [{ total: files.length }]
1893
- });
1864
+ emitEvent("kubb:files:processing:start", [{ total: files.length }]);
1894
1865
  });
1895
1866
  on("kubb:files:processing:update", ({ files }) => {
1896
- sendDataMessage({
1897
- type: "kubb:files:processing:update",
1898
- data: [{ files: files.map(({ file, processed, total, percentage }) => ({
1899
- file: file.path,
1900
- processed,
1901
- total,
1902
- percentage
1903
- })) }]
1904
- });
1867
+ emitEvent("kubb:files:processing:update", [{ files: files.map(({ file, processed, total, percentage }) => ({
1868
+ file: relativeStoragePath(root, file.path),
1869
+ processed,
1870
+ total,
1871
+ percentage
1872
+ })) }]);
1905
1873
  });
1906
1874
  on("kubb:files:processing:end", ({ files }) => {
1907
- sendDataMessage({
1908
- type: "kubb:files:processing:end",
1909
- data: [{ total: files.length }]
1910
- });
1875
+ emitEvent("kubb:files:processing:end", [{ total: files.length }]);
1911
1876
  });
1912
1877
  for (const type of [
1913
1878
  "kubb:info",
1914
1879
  "kubb:success",
1915
1880
  "kubb:warn"
1916
1881
  ]) on(type, ({ message, info }) => {
1917
- sendDataMessage({
1918
- type,
1919
- data: [{
1920
- message,
1921
- info
1922
- }]
1923
- });
1882
+ emitEvent(type, [{
1883
+ message,
1884
+ info
1885
+ }]);
1924
1886
  });
1925
1887
  on("kubb:generation:start", ({ config }) => {
1926
- sendDataMessage({
1927
- type: "kubb:generation:start",
1928
- data: [{
1929
- name: config.name,
1930
- plugins: config.plugins.length
1931
- }]
1932
- });
1888
+ emitEvent("kubb:generation:start", [{
1889
+ name: config.name,
1890
+ plugins: config.plugins.length
1891
+ }]);
1933
1892
  });
1934
1893
  on("kubb:generation:end", async ({ config, storage, diagnostics = [], status, hrStart, filesCreated }) => {
1935
1894
  const { peerDependencies, missingDependencies } = await resolvePeerDependencies(config.plugins.map(({ name }) => name));
@@ -1942,29 +1901,32 @@ function setupEventsStream(ws, hooks, jobId, options = {}) {
1942
1901
  peerDependencies,
1943
1902
  missingDependencies
1944
1903
  });
1945
- sendDataMessage({
1946
- type: "kubb:generation:end",
1947
- data: []
1948
- });
1904
+ emitEvent("kubb:generation:end", []);
1949
1905
  if (!hrStart) return;
1950
- sendDataMessage({
1951
- type: "kubb:generation:summary",
1952
- data: [{
1953
- duration: Math.round(getElapsedMs(hrStart)),
1954
- fileCount: filesCreated ?? 0,
1955
- failedPlugins: Diagnostics.failedPlugins(diagnostics).length,
1956
- status: status ?? "success"
1957
- }]
1958
- });
1906
+ emitEvent("kubb:generation:summary", [{
1907
+ duration: Math.round(getElapsedMs(hrStart)),
1908
+ fileCount: filesCreated ?? 0,
1909
+ failedPlugins: Diagnostics.failedPlugins(diagnostics).length,
1910
+ status: status ?? "success"
1911
+ }]);
1959
1912
  });
1960
1913
  on("kubb:error", ({ error }) => {
1961
- sendDataMessage({
1962
- type: "kubb:error",
1963
- data: [{
1964
- message: error.message,
1965
- stack: error.stack
1966
- }]
1967
- });
1914
+ emitEvent("kubb:error", [{
1915
+ message: error.message,
1916
+ stack: error.stack
1917
+ }]);
1918
+ });
1919
+ on("kubb:diagnostic", ({ diagnostic }) => {
1920
+ const cause = "cause" in diagnostic ? diagnostic.cause : void 0;
1921
+ emitEvent("kubb:diagnostic", [{
1922
+ code: diagnostic.code,
1923
+ message: diagnostic.message,
1924
+ severity: diagnostic.severity,
1925
+ location: "location" in diagnostic ? diagnostic.location : void 0,
1926
+ help: "help" in diagnostic ? diagnostic.help : void 0,
1927
+ plugin: "plugin" in diagnostic ? diagnostic.plugin : void 0,
1928
+ stack: cause?.stack
1929
+ }]);
1968
1930
  });
1969
1931
  for (const type of [
1970
1932
  "kubb:lifecycle:start",
@@ -1976,62 +1938,144 @@ function setupEventsStream(ws, hooks, jobId, options = {}) {
1976
1938
  "kubb:hooks:start",
1977
1939
  "kubb:hooks:end"
1978
1940
  ]) on(type, () => {
1979
- sendDataMessage({
1980
- type,
1981
- data: []
1982
- });
1941
+ emitEvent(type, []);
1983
1942
  });
1984
1943
  on("kubb:hook:start", ({ id, command, args }) => {
1985
- sendDataMessage({
1986
- type: "kubb:hook:start",
1987
- data: [{
1988
- id,
1989
- command,
1990
- args: args ? [...args] : void 0
1991
- }]
1992
- });
1944
+ emitEvent("kubb:hook:start", [{
1945
+ id,
1946
+ command,
1947
+ args: args ? [...args] : void 0
1948
+ }]);
1993
1949
  });
1994
1950
  on("kubb:hook:line", ({ id, line }) => {
1995
- sendDataMessage({
1996
- type: "kubb:hook:line",
1997
- data: [{
1998
- id,
1999
- line
2000
- }]
2001
- });
1951
+ emitEvent("kubb:hook:line", [{
1952
+ id,
1953
+ line
1954
+ }]);
2002
1955
  });
2003
1956
  on("kubb:hook:end", ({ id, command, args, success, error }) => {
2004
- sendDataMessage({
2005
- type: "kubb:hook:end",
2006
- data: [{
2007
- id,
2008
- command,
2009
- args: args ? [...args] : void 0,
2010
- success,
2011
- error: error ? {
2012
- message: error.message,
2013
- stack: error.stack
2014
- } : void 0
2015
- }]
2016
- });
1957
+ emitEvent("kubb:hook:end", [{
1958
+ id,
1959
+ command,
1960
+ args: args ? [...args] : void 0,
1961
+ success,
1962
+ error: error ? {
1963
+ message: error.message,
1964
+ stack: error.stack
1965
+ } : void 0
1966
+ }]);
2017
1967
  });
2018
- return () => {
1968
+ /**
1969
+ * Takes this generation's listeners off the session emitter. Safe to call twice.
1970
+ */
1971
+ function detach() {
2019
1972
  for (const unhook of unhooks) unhook();
1973
+ unhooks.length = 0;
1974
+ }
1975
+ async function close() {
1976
+ if (closed) return;
1977
+ closed = true;
1978
+ detach();
1979
+ await writes;
1980
+ if (streamError) return;
1981
+ await writer.close().catch(() => void 0);
1982
+ }
1983
+ function fail(error) {
1984
+ detach();
1985
+ if (closed) return;
1986
+ closed = true;
1987
+ writer.abort(error).catch(() => void 0);
1988
+ }
1989
+ return {
1990
+ stream: transform.readable,
1991
+ close,
1992
+ dispose: () => fail(),
1993
+ fail
2020
1994
  };
2021
1995
  }
2022
1996
  //#endregion
2023
- //#region src/StudioSession.ts
1997
+ //#region src/rpc.ts
2024
1998
  /**
2025
- * How many files are read from storage at once when serving `studio:files` or packing a
2026
- * `studio:snapshot`.
1999
+ * The only methods Studio may call on an agent. A `StudioSession` carries far more than
2000
+ * {@link AgentApi}, so it is wrapped rather than exposed: what Cap'n Web can reach is exactly what
2001
+ * this class re-declares.
2027
2002
  */
2028
- const FILE_READ_CONCURRENCY = 50;
2003
+ var AgentRpcTarget = class extends RpcTarget {
2004
+ api;
2005
+ constructor(api) {
2006
+ super();
2007
+ this.api = api;
2008
+ }
2009
+ connect() {
2010
+ return this.api.connect();
2011
+ }
2012
+ startGeneration(input) {
2013
+ return this.api.startGeneration(input);
2014
+ }
2015
+ saveConfig(input) {
2016
+ return this.api.saveConfig(input);
2017
+ }
2018
+ publishSnapshot(input) {
2019
+ return this.api.publishSnapshot(input);
2020
+ }
2021
+ readFiles(input) {
2022
+ return this.api.readFiles(input);
2023
+ }
2024
+ };
2025
+ /**
2026
+ * Opens an authenticated Cap'n Web session to Studio over a WebSocket. Rejects an unencrypted URL
2027
+ * before opening the socket, so a bearer token never reaches a plaintext host.
2028
+ *
2029
+ * @example
2030
+ * ```ts
2031
+ * const rpc = await connectWebSocketRpc({ url: 'wss://studio.kubb.dev/s/1', token, local: session })
2032
+ * await rpc.studio.ping()
2033
+ * ```
2034
+ */
2035
+ const connectWebSocketRpc = async ({ url, token, local }) => {
2036
+ const { protocol, hostname, host } = new URL(url);
2037
+ if (protocol !== "wss:" && !(protocol === "ws:" && (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"))) throw new Error(`Refusing unencrypted WebSocket to ${host}`);
2038
+ const socket = createWebsocket(url, { headers: { Authorization: `Bearer ${token}` } });
2039
+ const closed = new Promise((resolve) => socket.once("close", resolve));
2040
+ const studio = newWebSocketRpcSession(socket, new AgentRpcTarget(local));
2041
+ studio.onRpcBroken(() => socket.close());
2042
+ return {
2043
+ studio,
2044
+ closed,
2045
+ close: () => studio[Symbol.dispose]()
2046
+ };
2047
+ };
2048
+ //#endregion
2049
+ //#region src/StudioSession.ts
2029
2050
  /**
2030
- * How long the `agent:connect` handshake may go unacknowledged before `studio:ready` is given up
2031
- * on for this open. A Studio that predates the ack never sends one, so this only ever produces a
2032
- * warning, not a reconnect.
2051
+ * How many files are read from storage at once when serving `readFiles` or packing a snapshot.
2033
2052
  */
2034
- const READY_TIMEOUT_MS = 1e4;
2053
+ const FILE_READ_CONCURRENCY = 50;
2054
+ var GenerationRunTarget = class extends RpcTarget {
2055
+ generationStream;
2056
+ generationResult;
2057
+ cancelGeneration;
2058
+ stopGeneration;
2059
+ constructor(generationStream, generationResult, cancelGeneration, stopGeneration) {
2060
+ super();
2061
+ this.generationStream = generationStream;
2062
+ this.generationResult = generationResult;
2063
+ this.cancelGeneration = cancelGeneration;
2064
+ this.stopGeneration = stopGeneration;
2065
+ }
2066
+ async events() {
2067
+ return this.generationStream;
2068
+ }
2069
+ result() {
2070
+ return this.generationResult;
2071
+ }
2072
+ cancel() {
2073
+ return this.cancelGeneration();
2074
+ }
2075
+ [Symbol.dispose]() {
2076
+ this.stopGeneration();
2077
+ }
2078
+ };
2035
2079
  /**
2036
2080
  * Fills in a host's options: the hosted Studio URL, the current working directory, and every
2037
2081
  * permission off unless granted. Idempotent, so a reconnect can pass an already-resolved bag
@@ -2071,7 +2115,7 @@ function reconnect(options) {
2071
2115
  const timer = setTimeout(() => {
2072
2116
  signal?.removeEventListener("abort", cancel);
2073
2117
  if (signal?.aborted) return;
2074
- new StudioSession(options).connect().catch((error) => {
2118
+ new StudioSession(options).start().catch((error) => {
2075
2119
  if (logLevel$1 !== void 0 && logLevel$1 > logLevel.silent) console.error(styleText("red", `Reconnect attempt to Kubb Studio failed: ${getErrorMessage(error)}`));
2076
2120
  if (error instanceof InvalidAgentTokenError) {
2077
2121
  onTokenRejected?.(error);
@@ -2083,8 +2127,8 @@ function reconnect(options) {
2083
2127
  signal?.addEventListener("abort", cancel, { once: true });
2084
2128
  }
2085
2129
  /**
2086
- * One WebSocket session with Studio: opening it, keeping it alive, and running the commands it
2087
- * sends. `createClient` opens one per pool slot and is the only caller.
2130
+ * One agent-to-Studio RPC transport: opening it, keeping it alive, and serving remote methods.
2131
+ * `createClient` opens one per pool slot and is the only caller.
2088
2132
  */
2089
2133
  var StudioSession = class {
2090
2134
  #options;
@@ -2099,17 +2143,20 @@ var StudioSession = class {
2099
2143
  * Before it resolves there is nothing to disconnect and no sandbox flag to read.
2100
2144
  */
2101
2145
  #session;
2102
- #ws;
2146
+ #rpc;
2103
2147
  #studioVersion;
2104
- #activeJobId = null;
2105
2148
  #disposed = false;
2106
2149
  #isGenerating = false;
2107
2150
  #heartbeatTimer;
2108
- #readyTimer;
2109
- #lastPongAt = Date.now();
2110
2151
  #lastGeneration;
2152
+ /**
2153
+ * Resolves when Studio calls {@link StudioSession.connect}. `studio:ready` waits on this so the
2154
+ * host does not queue jobs before the agent session is registered.
2155
+ */
2156
+ #connectAck = Promise.withResolvers();
2111
2157
  constructor(options) {
2112
2158
  this.#options = applyStudioDefaults(options);
2159
+ this.#connectAck.promise.catch(() => {});
2113
2160
  }
2114
2161
  /**
2115
2162
  * A sandbox agent runs on Studio's own infrastructure, so it has no user project to touch.
@@ -2136,7 +2183,7 @@ var StudioSession = class {
2136
2183
  get #canRead() {
2137
2184
  return this.#isSandbox || this.#options.permissions.allowRead;
2138
2185
  }
2139
- async connect() {
2186
+ async start() {
2140
2187
  const { token, studioUrl, signal, heartbeatInterval, installLogger } = this.#options;
2141
2188
  await installLogger?.(this.#hooks);
2142
2189
  try {
@@ -2147,48 +2194,65 @@ var StudioSession = class {
2147
2194
  });
2148
2195
  this.#session = session;
2149
2196
  this.#studioVersion = session.version;
2150
- const ws = createWebsocket(session.wsUrl, { headers: { Authorization: `Bearer ${token}` } });
2151
- this.#ws = ws;
2152
- this.#listen(ws, "open", this.#onOpen);
2153
- this.#listen(ws, "close", this.#onClose);
2154
- this.#listen(ws, "error", this.#onError);
2155
- this.#listen(ws, "message", this.#onMessage);
2197
+ const rpc = await (this.#options.connector ?? connectWebSocketRpc)({
2198
+ url: session.url,
2199
+ token,
2200
+ local: this
2201
+ });
2202
+ this.#rpc = rpc;
2203
+ rpc.closed.then(this.#onClose);
2156
2204
  signal?.addEventListener("abort", this.#onAbort, { once: true });
2157
2205
  this.#unhooks.push(() => signal?.removeEventListener("abort", this.#onAbort));
2158
- this.#heartbeatTimer = setInterval(() => this.#sendHeartbeat(), heartbeatInterval);
2159
- this.#unhooks.push(this.#hooks.hook("kubb:error", ({ error }) => sendErrorMessage(ws, error, this.#activeJobId ?? "connection")));
2206
+ this.#scheduleHeartbeat(heartbeatInterval);
2207
+ await this.#hooks.callHook("studio:connected", {
2208
+ url: studioUrl,
2209
+ versions: {
2210
+ studio: this.#studioVersion,
2211
+ kubb: version,
2212
+ agent: this.#options.version
2213
+ }
2214
+ });
2215
+ await this.#connectAck.promise;
2216
+ await this.#hooks.callHook("studio:ready", {});
2160
2217
  } catch (error) {
2218
+ this.#disposed = true;
2219
+ this.dispose();
2161
2220
  await this.#hooks.callHook("studio:error", { error: toError(error) });
2162
2221
  if (error instanceof InvalidAgentTokenError) throw error;
2163
2222
  reconnect(this.#options);
2164
2223
  }
2165
2224
  }
2166
- /**
2167
- * Adds a socket listener and tracks its remover, so `dispose` detaches every listener at once.
2168
- */
2169
- #listen(ws, event, listener) {
2170
- ws.addEventListener(event, listener);
2171
- this.#unhooks.push(() => ws.removeEventListener(event, listener));
2172
- }
2173
2225
  #warn(message) {
2174
2226
  return this.#hooks.callHook("studio:warn", { message });
2175
2227
  }
2176
2228
  /**
2177
- * Forwards a failure to Studio over the connection emitter, which `connect` wired to this
2178
- * socket. Swallows a listener's own failure, since this is already the error path.
2229
+ * Declines a request: logs why locally, then tells Studio. The two wordings differ on purpose,
2230
+ * since the log names the request that was ignored and the error names what the caller can do.
2179
2231
  */
2180
- #emitError(error) {
2181
- return Promise.resolve(this.#hooks.callHook("kubb:error", { error })).catch(() => {});
2182
- }
2183
- #sendHeartbeat() {
2184
- if (Date.now() - this.#lastPongAt > this.#options.heartbeatInterval * 2) {
2185
- this.#warn("No reply from Kubb Studio, terminating the stale connection");
2186
- clearInterval(this.#heartbeatTimer);
2187
- this.#heartbeatTimer = void 0;
2188
- this.#ws?.terminate();
2189
- return;
2190
- }
2191
- if (this.#ws) sendAgentMessage(this.#ws, { type: "agent:ping" });
2232
+ async #refuse(reason, message) {
2233
+ await this.#warn(reason);
2234
+ throw new Error(message);
2235
+ }
2236
+ #scheduleHeartbeat(interval) {
2237
+ const rpc = this.#rpc;
2238
+ if (!rpc) return;
2239
+ this.#heartbeatTimer = setTimeout(async () => {
2240
+ try {
2241
+ await this.#ping(rpc);
2242
+ } catch {
2243
+ if (this.#rpc === rpc) rpc.close();
2244
+ return;
2245
+ }
2246
+ if (this.#rpc === rpc) this.#scheduleHeartbeat(interval);
2247
+ }, interval);
2248
+ }
2249
+ /**
2250
+ * Races `studio.ping()` against a deadline, so a half-open socket can't hang it forever.
2251
+ * */
2252
+ #ping(rpc) {
2253
+ const { promise: timedOut, reject: onTimeout } = Promise.withResolvers();
2254
+ const timer = setTimeout(() => onTimeout(/* @__PURE__ */ new Error("Heartbeat ping timed out")), agentDefaults.heartbeatTimeoutMs);
2255
+ return Promise.race([rpc.studio.ping(), timedOut]).finally(() => clearTimeout(timer));
2192
2256
  }
2193
2257
  /**
2194
2258
  * Reads `kubb.config.ts` and reports which plugin options Studio may edit.
@@ -2205,90 +2269,48 @@ var StudioSession = class {
2205
2269
  return;
2206
2270
  }
2207
2271
  }
2208
- async #sendConnectedPayload() {
2272
+ async connect() {
2209
2273
  const { configPath, root, version: version$1, loadConfig, permissions } = this.#options;
2210
- if (!this.#ws) return;
2211
- const config = await loadConfig();
2212
- sendAgentMessage(this.#ws, {
2213
- type: "agent:connect",
2214
- payload: {
2215
- versions: {
2216
- kubb: version,
2217
- agent: version$1
2218
- },
2219
- root,
2220
- config: {
2221
- path: configPath,
2222
- file: await this.#readConfigFileView(),
2223
- plugins: config.plugins.map((plugin) => ({
2224
- name: toPackageName(plugin.name),
2225
- options: plugin.options ?? {}
2226
- }))
2227
- },
2228
- permissions: {
2229
- ...permissions,
2230
- allowWrite: this.#canWrite,
2231
- allowInput: this.#canUseInput,
2232
- allowConfigEdit: this.#canEditConfig,
2233
- allowRead: this.#canRead
2234
- }
2235
- }
2236
- });
2237
- }
2238
- async #handleOpen() {
2239
- this.#lastPongAt = Date.now();
2240
- await this.#hooks.callHook("studio:connected", {
2241
- url: this.#options.studioUrl,
2274
+ const [config, file] = await Promise.all([loadConfig(), this.#readConfigFileView()]);
2275
+ const payload = {
2242
2276
  versions: {
2243
- studio: this.#studioVersion,
2244
2277
  kubb: version,
2245
- agent: this.#options.version
2278
+ agent: version$1
2279
+ },
2280
+ root,
2281
+ config: {
2282
+ path: configPath,
2283
+ file,
2284
+ plugins: config.plugins.map((plugin) => ({
2285
+ name: toPackageName(plugin.name),
2286
+ options: plugin.options ?? {}
2287
+ }))
2288
+ },
2289
+ permissions: {
2290
+ ...permissions,
2291
+ allowWrite: this.#canWrite,
2292
+ allowInput: this.#canUseInput,
2293
+ allowConfigEdit: this.#canEditConfig,
2294
+ allowRead: this.#canRead
2246
2295
  }
2247
- });
2248
- try {
2249
- await this.#sendConnectedPayload();
2250
- this.#waitForReady();
2251
- } catch (error) {
2252
- await this.#warn(`Failed to send the connect payload: ${getErrorMessage(error)}`);
2253
- }
2296
+ };
2297
+ this.#connectAck.resolve();
2298
+ return payload;
2254
2299
  }
2255
- /**
2256
- * Arms the timeout for Studio's `studio:ready` acknowledgement. Re-armed on every open, since a
2257
- * command sent while the agent is not attached is lost either way (see `#sendConnectedPayload`),
2258
- * so each reconnect needs its own fresh wait.
2259
- */
2260
- #waitForReady() {
2261
- if (this.#disposed) return;
2262
- clearTimeout(this.#readyTimer);
2263
- this.#readyTimer = setTimeout(() => {
2264
- this.#readyTimer = void 0;
2265
- Promise.resolve(this.#warn(`Kubb Studio did not confirm the connection was ready within ${READY_TIMEOUT_MS}ms`)).catch(() => {});
2266
- }, READY_TIMEOUT_MS);
2267
- }
2268
- #onOpen = () => void this.#handleOpen().catch(() => {});
2269
- #onAbort = () => void this.#end({
2270
- reason: "shutdown",
2271
- retry: false
2272
- });
2300
+ #onAbort = () => void this.#end({ retry: false });
2273
2301
  #onClose = () => void this.#end({ retry: true });
2274
- #onError = () => {
2275
- this.#hooks.callHook("studio:error", { error: /* @__PURE__ */ new Error("Failed to connect to Kubb Studio") });
2276
- this.#onClose();
2277
- };
2278
2302
  /**
2279
2303
  * Drops the socket and detaches every listener and timer this session added. Idempotent, and
2280
2304
  * safe before `connect` opened anything.
2281
2305
  *
2282
2306
  * @internal
2283
2307
  */
2284
- dispose(reason = "cleanup") {
2285
- clearInterval(this.#heartbeatTimer);
2308
+ dispose() {
2309
+ clearTimeout(this.#heartbeatTimer);
2286
2310
  this.#heartbeatTimer = void 0;
2287
- clearTimeout(this.#readyTimer);
2288
- this.#readyTimer = void 0;
2289
- try {
2290
- this.#ws?.close(1e3, reason);
2291
- } catch {}
2311
+ this.#rpc?.close();
2312
+ this.#rpc = void 0;
2313
+ this.#connectAck.reject(/* @__PURE__ */ new Error("Session ended before Studio called connect()"));
2292
2314
  for (const unhook of this.#unhooks) unhook();
2293
2315
  this.#unhooks.length = 0;
2294
2316
  }
@@ -2296,15 +2318,12 @@ var StudioSession = class {
2296
2318
  * Ends the session: tells Studio it is over, drops the socket, and optionally reconnects.
2297
2319
  * `#disposed` keeps the close event from running this twice, and a shutdown from reconnecting.
2298
2320
  */
2299
- async #end({ reason, retry }) {
2321
+ async #end({ retry }) {
2300
2322
  const { studioUrl, token, logLevel } = this.#options;
2301
2323
  if (this.#disposed) return;
2302
2324
  this.#disposed = true;
2303
- if (reason === "shutdown" && this.#ws) sendAgentMessage(this.#ws, {
2304
- type: "agent:disconnect",
2305
- reason: "shutdown"
2306
- });
2307
- this.dispose(reason);
2325
+ this.dispose();
2326
+ await this.#hooks.callHook("studio:disconnected", { reason: retry ? "connection closed" : "shutdown" });
2308
2327
  if (this.#session) await disconnect({
2309
2328
  sessionId: this.#session.sessionId,
2310
2329
  studioUrl,
@@ -2314,81 +2333,35 @@ var StudioSession = class {
2314
2333
  }).catch(() => {});
2315
2334
  if (retry) reconnect(this.#options);
2316
2335
  }
2317
- #onMessage = async (message) => {
2318
- try {
2319
- const data = JSON.parse(message.data);
2320
- if (isStudioPongMessage(data)) {
2321
- this.#lastPongAt = Date.now();
2322
- return;
2323
- }
2324
- if (isStudioReadyMessage(data)) {
2325
- clearTimeout(this.#readyTimer);
2326
- this.#readyTimer = void 0;
2327
- await this.#hooks.callHook("studio:ready", {});
2328
- return;
2329
- }
2330
- if (isDisconnectMessage(data)) {
2331
- await this.#handleDisconnect(data.reason);
2332
- return;
2333
- }
2334
- if (isCommandMessage(data)) {
2335
- await this.#handleCommand(data);
2336
- return;
2337
- }
2338
- await this.#warn(`Ignored an unknown message from Kubb Studio: ${data.type}`);
2339
- } catch (error) {
2340
- await this.#hooks.callHook("studio:error", { error: toError(error) });
2341
- await this.#emitError(toError(error));
2342
- }
2343
- };
2344
- /**
2345
- * Studio ended the session itself. A revoked one stays ended, an expired one gets a fresh
2346
- * session, and anything else is left to the socket's own close event.
2347
- */
2348
- async #handleDisconnect(reason) {
2349
- await this.#hooks.callHook("studio:disconnected", { reason });
2350
- if (reason !== "revoked" && reason !== "expired") return;
2351
- this.#disposed = true;
2352
- this.dispose(`session_${reason}`);
2353
- if (reason === "expired") reconnect(this.#options);
2354
- }
2355
- async #handleCommand(data) {
2356
- const ws = this.#ws;
2357
- if (!ws) return;
2358
- const command = data.type.slice(7);
2359
- await this.#hooks.callHook("studio:command:start", { command });
2360
- switch (data.type) {
2361
- case "studio:generate":
2362
- await this.#handleGenerate(ws, data, command);
2363
- return;
2364
- case "studio:connect":
2365
- this.#studioVersion = data.version ?? this.#studioVersion;
2366
- await this.#sendConnectedPayload();
2367
- await this.#hooks.callHook("studio:command:end", { command });
2368
- return;
2369
- case "studio:save":
2370
- await this.#handleSave(ws, data, command);
2371
- return;
2372
- case "studio:snapshot":
2373
- await this.#handleSnapshot(ws, data, command);
2374
- return;
2375
- case "studio:files":
2376
- await this.#handleFiles(ws, data, command);
2377
- return;
2378
- }
2336
+ startGeneration(data) {
2337
+ const generationStream = createGenerationStream(this.#hooks, data.jobId, { onGenerationEnd: (result) => {
2338
+ this.#lastGeneration = result;
2339
+ } });
2340
+ const controller = new AbortController();
2341
+ const result = this.#runGeneration(data, controller).then(async (value) => {
2342
+ await generationStream.close();
2343
+ return value;
2344
+ }).catch((error) => {
2345
+ generationStream.fail(error);
2346
+ throw error;
2347
+ });
2348
+ result.catch(() => {});
2349
+ return new GenerationRunTarget(generationStream.stream, result, async () => {
2350
+ controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
2351
+ }, () => {
2352
+ controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
2353
+ generationStream.dispose();
2354
+ });
2379
2355
  }
2380
- async #handleGenerate(ws, data, command) {
2381
- const { root, loadConfig, permissions, client } = this.#options;
2382
- if (this.#isGenerating) {
2383
- await this.#warn("Ignored generate: a generation is already in progress");
2384
- await this.#emitError(/* @__PURE__ */ new Error("A generation is already in progress, please wait for it to finish"));
2385
- return;
2386
- }
2356
+ async #runGeneration(data, controller) {
2357
+ if (this.#isGenerating) return this.#refuse("Ignored generate: a generation is already in progress", "A generation is already in progress, please wait for it to finish");
2387
2358
  this.#isGenerating = true;
2388
- this.#activeJobId = data.jobId;
2359
+ const command = "generate";
2360
+ const { root, loadConfig, permissions, client } = this.#options;
2389
2361
  try {
2362
+ await this.#hooks.callHook("studio:command:start", { command });
2390
2363
  const config = await loadConfig();
2391
- const patch = data.payload;
2364
+ const patch = data.config;
2392
2365
  const plugins = await mergePlugins(config.plugins, patch?.plugins);
2393
2366
  const adapter = await mergeAdapter(config.adapter, patch?.adapter);
2394
2367
  const inputOverride = this.#isSandbox ? patch?.input ?? "" : permissions.allowInput && patch?.input || void 0;
@@ -2399,9 +2372,7 @@ var StudioSession = class {
2399
2372
  }
2400
2373
  const resolvedPlugins = plugins ?? config.plugins;
2401
2374
  this.#lastGeneration = void 0;
2402
- const detach = [setupHookListener(this.#hooks, root), setupEventsStream(ws, this.#hooks, data.jobId, { onGenerationEnd: (result) => {
2403
- this.#lastGeneration = result;
2404
- } })];
2375
+ const detach = [setupHookListener(this.#hooks, root, controller.signal)];
2405
2376
  try {
2406
2377
  await generate({
2407
2378
  config: {
@@ -2418,7 +2389,8 @@ var StudioSession = class {
2418
2389
  plugins: resolvedPlugins,
2419
2390
  adapter
2420
2391
  },
2421
- hooks: this.#hooks
2392
+ hooks: this.#hooks,
2393
+ signal: controller.signal
2422
2394
  });
2423
2395
  } finally {
2424
2396
  for (const remove of detach) remove();
@@ -2427,102 +2399,70 @@ var StudioSession = class {
2427
2399
  command,
2428
2400
  info: `${resolvedPlugins.length} plugin${resolvedPlugins.length === 1 ? "" : "s"}, ${this.#canWrite ? "written to disk" : "in memory"}${inputOverride !== void 0 ? ", from a Studio spec" : ""}`
2429
2401
  });
2402
+ const files = [...this.#lastGeneration?.paths ?? []];
2403
+ return {
2404
+ status: "success",
2405
+ files,
2406
+ fileCount: files.length
2407
+ };
2430
2408
  } finally {
2431
2409
  this.#isGenerating = false;
2432
- this.#activeJobId = null;
2433
2410
  }
2434
2411
  }
2435
- async #handleSave(ws, data, command) {
2412
+ async saveConfig(data) {
2413
+ const command = "saveConfig";
2414
+ await this.#hooks.callHook("studio:command:start", { command });
2436
2415
  const { configPath, configFile } = this.#options;
2437
2416
  if (!Array.isArray(data.edits)) {
2438
2417
  await this.#warn("Ignored save: the message carried no edits");
2439
- sendAgentMessage(ws, {
2440
- type: "agent:save",
2441
- jobId: data.jobId,
2442
- payload: {
2443
- outcomes: [],
2444
- changed: false
2445
- }
2446
- });
2447
- return;
2418
+ return {
2419
+ outcomes: [],
2420
+ changed: false
2421
+ };
2448
2422
  }
2449
2423
  const edits = data.edits;
2450
- const refuse = (reason) => sendAgentMessage(ws, {
2451
- type: "agent:save",
2452
- jobId: data.jobId,
2453
- payload: {
2454
- outcomes: edits.map((edit) => ({
2455
- edit,
2456
- applied: false,
2457
- reason
2458
- })),
2459
- changed: false
2460
- }
2424
+ const refuse = (reason) => ({
2425
+ outcomes: edits.map((edit) => ({
2426
+ edit,
2427
+ applied: false,
2428
+ reason
2429
+ })),
2430
+ changed: false
2461
2431
  });
2462
2432
  if (!this.#canEditConfig) {
2463
2433
  await this.#warn("Ignored save: editing kubb.config.ts was not granted");
2464
- refuse("the agent was not granted permission to edit kubb.config.ts");
2465
- return;
2466
- }
2467
- if (this.#isGenerating) {
2468
- refuse("a generation is in progress");
2469
- return;
2434
+ return refuse("the agent was not granted permission to edit kubb.config.ts");
2470
2435
  }
2436
+ if (this.#isGenerating) return refuse("a generation is in progress");
2471
2437
  try {
2472
2438
  const { source: patched, outcomes, changed } = applyConfigEdits(await read(configFile), edits);
2473
2439
  if (changed) await writeFile(configFile, patched, "utf-8");
2474
- sendAgentMessage(ws, {
2475
- type: "agent:save",
2476
- jobId: data.jobId,
2477
- payload: {
2478
- outcomes,
2479
- changed,
2480
- file: changed ? await this.#readConfigFileView(patched) : void 0
2481
- }
2482
- });
2483
2440
  const applied = outcomes.filter((outcome) => outcome.applied).length;
2484
2441
  await this.#hooks.callHook("studio:command:end", {
2485
2442
  command,
2486
2443
  info: `applied ${applied}/${outcomes.length} edits to ${configPath}`
2487
2444
  });
2445
+ return {
2446
+ outcomes,
2447
+ changed,
2448
+ file: changed ? await this.#readConfigFileView(patched) : void 0
2449
+ };
2488
2450
  } catch (error) {
2489
2451
  await this.#hooks.callHook("studio:error", { error: toError(error) });
2490
- refuse(getErrorMessage(error));
2452
+ return refuse(getErrorMessage(error));
2491
2453
  }
2492
2454
  }
2493
- async #handleSnapshot(ws, data, command) {
2494
- const refuse = (message) => sendAgentMessage(ws, {
2495
- type: "agent:snapshot",
2496
- jobId: data.jobId,
2497
- payload: {
2498
- status: "error",
2499
- message
2500
- }
2501
- });
2502
- if (this.#isSandbox) {
2503
- await this.#warn("Ignored snapshot: a sandbox agent has no project to build a package from");
2504
- refuse("a sandbox agent has no project to build a package from");
2505
- return;
2506
- }
2507
- const { name, version, bundledDependencies, uploadPath } = data.payload;
2508
- if (!name || !version || !uploadPath) {
2509
- await this.#warn("Ignored snapshot: the message was missing required fields");
2510
- refuse("the message was missing required fields");
2511
- return;
2512
- }
2455
+ async publishSnapshot(data) {
2456
+ const command = "snapshot";
2457
+ await this.#hooks.callHook("studio:command:start", { command });
2458
+ if (this.#isSandbox) return this.#refuse("Ignored snapshot: a sandbox agent has no project to build a package from", "A sandbox agent has no project to build a package from");
2459
+ const { name, version, bundledDependencies, uploadPath } = data;
2460
+ if (!name || !version || !uploadPath) return this.#refuse("Ignored snapshot: the message was missing required fields", "The request was missing required fields");
2513
2461
  const generation = this.#lastGeneration;
2514
- if (!generation) {
2515
- await this.#warn("Ignored snapshot: no prior generation to pack");
2516
- refuse("no prior generation exists to pack, run a generation first");
2517
- return;
2518
- }
2462
+ if (!generation) return this.#refuse("Ignored snapshot: no prior generation to pack", "No prior generation exists to pack, run a generation first");
2519
2463
  const bundled = new Set(bundledDependencies ?? []);
2520
2464
  const missing = generation.missingDependencies.filter((dependency) => !bundled.has(dependency));
2521
- if (missing.length) {
2522
- await this.#warn(`Ignored snapshot: missing dependencies: ${missing.join(", ")}`);
2523
- refuse(`missing dependencies: ${missing.join(", ")}`);
2524
- return;
2525
- }
2465
+ if (missing.length) return this.#refuse(`Ignored snapshot: missing dependencies: ${missing.join(", ")}`, `Missing dependencies: ${missing.join(", ")}`);
2526
2466
  try {
2527
2467
  const files = {};
2528
2468
  await inParallel({
@@ -2539,68 +2479,50 @@ var StudioSession = class {
2539
2479
  peerDependencies: generation.peerDependencies
2540
2480
  });
2541
2481
  const { token, studioUrl } = this.#options;
2542
- const redirect = await fetch(new URL(uploadPath, studioUrl), {
2482
+ const uploadUrl = new URL(uploadPath, studioUrl);
2483
+ if (uploadUrl.origin !== new URL(studioUrl).origin) throw new Error("Snapshot upload path must stay on the Studio origin");
2484
+ const redirect = await fetch(uploadUrl, {
2543
2485
  method: "PUT",
2544
2486
  headers: { Authorization: `Bearer ${token}` },
2545
2487
  redirect: "manual"
2546
2488
  });
2547
2489
  const storageUrl = redirect.headers.get("location");
2548
2490
  if (redirect.status !== 307 || !storageUrl) throw new Error(`Studio did not provide a storage URL (status ${redirect.status})`);
2549
- const response = await fetch(storageUrl, {
2491
+ const storage = new URL(storageUrl);
2492
+ if (storage.protocol !== "https:" && storage.hostname !== "localhost" && storage.hostname !== "127.0.0.1") throw new Error(`Refusing snapshot upload to ${storage.origin}`);
2493
+ const response = await fetch(storage, {
2550
2494
  method: "PUT",
2551
- body: new Uint8Array(bytes)
2495
+ body: new Uint8Array(bytes),
2496
+ redirect: "error"
2552
2497
  });
2553
2498
  if (!response.ok) throw new Error(`Snapshot upload failed with status ${response.status}`);
2554
- sendAgentMessage(ws, {
2555
- type: "agent:snapshot",
2556
- jobId: data.jobId,
2557
- payload: {
2558
- status: "ok",
2559
- integrity,
2560
- peerDependencies: generation.peerDependencies
2561
- }
2562
- });
2563
2499
  await this.#hooks.callHook("studio:command:end", {
2564
2500
  command,
2565
2501
  info: `packed ${Object.keys(files).length} file${Object.keys(files).length === 1 ? "" : "s"}`
2566
2502
  });
2503
+ return {
2504
+ integrity,
2505
+ peerDependencies: generation.peerDependencies
2506
+ };
2567
2507
  } catch (error) {
2568
2508
  await this.#hooks.callHook("studio:error", { error: toError(error) });
2569
- refuse(getErrorMessage(error));
2509
+ throw error;
2570
2510
  }
2571
2511
  }
2572
- async #handleFiles(ws, data, command) {
2512
+ async readFiles(data) {
2513
+ const command = "readFiles";
2514
+ await this.#hooks.callHook("studio:command:start", { command });
2573
2515
  const { client } = this.#options;
2574
- const refuse = (message) => sendAgentMessage(ws, {
2575
- type: "agent:files",
2576
- jobId: data.jobId,
2577
- payload: {
2578
- status: "error",
2579
- message
2580
- }
2581
- });
2582
2516
  if (!this.#canRead) {
2583
2517
  await this.#warn("Ignored files: reading generated files was not granted");
2584
- refuse(`the agent was not granted permission to read generated files; set ${client?.kind === "cli" ? "--allow-read, or answer yes when kubb studio asks," : "KUBB_AGENT_ALLOW_READ=true"} to allow it`);
2585
- return;
2586
- }
2587
- if (!Array.isArray(data.payload?.paths)) {
2588
- await this.#warn("Ignored files: the message carried no paths");
2589
- refuse("the message carried no paths");
2590
- return;
2591
- }
2592
- const { paths } = data.payload;
2593
- if (paths.length > 50) {
2594
- await this.#warn(`Ignored files: requested ${paths.length} paths, more than the 50 allowed per request`);
2595
- refuse(`at most 50 paths may be requested at once`);
2596
- return;
2518
+ const remedy = client?.kind === "cli" ? "--allow-read, or answer yes when kubb studio asks," : "KUBB_AGENT_ALLOW_READ=true";
2519
+ throw new Error(`The agent was not granted permission to read generated files; set ${remedy} to allow it`);
2597
2520
  }
2521
+ if (!Array.isArray(data.paths)) return this.#refuse("Ignored files: the message carried no paths", "The request carried no paths");
2522
+ const { paths } = data;
2523
+ if (paths.length > 50) return this.#refuse(`Ignored files: requested ${paths.length} paths, more than the 50 allowed per request`, `At most 50 paths may be requested at once`);
2598
2524
  const generation = this.#lastGeneration;
2599
- if (!generation) {
2600
- await this.#warn("Ignored files: no prior generation to read from");
2601
- refuse("no prior generation to read from, run a generation first");
2602
- return;
2603
- }
2525
+ if (!generation) return this.#refuse("Ignored files: no prior generation to read from", "No prior generation to read from, run a generation first");
2604
2526
  const requested = paths.filter((path) => generation.paths.has(path));
2605
2527
  const files = {};
2606
2528
  await inParallel({
@@ -2611,18 +2533,11 @@ var StudioSession = class {
2611
2533
  if (content !== null) files[path] = content;
2612
2534
  }
2613
2535
  });
2614
- sendAgentMessage(ws, {
2615
- type: "agent:files",
2616
- jobId: data.jobId,
2617
- payload: {
2618
- status: "ok",
2619
- files
2620
- }
2621
- });
2622
2536
  await this.#hooks.callHook("studio:command:end", {
2623
2537
  command,
2624
2538
  info: `read ${Object.keys(files).length}/${paths.length} requested file${paths.length === 1 ? "" : "s"}`
2625
2539
  });
2540
+ return { files };
2626
2541
  }
2627
2542
  };
2628
2543
  //#endregion
@@ -2659,7 +2574,7 @@ function createClient({ storage, onAuthRequired, ...options }) {
2659
2574
  ...options,
2660
2575
  signal: controller.signal,
2661
2576
  onTokenRejected: notifyAuthRequired
2662
- }).connect()));
2577
+ }).start()));
2663
2578
  },
2664
2579
  disconnect() {
2665
2580
  controller.abort();
@@ -2829,6 +2744,6 @@ async function pollForPairingToken({ studioUrl = agentDefaults.studioUrl, sessio
2829
2744
  throw new Error("The pairing code expired, pair again");
2830
2745
  }
2831
2746
  //#endregion
2832
- export { InvalidAgentTokenError, PairingCanceledError, createAgent, createClient, createFileStorage, createJob, createJobId, defaultStudioUrl, machineTokenFrom, pollForPairingToken, runConnection, setStorage, startPairing, waitForJob };
2747
+ export { InvalidAgentTokenError, PairingCanceledError, connectWebSocketRpc, createAgent, createClient, createFileStorage, createJob, defaultStudioUrl, generationEventTypes, machineTokenFrom, pollForPairingToken, runConnection, setStorage, startPairing, waitForJob };
2833
2748
 
2834
2749
  //# sourceMappingURL=index.js.map