@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.cjs CHANGED
@@ -614,18 +614,41 @@ async function createJob({ studioUrl, token, type, agentId, name, version, confi
614
614
  return job;
615
615
  }
616
616
  /**
617
- * Polls `GET /api/jobs/{id}` until the job reaches `success` or `failed`.
617
+ * A job runs a generation and packs a tarball, so it is never done the instant it is queued.
618
+ */
619
+ const INITIAL_POLL_DELAY_MS = 2e3;
620
+ /**
621
+ * Slowest the poll backs off to. Requests per run are roughly `timeoutMs` divided by this, and
622
+ * every concurrent run on the same organization key draws on one budget.
623
+ */
624
+ const MAX_POLL_INTERVAL_MS = 3e4;
625
+ /**
626
+ * Polls `GET /api/jobs/{id}` until the job reaches `success` or `failed`, waiting
627
+ * {@link INITIAL_POLL_DELAY_MS} first and doubling up to {@link MAX_POLL_INTERVAL_MS} so a long
628
+ * job stays inside the API key's rate limit.
618
629
  *
619
630
  * A `failed` job resolves normally. Check `job.status` and `job.error`. Throws only when the
620
631
  * deadline passes before Studio finishes.
621
632
  */
622
633
  async function waitForJob({ studioUrl, token, id, timeoutMs = 6e4 }) {
623
634
  const deadline = Date.now() + timeoutMs;
635
+ let interval = INITIAL_POLL_DELAY_MS;
624
636
  for (;;) {
625
- const { job } = await (0, ofetch.ofetch)(`${studioUrl}/api/jobs/${id}`, { headers: { "x-api-key": token } });
626
- if (job.status === "success" || job.status === "failed") return job;
637
+ await new Promise((resolve) => setTimeout(resolve, Math.max(Math.min(interval, deadline - Date.now()), 0)));
627
638
  if (Date.now() >= deadline) throw new Error("Timed out waiting for the Studio job");
628
- await new Promise((resolve) => setTimeout(resolve, 1e3));
639
+ interval = Math.min(interval * 2, MAX_POLL_INTERVAL_MS);
640
+ try {
641
+ const { job } = await (0, ofetch.ofetch)(`${studioUrl}/api/jobs/${id}`, {
642
+ headers: { "x-api-key": token },
643
+ retry: false
644
+ });
645
+ if (job.status === "success" || job.status === "failed") return job;
646
+ } catch (error) {
647
+ const response = error.response;
648
+ if (response?.status !== 429) throw error;
649
+ const retryAfter = response._data?.data?.tryAgainIn;
650
+ interval = Math.max(interval, typeof retryAfter === "number" && Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : MAX_POLL_INTERVAL_MS);
651
+ }
629
652
  }
630
653
  }
631
654
  /**
@@ -655,7 +678,7 @@ async function createAgent({ studioUrl, token, name, machineToken }) {
655
678
  }
656
679
  //#endregion
657
680
  //#region package.json
658
- var version = "5.3.3";
681
+ var version = "5.3.5";
659
682
  //#endregion
660
683
  //#region src/hooks.ts
661
684
  /**
@@ -1718,12 +1741,6 @@ async function createSnapshotPackage(files, packageInfo) {
1718
1741
  //#endregion
1719
1742
  //#region src/ws.ts
1720
1743
  /**
1721
- * How many generated files are read from storage at once when building the
1722
- * `kubb:generation:end` payload. A spec producing thousands of files would otherwise fire one
1723
- * `storage.readItem` per file simultaneously.
1724
- */
1725
- const FILE_READ_CONCURRENCY = 50;
1726
- /**
1727
1744
  * How long the initial handshake may take before the socket is closed and the reconnect loop
1728
1745
  * takes over.
1729
1746
  */
@@ -1739,6 +1756,12 @@ const require$1 = (0, node_module.createRequire)(require("url").pathToFileURL(__
1739
1756
  function relativeStoragePath(root, filePath) {
1740
1757
  return ((0, node_path.isAbsolute)(filePath) ? (0, node_path.relative)((0, node_path.resolve)(root), filePath) : filePath).replaceAll("\\", "/");
1741
1758
  }
1759
+ /**
1760
+ * Inverse of {@link relativeStoragePath}: rebuilds the storage key a relative path came from.
1761
+ */
1762
+ function absoluteStoragePath(root, relativePath) {
1763
+ return (0, node_path.resolve)(root, relativePath);
1764
+ }
1742
1765
  async function resolvePeerDependencies(names) {
1743
1766
  const uniqueNames = [...new Set(names.map(toPackageName))];
1744
1767
  const peerDependencies = {};
@@ -1856,12 +1879,12 @@ function setupEventsStream(ws$5, hooks, jobId, options = {}) {
1856
1879
  }]
1857
1880
  });
1858
1881
  });
