@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/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
@@ -197,6 +197,13 @@ type StudioSessionOptions = {
197
197
  * default to.
198
198
  */
199
199
  installLogger?: (hooks: Hookable<KubbHooks>) => void | Promise<void>;
200
+ /**
201
+ * Threshold for the reconnect loop's own `console.error` lines, using the numeric constants
202
+ * `@kubb/core` exports as `logLevel`. Left out, those lines never print, the same silent default
203
+ * as an unset `installLogger` — a reconnect happens outside any one session's hooks, so it has no
204
+ * other way to ask a host how loud to be.
205
+ */
206
+ logLevel?: number;
200
207
  /**
201
208
  * Called when this session's background reconnect is rejected with an invalid token. Unlike
202
209
  * `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
  /**
@@ -646,7 +650,7 @@ async function createAgent({ studioUrl, token, name, machineToken }) {
646
650
  }
647
651
  //#endregion
648
652
  //#region package.json
649
- var version = "5.3.2";
653
+ var version = "5.3.3";
650
654
  //#endregion
651
655
  //#region src/hooks.ts
652
656
  /**
@@ -1579,6 +1583,134 @@ async function generate({ config, hooks }) {
1579
1583
  }
1580
1584
  }
1581
1585
  //#endregion
1586
+ //#region src/snapshotPackage.ts
1587
+ const gzipAsync = promisify(gzip);
1588
+ /**
1589
+ * Maps a generated file's path to its place inside the tarball, stripping everything before a
1590
+ * `src`/`dist` segment and any `..`/empty path segment so a crafted file name cannot escape the
1591
+ * `package/` root.
1592
+ */
1593
+ function packagePath(filePath) {
1594
+ const normalized = filePath.replaceAll("\\", "/");
1595
+ return `package/${(normalized.match(/\/(?:src|dist)\/.*$/)?.[0].slice(1) ?? normalized.replace(/^\/+/, "")).split("/").filter((part) => part && part !== "." && part !== "..").join("/")}`;
1596
+ }
1597
+ /**
1598
+ * Splits a tarball entry path into the legacy 100-byte `name` field and, when the path does not
1599
+ * fit, the 155-byte USTAR `prefix` field that extends it. Throws rather than silently truncating
1600
+ * a path the format cannot address (max 256 bytes: 100 name + 1 separator + 155 prefix).
1601
+ */
1602
+ function splitEntryPath(path) {
1603
+ if (Buffer.byteLength(path, "utf8") <= 100) return {
1604
+ name: path,
1605
+ prefix: ""
1606
+ };
1607
+ for (let i = path.length - 1; i >= 0; i--) {
1608
+ if (path[i] !== "/") continue;
1609
+ const prefix = path.slice(0, i);
1610
+ const name = path.slice(i + 1);
1611
+ if (Buffer.byteLength(prefix, "utf8") <= 155 && Buffer.byteLength(name, "utf8") <= 100) return {
1612
+ name,
1613
+ prefix
1614
+ };
1615
+ }
1616
+ throw new Error(`Snapshot path is too long for a tar entry: ${path}`);
1617
+ }
1618
+ function header(path, size) {
1619
+ const { name, prefix } = splitEntryPath(path);
1620
+ const value = Buffer.alloc(512);
1621
+ value.write(name, 0, "utf8");
1622
+ value.write("0000644\0", 100, "ascii");
1623
+ value.write("0000000\0", 108, "ascii");
1624
+ value.write("0000000\0", 116, "ascii");
1625
+ value.write(`${size.toString(8).padStart(11, "0")}\0`, 124, "ascii");
1626
+ value.write(`${Math.floor(Date.now() / 1e3).toString(8).padStart(11, "0")}\0`, 136, "ascii");
1627
+ value.fill(32, 148, 156);
1628
+ value.write("0", 156, "ascii");
1629
+ value.write("ustar\0", 257, "ascii");
1630
+ value.write("00", 263, "ascii");
1631
+ value.write("0000000\0", 265, "ascii");
1632
+ value.write("0000000\0", 297, "ascii");
1633
+ value.write(prefix, 345, "utf8");
1634
+ const checksum = [...value].reduce((sum, byte) => sum + byte, 0);
1635
+ value.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, "ascii");
1636
+ return value;
1637
+ }
1638
+ /**
1639
+ * Packs a generation's files into a gzipped, npm-installable tarball: a `package/` root with the
1640
+ * generated sources, a `tsdown`-built `dist/` (esm + cjs), and a `package.json` manifest.
1641
+ */
1642
+ async function createSnapshotPackage(files, packageInfo) {
1643
+ const root = await mkdtemp(join(tmpdir(), "kubb-snapshot-"));
1644
+ const dist = join(root, "dist");
1645
+ const resolvedPaths = Object.entries(files).map(([name, content]) => ({
1646
+ name,
1647
+ content,
1648
+ target: packagePath(name)
1649
+ }));
1650
+ const targetOwners = /* @__PURE__ */ new Map();
1651
+ for (const { name, target } of resolvedPaths) {
1652
+ const owner = targetOwners.get(target);
1653
+ if (owner) throw new Error(`Snapshot has two generated files that sanitize to the same path "${target}": "${owner}" and "${name}"`);
1654
+ targetOwners.set(target, name);
1655
+ }
1656
+ try {
1657
+ await mkdir(dist);
1658
+ await Promise.all(resolvedPaths.map(async ({ content, target }) => {
1659
+ const path = join(root, target.slice(8));
1660
+ await mkdir(join(path, ".."), { recursive: true });
1661
+ await writeFile(path, content);
1662
+ }));
1663
+ const sourceEntries = resolvedPaths.filter(({ name }) => /\.(?:[cm]?[jt]sx?)$/.test(name)).map(({ target }) => join(root, target.slice(8)));
1664
+ if (sourceEntries.length) await build({
1665
+ entry: sourceEntries,
1666
+ outDir: dist,
1667
+ format: ["esm", "cjs"],
1668
+ dts: false,
1669
+ sourcemap: false,
1670
+ unbundle: true,
1671
+ report: false,
1672
+ logLevel: "silent",
1673
+ fixedExtension: true
1674
+ });
1675
+ const builtEntries = await Promise.all((await Array.fromAsync(glob("**/*", {
1676
+ cwd: dist,
1677
+ withFileTypes: true
1678
+ }))).filter((entry) => entry.isFile()).map(async (entry) => {
1679
+ const filePath = join(entry.parentPath, entry.name);
1680
+ return [`package/dist/${relative(dist, filePath).split(sep).join("/")}`, await readFile(filePath, "utf8")];
1681
+ }));
1682
+ const entries = {
1683
+ "package/package.json": JSON.stringify({
1684
+ ...packageInfo,
1685
+ type: "module",
1686
+ main: "./dist/index.cjs",
1687
+ module: "./dist/index.mjs",
1688
+ exports: { ".": {
1689
+ import: "./dist/index.mjs",
1690
+ require: "./dist/index.cjs"
1691
+ } }
1692
+ }, null, 2),
1693
+ ...Object.fromEntries(resolvedPaths.map(({ content, target }) => [target, content])),
1694
+ ...Object.fromEntries(builtEntries)
1695
+ };
1696
+ const chunks = [];
1697
+ for (const [name, content] of Object.entries(entries)) {
1698
+ const bytes = Buffer.from(content);
1699
+ chunks.push(header(name, bytes.length), bytes, Buffer.alloc((512 - bytes.length % 512) % 512));
1700
+ }
1701
+ const bytes = await gzipAsync(Buffer.concat([...chunks, Buffer.alloc(1024)]));
1702
+ return {
1703
+ bytes,
1704
+ integrity: `sha512-${createHash("sha512").update(bytes).digest("base64")}`
1705
+ };
1706
+ } finally {
1707
+ await rm(root, {
1708
+ recursive: true,
1709
+ force: true
1710
+ });
1711
+ }
1712
+ }
1713
+ //#endregion
1582
1714
  //#region src/ws.ts
