@qoretechnologies/qlip 0.1.3-pr.31.g4b62f97 → 0.1.3-pr.32.g3ecd7a4

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
@@ -317,6 +317,10 @@ Several `vitest run` invocations (or `test-storybook` shards) can capture into o
317
317
 
318
318
  Add `--fail-on-partial-build` to `qlip-upload` (or `upload.failOnPartialBuild` on the plugin) to turn "screenshots on disk that never reached the manifest" from a warning into a red build.
319
319
 
320
+ ### Large builds: processing timeout
321
+
322
+ The server diffs the whole build before it answers the final upload request, which takes minutes for a large Storybook (about 8–12 minutes for ~2,200 snapshots). `qlip-upload` waits up to 30 minutes for that answer; raise it with `--processing-timeout <seconds>` (or `$QLIP_PROCESSING_TIMEOUT`, or `upload.processingTimeoutMs` on the plugin). A timeout or network failure is reported with the failing phase and its cause; add `--fail-on-upload-error` (or `upload.failOnUploadError`) to make it fail the CI step instead of logging it.
323
+
320
324
  ## Manifest
321
325
 
322
326
  Each run writes `manifest.json` inside the build folder with tool metadata, defaults, stats, and per-screenshot entries (auto + manual).
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { qlipVitestPlugin } from './plugin/vitestPlugin.js';
2
2
  export { screenshot } from './runtime/screenshot.js';
3
- export { uploadBuild, QlipUploadError, type QlipUploadInput, type QlipUploadResult, } from './upload/upload.js';
3
+ export { uploadBuild, QlipUploadError, QlipUploadNetworkError, DEFAULT_PROCESSING_TIMEOUT_MS, type QlipUploadInput, type QlipUploadResult, } from './upload/upload.js';
4
4
  export { autodetectBranch, autodetectCommit, autodetectBaseBranch, autodetectAncestorCommits, } from './upload/autodetect.js';
5
5
  export type { QlipConsoleLevel, QlipConsoleMessage, QlipManifest, QlipManifestEntry, QlipParameters, QlipPluginOptions, QlipResolvedDefaults, QlipScreenshotOptions, QlipUploadOptions, QlipViewport, } from './types.js';
6
6
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAC5D,OAAO,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AACrD,OAAO,EACL,WAAW,EACX,eAAe,EACf,KAAK,eAAe,EACpB,KAAK,gBAAgB,GACtB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,gBAAgB,EAChB,gBAAgB,EAChB,oBAAoB,EACpB,yBAAyB,GAC1B,MAAM,wBAAwB,CAAC;AAChC,YAAY,EACV,gBAAgB,EAChB,kBAAkB,EAClB,YAAY,EACZ,iBAAiB,EACjB,cAAc,EACd,iBAAiB,EACjB,oBAAoB,EACpB,qBAAqB,EACrB,iBAAiB,EACjB,YAAY,GACb,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAC5D,OAAO,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AACrD,OAAO,EACL,WAAW,EACX,eAAe,EACf,sBAAsB,EACtB,6BAA6B,EAC7B,KAAK,eAAe,EACpB,KAAK,gBAAgB,GACtB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,gBAAgB,EAChB,gBAAgB,EAChB,oBAAoB,EACpB,yBAAyB,GAC1B,MAAM,wBAAwB,CAAC;AAChC,YAAY,EACV,gBAAgB,EAChB,kBAAkB,EAClB,YAAY,EACZ,iBAAiB,EACjB,cAAc,EACd,iBAAiB,EACjB,oBAAoB,EACpB,qBAAqB,EACrB,iBAAiB,EACjB,YAAY,GACb,MAAM,YAAY,CAAC"}
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export { qlipVitestPlugin } from './plugin/vitestPlugin.js';
2
2
  export { screenshot } from './runtime/screenshot.js';
3
- export { uploadBuild, QlipUploadError, } from './upload/upload.js';
3
+ export { uploadBuild, QlipUploadError, QlipUploadNetworkError, DEFAULT_PROCESSING_TIMEOUT_MS, } from './upload/upload.js';
4
4
  export { autodetectBranch, autodetectCommit, autodetectBaseBranch, autodetectAncestorCommits, } from './upload/autodetect.js';
@@ -1 +1 @@
1
- {"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../../../src/test-runner/cli/upload.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAoIH,MAAM,WAAW,gBAAgB;IAC/B,+CAA+C;IAC/C,QAAQ,EAAE,MAAM,CAAC;IACjB,uEAAuE;IACvE,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;GAIG;AACH,eAAO,MAAM,SAAS,GACpB,MAAM,MAAM,EAAE,EACd,MAAK,MAAM,CAAC,UAAwB,EACpC,SAAQ;IAAE,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAAC,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAAY,KAC7E,OAAO,CAAC,gBAAgB,CAsE1B,CAAC"}
1
+ {"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../../../src/test-runner/cli/upload.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AA2IH,MAAM,WAAW,gBAAgB;IAC/B,+CAA+C;IAC/C,QAAQ,EAAE,MAAM,CAAC;IACjB,uEAAuE;IACvE,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;GAIG;AACH,eAAO,MAAM,SAAS,GACpB,MAAM,MAAM,EAAE,EACd,MAAK,MAAM,CAAC,UAAwB,EACpC,SAAQ;IAAE,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAAC,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAAY,KAC7E,OAAO,CAAC,gBAAgB,CAqF1B,CAAC"}
@@ -52,6 +52,9 @@ Options:
52
52
  --token <token> Bearer token. Default: \$QLIP_UPLOAD_TOKEN.
53
53
  --branch <branch> Override branch detection.
54
54
  --commit <sha> Override commit detection.
