@kubb/studio 5.3.3 → 5.3.5

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.d.ts CHANGED
@@ -98,7 +98,9 @@ export declare function createJob({ studioUrl, token, type, agentId, name, versi
98
98
  config?: Record<string, unknown>;
99
99
  }): Promise<StudioJob>;
100
100
  /**
101
- * Polls `GET /api/jobs/{id}` until the job reaches `success` or `failed`.
101
+ * Polls `GET /api/jobs/{id}` until the job reaches `success` or `failed`, waiting
102
+ * {@link INITIAL_POLL_DELAY_MS} first and doubling up to {@link MAX_POLL_INTERVAL_MS} so a long
103
+ * job stays inside the API key's rate limit.
102
104
  *
103
105
  * A `failed` job resolves normally. Check `job.status` and `job.error`. Throws only when the
104
106
  * deadline passes before Studio finishes.
package/dist/index.js CHANGED
@@ -609,18 +609,41 @@ async function createJob({ studioUrl, token, type, agentId, name, version, confi
609
609
  return job;
610
610
  }
611
611
  /**
612
- * Polls `GET /api/jobs/{id}` until the job reaches `success` or `failed`.
612
+ * A job runs a generation and packs a tarball, so it is never done the instant it is queued.
613
+ */
614
+ const INITIAL_POLL_DELAY_MS = 2e3;
615
+ /**
616
+ * Slowest the poll backs off to. Requests per run are roughly `timeoutMs` divided by this, and
617
+ * every concurrent run on the same organization key draws on one budget.
618
+ */
619
+ const MAX_POLL_INTERVAL_MS = 3e4;
620
+ /**
621
+ * Polls `GET /api/jobs/{id}` until the job reaches `success` or `failed`, waiting
622
+ * {@link INITIAL_POLL_DELAY_MS} first and doubling up to {@link MAX_POLL_INTERVAL_MS} so a long
623
+ * job stays inside the API key's rate limit.
613
624
  *
614
625
  * A `failed` job resolves normally. Check `job.status` and `job.error`. Throws only when the
615
626
  * deadline passes before Studio finishes.
616
627
  */
617
628
  async function waitForJob({ studioUrl, token, id, timeoutMs = 6e4 }) {
618
629
  const deadline = Date.now() + timeoutMs;
630
+ let interval = INITIAL_POLL_DELAY_MS;
619
631
  for (;;) {
620
- const { job } = await ofetch(`${studioUrl}/api/jobs/${id}`, { headers: { "x-api-key": token } });
621
- if (job.status === "success" || job.status === "failed") return job;
632
+ await new Promise((resolve) => setTimeout(resolve, Math.max(Math.min(interval, deadline - Date.now()), 0)));
622
633
  if (Date.now() >= deadline) throw new Error("Timed out waiting for the Studio job");
623
- await new Promise((resolve) => setTimeout(resolve, 1e3));
634
+ interval = Math.min(interval * 2, MAX_POLL_INTERVAL_MS);
635
+ try {
636
+ const { job } = await ofetch(`${studioUrl}/api/jobs/${id}`, {
637
+ headers: { "x-api-key": token },
638
+ retry: false
639
+ });
640
+ if (job.status === "success" || job.status === "failed") return job;
641
+ } catch (error) {
642
+ const response = error.response;
643
+ if (response?.status !== 429) throw error;
644
+ const retryAfter = response._data?.data?.tryAgainIn;
645
+ interval = Math.max(interval, typeof retryAfter === "number" && Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : MAX_POLL_INTERVAL_MS);
646
+ }
624
647
  }
625
648
  }
626
649
  /**
@@ -650,7 +673,7 @@ async function createAgent({ studioUrl, token, name, machineToken }) {
650
673
  }
651
674
  //#endregion
652
675
  //#region package.json
653
- var version = "5.3.3";
676
+ var version = "5.3.5";
654
677
  //#endregion
655
678
  //#region src/hooks.ts
656
679
  /**
@@ -1713,12 +1736,6 @@ async function createSnapshotPackage(files, packageInfo) {
1713
1736
  //#endregion
1714
1737
  //#region src/ws.ts
1715
1738
  /**
1716
- * How many generated files are read from storage at once when building the
1717
- * `kubb:generation:end` payload. A spec producing thousands of files would otherwise fire one
1718
- * `storage.readItem` per file simultaneously.
1719
- */
1720
- const FILE_READ_CONCURRENCY = 50;
1721
- /**
1722
1739
  * How long the initial handshake may take before the socket is closed and the reconnect loop
1723
1740
  * takes over.
1724
1741
  */
@@ -1734,6 +1751,12 @@ const require = createRequire(import.meta.url);
1734
1751
  function relativeStoragePath(root, filePath) {
1735
1752
  return (isAbsolute(filePath) ? relative(resolve(root), filePath) : filePath).replaceAll("\\", "/");
1736
1753
  }