1859
- on("kubb:build:end", ({ files, outputDir }) => {
1882
+ on("kubb:build:end", ({ files, config, outputDir }) => {
1860
1883
  sendDataMessage({
1861
1884
  type: "kubb:build:end",
1862
1885
  data: [{
1863
1886
  files: files.map((file) => ({
1864
- path: file.path,
1887
+ path: relativeStoragePath(config.root, file.path),
1865
1888
  name: file.name
1866
1889
  })),
1867
1890
  outputDir
@@ -1915,25 +1938,18 @@ function setupEventsStream(ws$5, hooks, jobId, options = {}) {
1915
1938
  });
1916
1939
  on("kubb:generation:end", async ({ config, storage, diagnostics = [], status, hrStart, filesCreated }) => {
1917
1940
  const { peerDependencies, missingDependencies } = await resolvePeerDependencies(config.plugins.map(({ name }) => name));
1918
- const paths = await storage.readKeys();
1919
- const files = {};
1920
- await inParallel({
1921
- items: paths,
1922
- limit: FILE_READ_CONCURRENCY,
1923
- run: async (path) => {
1924
- const content = await storage.readItem(path);
1925
- if (content !== null) files[relativeStoragePath(config.root, path)] = content;
1926
- }
1941
+ const keys = await storage.readKeys();
1942
+ const paths = new Set(keys.map((key) => relativeStoragePath(config.root, key)));
1943
+ options.onGenerationEnd?.({
1944
+ storage,
1945
+ root: config.root,
1946
+ paths,
1947
+ peerDependencies,
1948
+ missingDependencies
1927
1949
  });
1928
- options.onGenerationEnd?.(files);
1929
1950
  sendDataMessage({
1930
1951
  type: "kubb:generation:end",
1931
- data: [{
1932
- config,
1933
- storage: options.skipStorage ? {} : files,
1934
- peerDependencies,
1935
- missingDependencies
1936
- }]
1952
+ data: []
1937
1953
  });
1938
1954
  if (!hrStart) return;
1939
1955
  sendDataMessage({
@@ -2011,6 +2027,11 @@ function setupEventsStream(ws$5, hooks, jobId, options = {}) {
2011
2027
  //#endregion
2012
2028
  //#region src/StudioSession.ts
2013
2029
  /**
2030
+ * How many files are read from storage at once when serving `studio:files` or packing a
2031
+ * `studio:snapshot`.
2032
+ */
2033
+ const FILE_READ_CONCURRENCY = 50;
2034
+ /**
2014
2035
  * How long the `agent:connect` handshake may go unacknowledged before `studio:ready` is given up
2015
2036
  * on for this open. A Studio that predates the ack never sends one, so this only ever produces a
2016
2037
  * warning, not a reconnect.
@@ -2033,6 +2054,7 @@ function applyStudioDefaults(options) {
2033
2054
  allowConfigEdit: false,
2034
2055
  allowInput: false,
2035
2056
  allowExec: false,
2057
+ allowRead: false,
2036
2058
  ...options.permissions
2037
2059
  },
2038
2060
  retryInterval: options.retryInterval ?? agentDefaults.retryIntervalMs,
@@ -2113,6 +2135,12 @@ var StudioSession = class {
2113
2135
  get #canUseInput() {
2114
2136
  return this.#isSandbox || this.#options.permissions.allowInput;
2115
2137
  }
2138
+ /**
2139
+ * A sandbox agent always allows reading its output back; a local agent only when opted in.
2140
+ */
2141
+ get #canRead() {
2142
+ return this.#isSandbox || this.#options.permissions.allowRead;
2143
+ }
2116
2144
  async connect() {
2117
2145
  const { token, studioUrl, signal, heartbeatInterval, installLogger } = this.#options;
2118
2146
  await installLogger?.(this.#hooks);
@@ -2206,7 +2234,8 @@ var StudioSession = class {
2206
2234
  ...permissions,
2207
2235
  allowWrite: this.#canWrite,
2208
2236
  allowInput: this.#canUseInput,
2209
- allowConfigEdit: this.#canEditConfig
2237
+ allowConfigEdit: this.#canEditConfig,
2238
+ allowRead: this.#canRead
2210
2239
  }
2211
2240
  }
2212
2241
  });
@@ -2348,6 +2377,9 @@ var StudioSession = class {
2348
2377
  case "studio:snapshot":
2349
2378
  await this.#handleSnapshot(ws, data, command);
2350
2379
  return;
2380
+ case "studio:files":
2381
+ await this.#handleFiles(ws, data, command);
2382
+ return;
2351
2383
  }
2352
2384
  }
2353
2385
  async #handleGenerate(ws, data, command) {
@@ -2371,13 +2403,10 @@ var StudioSession = class {
2371
2403
  await this.#warn(`Ignored the spec from Studio; set ${remedy} to generate from it`);
2372
2404
  }
2373
2405
  const resolvedPlugins = plugins ?? config.plugins;
2374
- let generatedFiles;
2375
- const detach = [setupHookListener(this.#hooks, root), setupEventsStream(ws, this.#hooks, data.jobId, {
2376
- skipStorage: client?.kind === "ci",
2377
- onGenerationEnd: (files) => {
2378
- generatedFiles = files;
2379
- }
2380
- })];
2406
+ this.#lastGeneration = void 0;
2407
+ const detach = [setupHookListener(this.#hooks, root), setupEventsStream(ws, this.#hooks, data.jobId, { onGenerationEnd: (result) => {
2408
+ this.#lastGeneration = result;
2409
+ } })];
2381
2410
  try {
2382
2411
  await generate({
2383
2412
  config: {
@@ -2398,7 +2427,6 @@ var StudioSession = class {
2398
2427
  });
2399
2428
  } finally {
2400
2429
  for (const remove of detach) remove();
2401
- this.#lastGeneration = generatedFiles;
2402
2430
  }
2403
2431
  await this.#hooks.callHook("studio:command:end", {
2404
2432
  command,
@@ -2481,23 +2509,39 @@ var StudioSession = class {
2481
2509
  refuse("a sandbox agent has no project to build a package from");
2482
2510
  return;
2483
2511
  }
2484
- const { name, version, peerDependencies, uploadPath } = data.payload;
2512
+ const { name, version, bundledDependencies, uploadPath } = data.payload;
2485
2513
  if (!name || !version || !uploadPath) {
2486
2514
  await this.#warn("Ignored snapshot: the message was missing required fields");
2487
2515
  refuse("the message was missing required fields");
2488
2516
  return;
2489
2517
  }
2490
- const files = this.#lastGeneration;
2491
- if (!files) {
2518
+ const generation = this.#lastGeneration;
2519
+ if (!generation) {
2492
2520
  await this.#warn("Ignored snapshot: no prior generation to pack");
2493
2521
  refuse("no prior generation exists to pack, run a generation first");
2494
2522
  return;
2495
2523
  }
2524
+ const bundled = new Set(bundledDependencies ?? []);
2525
+ const missing = generation.missingDependencies.filter((dependency) => !bundled.has(dependency));
2526
+ if (missing.length) {
2527
+ await this.#warn(`Ignored snapshot: missing dependencies: ${missing.join(", ")}`);
2528
+ refuse(`missing dependencies: ${missing.join(", ")}`);
2529
+ return;
2530
+ }
2496
2531
  try {
2532
+ const files = {};
2533
+ await inParallel({
2534
+ items: [...generation.paths],
2535
+ limit: FILE_READ_CONCURRENCY,
2536
+ run: async (relativePath) => {
2537
+ const content = await generation.storage.readItem(absoluteStoragePath(generation.root, relativePath));
2538
+ if (content !== null) files[relativePath] = content;
2539
+ }
2540
+ });
2497
2541
  const { bytes, integrity } = await createSnapshotPackage(files, {
2498
2542
  name,
2499
2543
  version,
2500
- peerDependencies: peerDependencies ?? {}
2544
+ peerDependencies: generation.peerDependencies
2501
2545
  });
2502
2546
  const { token, studioUrl } = this.#options;
2503
2547
  const redirect = await fetch(new URL(uploadPath, studioUrl), {
@@ -2517,7 +2561,8 @@ var StudioSession = class {
2517
2561
  jobId: data.jobId,
2518
2562
  payload: {
2519
2563
  status: "ok",
2520
- integrity
2564
+ integrity,
2565
+ peerDependencies: generation.peerDependencies
2521
2566
  }
2522
2567
  });
2523
2568
  await this.#hooks.callHook("studio:command:end", {
@@ -2529,6 +2574,61 @@ var StudioSession = class {
2529
2574
  refuse(getErrorMessage(error));
2530
2575
  }
2531
2576
  }
2577
+ async #handleFiles(ws, data, command) {
2578
+ const { client } = this.#options;
2579
+ const refuse = (message) => sendAgentMessage(ws, {
2580
+ type: "agent:files",
2581
+ jobId: data.jobId,
2582
+ payload: {
2583
+ status: "error",
2584
+ message
2585
+ }
2586
+ });
2587
+ if (!this.#canRead) {
2588
+ await this.#warn("Ignored files: reading generated files was not granted");
2589
+ 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`);
2590
+ return;
2591
+ }
2592
+ if (!Array.isArray(data.payload?.paths)) {
2593
+ await this.#warn("Ignored files: the message carried no paths");
2594
+ refuse("the message carried no paths");
2595
+ return;
2596
+ }
2597
+ const { paths } = data.payload;
2598
+ if (paths.length > 50) {
2599
+ await this.#warn(`Ignored files: requested ${paths.length} paths, more than the 50 allowed per request`);
2600
+ refuse(`at most 50 paths may be requested at once`);
2601
+ return;
2602
+ }
2603
+ const generation = this.#lastGeneration;
2604
+ if (!generation) {
2605
+ await this.#warn("Ignored files: no prior generation to read from");
2606
+ refuse("no prior generation to read from, run a generation first");
2607
+ return;
2608
+ }
2609
+ const requested = paths.filter((path) => generation.paths.has(path));
2610
+ const files = {};
2611
+ await inParallel({
2612
+ items: requested,
2613
+ limit: FILE_READ_CONCURRENCY,
2614
+ run: async (path) => {
2615
+ const content = await generation.storage.readItem(absoluteStoragePath(generation.root, path));
2616
+ if (content !== null) files[path] = content;
2617
+ }
2618
+ });
2619
+ sendAgentMessage(ws, {
2620
+ type: "agent:files",
2621
+ jobId: data.jobId,
2622
+ payload: {
2623
+ status: "ok",
2624
+ files
2625
+ }
2626
+ });
2627
+ await this.#hooks.callHook("studio:command:end", {
2628
+ command,
2629
+ info: `read ${Object.keys(files).length}/${paths.length} requested file${paths.length === 1 ? "" : "s"}`
2630
+ });
2631
+ }
2532
2632
  };
2533
2633
  //#endregion
2534
2634
  //#region src/client.ts