@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/README.md CHANGED
@@ -128,6 +128,10 @@ if (finished.status === 'failed') throw new Error(finished.error)
128
128
  const snapshot = finished.snapshot
129
129
  ```
130
130
 
131
+ A snapshot job packs the tarball on the agent, not on Studio. The agent `PUT`s an empty request to
132
+ a path Studio provides, gets back a redirect to a short-lived storage URL, and uploads the tarball
133
+ there. The storage URL never crosses the WebSocket.
134
+
131
135
  ## Protocol
132
136
 
133
137
  `@kubb/studio/protocol` holds the WebSocket message types shared by both ends, so the agent and
package/dist/index.cjs CHANGED
@@ -8,18 +8,21 @@ let node_child_process = require("node:child_process");
8
8
  let node_fs_promises = require("node:fs/promises");
9
9
  let node_path = require("node:path");
10
10
  node_path = require_rolldown_runtime.__toESM(node_path, 1);
11
+ let _kubb_core = require("@kubb/core");
11
12
  let ofetch = require("ofetch");
12
13
  let node_crypto = require("node:crypto");
13
14
  let unstorage = require("unstorage");
14
15
  let unstorage_drivers_fs = require("unstorage/drivers/fs");
15
16
  unstorage_drivers_fs = require_rolldown_runtime.__toESM(unstorage_drivers_fs, 1);
16
- let _kubb_core = require("@kubb/core");
17
17
  let tinyexec = require("tinyexec");
18
18
  let magicast = require("magicast");
19
19
  let node_fs = require("node:fs");
20
20
  let node_module = require("node:module");
21
21
  let node_url = require("node:url");
22
22
  let remeda = require("remeda");
23
+ let node_os = require("node:os");
24
+ let node_zlib = require("node:zlib");
25
+ let tsdown = require("tsdown");
23
26
  let ws = require("ws");
24
27
  ws = require_rolldown_runtime.__toESM(ws, 1);
25
28
  let node_timers_promises = require("node:timers/promises");
@@ -561,19 +564,20 @@ async function runRegistration({ token, studioUrl, poolSize }) {
561
564
  * Called on process termination or server close. A failed notify is logged and swallowed: the
562
565
  * local socket is already gone, and failing teardown must not block shutdown or reconnect.
563
566
  */
564
- async function disconnect({ sessionId, token, studioUrl, slug }) {
567
+ async function disconnect({ sessionId, token, studioUrl, slug, logLevel }) {
565
568
  const url = `${studioUrl}/api/agent/sessions/${sessionId}/disconnect`;
566
569
  const tag = slug ?? "agent";
570
+ const canLog = logLevel !== void 0 && logLevel > _kubb_core.logLevel.silent;
567
571
  try {
568
572
  await (0, ofetch.ofetch)(url, {
569
573
  method: "POST",
570
574
  headers: { Authorization: `Bearer ${token}` }
571
575
  });
572
- console.log((0, node_util.styleText)("green", `[${tag}] Disconnected from Studio`));
576
+ if (canLog) console.error((0, node_util.styleText)("green", `[${tag}] Disconnected from Studio`));
573
577
  } catch (error) {
574
578
  const statusCode = error?.statusCode;
575
579
  if (statusCode !== void 0 && statusCode >= 400 && statusCode < 500) return;
576
- console.warn((0, node_util.styleText)("yellow", `[${tag}] Failed to notify Studio of disconnection: ${getErrorMessage(error)}`));
580
+ if (canLog) console.warn((0, node_util.styleText)("yellow", `[${tag}] Failed to notify Studio of disconnection: ${getErrorMessage(error)}`));
577
581
  }
578
582
  }
579
583
  /**
@@ -610,18 +614,41 @@ async function createJob({ studioUrl, token, type, agentId, name, version, confi
610
614
  return job;
611
615
  }
612
616
  /**
613
- * 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.
614
629
  *
615
630
  * A `failed` job resolves normally. Check `job.status` and `job.error`. Throws only when the
616
631
  * deadline passes before Studio finishes.
617
632
  */
618
633
  async function waitForJob({ studioUrl, token, id, timeoutMs = 6e4 }) {
619
634
  const deadline = Date.now() + timeoutMs;
635
+ let interval = INITIAL_POLL_DELAY_MS;
620
636
  for (;;) {
621
- const { job } = await (0, ofetch.ofetch)(`${studioUrl}/api/jobs/${id}`, { headers: { "x-api-key": token } });
622
- 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)));
623
638
  if (Date.now() >= deadline) throw new Error("Timed out waiting for the Studio job");
624
- 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
+ }
625
652
  }
626
653
  }
627
654
  /**
@@ -651,7 +678,7 @@ async function createAgent({ studioUrl, token, name, machineToken }) {
651
678
  }
652
679
  //#endregion
653
680
  //#region package.json
654
- var version = "5.3.2";
681
+ var version = "5.3.4";
655
682
  //#endregion
656
683
  //#region src/hooks.ts
657
684
  /**
@@ -1584,6 +1611,134 @@ async function generate({ config, hooks }) {
1584
1611
  }
1585
1612
  }
1586
1613
  //#endregion
1614
+ //#region src/snapshotPackage.ts
1615
+ const gzipAsync = (0, node_util.promisify)(node_zlib.gzip);
1616
+ /**
1617
+ * Maps a generated file's path to its place inside the tarball, stripping everything before a
1618
+ * `src`/`dist` segment and any `..`/empty path segment so a crafted file name cannot escape the
1619
+ * `package/` root.
1620
+ */
1621
+ function packagePath(filePath) {
1622
+ const normalized = filePath.replaceAll("\\", "/");
1623
+ return `package/${(normalized.match(/\/(?:src|dist)\/.*$/)?.[0].slice(1) ?? normalized.replace(/^\/+/, "")).split("/").filter((part) => part && part !== "." && part !== "..").join("/")}`;
1624
+ }
1625
+ /**
1626
+ * Splits a tarball entry path into the legacy 100-byte `name` field and, when the path does not
1627
+ * fit, the 155-byte USTAR `prefix` field that extends it. Throws rather than silently truncating
1628
+ * a path the format cannot address (max 256 bytes: 100 name + 1 separator + 155 prefix).
1629
+ */
1630
+ function splitEntryPath(path) {
1631
+ if (Buffer.byteLength(path, "utf8") <= 100) return {
1632
+ name: path,
1633
+ prefix: ""
1634
+ };
1635
+ for (let i = path.length - 1; i >= 0; i--) {
1636
+ if (path[i] !== "/") continue;
1637
+ const prefix = path.slice(0, i);
1638
+ const name = path.slice(i + 1);
1639
+ if (Buffer.byteLength(prefix, "utf8") <= 155 && Buffer.byteLength(name, "utf8") <= 100) return {
1640
+ name,
1641
+ prefix
1642
+ };
1643
+ }
1644
+ throw new Error(`Snapshot path is too long for a tar entry: ${path}`);
1645
+ }
1646
+ function header(path, size) {
1647
+ const { name, prefix } = splitEntryPath(path);
1648
+ const value = Buffer.alloc(512);
1649
+ value.write(name, 0, "utf8");
1650
+ value.write("0000644\0", 100, "ascii");
1651
+ value.write("0000000\0", 108, "ascii");
1652
+ value.write("0000000\0", 116, "ascii");
1653
+ value.write(`${size.toString(8).padStart(11, "0")}\0`, 124, "ascii");
1654
+ value.write(`${Math.floor(Date.now() / 1e3).toString(8).padStart(11, "0")}\0`, 136, "ascii");
1655
+ value.fill(32, 148, 156);
1656
+ value.write("0", 156, "ascii");
1657
+ value.write("ustar\0", 257, "ascii");
1658
+ value.write("00", 263, "ascii");
1659
+ value.write("0000000\0", 265, "ascii");
1660
+ value.write("0000000\0", 297, "ascii");
1661
+ value.write(prefix, 345, "utf8");
1662
+ const checksum = [...value].reduce((sum, byte) => sum + byte, 0);
1663
+ value.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, "ascii");
1664
+ return value;
1665
+ }
1666
+ /**
1667
+ * Packs a generation's files into a gzipped, npm-installable tarball: a `package/` root with the
1668
+ * generated sources, a `tsdown`-built `dist/` (esm + cjs), and a `package.json` manifest.
1669
+ */
1670
+ async function createSnapshotPackage(files, packageInfo) {
1671
+ const root = await (0, node_fs_promises.mkdtemp)((0, node_path.join)((0, node_os.tmpdir)(), "kubb-snapshot-"));
1672
+ const dist = (0, node_path.join)(root, "dist");
1673
+ const resolvedPaths = Object.entries(files).map(([name, content]) => ({
1674
+ name,
1675
+ content,
1676
+ target: packagePath(name)
1677
+ }));
1678
+ const targetOwners = /* @__PURE__ */ new Map();
1679
+ for (const { name, target } of resolvedPaths) {
1680
+ const owner = targetOwners.get(target);
1681
+ if (owner) throw new Error(`Snapshot has two generated files that sanitize to the same path "${target}": "${owner}" and "${name}"`);
1682
+ targetOwners.set(target, name);
1683
+ }
1684
+ try {
1685
+ await (0, node_fs_promises.mkdir)(dist);
1686
+ await Promise.all(resolvedPaths.map(async ({ content, target }) => {
1687
+ const path = (0, node_path.join)(root, target.slice(8));
1688
+ await (0, node_fs_promises.mkdir)((0, node_path.join)(path, ".."), { recursive: true });
1689
+ await (0, node_fs_promises.writeFile)(path, content);
1690
+ }));
1691
+ const sourceEntries = resolvedPaths.filter(({ name }) => /\.(?:[cm]?[jt]sx?)$/.test(name)).map(({ target }) => (0, node_path.join)(root, target.slice(8)));
1692
+ if (sourceEntries.length) await (0, tsdown.build)({
1693
+ entry: sourceEntries,
1694
+ outDir: dist,
1695
+ format: ["esm", "cjs"],
1696
+ dts: false,
1697
+ sourcemap: false,
1698
+ unbundle: true,
1699
+ report: false,
1700
+ logLevel: "silent",
1701
+ fixedExtension: true
1702
+ });
1703
+ const builtEntries = await Promise.all((await Array.fromAsync((0, node_fs_promises.glob)("**/*", {
1704
+ cwd: dist,
1705
+ withFileTypes: true
1706
+ }))).filter((entry) => entry.isFile()).map(async (entry) => {
1707
+ const filePath = (0, node_path.join)(entry.parentPath, entry.name);
1708
+ return [`package/dist/${(0, node_path.relative)(dist, filePath).split(node_path.sep).join("/")}`, await (0, node_fs_promises.readFile)(filePath, "utf8")];
1709
+ }));
1710
+ const entries = {
1711
+ "package/package.json": JSON.stringify({
1712
+ ...packageInfo,
1713
+ type: "module",
1714
+ main: "./dist/index.cjs",
1715
+ module: "./dist/index.mjs",
1716
+ exports: { ".": {
1717
+ import: "./dist/index.mjs",
1718
+ require: "./dist/index.cjs"
1719
+ } }
1720
+ }, null, 2),
1721
+ ...Object.fromEntries(resolvedPaths.map(({ content, target }) => [target, content])),
1722
+ ...Object.fromEntries(builtEntries)
1723
+ };
1724
+ const chunks = [];
1725
+ for (const [name, content] of Object.entries(entries)) {
1726
+ const bytes = Buffer.from(content);
1727
+ chunks.push(header(name, bytes.length), bytes, Buffer.alloc((512 - bytes.length % 512) % 512));
1728
+ }
1729
+ const bytes = await gzipAsync(Buffer.concat([...chunks, Buffer.alloc(1024)]));
1730
+ return {
1731
+ bytes,
1732
+ integrity: `sha512-${(0, node_crypto.createHash)("sha512").update(bytes).digest("base64")}`
1733
+ };
1734
+ } finally {
1735
+ await (0, node_fs_promises.rm)(root, {
1736
+ recursive: true,
1737
+ force: true
1738
+ });
1739
+ }
1740
+ }
1741
+ //#endregion
1587
1742
  //#region src/ws.ts
1588
1743
  /**
1589
1744
  * How many generated files are read from storage at once when building the
@@ -1679,7 +1834,7 @@ function sendErrorMessage(ws$4, error, jobId) {
1679
1834
  /**
1680
1835
  * Forwards selected Kubb lifecycle events to Studio as data messages for the active session.
1681
1836
  */
1682
- function setupEventsStream(ws$5, hooks, jobId) {
1837
+ function setupEventsStream(ws$5, hooks, jobId, options = {}) {
1683
1838
  const unhooks = [];
1684
1839
  /**
1685
1840
  * Registers a listener and keeps its remover, so one generation's listeners come off the session
@@ -1793,11 +1948,12 @@ function setupEventsStream(ws$5, hooks, jobId) {
1793
1948
  if (content !== null) files[relativeStoragePath(config.root, path)] = content;
1794
1949
  }
1795
1950
  });
1951
+ options.onGenerationEnd?.(files);
1796
1952
  sendDataMessage({
1797
1953
  type: "kubb:generation:end",
1798
1954
  data: [{
1799
1955
  config,
1800
- storage: files,
1956
+ storage: options.skipStorage ? {} : files,
1801
1957
  peerDependencies,
1802
1958
  missingDependencies
1803
1959
  }]
@@ -1914,15 +2070,15 @@ function applyStudioDefaults(options) {
1914
2070
  * socket, its hook emitter, or its session id alive for the length of the retry interval.
1915
2071
  */
1916
2072
  function reconnect(options) {
1917
- const { signal, retryInterval, onTokenRejected } = options;
2073
+ const { signal, retryInterval, onTokenRejected, logLevel } = options;
1918
2074
  if (signal?.aborted) return;
1919
- console.info((0, node_util.styleText)("dim", `Retrying connection in ${retryInterval}ms to Kubb Studio ...`));
2075
+ if (logLevel !== void 0 && logLevel > _kubb_core.logLevel.silent) console.error((0, node_util.styleText)("dim", `Retrying connection in ${retryInterval}ms to Kubb Studio ...`));
1920
2076
  const cancel = () => clearTimeout(timer);
1921
2077
  const timer = setTimeout(() => {
1922
2078
  signal?.removeEventListener("abort", cancel);
1923
2079
  if (signal?.aborted) return;
1924
2080
  new StudioSession(options).connect().catch((error) => {
1925
- console.error((0, node_util.styleText)("red", `Reconnect attempt to Kubb Studio failed: ${getErrorMessage(error)}`));
2081
+ if (logLevel !== void 0 && logLevel > _kubb_core.logLevel.silent) console.error((0, node_util.styleText)("red", `Reconnect attempt to Kubb Studio failed: ${getErrorMessage(error)}`));
1926
2082
  if (error instanceof InvalidAgentTokenError) {
1927
2083
  onTokenRejected?.(error);
1928
2084
  return;
@@ -1957,6 +2113,7 @@ var StudioSession = class {
1957
2113
  #heartbeatTimer;
1958
2114
  #readyTimer;
1959
2115
  #lastPongAt = Date.now();
2116
+ #lastGeneration;
1960
2117
  constructor(options) {
1961
2118
  this.#options = applyStudioDefaults(options);
1962
2119
  }
@@ -2139,7 +2296,7 @@ var StudioSession = class {
2139
2296
  * `#disposed` keeps the close event from running this twice, and a shutdown from reconnecting.
