@kubb/studio 5.3.2 → 5.3.4

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
@@ -1,7 +1,7 @@
1
1
  import { t as __name } from "./rolldown-runtime-CRm0XQPb.js";
2
2
  import { AgentPermissions, ClientInfo, createJobId } from "./protocol.js";
3
- import { Storage } from "unstorage";
4
3
  import { Config, Hookable, KubbHooks } from "@kubb/core";
4
+ import { Storage } from "unstorage";
5
5
  //#region src/api.d.ts
6
6
  /**
7
7
  * Thrown when Studio rejects the agent token itself (401). Retrying cannot help: the token was
@@ -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.
@@ -197,6 +199,13 @@ type StudioSessionOptions = {
197
199
  * default to.
198
200
  */
199
201
  installLogger?: (hooks: Hookable<KubbHooks>) => void | Promise<void>;
202
+ /**
203
+ * Threshold for the reconnect loop's own `console.error` lines, using the numeric constants
204
+ * `@kubb/core` exports as `logLevel`. Left out, those lines never print, the same silent default
205
+ * as an unset `installLogger` — a reconnect happens outside any one session's hooks, so it has no
206
+ * other way to ask a host how loud to be.
207
+ */
208
+ logLevel?: number;
200
209
  /**
201
210
  * Called when this session's background reconnect is rejected with an invalid token. Unlike
202
211
  * `ClientOptions.onAuthRequired`, this fires once per session rather than once per pool:
package/dist/index.js CHANGED
@@ -1,21 +1,24 @@
1
1
  import { t as __name } from "./rolldown-runtime-CRm0XQPb.js";
2
2
  import { createJobId, isCommandMessage, isDisconnectMessage, isStudioPongMessage, isStudioReadyMessage } from "./protocol.js";
3
3
  import { createRequire } from "node:module";
4
- import { styleText } from "node:util";
4
+ import { promisify, styleText } from "node:util";
5
5
  import process$1 from "node:process";
6
6
  import { spawn } from "node:child_process";
7
- import { readFile, writeFile } from "node:fs/promises";
8
- import path, { isAbsolute, relative, resolve } from "node:path";
7
+ import { glob, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
8
+ import path, { isAbsolute, join, relative, resolve, sep } from "node:path";
9
+ import { Diagnostics, Hookable, createKubb, fsStorage, logLevel, memoryStorage } from "@kubb/core";
9
10
  import { FetchError, ofetch } from "ofetch";
10
- import { hash, randomBytes } from "node:crypto";
11
+ import { createHash, hash, randomBytes } from "node:crypto";
11
12
  import { createStorage } from "unstorage";
12
13
  import fsDriver from "unstorage/drivers/fs";
13
- import { Diagnostics, Hookable, createKubb, fsStorage, memoryStorage } from "@kubb/core";
14
14
  import { x } from "tinyexec";
15
15
  import { builders, detectCodeFormat, generateCode, parseModule } from "magicast";
16
16
  import { existsSync } from "node:fs";
17
17
  import { pathToFileURL } from "node:url";
18
18
  import { mergeDeep } from "remeda";
19
+ import { tmpdir } from "node:os";
20
+ import { gzip } from "node:zlib";
21
+ import { build } from "tsdown";
19
22
  import WebSocket from "ws";
20
23
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
21
24
  //#region src/constants.ts
@@ -556,19 +559,20 @@ async function runRegistration({ token, studioUrl, poolSize }) {
556
559
  * Called on process termination or server close. A failed notify is logged and swallowed: the
557
560
  * local socket is already gone, and failing teardown must not block shutdown or reconnect.
558
561
  */