1754
+ /**
1755
+ * Inverse of {@link relativeStoragePath}: rebuilds the storage key a relative path came from.
1756
+ */
1757
+ function absoluteStoragePath(root, relativePath) {
1758
+ return resolve(root, relativePath);
1759
+ }
1737
1760
  async function resolvePeerDependencies(names) {
1738
1761
  const uniqueNames = [...new Set(names.map(toPackageName))];
1739
1762
  const peerDependencies = {};
@@ -1851,12 +1874,12 @@ function setupEventsStream(ws, hooks, jobId, options = {}) {
1851
1874
  }]
1852
1875
  });
1853
1876
  });
1854
- on("kubb:build:end", ({ files, outputDir }) => {
1877
+ on("kubb:build:end", ({ files, config, outputDir }) => {
1855
1878
  sendDataMessage({
1856
1879
  type: "kubb:build:end",
1857
1880
  data: [{
1858
1881
  files: files.map((file) => ({
1859
- path: file.path,
1882
+ path: relativeStoragePath(config.root, file.path),
1860
1883
  name: file.name
1861
1884
  })),
1862
1885
  outputDir
@@ -1910,25 +1933,18 @@ function setupEventsStream(ws, hooks, jobId, options = {}) {
1910
1933
  });
1911
1934
  on("kubb:generation:end", async ({ config, storage, diagnostics = [], status, hrStart, filesCreated }) => {
1912
1935
  const { peerDependencies, missingDependencies } = await resolvePeerDependencies(config.plugins.map(({ name }) => name));
1913
- const paths = await storage.readKeys();
1914
- const files = {};
1915
- await inParallel({
1916
- items: paths,
1917
- limit: FILE_READ_CONCURRENCY,
1918
- run: async (path) => {
1919
- const content = await storage.readItem(path);
1920
- if (content !== null) files[relativeStoragePath(config.root, path)] = content;
1921
- }
1936
+ const keys = await storage.readKeys();
1937
+ const paths = new Set(keys.map((key) => relativeStoragePath(config.root, key)));
1938
+ options.onGenerationEnd?.({
1939
+ storage,
1940
+ root: config.root,
1941
+ paths,
1942
+ peerDependencies,
1943
+ missingDependencies
1922
1944
  });
1923
- options.onGenerationEnd?.(files);
1924
1945
  sendDataMessage({
1925
1946
  type: "kubb:generation:end",
1926
- data: [{
1927
- config,
1928
- storage: options.skipStorage ? {} : files,
1929
- peerDependencies,
1930
- missingDependencies
1931
- }]
1947
+ data: []
1932
1948
  });
1933
1949
  if (!hrStart) return;
1934
1950
  sendDataMessage({
@@ -2006,6 +2022,11 @@ function setupEventsStream(ws, hooks, jobId, options = {}) {
2006
2022
  //#endregion
2007
2023
  //#region src/StudioSession.ts
2008
2024
  /**
2025
+ * How many files are read from storage at once when serving `studio:files` or packing a
2026
+ * `studio:snapshot`.
2027
+ */
2028
+ const FILE_READ_CONCURRENCY = 50;
2029
+ /**
2009
2030
  * How long the `agent:connect` handshake may go unacknowledged before `studio:ready` is given up
2010
2031
  * on for this open. A Studio that predates the ack never sends one, so this only ever produces a
2011
2032
  * warning, not a reconnect.
@@ -2028,6 +2049,7 @@ function applyStudioDefaults(options) {
2028
2049
  allowConfigEdit: false,
2029
2050
  allowInput: false,
2030
2051
  allowExec: false,
2052
+ allowRead: false,
2031
2053
  ...options.permissions
2032
2054
  },
2033
2055
  retryInterval: options.retryInterval ?? agentDefaults.retryIntervalMs,
@@ -2108,6 +2130,12 @@ var StudioSession = class {
2108
2130
  get #canUseInput() {
2109
2131
  return this.#isSandbox || this.#options.permissions.allowInput;
2110
2132
  }
2133
+ /**
2134
+ * A sandbox agent always allows reading its output back; a local agent only when opted in.
2135
+ */
2136
+ get #canRead() {
2137
+ return this.#isSandbox || this.#options.permissions.allowRead;
2138
+ }
2111
2139
  async connect() {
2112
2140
  const { token, studioUrl, signal, heartbeatInterval, installLogger } = this.#options;
2113
2141
  await installLogger?.(this.#hooks);
@@ -2201,7 +2229,8 @@ var StudioSession = class {
2201
2229
  ...permissions,
2202
2230
  allowWrite: this.#canWrite,
2203
2231
  allowInput: this.#canUseInput,
2204
- allowConfigEdit: this.#canEditConfig
2232
+ allowConfigEdit: this.#canEditConfig,
2233
+ allowRead: this.#canRead
2205
2234
  }
2206
2235
  }
2207
2236
  });
@@ -2343,6 +2372,9 @@ var StudioSession = class {
2343
2372
  case "studio:snapshot":
2344
2373
  await this.#handleSnapshot(ws, data, command);
2345
2374
  return;
2375
+ case "studio:files":
2376
+ await this.#handleFiles(ws, data, command);
2377
+ return;
2346
2378
  }
2347
2379
  }
2348
2380
  async #handleGenerate(ws, data, command) {
@@ -2366,13 +2398,10 @@ var StudioSession = class {
2366
2398
  await this.#warn(`Ignored the spec from Studio; set ${remedy} to generate from it`);
2367
2399
  }
2368
2400
  const resolvedPlugins = plugins ?? config.plugins;
2369
- let generatedFiles;
2370
- const detach = [setupHookListener(this.#hooks, root), setupEventsStream(ws, this.#hooks, data.jobId, {
2371
- skipStorage: client?.kind === "ci",
2372
- onGenerationEnd: (files) => {
2373
- generatedFiles = files;
2374
- }
2375
- })];
2401
+ this.#lastGeneration = void 0;
2402
+ const detach = [setupHookListener(this.#hooks, root), setupEventsStream(ws, this.#hooks, data.jobId, { onGenerationEnd: (result) => {
2403
+ this.#lastGeneration = result;
2404
+ } })];
2376
2405
  try {
2377
2406
  await generate({
2378
2407
  config: {
@@ -2393,7 +2422,6 @@ var StudioSession = class {
2393
2422
  });
2394
2423
  } finally {
2395
2424
  for (const remove of detach) remove();
2396
- this.#lastGeneration = generatedFiles;
2397
2425
  }
2398
2426
  await this.#hooks.callHook("studio:command:end", {
2399
2427
  command,
@@ -2476,23 +2504,39 @@ var StudioSession = class {
2476
2504
  refuse("a sandbox agent has no project to build a package from");
2477
2505
  return;
2478
2506
  }
2479
- const { name, version, peerDependencies, uploadPath } = data.payload;
2507
+ const { name, version, bundledDependencies, uploadPath } = data.payload;
2480
2508
  if (!name || !version || !uploadPath) {
2481
2509
  await this.#warn("Ignored snapshot: the message was missing required fields");
2482
2510
  refuse("the message was missing required fields");
2483
2511
  return;
2484
2512
  }
2485
- const files = this.#lastGeneration;
2486
- if (!files) {
2513
+ const generation = this.#lastGeneration;
2514
+ if (!generation) {
2487
2515
  await this.#warn("Ignored snapshot: no prior generation to pack");
2488
2516
  refuse("no prior generation exists to pack, run a generation first");
2489
2517
  return;
2490
2518
  }
2519
+ const bundled = new Set(bundledDependencies ?? []);
2520
+ 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
+ }
2491
2526
  try {
2527
+ const files = {};
2528
+ await inParallel({
2529
+ items: [...generation.paths],
2530
+ limit: FILE_READ_CONCURRENCY,
2531
+ run: async (relativePath) => {
2532
+ const content = await generation.storage.readItem(absoluteStoragePath(generation.root, relativePath));
2533
+ if (content !== null) files[relativePath] = content;
2534
+ }
2535
+ });
2492
2536
  const { bytes, integrity } = await createSnapshotPackage(files, {
2493
2537
  name,
2494
2538
  version,
2495
- peerDependencies: peerDependencies ?? {}
2539
+ peerDependencies: generation.peerDependencies
2496
2540
  });
2497
2541
  const { token, studioUrl } = this.#options;
2498
2542
  const redirect = await fetch(new URL(uploadPath, studioUrl), {
@@ -2512,7 +2556,8 @@ var StudioSession = class {
2512
2556
  jobId: data.jobId,
2513
2557
  payload: {
2514
2558
  status: "ok",
2515
- integrity
2559
+ integrity,
2560
+ peerDependencies: generation.peerDependencies
2516
2561
  }
2517
2562
  });
2518
2563
  await this.#hooks.callHook("studio:command:end", {
@@ -2524,6 +2569,61 @@ var StudioSession = class {
2524
2569
  refuse(getErrorMessage(error));
2525
2570
  }
2526
2571
  }
2572
+ async #handleFiles(ws, data, command) {
2573
+ 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
+ if (!this.#canRead) {
2583
+ 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;
2597
+ }
2598
+ 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
+ }
2604
+ const requested = paths.filter((path) => generation.paths.has(path));
2605
+ const files = {};
2606
+ await inParallel({
2607
+ items: requested,
2608
+ limit: FILE_READ_CONCURRENCY,
2609
+ run: async (path) => {
2610
+ const content = await generation.storage.readItem(absoluteStoragePath(generation.root, path));
2611
+ if (content !== null) files[path] = content;
2612
+ }
2613
+ });
2614
+ sendAgentMessage(ws, {
2615
+ type: "agent:files",
2616
+ jobId: data.jobId,
2617
+ payload: {
2618
+ status: "ok",
2619
+ files
2620
+ }
2621
+ });
2622
+ await this.#hooks.callHook("studio:command:end", {
2623
+ command,
2624
+ info: `read ${Object.keys(files).length}/${paths.length} requested file${paths.length === 1 ? "" : "s"}`
2625
+ });
2626
+ }
2527
2627
  };
2528
2628
  //#endregion
2529
2629
  //#region src/client.ts