2140
2297
  */
2141
2298
  async #end({ reason, retry }) {
2142
- const { studioUrl, token } = this.#options;
2299
+ const { studioUrl, token, logLevel } = this.#options;
2143
2300
  if (this.#disposed) return;
2144
2301
  this.#disposed = true;
2145
2302
  if (reason === "shutdown" && this.#ws) sendAgentMessage(this.#ws, {
@@ -2151,7 +2308,8 @@ var StudioSession = class {
2151
2308
  sessionId: this.#session.sessionId,
2152
2309
  studioUrl,
2153
2310
  token,
2154
- slug: this.#session.slug
2311
+ slug: this.#session.slug,
2312
+ logLevel
2155
2313
  }).catch(() => {});
2156
2314
  if (retry) reconnect(this.#options);
2157
2315
  }
@@ -2210,6 +2368,9 @@ var StudioSession = class {
2210
2368
  case "studio:save":
2211
2369
  await this.#handleSave(ws, data, command);
2212
2370
  return;
2371
+ case "studio:snapshot":
2372
+ await this.#handleSnapshot(ws, data, command);
2373
+ return;
2213
2374
  }
2214
2375
  }
2215
2376
  async #handleGenerate(ws, data, command) {
@@ -2233,7 +2394,13 @@ var StudioSession = class {
2233
2394
  await this.#warn(`Ignored the spec from Studio; set ${remedy} to generate from it`);
2234
2395
  }
