@kubb/studio 5.3.2 → 5.3.3

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
  /**
@@ -651,7 +655,7 @@ async function createAgent({ studioUrl, token, name, machineToken }) {
651
655
  }
652
656
  //#endregion
653
657
  //#region package.json
654
- var version = "5.3.2";
658
+ var version = "5.3.3";
655
659
  //#endregion
656
660
  //#region src/hooks.ts
657
661
  /**
@@ -1584,6 +1588,134 @@ async function generate({ config, hooks }) {
1584
1588
  }
1585
1589
  }
1586
1590
  //#endregion
1591
+ //#region src/snapshotPackage.ts
1592
+ const gzipAsync = (0, node_util.promisify)(node_zlib.gzip);
1593
+ /**
1594
+ * Maps a generated file's path to its place inside the tarball, stripping everything before a
1595
+ * `src`/`dist` segment and any `..`/empty path segment so a crafted file name cannot escape the
1596
+ * `package/` root.
1597
+ */
1598
+ function packagePath(filePath) {
1599
+ const normalized = filePath.replaceAll("\\", "/");
1600
+ return `package/${(normalized.match(/\/(?:src|dist)\/.*$/)?.[0].slice(1) ?? normalized.replace(/^\/+/, "")).split("/").filter((part) => part && part !== "." && part !== "..").join("/")}`;
1601
+ }
1602
+ /**
1603
+ * Splits a tarball entry path into the legacy 100-byte `name` field and, when the path does not
1604
+ * fit, the 155-byte USTAR `prefix` field that extends it. Throws rather than silently truncating
1605
+ * a path the format cannot address (max 256 bytes: 100 name + 1 separator + 155 prefix).
1606
+ */
1607
+ function splitEntryPath(path) {
1608
+ if (Buffer.byteLength(path, "utf8") <= 100) return {
1609
+ name: path,
1610
+ prefix: ""
1611
+ };
1612
+ for (let i = path.length - 1; i >= 0; i--) {
1613
+ if (path[i] !== "/") continue;
1614
+ const prefix = path.slice(0, i);
1615
+ const name = path.slice(i + 1);
1616
+ if (Buffer.byteLength(prefix, "utf8") <= 155 && Buffer.byteLength(name, "utf8") <= 100) return {
1617
+ name,
1618
+ prefix
1619
+ };
1620
+ }
1621
+ throw new Error(`Snapshot path is too long for a tar entry: ${path}`);
1622
+ }
1623
+ function header(path, size) {
1624
+ const { name, prefix } = splitEntryPath(path);
1625
+ const value = Buffer.alloc(512);
1626
+ value.write(name, 0, "utf8");
1627
+ value.write("0000644\0", 100, "ascii");
1628
+ value.write("0000000\0", 108, "ascii");
1629
+ value.write("0000000\0", 116, "ascii");
1630
+ value.write(`${size.toString(8).padStart(11, "0")}\0`, 124, "ascii");
1631
+ value.write(`${Math.floor(Date.now() / 1e3).toString(8).padStart(11, "0")}\0`, 136, "ascii");
1632
+ value.fill(32, 148, 156);
1633
+ value.write("0", 156, "ascii");
1634
+ value.write("ustar\0", 257, "ascii");
1635
+ value.write("00", 263, "ascii");
1636
+ value.write("0000000\0", 265, "ascii");
1637
+ value.write("0000000\0", 297, "ascii");
1638
+ value.write(prefix, 345, "utf8");
1639
+ const checksum = [...value].reduce((sum, byte) => sum + byte, 0);
1640
+ value.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, "ascii");
1641
+ return value;
1642
+ }
1643
+ /**
1644
+ * Packs a generation's files into a gzipped, npm-installable tarball: a `package/` root with the
1645
+ * generated sources, a `tsdown`-built `dist/` (esm + cjs), and a `package.json` manifest.
1646
+ */
1647
+ async function createSnapshotPackage(files, packageInfo) {
1648
+ const root = await (0, node_fs_promises.mkdtemp)((0, node_path.join)((0, node_os.tmpdir)(), "kubb-snapshot-"));
1649
+ const dist = (0, node_path.join)(root, "dist");
1650
+ const resolvedPaths = Object.entries(files).map(([name, content]) => ({
1651
+ name,
1652
+ content,
1653
+ target: packagePath(name)
1654
+ }));
1655
+ const targetOwners = /* @__PURE__ */ new Map();
1656
+ for (const { name, target } of resolvedPaths) {
1657
+ const owner = targetOwners.get(target);
1658
+ if (owner) throw new Error(`Snapshot has two generated files that sanitize to the same path "${target}": "${owner}" and "${name}"`);
1659
+ targetOwners.set(target, name);
1660
+ }
1661
+ try {
1662
+ await (0, node_fs_promises.mkdir)(dist);
1663
+ await Promise.all(resolvedPaths.map(async ({ content, target }) => {
1664
+ const path = (0, node_path.join)(root, target.slice(8));
1665
+ await (0, node_fs_promises.mkdir)((0, node_path.join)(path, ".."), { recursive: true });
1666
+ await (0, node_fs_promises.writeFile)(path, content);
1667
+ }));
1668
+ const sourceEntries = resolvedPaths.filter(({ name }) => /\.(?:[cm]?[jt]sx?)$/.test(name)).map(({ target }) => (0, node_path.join)(root, target.slice(8)));
1669
+ if (sourceEntries.length) await (0, tsdown.build)({
1670
+ entry: sourceEntries,
1671
+ outDir: dist,
1672
+ format: ["esm", "cjs"],
1673
+ dts: false,
1674
+ sourcemap: false,
1675
+ unbundle: true,
1676
+ report: false,
1677
+ logLevel: "silent",
1678
+ fixedExtension: true
1679
+ });
1680
+ const builtEntries = await Promise.all((await Array.fromAsync((0, node_fs_promises.glob)("**/*", {
1681
+ cwd: dist,
1682
+ withFileTypes: true
1683
+ }))).filter((entry) => entry.isFile()).map(async (entry) => {
1684
+ const filePath = (0, node_path.join)(entry.parentPath, entry.name);
1685
+ return [`package/dist/${(0, node_path.relative)(dist, filePath).split(node_path.sep).join("/")}`, await (0, node_fs_promises.readFile)(filePath, "utf8")];
1686
+ }));
1687
+ const entries = {
1688
+ "package/package.json": JSON.stringify({
1689
+ ...packageInfo,
1690
+ type: "module",
1691
+ main: "./dist/index.cjs",
1692
+ module: "./dist/index.mjs",
1693
+ exports: { ".": {
1694
+ import: "./dist/index.mjs",
1695
+ require: "./dist/index.cjs"
1696
+ } }
1697
+ }, null, 2),
1698
+ ...Object.fromEntries(resolvedPaths.map(({ content, target }) => [target, content])),
1699
+ ...Object.fromEntries(builtEntries)
1700
+ };
1701
+ const chunks = [];
1702
+ for (const [name, content] of Object.entries(entries)) {
1703
+ const bytes = Buffer.from(content);
1704
+ chunks.push(header(name, bytes.length), bytes, Buffer.alloc((512 - bytes.length % 512) % 512));
1705
+ }
1706
+ const bytes = await gzipAsync(Buffer.concat([...chunks, Buffer.alloc(1024)]));
1707
+ return {
1708
+ bytes,
1709
+ integrity: `sha512-${(0, node_crypto.createHash)("sha512").update(bytes).digest("base64")}`
1710
+ };
1711
+ } finally {
1712
+ await (0, node_fs_promises.rm)(root, {
1713
+ recursive: true,
1714
+ force: true
1715
+ });
1716
+ }
1717
+ }
1718
+ //#endregion
1587
1719
  //#region src/ws.ts