559
- async function disconnect({ sessionId, token, studioUrl, slug }) {
562
+ async function disconnect({ sessionId, token, studioUrl, slug, logLevel: logLevel$2 }) {
560
563
  const url = `${studioUrl}/api/agent/sessions/${sessionId}/disconnect`;
561
564
  const tag = slug ?? "agent";
565
+ const canLog = logLevel$2 !== void 0 && logLevel$2 > logLevel.silent;
562
566
  try {
563
567
  await ofetch(url, {
564
568
  method: "POST",
565
569
  headers: { Authorization: `Bearer ${token}` }
566
570
  });
567
- console.log(styleText("green", `[${tag}] Disconnected from Studio`));
571
+ if (canLog) console.error(styleText("green", `[${tag}] Disconnected from Studio`));
568
572
  } catch (error) {
569
573
  const statusCode = error?.statusCode;
570
574
  if (statusCode !== void 0 && statusCode >= 400 && statusCode < 500) return;
571
- console.warn(styleText("yellow", `[${tag}] Failed to notify Studio of disconnection: ${getErrorMessage(error)}`));
575
+ if (canLog) console.warn(styleText("yellow", `[${tag}] Failed to notify Studio of disconnection: ${getErrorMessage(error)}`));
572
576
  }
573
577
  }
574
578
  /**
@@ -605,18 +609,41 @@ async function createJob({ studioUrl, token, type, agentId, name, version, confi
605
609
  return job;
606
610
  }
607
611
  /**
608
- * 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.
609
624
  *
610
625
  * A `failed` job resolves normally. Check `job.status` and `job.error`. Throws only when the
611
626
  * deadline passes before Studio finishes.
612
627
  */
613
628
  async function waitForJob({ studioUrl, token, id, timeoutMs = 6e4 }) {
614
629
  const deadline = Date.now() + timeoutMs;
630
+ let interval = INITIAL_POLL_DELAY_MS;
615
631
  for (;;) {
616
- const { job } = await ofetch(`${studioUrl}/api/jobs/${id}`, { headers: { "x-api-key": token } });
617
- 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)));
618
633
  if (Date.now() >= deadline) throw new Error("Timed out waiting for the Studio job");
619
- 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
+ }
620
647
  }
621
648
  }