2235
2396
  const resolvedPlugins = plugins ?? config.plugins;
2236
- const detach = [setupHookListener(this.#hooks, root), setupEventsStream(ws, this.#hooks, data.jobId)];
2397
+ let generatedFiles;
2398
+ const detach = [setupHookListener(this.#hooks, root), setupEventsStream(ws, this.#hooks, data.jobId, {
2399
+ skipStorage: client?.kind === "ci",
2400
+ onGenerationEnd: (files) => {
2401
+ generatedFiles = files;
2402
+ }
2403
+ })];
2237
2404
  try {
2238
2405
  await generate({
2239
2406
  config: {
@@ -2254,6 +2421,7 @@ var StudioSession = class {
2254
2421
  });
2255
2422
  } finally {
2256
2423
  for (const remove of detach) remove();
2424
+ this.#lastGeneration = generatedFiles;
2257
2425
  }
2258
2426
  await this.#hooks.callHook("studio:command:end", {
2259
2427
  command,
@@ -2322,6 +2490,68 @@ var StudioSession = class {
2322
2490
  refuse(getErrorMessage(error));
2323
2491
  }
2324
2492
  }
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, peerDependencies, 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
+ }
2513
+ const files = this.#lastGeneration;
2514
+ if (!files) {
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
+ }
2519
+ try {
2520
+ const { bytes, integrity } = await createSnapshotPackage(files, {
2521
+ name,
2522
+ version,
2523
+ peerDependencies: peerDependencies ?? {}
2524
+ });
2525
+ const { token, studioUrl } = this.#options;
2526
+ const redirect = await fetch(new URL(uploadPath, studioUrl), {
2527
+ method: "PUT",
2528
+ headers: { Authorization: `Bearer ${token}` },
2529
+ redirect: "manual"
2530
+ });
2531
+ const storageUrl = redirect.headers.get("location");
2532
+ if (redirect.status !== 307 || !storageUrl) throw new Error(`Studio did not provide a storage URL (status ${redirect.status})`);
2533
+ const response = await fetch(storageUrl, {
2534
+ method: "PUT",
2535
+ body: new Uint8Array(bytes)
2536
+ });
2537
+ if (!response.ok) throw new Error(`Snapshot upload failed with status ${response.status}`);
2538
+ sendAgentMessage(ws, {
2539
+ type: "agent:snapshot",
2540
+ jobId: data.jobId,
2541
+ payload: {
2542
+ status: "ok",
2543
+ integrity
2544
+ }
2545
+ });
2546
+ await this.#hooks.callHook("studio:command:end", {
2547
+ command,
2548
+ info: `packed ${Object.keys(files).length} file${Object.keys(files).length === 1 ? "" : "s"}`
2549
+ });
2550
+ } catch (error) {
2551
+ await this.#hooks.callHook("studio:error", { error: toError(error) });
2552
+ refuse(getErrorMessage(error));
2553
+ }
2554
+ }
2325
2555
  };
2326
2556
  //#endregion
2327
2557
  //#region src/client.ts