1588
1720
  /**
1589
1721
  * How many generated files are read from storage at once when building the
@@ -1679,7 +1811,7 @@ function sendErrorMessage(ws$4, error, jobId) {
1679
1811
  /**
1680
1812
  * Forwards selected Kubb lifecycle events to Studio as data messages for the active session.
1681
1813
  */
1682
- function setupEventsStream(ws$5, hooks, jobId) {
1814
+ function setupEventsStream(ws$5, hooks, jobId, options = {}) {
1683
1815
  const unhooks = [];
1684
1816
  /**
1685
1817
  * Registers a listener and keeps its remover, so one generation's listeners come off the session
@@ -1793,11 +1925,12 @@ function setupEventsStream(ws$5, hooks, jobId) {
1793
1925
  if (content !== null) files[relativeStoragePath(config.root, path)] = content;
1794
1926
  }
1795
1927
  });
1928
+ options.onGenerationEnd?.(files);
1796
1929
  sendDataMessage({
1797
1930
  type: "kubb:generation:end",
1798
1931
  data: [{
1799
1932
  config,
1800
- storage: files,
1933
+ storage: options.skipStorage ? {} : files,
1801
1934
  peerDependencies,
1802
1935
  missingDependencies
1803
1936
  }]
@@ -1914,15 +2047,15 @@ function applyStudioDefaults(options) {
1914
2047
  * socket, its hook emitter, or its session id alive for the length of the retry interval.
1915
2048
  */
1916
2049
  function reconnect(options) {
1917
- const { signal, retryInterval, onTokenRejected } = options;
2050
+ const { signal, retryInterval, onTokenRejected, logLevel } = options;
1918
2051
  if (signal?.aborted) return;
1919
- console.info((0, node_util.styleText)("dim", `Retrying connection in ${retryInterval}ms to Kubb Studio ...`));
2052
+ 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
2053
  const cancel = () => clearTimeout(timer);
1921
2054
  const timer = setTimeout(() => {
1922
2055
  signal?.removeEventListener("abort", cancel);
1923
2056
  if (signal?.aborted) return;
1924
2057
  new StudioSession(options).connect().catch((error) => {
1925
- console.error((0, node_util.styleText)("red", `Reconnect attempt to Kubb Studio failed: ${getErrorMessage(error)}`));
2058
+ 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
2059
  if (error instanceof InvalidAgentTokenError) {
1927
2060
  onTokenRejected?.(error);
1928
2061
  return;
@@ -1957,6 +2090,7 @@ var StudioSession = class {
1957
2090
  #heartbeatTimer;
1958
2091
  #readyTimer;
1959
2092
  #lastPongAt = Date.now();
2093
+ #lastGeneration;
1960
2094
  constructor(options) {
1961
2095
  this.#options = applyStudioDefaults(options);
1962
2096
  }
@@ -2139,7 +2273,7 @@ var StudioSession = class {
2139
2273
  * `#disposed` keeps the close event from running this twice, and a shutdown from reconnecting.
2140
2274
  */
2141
2275
  async #end({ reason, retry }) {
2142
- const { studioUrl, token } = this.#options;
2276
+ const { studioUrl, token, logLevel } = this.#options;
2143
2277
  if (this.#disposed) return;
2144
2278
  this.#disposed = true;
2145
2279
  if (reason === "shutdown" && this.#ws) sendAgentMessage(this.#ws, {
@@ -2151,7 +2285,8 @@ var StudioSession = class {
2151
2285
  sessionId: this.#session.sessionId,
2152
2286
  studioUrl,
2153
2287
  token,
2154
- slug: this.#session.slug
2288
+ slug: this.#session.slug,
2289
+ logLevel
2155
2290
  }).catch(() => {});
2156
2291
  if (retry) reconnect(this.#options);
2157
2292
  }
@@ -2210,6 +2345,9 @@ var StudioSession = class {
2210
2345
  case "studio:save":
2211
2346
  await this.#handleSave(ws, data, command);
2212
2347
  return;
2348
+ case "studio:snapshot":
2349
+ await this.#handleSnapshot(ws, data, command);
2350
+ return;
2213
2351
  }
2214
2352
  }
2215
2353
  async #handleGenerate(ws, data, command) {
@@ -2233,7 +2371,13 @@ var StudioSession = class {
2233
2371
  await this.#warn(`Ignored the spec from Studio; set ${remedy} to generate from it`);
2234
2372
  }
2235
2373
  const resolvedPlugins = plugins ?? config.plugins;
2236
- const detach = [setupHookListener(this.#hooks, root), setupEventsStream(ws, this.#hooks, data.jobId)];
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
+ })];
2237
2381
  try {
2238
2382
  await generate({
2239
2383
  config: {
@@ -2254,6 +2398,7 @@ var StudioSession = class {
2254
2398
  });
2255
2399
  } finally {
2256
2400
  for (const remove of detach) remove();
2401
+ this.#lastGeneration = generatedFiles;
2257
2402
  }
2258
2403
  await this.#hooks.callHook("studio:command:end", {
2259
2404
  command,
@@ -2322,6 +2467,68 @@ var StudioSession = class {
2322
2467
  refuse(getErrorMessage(error));
2323
2468
  }
2324
2469
  }
2470
+ async #handleSnapshot(ws, data, command) {
2471
+ const refuse = (message) => sendAgentMessage(ws, {
2472
+ type: "agent:snapshot",
2473
+ jobId: data.jobId,
2474
+ payload: {
2475
+ status: "error",
2476
+ message
2477
+ }
2478
+ });
2479
+ if (this.#isSandbox) {
2480
+ await this.#warn("Ignored snapshot: a sandbox agent has no project to build a package from");
2481
+ refuse("a sandbox agent has no project to build a package from");
2482
+ return;
2483
+ }
2484
+ const { name, version, peerDependencies, uploadPath } = data.payload;
2485
+ if (!name || !version || !uploadPath) {
2486
+ await this.#warn("Ignored snapshot: the message was missing required fields");
2487
+ refuse("the message was missing required fields");
2488
+ return;
2489
+ }
2490
+ const files = this.#lastGeneration;
2491
+ if (!files) {
2492
+ await this.#warn("Ignored snapshot: no prior generation to pack");
2493
+ refuse("no prior generation exists to pack, run a generation first");
2494
+ return;
2495
+ }
2496
+ try {
2497
+ const { bytes, integrity } = await createSnapshotPackage(files, {
2498
+ name,
2499
+ version,
2500
+ peerDependencies: peerDependencies ?? {}
2501
+ });
2502
+ const { token, studioUrl } = this.#options;
2503
+ const redirect = await fetch(new URL(uploadPath, studioUrl), {
2504
+ method: "PUT",
2505
+ headers: { Authorization: `Bearer ${token}` },
2506
+ redirect: "manual"
2507
+ });
2508
+ const storageUrl = redirect.headers.get("location");
2509
+ if (redirect.status !== 307 || !storageUrl) throw new Error(`Studio did not provide a storage URL (status ${redirect.status})`);
2510
+ const response = await fetch(storageUrl, {
2511
+ method: "PUT",
2512
+ body: new Uint8Array(bytes)
2513
+ });
2514
+ if (!response.ok) throw new Error(`Snapshot upload failed with status ${response.status}`);
2515
+ sendAgentMessage(ws, {
2516
+ type: "agent:snapshot",
2517
+ jobId: data.jobId,
2518
+ payload: {
2519
+ status: "ok",
2520
+ integrity
2521
+ }
2522
+ });
2523
+ await this.#hooks.callHook("studio:command:end", {
2524
+ command,
2525
+ info: `packed ${Object.keys(files).length} file${Object.keys(files).length === 1 ? "" : "s"}`
2526
+ });
2527
+ } catch (error) {
2528
+ await this.#hooks.callHook("studio:error", { error: toError(error) });
2529
+ refuse(getErrorMessage(error));
2530
+ }
2531
+ }
2325
2532
  };
2326
2533
  //#endregion
2327
2534
  //#region src/client.ts