622
649
  /**
@@ -646,7 +673,7 @@ async function createAgent({ studioUrl, token, name, machineToken }) {
646
673
  }
647
674
  //#endregion
648
675
  //#region package.json
649
- var version = "5.3.2";
676
+ var version = "5.3.4";
650
677
  //#endregion
651
678
  //#region src/hooks.ts
652
679
  /**
@@ -1579,6 +1606,134 @@ async function generate({ config, hooks }) {
1579
1606
  }
1580
1607
  }
1581
1608
  //#endregion
1609
+ //#region src/snapshotPackage.ts
1610
+ const gzipAsync = promisify(gzip);
1611
+ /**
1612
+ * Maps a generated file's path to its place inside the tarball, stripping everything before a
1613
+ * `src`/`dist` segment and any `..`/empty path segment so a crafted file name cannot escape the
1614
+ * `package/` root.
1615
+ */
1616
+ function packagePath(filePath) {
1617
+ const normalized = filePath.replaceAll("\\", "/");
1618
+ return `package/${(normalized.match(/\/(?:src|dist)\/.*$/)?.[0].slice(1) ?? normalized.replace(/^\/+/, "")).split("/").filter((part) => part && part !== "." && part !== "..").join("/")}`;
1619
+ }
1620
+ /**
1621
+ * Splits a tarball entry path into the legacy 100-byte `name` field and, when the path does not
1622
+ * fit, the 155-byte USTAR `prefix` field that extends it. Throws rather than silently truncating
1623
+ * a path the format cannot address (max 256 bytes: 100 name + 1 separator + 155 prefix).
1624
+ */
1625
+ function splitEntryPath(path) {
1626
+ if (Buffer.byteLength(path, "utf8") <= 100) return {
1627
+ name: path,
1628
+ prefix: ""
1629
+ };
1630
+ for (let i = path.length - 1; i >= 0; i--) {
1631
+ if (path[i] !== "/") continue;
1632
+ const prefix = path.slice(0, i);
1633
+ const name = path.slice(i + 1);
1634
+ if (Buffer.byteLength(prefix, "utf8") <= 155 && Buffer.byteLength(name, "utf8") <= 100) return {
1635
+ name,
1636
+ prefix
1637
+ };
1638
+ }
1639
+ throw new Error(`Snapshot path is too long for a tar entry: ${path}`);
1640
+ }
1641
+ function header(path, size) {
1642
+ const { name, prefix } = splitEntryPath(path);
1643
+ const value = Buffer.alloc(512);
1644
+ value.write(name, 0, "utf8");
1645
+ value.write("0000644\0", 100, "ascii");
1646
+ value.write("0000000\0", 108, "ascii");
1647
+ value.write("0000000\0", 116, "ascii");
1648
+ value.write(`${size.toString(8).padStart(11, "0")}\0`, 124, "ascii");
1649
+ value.write(`${Math.floor(Date.now() / 1e3).toString(8).padStart(11, "0")}\0`, 136, "ascii");
1650
+ value.fill(32, 148, 156);
1651
+ value.write("0", 156, "ascii");
1652
+ value.write("ustar\0", 257, "ascii");
1653
+ value.write("00", 263, "ascii");
1654
+ value.write("0000000\0", 265, "ascii");
1655
+ value.write("0000000\0", 297, "ascii");
1656
+ value.write(prefix, 345, "utf8");
1657
+ const checksum = [...value].reduce((sum, byte) => sum + byte, 0);
1658
+ value.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, "ascii");
1659
+ return value;
1660
+ }
1661
+ /**
1662
+ * Packs a generation's files into a gzipped, npm-installable tarball: a `package/` root with the
1663
+ * generated sources, a `tsdown`-built `dist/` (esm + cjs), and a `package.json` manifest.
1664
+ */
1665
+ async function createSnapshotPackage(files, packageInfo) {
1666
+ const root = await mkdtemp(join(tmpdir(), "kubb-snapshot-"));
1667
+ const dist = join(root, "dist");
1668
+ const resolvedPaths = Object.entries(files).map(([name, content]) => ({
1669
+ name,
1670
+ content,
1671
+ target: packagePath(name)
1672
+ }));
1673
+ const targetOwners = /* @__PURE__ */ new Map();
1674
+ for (const { name, target } of resolvedPaths) {
1675
+ const owner = targetOwners.get(target);
1676
+ if (owner) throw new Error(`Snapshot has two generated files that sanitize to the same path "${target}": "${owner}" and "${name}"`);
1677
+ targetOwners.set(target, name);
1678
+ }
1679
+ try {
1680
+ await mkdir(dist);
1681
+ await Promise.all(resolvedPaths.map(async ({ content, target }) => {
1682
+ const path = join(root, target.slice(8));
1683
+ await mkdir(join(path, ".."), { recursive: true });
1684
+ await writeFile(path, content);
1685
+ }));
1686
+ const sourceEntries = resolvedPaths.filter(({ name }) => /\.(?:[cm]?[jt]sx?)$/.test(name)).map(({ target }) => join(root, target.slice(8)));
1687
+ if (sourceEntries.length) await build({
1688
+ entry: sourceEntries,
1689
+ outDir: dist,
1690
+ format: ["esm", "cjs"],
1691
+ dts: false,
1692
+ sourcemap: false,
1693
+ unbundle: true,
1694
+ report: false,
1695
+ logLevel: "silent",
1696
+ fixedExtension: true
1697
+ });
1698
+ const builtEntries = await Promise.all((await Array.fromAsync(glob("**/*", {
1699
+ cwd: dist,
1700
+ withFileTypes: true
1701
+ }))).filter((entry) => entry.isFile()).map(async (entry) => {
1702
+ const filePath = join(entry.parentPath, entry.name);
1703
+ return [`package/dist/${relative(dist, filePath).split(sep).join("/")}`, await readFile(filePath, "utf8")];
1704
+ }));
1705
+ const entries = {
1706
+ "package/package.json": JSON.stringify({
1707
+ ...packageInfo,
1708
+ type: "module",
1709
+ main: "./dist/index.cjs",
1710
+ module: "./dist/index.mjs",
1711
+ exports: { ".": {
1712
+ import: "./dist/index.mjs",
1713
+ require: "./dist/index.cjs"
1714
+ } }
1715
+ }, null, 2),
1716
+ ...Object.fromEntries(resolvedPaths.map(({ content, target }) => [target, content])),
1717
+ ...Object.fromEntries(builtEntries)
1718
+ };
1719
+ const chunks = [];
1720
+ for (const [name, content] of Object.entries(entries)) {
1721
+ const bytes = Buffer.from(content);
1722
+ chunks.push(header(name, bytes.length), bytes, Buffer.alloc((512 - bytes.length % 512) % 512));
1723
+ }
1724
+ const bytes = await gzipAsync(Buffer.concat([...chunks, Buffer.alloc(1024)]));
1725
+ return {
1726
+ bytes,
1727
+ integrity: `sha512-${createHash("sha512").update(bytes).digest("base64")}`
1728
+ };
1729
+ } finally {
1730
+ await rm(root, {
1731
+ recursive: true,
1732
+ force: true
1733
+ });
1734
+ }
1735
+ }
1736
+ //#endregion
1582
1737
  //#region src/ws.ts