1583
1715
  /**
1584
1716
  * How many generated files are read from storage at once when building the
@@ -1674,7 +1806,7 @@ function sendErrorMessage(ws, error, jobId) {
1674
1806
  /**
1675
1807
  * Forwards selected Kubb lifecycle events to Studio as data messages for the active session.
1676
1808
  */
1677
- function setupEventsStream(ws, hooks, jobId) {
1809
+ function setupEventsStream(ws, hooks, jobId, options = {}) {
1678
1810
  const unhooks = [];
1679
1811
  /**
1680
1812
  * Registers a listener and keeps its remover, so one generation's listeners come off the session
@@ -1788,11 +1920,12 @@ function setupEventsStream(ws, hooks, jobId) {
1788
1920
  if (content !== null) files[relativeStoragePath(config.root, path)] = content;
1789
1921
  }
1790
1922
  });
1923
+ options.onGenerationEnd?.(files);
1791
1924
  sendDataMessage({
1792
1925
  type: "kubb:generation:end",
1793
1926
  data: [{
1794
1927
  config,
1795
- storage: files,
1928
+ storage: options.skipStorage ? {} : files,
1796
1929
  peerDependencies,
1797
1930
  missingDependencies
1798
1931
  }]
@@ -1909,15 +2042,15 @@ function applyStudioDefaults(options) {
1909
2042
  * socket, its hook emitter, or its session id alive for the length of the retry interval.
1910
2043
  */
1911
2044
  function reconnect(options) {
1912
- const { signal, retryInterval, onTokenRejected } = options;
2045
+ const { signal, retryInterval, onTokenRejected, logLevel: logLevel$1 } = options;
1913
2046
  if (signal?.aborted) return;
1914
- console.info(styleText("dim", `Retrying connection in ${retryInterval}ms to Kubb Studio ...`));
2047
+ if (logLevel$1 !== void 0 && logLevel$1 > logLevel.silent) console.error(styleText("dim", `Retrying connection in ${retryInterval}ms to Kubb Studio ...`));
1915
2048
  const cancel = () => clearTimeout(timer);
1916
2049
  const timer = setTimeout(() => {
1917
2050
  signal?.removeEventListener("abort", cancel);
1918
2051
  if (signal?.aborted) return;
1919
2052
  new StudioSession(options).connect().catch((error) => {
1920
- console.error(styleText("red", `Reconnect attempt to Kubb Studio failed: ${getErrorMessage(error)}`));
2053
+ if (logLevel$1 !== void 0 && logLevel$1 > logLevel.silent) console.error(styleText("red", `Reconnect attempt to Kubb Studio failed: ${getErrorMessage(error)}`));
1921
2054
  if (error instanceof InvalidAgentTokenError) {
1922
2055
  onTokenRejected?.(error);
1923
2056
  return;
@@ -1952,6 +2085,7 @@ var StudioSession = class {
1952
2085
  #heartbeatTimer;
1953
2086
  #readyTimer;
1954
2087
  #lastPongAt = Date.now();
2088
+ #lastGeneration;
1955
2089
  constructor(options) {
1956
2090
  this.#options = applyStudioDefaults(options);
1957
2091
  }
@@ -2134,7 +2268,7 @@ var StudioSession = class {
2134
2268
  * `#disposed` keeps the close event from running this twice, and a shutdown from reconnecting.
2135
2269
  */
2136
2270
  async #end({ reason, retry }) {
2137
- const { studioUrl, token } = this.#options;
2271
+ const { studioUrl, token, logLevel } = this.#options;
2138
2272
  if (this.#disposed) return;
2139
2273
  this.#disposed = true;
2140
2274
  if (reason === "shutdown" && this.#ws) sendAgentMessage(this.#ws, {
@@ -2146,7 +2280,8 @@ var StudioSession = class {
2146
2280
  sessionId: this.#session.sessionId,
2147
2281
  studioUrl,
2148
2282
  token,
2149
- slug: this.#session.slug
2283
+ slug: this.#session.slug,
2284
+ logLevel
2150
2285
  }).catch(() => {});
2151
2286
  if (retry) reconnect(this.#options);
2152
2287
  }
@@ -2205,6 +2340,9 @@ var StudioSession = class {
2205
2340
  case "studio:save":
2206
2341
  await this.#handleSave(ws, data, command);
2207
2342
  return;
2343
+ case "studio:snapshot":
2344
+ await this.#handleSnapshot(ws, data, command);
2345
+ return;
2208
2346
  }
2209
2347
  }
2210
2348
  async #handleGenerate(ws, data, command) {
@@ -2228,7 +2366,13 @@ var StudioSession = class {
2228
2366
  await this.#warn(`Ignored the spec from Studio; set ${remedy} to generate from it`);
2229
2367
  }
2230
2368
  const resolvedPlugins = plugins ?? config.plugins;
2231
- const detach = [setupHookListener(this.#hooks, root), setupEventsStream(ws, this.#hooks, data.jobId)];
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
+ })];
2232
2376
  try {
2233
2377
  await generate({
2234
2378
  config: {
@@ -2249,6 +2393,7 @@ var StudioSession = class {
2249
2393
  });
2250
2394
  } finally {
2251
2395
  for (const remove of detach) remove();
2396
+ this.#lastGeneration = generatedFiles;
2252
2397
  }
2253
2398
  await this.#hooks.callHook("studio:command:end", {
2254
2399
  command,
@@ -2317,6 +2462,68 @@ var StudioSession = class {
2317
2462
  refuse(getErrorMessage(error));
2318
2463
  }
2319
2464
  }
2465
+ async #handleSnapshot(ws, data, command) {
2466
+ const refuse = (message) => sendAgentMessage(ws, {
2467
+ type: "agent:snapshot",
2468
+ jobId: data.jobId,
2469
+ payload: {
2470
+ status: "error",
2471
+ message
2472
+ }
2473
+ });
2474
+ if (this.#isSandbox) {
2475
+ await this.#warn("Ignored snapshot: a sandbox agent has no project to build a package from");
2476
+ refuse("a sandbox agent has no project to build a package from");
2477
+ return;
2478
+ }
2479
+ const { name, version, peerDependencies, uploadPath } = data.payload;
2480
+ if (!name || !version || !uploadPath) {
2481
+ await this.#warn("Ignored snapshot: the message was missing required fields");
2482
+ refuse("the message was missing required fields");
2483
+ return;
2484
+ }
2485
+ const files = this.#lastGeneration;
2486
+ if (!files) {
2487
+ await this.#warn("Ignored snapshot: no prior generation to pack");
2488
+ refuse("no prior generation exists to pack, run a generation first");
2489
+ return;
2490
+ }
2491
+ try {
2492
+ const { bytes, integrity } = await createSnapshotPackage(files, {
2493
+ name,
2494
+ version,
2495
+ peerDependencies: peerDependencies ?? {}
2496
+ });
2497
+ const { token, studioUrl } = this.#options;
2498
+ const redirect = await fetch(new URL(uploadPath, studioUrl), {
2499
+ method: "PUT",
2500
+ headers: { Authorization: `Bearer ${token}` },
2501
+ redirect: "manual"
2502
+ });
2503
+ const storageUrl = redirect.headers.get("location");
2504
+ if (redirect.status !== 307 || !storageUrl) throw new Error(`Studio did not provide a storage URL (status ${redirect.status})`);
2505
+ const response = await fetch(storageUrl, {
2506
+ method: "PUT",
2507
+ body: new Uint8Array(bytes)
2508
+ });
2509
+ if (!response.ok) throw new Error(`Snapshot upload failed with status ${response.status}`);
2510
+ sendAgentMessage(ws, {
2511
+ type: "agent:snapshot",
2512
+ jobId: data.jobId,
2513
+ payload: {
2514
+ status: "ok",
2515
+ integrity
2516
+ }
2517
+ });
2518
+ await this.#hooks.callHook("studio:command:end", {
2519
+ command,
2520
+ info: `packed ${Object.keys(files).length} file${Object.keys(files).length === 1 ? "" : "s"}`
2521
+ });
2522
+ } catch (error) {
2523
+ await this.#hooks.callHook("studio:error", { error: toError(error) });
2524
+ refuse(getErrorMessage(error));
2525
+ }
2526
+ }
2320
2527
  };
2321
2528
  //#endregion
2322
2529
  //#region src/client.ts