55
+ --processing-timeout <s> Seconds to wait for the server to process
56
+ the build. Default:
57
+ \$QLIP_PROCESSING_TIMEOUT or 1800.
55
58
  --fail-on-upload-error Exit non-zero on upload failure (default:
56
59
  log error + exit 0).
57
60
  --fail-on-partial-build Exit non-zero when screenshots on disk are
@@ -95,6 +98,9 @@ const parseArgs = (argv) => {
95
98
  case '--commit':
96
99
  args.commit = next();
97
100
  break;
101
+ case '--processing-timeout':
102
+ args.processingTimeout = next();
103
+ break;
98
104
  case '--fail-on-upload-error':
99
105
  args.failOnUploadError = true;
100
106
  break;
@@ -172,6 +178,16 @@ export const runUpload = async (argv, env = process.env, logger = console) => {
172
178
  logger.error(`qlip-upload: ${err.message}`);
173
179
  return { exitCode: 1 };
174
180
  }
181
+ const processingTimeout = parsed.processingTimeout ?? env['QLIP_PROCESSING_TIMEOUT'];
182
+ let processingTimeoutMs;
183
+ if (processingTimeout !== undefined && processingTimeout !== '') {
184
+ const seconds = Number(processingTimeout);
185
+ if (!Number.isFinite(seconds) || seconds <= 0) {
186
+ logger.error(`qlip-upload: --processing-timeout must be a positive number of seconds, got "${processingTimeout}"`);
187
+ return { exitCode: 1 };
188
+ }
189
+ processingTimeoutMs = seconds * 1000;
190
+ }
175
191
  const project = parsed.project ?? env['QLIP_PROJECT'] ?? 'default';
176
192
  const token = parsed.token ?? env['QLIP_UPLOAD_TOKEN'];
177
193
  const upload = {
@@ -180,6 +196,7 @@ export const runUpload = async (argv, env = process.env, logger = console) => {
180
196
  ...(token !== undefined && token !== '' ? { uploadToken: token } : {}),
181
197
  ...(parsed.branch !== undefined ? { branch: parsed.branch } : {}),
182
198
  ...(parsed.commit !== undefined ? { commit: parsed.commit } : {}),
199
+ ...(processingTimeoutMs !== undefined ? { processingTimeoutMs } : {}),
183
200
  failOnUploadError: parsed.failOnUploadError,
184
201
  failOnPartialBuild: parsed.failOnPartialBuild,
185
202
  };
package/dist/types.d.ts CHANGED
@@ -170,6 +170,12 @@ export interface QlipUploadOptions {
170
170
  * Set to `true` in CI when you want red builds on upload errors.
171
171
  */
172
172
  failOnUploadError?: boolean;
173
+ /**
174
+ * How long to wait for the server to answer the requests it answers only
175
+ * after processing the build (finalize, or the legacy upload), in ms.
176
+ * Default: 30 minutes. See `design/UPLOAD.md` "Long-running requests".
177
+ */
178
+ processingTimeoutMs?: number;
173
179
  /**
174
180
  * Whether a partial build should fail the run. A build is partial
175
181
  * when PNGs exist on disk that no manifest entry references — i.e.
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,15 @@
1
+ /**
2
+ * HTTP transport for the upload requests qlip-server answers only once it
3
+ * has processed the whole build: `finalize` and the legacy multipart POST.
4
+ *
5
+ * Node's global `fetch` (undici) fails any request whose response headers
6
+ * take longer than 300 s (`headersTimeout`) with a bare "fetch failed", and
7
+ * the limit can't be raised without depending on the `undici` package. A
8
+ * large build's diff runs longer than that, so these requests go through
9
+ * `node:http`, which imposes no response deadline: the caller's `signal` is
10
+ * the only one. See design/UPLOAD.md "Long-running requests".
11
+ */
12
+ /** The subset of `fetch` the upload client uses; `fetch` itself satisfies it. */
13
+ export type TFetchLike = (url: string, init: RequestInit) => Promise<Response>;
14
+ export declare const fetchAwaitingProcessing: TFetchLike;
15
+ //# sourceMappingURL=processing-request.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"processing-request.d.ts","sourceRoot":"","sources":["../../src/upload/processing-request.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAKH,iFAAiF;AACjF,MAAM,MAAM,UAAU,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;AAqC/E,eAAO,MAAM,uBAAuB,EAAE,UAsDrC,CAAC"}
@@ -0,0 +1,89 @@
1
+ /**
2
+ * HTTP transport for the upload requests qlip-server answers only once it
3
+ * has processed the whole build: `finalize` and the legacy multipart POST.
4
+ *
5
+ * Node's global `fetch` (undici) fails any request whose response headers
6
+ * take longer than 300 s (`headersTimeout`) with a bare "fetch failed", and
7
+ * the limit can't be raised without depending on the `undici` package. A
8
+ * large build's diff runs longer than that, so these requests go through
9
+ * `node:http`, which imposes no response deadline: the caller's `signal` is
10
+ * the only one. See design/UPLOAD.md "Long-running requests".
11
+ */
12
+ import http from 'node:http';
13
+ import https from 'node:https';
14
+ const NULL_BODY_STATUSES = new Set([204, 205, 304]);
15
+ const encodeBody = async (body) => {
16
+ if (body === undefined || body === null) {
17
+ return { bytes: new Uint8Array(0), contentType: null };
18
+ }
19
+ // Response serializes every BodyInit (FormData gets its multipart boundary).
20
+ const encoded = new Response(body);
21
+ return {
22
+ bytes: new Uint8Array(await encoded.arrayBuffer()),
23
+ contentType: encoded.headers.get('content-type'),
24
+ };
25
+ };
26
+ const toResponse = (res, body) => {
27
+ const headers = new Headers();
28
+ for (const [name, value] of Object.entries(res.headers)) {
29
+ if (value === undefined)
30
+ continue;
31
+ for (const item of Array.isArray(value) ? value : [value]) {
32
+ headers.append(name, item);
33
+ }
34
+ }
35
+ const status = res.statusCode ?? 0;
36
+ return new Response(NULL_BODY_STATUSES.has(status) ? null : new Uint8Array(body), {
37
+ status,
38
+ statusText: res.statusMessage ?? '',
39
+ headers,
40
+ });
41
+ };
42
+ export const fetchAwaitingProcessing = async (url, init) => {
43
+ const target = new URL(url);
44
+ const transport = target.protocol === 'https:'
45
+ ? https
46
+ : target.protocol === 'http:'
47
+ ? http
48
+ : undefined;
49
+ if (transport === undefined) {
50
+ throw new TypeError(`unsupported protocol: ${target.protocol}`);
51
+ }
52
+ const { bytes, contentType } = await encodeBody(init.body);
53
+ const headers = {};
54
+ if (contentType !== null)
55
+ headers['content-type'] = contentType;
56
+ new Headers(init.headers).forEach((value, name) => {
57
+ headers[name] = value;
58
+ });
59
+ headers['content-length'] = String(bytes.byteLength);
60
+ return new Promise((resolve, reject) => {
61
+ const req = transport.request(target, {
62
+ method: init.method ?? 'GET',
63
+ headers,
64
+ ...(init.signal ? { signal: init.signal } : {}),
65
+ }, (res) => {
66
+ const chunks = [];
67
+ res.on('data', (chunk) => {
68
+ chunks.push(chunk);
69
+ });
70
+ res.on('error', reject);
71
+ // A connection dropped mid-body ends the stream without `end`.
72
+ res.on('close', () => {
73
+ if (!res.complete) {
74
+ reject(new Error('connection closed before the response completed'));
75
+ }
76
+ });
77
+ res.on('end', () => {
78
+ try {
79
+ resolve(toResponse(res, Buffer.concat(chunks)));
80
+ }
81
+ catch (err) {
82
+ reject(err);
83
+ }
84
+ });
85
+ });
86
+ req.on('error', reject);
87
+ req.end(bytes);
88
+ });
89
+ };
@@ -3,6 +3,7 @@
3
3
  * Wire contract: `qlip-server/design/UPLOAD.md §0`.
4
4
  */
5
5
  import type { QlipUploadOptions } from '../types.js';
6
+ import type { TFetchLike } from './processing-request.js';
6
7
  export interface QlipUploadResult {
7
8
  /** Build ID echoed by the server (matches the manifest's buildId). */
8
9
  buildId: string;
@@ -20,13 +21,24 @@ export interface QlipUploadInput {
20
21
  buildDir: string;
21
22
  /** Resolved upload options (branch/commit already auto-detected). */
22
23
  options: QlipUploadOptions;
23
- /** Override for tests. Defaults to global fetch. */
24
- fetchImpl?: typeof fetch;
24
+ /**
25
+ * Override for tests. Defaults to global fetch for create + blobs, and to
26
+ * a `node:http` transport for the finalize / legacy requests the server
27
+ * answers only after processing (see `processing-request.ts`). An
28
+ * override is used for every phase, so its own response timeouts apply.
29
+ */
30
+ fetchImpl?: TFetchLike;
25
31
  /** Base backoff (ms) between blob-PUT retries; test seam, attempt N waits N×. */
26
32
  retryBaseDelayMs?: number;
27
33
  }
28
34
  /** Default server when `upload.serverUrl` is omitted: the hosted instance. */
29
35
  export declare const DEFAULT_SERVER_URL = "https://qlip.qoretechnologies.com";
36
+ /**
37
+ * Default deadline for the finalize / legacy response. qorus-ide builds of
38
+ * ~2.1k snapshots measured 470–713 s of server processing (2026-10, see
39
+ * design/UPLOAD.md "Long-running requests"); 30 min leaves 2.5× headroom.
40
+ */
41
+ export declare const DEFAULT_PROCESSING_TIMEOUT_MS: number;
30
42
  export type TUploadPhase = 'create' | 'blob' | 'finalize' | 'legacy';
31
43
  /**
32
44
  * Resolve the effective server base URL (default applied, trailing slash
@@ -39,5 +51,15 @@ export declare class QlipUploadError extends Error {
39
51
  readonly phase: TUploadPhase;
40
52
  constructor(status: number, statusText: string, body: string, phase?: TUploadPhase);
41
53
  }
54
+ /**
55
+ * The request never produced an HTTP response: a network error, or no
56
+ * response before the client's deadline. `status`-less, unlike
57
+ * `QlipUploadError`.
58
+ */
59
+ export declare class QlipUploadNetworkError extends Error {
60
+ readonly phase: TUploadPhase;
61
+ readonly cause: unknown;
62
+ constructor(phase: TUploadPhase, cause: unknown, detail?: string);
63
+ }
42
64
  export declare const uploadBuild: (input: QlipUploadInput) => Promise<QlipUploadResult>;
43
65
  //# sourceMappingURL=upload.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../../src/upload/upload.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAIH,OAAO,KAAK,EAAgB,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAGnE,MAAM,WAAW,gBAAgB;IAC/B,sEAAsE;IACtE,OAAO,EAAE,MAAM,CAAC;IAChB,uEAAuE;IACvE,MAAM,EAAE,MAAM,CAAC;IACf,wCAAwC;IACxC,QAAQ,EAAE,IAAI,GAAG,QAAQ,CAAC;IAC1B,wEAAwE;IACxE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,kEAAkE;IAClE,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,eAAe;IAC9B,qEAAqE;IACrE,QAAQ,EAAE,MAAM,CAAC;IACjB,qEAAqE;IACrE,OAAO,EAAE,iBAAiB,CAAC;IAC3B,oDAAoD;IACpD,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,iFAAiF;IACjF,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,8EAA8E;AAC9E,eAAO,MAAM,kBAAkB,sCAAsC,CAAC;AAOtE,MAAM,MAAM,YAAY,GAAG,QAAQ,GAAG,MAAM,GAAG,UAAU,GAAG,QAAQ,CAAC;AAErE;;;GAGG;AACH,eAAO,MAAM,gBAAgB,GAC3B,SAAS,IAAI,CAAC,iBAAiB,EAAE,WAAW,CAAC,KAC5C,MAAsE,CAAC;AAE1E,qBAAa,eAAgB,SAAQ,KAAK;IACxC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAC;gBAG3B,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,EACZ,KAAK,GAAE,YAAuB;CAUjC;AA6JD,eAAO,MAAM,WAAW,GACtB,OAAO,eAAe,KACrB,OAAO,CAAC,gBAAgB,CAkG1B,CAAC"}
1
+ {"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../../src/upload/upload.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAIH,OAAO,KAAK,EAAgB,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAGnE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAE1D,MAAM,WAAW,gBAAgB;IAC/B,sEAAsE;IACtE,OAAO,EAAE,MAAM,CAAC;IAChB,uEAAuE;IACvE,MAAM,EAAE,MAAM,CAAC;IACf,wCAAwC;IACxC,QAAQ,EAAE,IAAI,GAAG,QAAQ,CAAC;IAC1B,wEAAwE;IACxE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,kEAAkE;IAClE,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,eAAe;IAC9B,qEAAqE;IACrE,QAAQ,EAAE,MAAM,CAAC;IACjB,qEAAqE;IACrE,OAAO,EAAE,iBAAiB,CAAC;IAC3B;;;;;OAKG;IACH,SAAS,CAAC,EAAE,UAAU,CAAC;IACvB,iFAAiF;IACjF,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,8EAA8E;AAC9E,eAAO,MAAM,kBAAkB,sCAAsC,CAAC;AAMtE;;;;GAIG;AACH,eAAO,MAAM,6BAA6B,QAAc,CAAC;AAEzD,MAAM,MAAM,YAAY,GAAG,QAAQ,GAAG,MAAM,GAAG,UAAU,GAAG,QAAQ,CAAC;AAErE;;;GAGG;AACH,eAAO,MAAM,gBAAgB,GAC3B,SAAS,IAAI,CAAC,iBAAiB,EAAE,WAAW,CAAC,KAC5C,MAAsE,CAAC;AAE1E,qBAAa,eAAgB,SAAQ,KAAK;IACxC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAC;gBAG3B,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,EACZ,KAAK,GAAE,YAAuB;CAUjC;AA4BD;;;;GAIG;AACH,qBAAa,sBAAuB,SAAQ,KAAK;IAC/C,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAC;IAC7B,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;gBAEZ,KAAK,EAAE,YAAY,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,MAAM;CAQjE;AAiND,eAAO,MAAM,WAAW,GACtB,OAAO,eAAe,KACrB,OAAO,CAAC,gBAAgB,CAgH1B,CAAC"}
@@ -5,12 +5,19 @@
5
5
  import { readFile } from 'node:fs/promises';
6
6
  import path from 'node:path';
7
7
  import { hashFile } from './hash.js';
8
+ import { fetchAwaitingProcessing } from './processing-request.js';
8
9
  /** Default server when `upload.serverUrl` is omitted: the hosted instance. */
9
10
  export const DEFAULT_SERVER_URL = 'https://qlip.qoretechnologies.com';
10
11
  const TRANSFER_CONCURRENCY = 10;
11
12
  const BLOB_RETRIES = 2;
12
13
  const BLOB_TIMEOUT_MS = 30000;
13
14
  const DEFAULT_RETRY_BASE_DELAY_MS = 500;
15
+ /**
16
+ * Default deadline for the finalize / legacy response. qorus-ide builds of
17
+ * ~2.1k snapshots measured 470–713 s of server processing (2026-10, see
18
+ * design/UPLOAD.md "Long-running requests"); 30 min leaves 2.5× headroom.
19
+ */
20
+ export const DEFAULT_PROCESSING_TIMEOUT_MS = 30 * 60000;
14
21
  /**
15
22
  * Resolve the effective server base URL (default applied, trailing slash
16
23
  * stripped). Exported so log lines report the URL the upload actually hits.
@@ -25,6 +32,76 @@ export class QlipUploadError extends Error {
25
32
  this.phase = phase;
26
33
  }
27
34
  }
35
+ /**
36
+ * `fetch failed` alone hides why: the reason (a timeout, a reset, a DNS
37
+ * failure) lives on the `cause` chain, so the message spells that out.
38
+ */
39
+ const describeCause = (err) => {
40
+ const parts = [];
41
+ const seen = new Set();
42
+ let current = err;
43
+ while (current !== undefined && current !== null && !seen.has(current)) {
44
+ seen.add(current);
45
+ if (current instanceof Error) {
46
+ const code = current.code;
47
+ parts.push(typeof code === 'string' && code !== ''
48
+ ? `${current.message} (${code})`
49
+ : current.message);
50
+ current = current.cause;
51
+ }
52
+ else {
53
+ parts.push(String(current));
54
+ break;
55
+ }
56
+ }
57
+ return parts.join(': ');
58
+ };
59
+ /**
60
+ * The request never produced an HTTP response: a network error, or no
61
+ * response before the client's deadline. `status`-less, unlike
62
+ * `QlipUploadError`.
63
+ */
64
+ export class QlipUploadNetworkError extends Error {
65
+ constructor(phase, cause, detail) {
66
+ super(`qlip upload failed [${phase}]: ${detail ?? `network error — ${describeCause(cause)}`}`);
67
+ this.name = 'QlipUploadNetworkError';
68
+ this.phase = phase;
69
+ this.cause = cause;
70
+ }
71
+ }
72
+ /** `fetchImpl`, with a transport failure reported as a phase-tagged error. */
73
+ const withPhase = (phase, fetchImpl) => async (url, init) => {
74
+ try {
75
+ return await fetchImpl(url, init);
76
+ }
77
+ catch (err) {
78
+ throw new QlipUploadNetworkError(phase, err);
79
+ }
80
+ };
81
+ /**
82
+ * Send a request the server answers only after processing the build. No
83
+ * retry: the server keeps processing after a dropped connection, and a
84
+ * second finalize would race the first.
85
+ */
86
+ const sendAwaitingProcessing = async (phase, fetchImpl, url, init, timeoutMs) => {
87
+ const signal = AbortSignal.timeout(timeoutMs);
88
+ try {
89
+ return await fetchImpl(url, { ...init, signal });
90
+ }
91
+ catch (err) {
92
+ if (signal.aborted) {
93
+ throw new QlipUploadNetworkError(phase, err, `no response within ${String(timeoutMs / 1000)} s; the server may still be processing the build — check it in the dashboard, and raise the processing timeout if builds this size take longer`);
94
+ }
95
+ throw new QlipUploadNetworkError(phase, err);
96
+ }
97
+ };
98
+ const resolveProcessingTimeoutMs = (options) => {
99
+ const value = options.processingTimeoutMs ?? DEFAULT_PROCESSING_TIMEOUT_MS;
100
+ if (!Number.isFinite(value) || value <= 0) {
101
+ throw new RangeError(`upload.processingTimeoutMs must be a positive number of milliseconds, got ${String(value)}`);
102
+ }
103
+ return value;
104
+ };
28
105
  /** Contextual hint appended to 413s, tailored to the phase that hit it. */
29
106
  const hintFor = (status, phase) => {
30
107
  if (status !== 413)
@@ -97,10 +174,12 @@ const putBlob = async (fetchImpl, url, bytes, baseHeaders, retryBaseDelayMs) =>
97
174
  if (attempt < BLOB_RETRIES)
98
175
  await delay(retryBaseDelayMs * (attempt + 1));
99
176
  }
100
- throw lastError;
177
+ if (lastError instanceof QlipUploadError)
178
+ throw lastError;
179
+ throw new QlipUploadNetworkError('blob', lastError);
101
180
  };
102
181
  /** Pre-v2 single-multipart upload; fallback when the server lacks v2 routes. */
103
- const uploadBuildLegacy = async (fetchImpl, baseUrl, buildDir, options, manifest) => {
182
+ const uploadBuildLegacy = async (fetchImpl, baseUrl, buildDir, options, manifest, processingTimeoutMs) => {
104
183
  const form = new FormData();
105
184
  form.append('manifest', new Blob([JSON.stringify(manifest)], { type: 'application/json' }), 'manifest.json');
106
185
  const captured = manifest.entries.filter((e) => e.status === 'captured');
@@ -131,18 +210,16 @@ const uploadBuildLegacy = async (fetchImpl, baseUrl, buildDir, options, manifest
131
210
  if (options.triggeredById !== undefined) {
132
211
  form.append('triggeredById', String(options.triggeredById));
133
212
  }
134
- const response = await fetchImpl(`${baseUrl}/api/builds/upload`, {
135
- method: 'POST',
136
- body: form,
137
- headers: authHeaders(options),
138
- });
213
+ const response = await sendAwaitingProcessing('legacy', fetchImpl, `${baseUrl}/api/builds/upload`, { method: 'POST', body: form, headers: authHeaders(options) }, processingTimeoutMs);
139
214
  if (!response.ok)
140
215
  throw await errorFromResponse(response, 'legacy');
141
216
  return { buildId: manifest.buildId, status: response.status, protocol: 'legacy' };
142
217
  };
143
218
  export const uploadBuild = async (input) => {
144
219
  const { buildDir, options } = input;
220
+ const processingTimeoutMs = resolveProcessingTimeoutMs(options);
145
221
  const fetchImpl = input.fetchImpl ?? globalThis.fetch.bind(globalThis);
222
+ const processingFetch = input.fetchImpl ?? fetchAwaitingProcessing;
146
223
  const retryBaseDelayMs = input.retryBaseDelayMs ?? DEFAULT_RETRY_BASE_DELAY_MS;
147
224
  const baseUrl = resolveServerUrl(options);
148
225
  const headers = authHeaders(options);
@@ -154,7 +231,8 @@ export const uploadBuild = async (input) => {
154
231
  entry.sha256 = sha256;
155
232
  entry.sizeBytes = sizeBytes;
156
233
  });
157
- const createResponse = await fetchImpl(`${baseUrl}/api/builds`, {
234
+ const createFetch = withPhase('create', fetchImpl);
235
+ const createResponse = await createFetch(`${baseUrl}/api/builds`, {
158
236
  method: 'POST',
159
237
  headers: { ...headers, 'Content-Type': 'application/json' },
160
238
  body: JSON.stringify({
@@ -181,7 +259,7 @@ export const uploadBuild = async (input) => {
181
259
  });
182
260
  // 404 ⇒ server predates the v2 routes; fall back to legacy multipart.
183
261
  if (createResponse.status === 404) {
184
- return uploadBuildLegacy(fetchImpl, baseUrl, buildDir, options, manifest);
262
+ return uploadBuildLegacy(processingFetch, baseUrl, buildDir, options, manifest, processingTimeoutMs);
185
263
  }
186
264
  if (!createResponse.ok)
187
265
  throw await errorFromResponse(createResponse, 'create');
@@ -201,7 +279,7 @@ export const uploadBuild = async (input) => {
201
279
  const bytes = await readFile(path.join(buildDir, relPath));
202
280
  await putBlob(fetchImpl, `${baseUrl}/api/builds/${buildPath}/blobs/${key}`, bytes, headers, retryBaseDelayMs);
203
281
  });
204
- const finalizeResponse = await fetchImpl(`${baseUrl}/api/builds/${buildPath}/finalize`, { method: 'POST', headers });
282
+ const finalizeResponse = await sendAwaitingProcessing('finalize', processingFetch, `${baseUrl}/api/builds/${buildPath}/finalize`, { method: 'POST', headers }, processingTimeoutMs);
205
283
  if (!finalizeResponse.ok) {
206
284
  throw await errorFromResponse(finalizeResponse, 'finalize');
207
285
  }
@@ -58,6 +58,9 @@ Options:
58
58
  --token <token> Bearer token. Default: \$QLIP_UPLOAD_TOKEN.
59
59
  --branch <branch> Override branch detection.
60
60
  --commit <sha> Override commit detection.
61
+ --processing-timeout <s> Seconds to wait for the server to process
62
+ the build. Default:
63
+ \$QLIP_PROCESSING_TIMEOUT or 1800.
61
64
  --fail-on-upload-error Exit non-zero on upload failure (default:
62
65
  log error + exit 0).
63
66
  --fail-on-partial-build Exit non-zero when screenshots on disk are
@@ -101,6 +104,9 @@ const parseArgs = (argv) => {
101
104
  case '--commit':
102
105
  args.commit = next();
103
106
  break;
107
+ case '--processing-timeout':
108
+ args.processingTimeout = next();
109
+ break;
104
110
  case '--fail-on-upload-error':
105
111
  args.failOnUploadError = true;
106
112
  break;
@@ -178,6 +184,16 @@ const runUpload = async (argv, env = process.env, logger = console) => {
178
184
  logger.error(`qlip-upload: ${err.message}`);
179
185
  return { exitCode: 1 };
180
186
  }
187
+ const processingTimeout = parsed.processingTimeout ?? env['QLIP_PROCESSING_TIMEOUT'];
188
+ let processingTimeoutMs;
189
+ if (processingTimeout !== undefined && processingTimeout !== '') {
190
+ const seconds = Number(processingTimeout);
191
+ if (!Number.isFinite(seconds) || seconds <= 0) {
192
+ logger.error(`qlip-upload: --processing-timeout must be a positive number of seconds, got "${processingTimeout}"`);
193
+ return { exitCode: 1 };
194
+ }
195
+ processingTimeoutMs = seconds * 1000;
196
+ }
181
197
  const project = parsed.project ?? env['QLIP_PROJECT'] ?? 'default';
182
198
  const token = parsed.token ?? env['QLIP_UPLOAD_TOKEN'];
183
199
  const upload = {
@@ -186,6 +202,7 @@ const runUpload = async (argv, env = process.env, logger = console) => {
186
202
  ...(token !== undefined && token !== '' ? { uploadToken: token } : {}),
187
203
  ...(parsed.branch !== undefined ? { branch: parsed.branch } : {}),
188
204
  ...(parsed.commit !== undefined ? { commit: parsed.commit } : {}),
205
+ ...(processingTimeoutMs !== undefined ? { processingTimeoutMs } : {}),
189
206
  failOnUploadError: parsed.failOnUploadError,
190
207
  failOnPartialBuild: parsed.failOnPartialBuild,
191
208
  };
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ /**
3
+ * HTTP transport for the upload requests qlip-server answers only once it
4
+ * has processed the whole build: `finalize` and the legacy multipart POST.
5
+ *
6
+ * Node's global `fetch` (undici) fails any request whose response headers
7
+ * take longer than 300 s (`headersTimeout`) with a bare "fetch failed", and
8
+ * the limit can't be raised without depending on the `undici` package. A
9
+ * large build's diff runs longer than that, so these requests go through
10
+ * `node:http`, which imposes no response deadline: the caller's `signal` is
11
+ * the only one. See design/UPLOAD.md "Long-running requests".
12
+ */
13
+ var __importDefault = (this && this.__importDefault) || function (mod) {
14
+ return (mod && mod.__esModule) ? mod : { "default": mod };
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.fetchAwaitingProcessing = void 0;
18
+ const node_http_1 = __importDefault(require("node:http"));
19
+ const node_https_1 = __importDefault(require("node:https"));
20
+ const NULL_BODY_STATUSES = new Set([204, 205, 304]);
21
+ const encodeBody = async (body) => {
22
+ if (body === undefined || body === null) {
23
+ return { bytes: new Uint8Array(0), contentType: null };
24
+ }
25
+ // Response serializes every BodyInit (FormData gets its multipart boundary).
26
+ const encoded = new Response(body);
27
+ return {
28
+ bytes: new Uint8Array(await encoded.arrayBuffer()),
29
+ contentType: encoded.headers.get('content-type'),
30
+ };
31
+ };
32
+ const toResponse = (res, body) => {
33
+ const headers = new Headers();
34
+ for (const [name, value] of Object.entries(res.headers)) {
35
+ if (value === undefined)
36
+ continue;
37
+ for (const item of Array.isArray(value) ? value : [value]) {
38
+ headers.append(name, item);
39
+ }
40
+ }
41
+ const status = res.statusCode ?? 0;
42
+ return new Response(NULL_BODY_STATUSES.has(status) ? null : new Uint8Array(body), {
43
+ status,
44
+ statusText: res.statusMessage ?? '',
45
+ headers,
46
+ });
47
+ };
48
+ const fetchAwaitingProcessing = async (url, init) => {
49
+ const target = new URL(url);
50
+ const transport = target.protocol === 'https:'
51
+ ? node_https_1.default
52
+ : target.protocol === 'http:'
53
+ ? node_http_1.default
54
+ : undefined;
55
+ if (transport === undefined) {
56
+ throw new TypeError(`unsupported protocol: ${target.protocol}`);
57
+ }
58
+ const { bytes, contentType } = await encodeBody(init.body);
59
+ const headers = {};
60
+ if (contentType !== null)
61
+ headers['content-type'] = contentType;
62
+ new Headers(init.headers).forEach((value, name) => {
63
+ headers[name] = value;
64
+ });
65
+ headers['content-length'] = String(bytes.byteLength);
66
+ return new Promise((resolve, reject) => {
67
+ const req = transport.request(target, {
68
+ method: init.method ?? 'GET',
69
+ headers,
70
+ ...(init.signal ? { signal: init.signal } : {}),
71
+ }, (res) => {
72
+ const chunks = [];
73
+ res.on('data', (chunk) => {
74
+ chunks.push(chunk);
75
+ });
76
+ res.on('error', reject);
77
+ // A connection dropped mid-body ends the stream without `end`.
78
+ res.on('close', () => {
79
+ if (!res.complete) {
80
+ reject(new Error('connection closed before the response completed'));
81
+ }
82
+ });
83
+ res.on('end', () => {
84
+ try {
85
+ resolve(toResponse(res, Buffer.concat(chunks)));
86
+ }
87
+ catch (err) {
88
+ reject(err);
89
+ }
90
+ });
91
+ });
92
+ req.on('error', reject);
93
+ req.end(bytes);
94
+ });
95
+ };
96
+ exports.fetchAwaitingProcessing = fetchAwaitingProcessing;
@@ -7,16 +7,23 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
7
7
  return (mod && mod.__esModule) ? mod : { "default": mod };
8
8
  };
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
- exports.uploadBuild = exports.QlipUploadError = exports.resolveServerUrl = exports.DEFAULT_SERVER_URL = void 0;
10
+ exports.uploadBuild = exports.QlipUploadNetworkError = exports.QlipUploadError = exports.resolveServerUrl = exports.DEFAULT_PROCESSING_TIMEOUT_MS = exports.DEFAULT_SERVER_URL = void 0;
11
11
  const promises_1 = require("node:fs/promises");
12
12
  const node_path_1 = __importDefault(require("node:path"));
13
13
  const hash_js_1 = require("./hash.js");
14
+ const processing_request_js_1 = require("./processing-request.js");
14
15
  /** Default server when `upload.serverUrl` is omitted: the hosted instance. */
15
16
  exports.DEFAULT_SERVER_URL = 'https://qlip.qoretechnologies.com';
16
17
  const TRANSFER_CONCURRENCY = 10;
17
18
  const BLOB_RETRIES = 2;
18
19
  const BLOB_TIMEOUT_MS = 30000;
19
20
  const DEFAULT_RETRY_BASE_DELAY_MS = 500;
21
+ /**
22
+ * Default deadline for the finalize / legacy response. qorus-ide builds of
23
+ * ~2.1k snapshots measured 470–713 s of server processing (2026-10, see
24
+ * design/UPLOAD.md "Long-running requests"); 30 min leaves 2.5× headroom.
25
+ */
26
+ exports.DEFAULT_PROCESSING_TIMEOUT_MS = 30 * 60000;
20
27
  /**
21
28
  * Resolve the effective server base URL (default applied, trailing slash
22
29
  * stripped). Exported so log lines report the URL the upload actually hits.
@@ -33,6 +40,77 @@ class QlipUploadError extends Error {
33
40
  }
34
41
  }
35
42
  exports.QlipUploadError = QlipUploadError;
43
+ /**
44
+ * `fetch failed` alone hides why: the reason (a timeout, a reset, a DNS
45
+ * failure) lives on the `cause` chain, so the message spells that out.
46
+ */
47
+ const describeCause = (err) => {
48
+ const parts = [];
49
+ const seen = new Set();
50
+ let current = err;
51
+ while (current !== undefined && current !== null && !seen.has(current)) {
52
+ seen.add(current);
53
+ if (current instanceof Error) {
54
+ const code = current.code;
55
+ parts.push(typeof code === 'string' && code !== ''
56
+ ? `${current.message} (${code})`
57
+ : current.message);
58
+ current = current.cause;
59
+ }
60
+ else {
61
+ parts.push(String(current));
62
+ break;
63
+ }
64
+ }
65
+ return parts.join(': ');
66
+ };
67
+ /**
68
+ * The request never produced an HTTP response: a network error, or no
69
+ * response before the client's deadline. `status`-less, unlike
70
+ * `QlipUploadError`.
71
+ */
72
+ class QlipUploadNetworkError extends Error {
73
+ constructor(phase, cause, detail) {
74
+ super(`qlip upload failed [${phase}]: ${detail ?? `network error — ${describeCause(cause)}`}`);
75
+ this.name = 'QlipUploadNetworkError';
76
+ this.phase = phase;
77
+ this.cause = cause;
78
+ }
79
+ }
80
+ exports.QlipUploadNetworkError = QlipUploadNetworkError;
81
+ /** `fetchImpl`, with a transport failure reported as a phase-tagged error. */
82
+ const withPhase = (phase, fetchImpl) => async (url, init) => {
83
+ try {
84
+ return await fetchImpl(url, init);
85
+ }
86
+ catch (err) {
87
+ throw new QlipUploadNetworkError(phase, err);
88
+ }
89
+ };
90
+ /**
91
+ * Send a request the server answers only after processing the build. No
92
+ * retry: the server keeps processing after a dropped connection, and a
93
+ * second finalize would race the first.
94
+ */
95
+ const sendAwaitingProcessing = async (phase, fetchImpl, url, init, timeoutMs) => {
96
+ const signal = AbortSignal.timeout(timeoutMs);
97
+ try {
98
+ return await fetchImpl(url, { ...init, signal });
99
+ }
100
+ catch (err) {
101
+ if (signal.aborted) {
102
+ throw new QlipUploadNetworkError(phase, err, `no response within ${String(timeoutMs / 1000)} s; the server may still be processing the build — check it in the dashboard, and raise the processing timeout if builds this size take longer`);
103
+ }
104
+ throw new QlipUploadNetworkError(phase, err);
105
+ }
106
+ };
107
+ const resolveProcessingTimeoutMs = (options) => {
108
+ const value = options.processingTimeoutMs ?? exports.DEFAULT_PROCESSING_TIMEOUT_MS;
109
+ if (!Number.isFinite(value) || value <= 0) {
110
+ throw new RangeError(`upload.processingTimeoutMs must be a positive number of milliseconds, got ${String(value)}`);
111
+ }
112
+ return value;
113
+ };
36
114
  /** Contextual hint appended to 413s, tailored to the phase that hit it. */
37
115
  const hintFor = (status, phase) => {
38
116
  if (status !== 413)
@@ -105,10 +183,12 @@ const putBlob = async (fetchImpl, url, bytes, baseHeaders, retryBaseDelayMs) =>
105
183
  if (attempt < BLOB_RETRIES)
106
184
  await delay(retryBaseDelayMs * (attempt + 1));
107
185
  }
108
- throw lastError;
186
+ if (lastError instanceof QlipUploadError)
187
+ throw lastError;
188
+ throw new QlipUploadNetworkError('blob', lastError);
109
189
  };
110
190
  /** Pre-v2 single-multipart upload; fallback when the server lacks v2 routes. */
111
- const uploadBuildLegacy = async (fetchImpl, baseUrl, buildDir, options, manifest) => {
191
+ const uploadBuildLegacy = async (fetchImpl, baseUrl, buildDir, options, manifest, processingTimeoutMs) => {
112
192
  const form = new FormData();
113
193
  form.append('manifest', new Blob([JSON.stringify(manifest)], { type: 'application/json' }), 'manifest.json');
114
194
  const captured = manifest.entries.filter((e) => e.status === 'captured');
@@ -139,18 +219,16 @@ const uploadBuildLegacy = async (fetchImpl, baseUrl, buildDir, options, manifest
139
219
  if (options.triggeredById !== undefined) {
140
220
  form.append('triggeredById', String(options.triggeredById));
141
221
  }
142
- const response = await fetchImpl(`${baseUrl}/api/builds/upload`, {
143
- method: 'POST',
144
- body: form,
145
- headers: authHeaders(options),
146
- });
222
+ const response = await sendAwaitingProcessing('legacy', fetchImpl, `${baseUrl}/api/builds/upload`, { method: 'POST', body: form, headers: authHeaders(options) }, processingTimeoutMs);
147
223
  if (!response.ok)
148
224
  throw await errorFromResponse(response, 'legacy');
149
225
  return { buildId: manifest.buildId, status: response.status, protocol: 'legacy' };
150
226
  };
151
227
  const uploadBuild = async (input) => {
152
228
  const { buildDir, options } = input;
229
+ const processingTimeoutMs = resolveProcessingTimeoutMs(options);
153
230
  const fetchImpl = input.fetchImpl ?? globalThis.fetch.bind(globalThis);
231
+ const processingFetch = input.fetchImpl ?? processing_request_js_1.fetchAwaitingProcessing;
154
232
  const retryBaseDelayMs = input.retryBaseDelayMs ?? DEFAULT_RETRY_BASE_DELAY_MS;
155
233
  const baseUrl = (0, exports.resolveServerUrl)(options);
156
234
  const headers = authHeaders(options);
@@ -162,7 +240,8 @@ const uploadBuild = async (input) => {
162
240
  entry.sha256 = sha256;
163
241
  entry.sizeBytes = sizeBytes;
164
242
  });
165
- const createResponse = await fetchImpl(`${baseUrl}/api/builds`, {
243
+ const createFetch = withPhase('create', fetchImpl);
244
+ const createResponse = await createFetch(`${baseUrl}/api/builds`, {
166
245
  method: 'POST',
167
246
  headers: { ...headers, 'Content-Type': 'application/json' },
168
247
  body: JSON.stringify({
@@ -189,7 +268,7 @@ const uploadBuild = async (input) => {
189
268
  });
190
269
  // 404 ⇒ server predates the v2 routes; fall back to legacy multipart.
191
270
  if (createResponse.status === 404) {
192
- return uploadBuildLegacy(fetchImpl, baseUrl, buildDir, options, manifest);
271
+ return uploadBuildLegacy(processingFetch, baseUrl, buildDir, options, manifest, processingTimeoutMs);
193
272
  }
194
273
  if (!createResponse.ok)
195
274
  throw await errorFromResponse(createResponse, 'create');
@@ -209,7 +288,7 @@ const uploadBuild = async (input) => {
209
288
  const bytes = await (0, promises_1.readFile)(node_path_1.default.join(buildDir, relPath));
210
289
  await putBlob(fetchImpl, `${baseUrl}/api/builds/${buildPath}/blobs/${key}`, bytes, headers, retryBaseDelayMs);
211
290
  });
212
- const finalizeResponse = await fetchImpl(`${baseUrl}/api/builds/${buildPath}/finalize`, { method: 'POST', headers });
291
+ const finalizeResponse = await sendAwaitingProcessing('finalize', processingFetch, `${baseUrl}/api/builds/${buildPath}/finalize`, { method: 'POST', headers }, processingTimeoutMs);
213
292
  if (!finalizeResponse.ok) {
214
293
  throw await errorFromResponse(finalizeResponse, 'finalize');
215
294
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qoretechnologies/qlip",
3
- "version": "0.1.3-pr.31.g4b62f97",
3
+ "version": "0.1.3-pr.32.g3ecd7a4",
4
4
  "description": "Storybook screenshot capture for the Vitest addon",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",