1583
1738
  /**
1584
1739
  * How many generated files are read from storage at once when building the
@@ -1674,7 +1829,7 @@ function sendErrorMessage(ws, error, jobId) {
1674
1829
  /**
1675
1830
  * Forwards selected Kubb lifecycle events to Studio as data messages for the active session.
1676
1831
  */
1677
- function setupEventsStream(ws, hooks, jobId) {
1832
+ function setupEventsStream(ws, hooks, jobId, options = {}) {
1678
1833
  const unhooks = [];
1679
1834
  /**
1680
1835
  * Registers a listener and keeps its remover, so one generation's listeners come off the session
@@ -1788,11 +1943,12 @@ function setupEventsStream(ws, hooks, jobId) {
1788
1943
  if (content !== null) files[relativeStoragePath(config.root, path)] = content;
1789
1944
  }
1790
1945
  });
1946
+ options.onGenerationEnd?.(files);
1791
1947
  sendDataMessage({
1792
1948
  type: "kubb:generation:end",
1793
1949
  data: [{
1794
1950
  config,
1795
- storage: files,
1951
+ storage: options.skipStorage ? {} : files,
1796
1952
  peerDependencies,
1797
1953
  missingDependencies
1798
1954
  }]
@@ -1909,15 +2065,15 @@ function applyStudioDefaults(options) {
1909
2065
  * socket, its hook emitter, or its session id alive for the length of the retry interval.
1910
2066
  */
1911
2067
  function reconnect(options) {
1912
- const { signal, retryInterval, onTokenRejected } = options;
2068
+ const { signal, retryInterval, onTokenRejected, logLevel: logLevel$1 } = options;
1913
2069
  if (signal?.aborted) return;
1914
- console.info(styleText("dim", `Retrying connection in ${retryInterval}ms to Kubb Studio ...`));
2070
+ if (logLevel$1 !== void 0 && logLevel$1 > logLevel.silent) console.error(styleText("dim", `Retrying connection in ${retryInterval}ms to Kubb Studio ...`));
1915
2071
  const cancel = () => clearTimeout(timer);
1916
2072
  const timer = setTimeout(() => {
1917
2073
  signal?.removeEventListener("abort", cancel);
1918
2074
  if (signal?.aborted) return;
1919
2075
  new StudioSession(options).connect().catch((error) => {
1920
- console.error(styleText("red", `Reconnect attempt to Kubb Studio failed: ${getErrorMessage(error)}`));
2076
+ if (logLevel$1 !== void 0 && logLevel$1 > logLevel.silent) console.error(styleText("red", `Reconnect attempt to Kubb Studio failed: ${getErrorMessage(error)}`));
1921
2077
  if (error instanceof InvalidAgentTokenError) {
1922
2078
  onTokenRejected?.(error);
1923
2079
  return;
@@ -1952,6 +2108,7 @@ var StudioSession = class {
1952
2108
  #heartbeatTimer;
1953
2109
  #readyTimer;
1954
2110
  #lastPongAt = Date.now();
2111
+ #lastGeneration;
1955
2112
  constructor(options) {
1956
2113
  this.#options = applyStudioDefaults(options);
1957
2114
  }
@@ -2134,7 +2291,7 @@ var StudioSession = class {
2134
2291
  * `#disposed` keeps the close event from running this twice, and a shutdown from reconnecting.
2135
2292
  */
2136
2293
  async #end({ reason, retry }) {
2137
- const { studioUrl, token } = this.#options;
2294
+ const { studioUrl, token, logLevel } = this.#options;
2138
2295
  if (this.#disposed) return;
2139
2296
  this.#disposed = true;
2140
2297
  if (reason === "shutdown" && this.#ws) sendAgentMessage(this.#ws, {
@@ -2146,7 +2303,8 @@ var StudioSession = class {
2146
2303
  sessionId: this.#session.sessionId,
2147
2304
  studioUrl,
2148
2305
  token,
2149
- slug: this.#session.slug
2306
+ slug: this.#session.slug,
2307
+ logLevel
2150
2308
  }).catch(() => {});
2151
2309
  if (retry) reconnect(this.#options);
2152
2310
  }
@@ -2205,6 +2363,9 @@ var StudioSession = class {
2205
2363
  case "studio:save":
2206
2364
  await this.#handleSave(ws, data, command);
2207
2365
  return;
2366
+ case "studio:snapshot":
2367
+ await this.#handleSnapshot(ws, data, command);
2368
+ return;
2208
2369
  }
2209
2370
  }
2210
2371
  async #handleGenerate(ws, data, command) {
@@ -2228,7 +2389,13 @@ var StudioSession = class {
2228
2389
  await this.#warn(`Ignored the spec from Studio; set ${remedy} to generate from it`);
2229
2390
  }
2230
2391
  const resolvedPlugins = plugins ?? config.plugins;
2231
- const detach = [setupHookListener(this.#hooks, root), setupEventsStream(ws, this.#hooks, data.jobId)];
2392
+ let generatedFiles;
2393
+ const detach = [setupHookListener(this.#hooks, root), setupEventsStream(ws, this.#hooks, data.jobId, {
2394
+ skipStorage: client?.kind === "ci",
2395
+ onGenerationEnd: (files) => {
2396
+ generatedFiles = files;
2397
+ }
2398
+ })];
2232
2399
  try {
2233
2400
  await generate({
2234
2401
  config: {
@@ -2249,6 +2416,7 @@ var StudioSession = class {
2249
2416
  });
2250
2417
  } finally {
2251
2418
  for (const remove of detach) remove();
2419
+ this.#lastGeneration = generatedFiles;
2252
2420
  }
2253
2421
  await this.#hooks.callHook("studio:command:end", {
2254
2422
  command,
@@ -2317,6 +2485,68 @@ var StudioSession = class {
2317
2485
  refuse(getErrorMessage(error));
2318
2486
  }
2319
2487
  }
2488
+ async #handleSnapshot(ws, data, command) {
2489
+ const refuse = (message) => sendAgentMessage(ws, {
2490
+ type: "agent:snapshot",
2491
+ jobId: data.jobId,
2492
+ payload: {
2493
+ status: "error",
2494
+ message
2495
+ }
2496
+ });
2497
+ if (this.#isSandbox) {
2498
+ await this.#warn("Ignored snapshot: a sandbox agent has no project to build a package from");
2499
+ refuse("a sandbox agent has no project to build a package from");
2500
+ return;
2501
+ }
2502
+ const { name, version, peerDependencies, uploadPath } = data.payload;
2503
+ if (!name || !version || !uploadPath) {
2504
+ await this.#warn("Ignored snapshot: the message was missing required fields");
2505
+ refuse("the message was missing required fields");
2506
+ return;
2507
+ }
2508
+ const files = this.#lastGeneration;
2509
+ if (!files) {
2510
+ await this.#warn("Ignored snapshot: no prior generation to pack");
2511
+ refuse("no prior generation exists to pack, run a generation first");
2512
+ return;
2513
+ }
2514
+ try {
2515
+ const { bytes, integrity } = await createSnapshotPackage(files, {
2516
+ name,
2517
+ version,
2518
+ peerDependencies: peerDependencies ?? {}
2519
+ });
2520
+ const { token, studioUrl } = this.#options;
2521
+ const redirect = await fetch(new URL(uploadPath, studioUrl), {
2522
+ method: "PUT",
2523
+ headers: { Authorization: `Bearer ${token}` },
2524
+ redirect: "manual"
2525
+ });
2526
+ const storageUrl = redirect.headers.get("location");
2527
+ if (redirect.status !== 307 || !storageUrl) throw new Error(`Studio did not provide a storage URL (status ${redirect.status})`);
2528
+ const response = await fetch(storageUrl, {
2529
+ method: "PUT",
2530
+ body: new Uint8Array(bytes)
2531
+ });
2532
+ if (!response.ok) throw new Error(`Snapshot upload failed with status ${response.status}`);
2533
+ sendAgentMessage(ws, {
2534
+ type: "agent:snapshot",
2535
+ jobId: data.jobId,
2536
+ payload: {
2537
+ status: "ok",
2538
+ integrity
2539
+ }
2540
+ });
2541
+ await this.#hooks.callHook("studio:command:end", {
2542
+ command,
2543
+ info: `packed ${Object.keys(files).length} file${Object.keys(files).length === 1 ? "" : "s"}`
2544
+ });
2545
+ } catch (error) {
2546
+ await this.#hooks.callHook("studio:error", { error: toError(error) });
2547
+ refuse(getErrorMessage(error));
2548
+ }
2549
+ }
2320
2550
  };
2321
2551
  //#endregion
2322
2552
  //#region src/client.ts