@useblacksmith/vitest 0.2.1 → 0.4.0

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
@@ -24,6 +24,23 @@ With the plugin, vitest only runs tests impacted by changes since the last succe
24
24
 
25
25
  Requires Vitest 5.1 or newer.
26
26
 
27
+ ### Vite+
28
+
29
+ Vite+ runs Vitest under `vp test`, so the plugin is loaded from the shared `vite.config.ts`:
30
+
31
+ ```ts
32
+ // vite.config.ts
33
+ import 'vite-plus/test/config'
34
+ import { defineConfig } from 'vite-plus'
35
+ import blacksmith from '@useblacksmith/vitest'
36
+
37
+ export default defineConfig({
38
+ plugins: [blacksmith()],
39
+ })
40
+ ```
41
+
42
+ `vp test run` then runs the affected selection, including with `--shard`, test file filters, and `--config <path> --dir .` from a workspace package. Requires `vite-plus` 1.5.0 or newer, the first release that ships Vitest 5.1.
43
+
27
44
  ## Running locally
28
45
 
29
46
  The same selection applies locally: a run reruns what changed on disk since the last successful run, committed or not. To run every test locally and select only in CI:
@@ -2,7 +2,7 @@ import { BloomFilter } from './bloom.js';
2
2
  import type { Reporter, TestModule, TestRunEndReason, TestSpecification, VCSProvider, VCSProviderOptions, Vitest } from 'vitest/node';
3
3
  import { type Tree } from './git.js';
4
4
  export declare const SNAPSHOT_FILE = "blacksmith.json";
5
- declare const SNAPSHOT_VERSION = 4;
5
+ declare const SNAPSHOT_VERSION = 5;
6
6
  /** A `dependencies` entry, compiled: test files matching `tests` depend on files matching `deps`; both root-relative. */
7
7
  export interface DependencyRule {
8
8
  tests: (file: string) => boolean;
@@ -20,6 +20,8 @@ export interface Shard {
20
20
  export interface Snapshot {
21
21
  version: typeof SNAPSHOT_VERSION;
22
22
  head: string;
23
+ /** Vitest's root relative to the repository, so one package's snapshot never vouches for another's. */
24
+ root: string;
23
25
  shard: Shard | null;
24
26
  /** Names of the projects the run covered (`--project` narrows them). */
25
27
  projects: string[];
package/dist/affected.js CHANGED
@@ -11,7 +11,7 @@ import { shardOwner } from './sequencer.js';
11
11
  export const SNAPSHOT_FILE = 'blacksmith.json';
12
12
  // Bumped whenever the snapshot's shape changes or the records an anchor
13
13
  // vouches for gain a kind of dependency, so an older snapshot is not trusted.
14
- const SNAPSHOT_VERSION = 4;
14
+ const SNAPSHOT_VERSION = 5;
15
15
  export function compileDependencies(dependencies) {
16
16
  return Object.entries(dependencies ?? {}).map(([tests, deps]) => ({
17
17
  tests: picomatch(tests, { dot: true }),
@@ -138,10 +138,11 @@ async function readSnapshot(file) {
138
138
  }
139
139
  if (typeof parsed !== 'object' || parsed === null)
140
140
  return null;
141
- const { version, head, shard, projects, tree, tests } = parsed;
141
+ const { version, head, root, shard, projects, tree, tests } = parsed;
142
142
  const covered = BloomFilter.parse(tests);
143
143
  if (version !== SNAPSHOT_VERSION ||
144
144
  typeof head !== 'string' ||
145
+ typeof root !== 'string' ||
145
146
  !isShard(shard) ||
146
147
  !Array.isArray(projects) ||
147
148
  !projects.every((name) => typeof name === 'string') ||
@@ -152,6 +153,7 @@ async function readSnapshot(file) {
152
153
  return {
153
154
  version: SNAPSHOT_VERSION,
154
155
  head,
156
+ root,
155
157
  shard,
156
158
  projects: projects,
157
159
  tree,
@@ -213,6 +215,12 @@ export class TreeSnapshotProvider {
213
215
  log(`no dependency snapshot, running every test of the ${describeShard(shard)}`);
214
216
  return null;
215
217
  }
218
+ const repo = await repoRoot(root);
219
+ const here = repoRelative(repo, this.vitest.config.root);
220
+ if (snapshot.root !== here) {
221
+ log(`dependency snapshot is for ${snapshot.root || 'the repository root'}, this run is for ${here || 'the repository root'}, running every test of the ${describeShard(shard)}`);
222
+ return null;
223
+ }
216
224
  if (!sameShard(snapshot.shard, shard)) {
217
225
  log(`dependency snapshot is for ${describeShard(snapshot.shard)}, running every test of the ${describeShard(shard)}`);
218
226
  return null;
@@ -222,7 +230,6 @@ export class TreeSnapshotProvider {
222
230
  log(`dependency snapshot covers projects [${snapshot.projects.join(', ')}], this run covers [${projects.join(', ')}], running every test of the ${describeShard(shard)}`);
223
231
  return null;
224
232
  }
225
- const repo = await repoRoot(root);
226
233
  const tree = await anchorTree(repo, await anchoredPaths(this.vitest, repo, dir, this.dependencies));
227
234
  const changedPaths = diffTrees(snapshot.tree, tree);
228
235
  const owns = await shardOwner(this.vitest);
@@ -307,6 +314,7 @@ export class ShadowAnalysis {
307
314
  const repo = await repoRoot(vitest.config.root);
308
315
  const key = cacheKey({
309
316
  snapshotVersion: SNAPSHOT_VERSION,
317
+ root: repoRelative(repo, vitest.config.root),
310
318
  projects: projectNames(vitest),
311
319
  shard: vitest.config.shard ?? null,
312
320
  head: process.env.GITHUB_SHA || (await headSha(repo)),
@@ -480,6 +488,7 @@ export class SnapshotRecorder {
480
488
  const snapshot = {
481
489
  version: SNAPSHOT_VERSION,
482
490
  head: await headSha(repo),
491
+ root: repoRelative(repo, root),
483
492
  shard: shard ?? null,
484
493
  projects: projectNames(this.vitest),
485
494
  tree: await anchorTree(repo, await anchoredPaths(this.vitest, repo, cacheDir, this.provider.dependencies)),
@@ -499,6 +508,7 @@ export class SnapshotRecorder {
499
508
  return;
500
509
  const key = cacheKey({
501
510
  snapshotVersion: snapshot.version,
511
+ root: snapshot.root,
502
512
  projects: snapshot.projects,
503
513
  shard: snapshot.shard,
504
514
  head: snapshot.head,
@@ -554,6 +564,7 @@ export async function restoreDependencyCache(vitest) {
554
564
  const repo = await repoRoot(vitest.config.root);
555
565
  const key = cacheKey({
556
566
  snapshotVersion: SNAPSHOT_VERSION,
567
+ root: repoRelative(repo, vitest.config.root),
557
568
  projects: projectNames(vitest),
558
569
  shard: vitest.config.shard ?? null,
559
570
  head: process.env.GITHUB_SHA || (await headSha(repo)),
package/dist/cache.d.ts CHANGED
@@ -21,6 +21,8 @@ export interface CacheKey {
21
21
  export declare const DEFAULT_CACHE_PREFIX = "blacksmith-vitest";
22
22
  export declare function cacheKey(input: {
23
23
  snapshotVersion: number;
24
+ /** Vitest's root relative to the repository: each package of a monorepo keeps its own entries. */
25
+ root: string;
24
26
  projects: string[];
25
27
  shard: {
26
28
  index: number;
package/dist/cache.js CHANGED
@@ -21,9 +21,9 @@ export const DEFAULT_CACHE_PREFIX = 'blacksmith-vitest';
21
21
  export function cacheKey(input) {
22
22
  const scope = [
23
23
  `v${input.snapshotVersion}`,
24
- input.projects.some(Boolean)
24
+ input.root !== '' || input.projects.some(Boolean)
25
25
  ? createHash('sha1')
26
- .update(input.projects.join('\n'))
26
+ .update([input.root, ...input.projects].join('\n'))
27
27
  .digest('hex')
28
28
  .slice(0, 12)
29
29
  : 'all',
package/dist/git.js CHANGED
@@ -116,6 +116,15 @@ function under(sorted, dir) {
116
116
  return listing;
117
117
  }
118
118
  const DIRECTORY_MODE = '040000';
119
+ /** A path nothing could read, e.g. a component over NAME_MAX or a NUL byte, is as absent as a missing one. */
120
+ function lstat(file) {
121
+ try {
122
+ return lstatSync(file, { throwIfNoEntry: false });
123
+ }
124
+ catch {
125
+ return undefined;
126
+ }
127
+ }
119
128
  /**
120
129
  * What each path looks like now: a file is `<mode> <blob>` as `git ls-tree`
121
130
  * prints it, a symlink hashes its target, a directory hashes its listing (every
@@ -139,7 +148,7 @@ export async function anchorTree(repo, paths) {
139
148
  return tree;
140
149
  const present = new Map();
141
150
  for (const file of wanted) {
142
- const stat = lstatSync(path.join(repo, file), { throwIfNoEntry: false });
151
+ const stat = lstat(path.join(repo, file));
143
152
  if (stat !== undefined)
144
153
  present.set(file, stat);
145
154
  }
package/dist/index.d.ts CHANGED
@@ -1,8 +1,4 @@
1
1
  import type { Plugin } from 'vitest/config';
2
- export { BlacksmithReporter } from './reporter.js';
3
- export { HashShardSequencer, shardFor } from './sequencer.js';
4
- export { ShadowAnalysis, type ShadowResult, SNAPSHOT_FILE, type Snapshot, } from './affected.js';
5
- export type { Payload, RunError, TestResultRecord, TestStatus, } from './payload.js';
6
2
  export interface BlacksmithOptions {
7
3
  /**
8
4
  * Run every test instead of only those affected since the last green run.
package/dist/index.js CHANGED
@@ -1,9 +1,6 @@
1
1
  import { fileURLToPath } from 'node:url';
2
2
  import { compileDependencies, configuredProvider, isTreeSnapshotProvider, isSnapshotRecorder, restoreDependencyCache, ShadowAnalysis, SnapshotRecorder, TreeSnapshotProvider, } from './affected.js';
3
3
  import { customSequencer, HashShardSequencer, stableShards, } from './sequencer.js';
4
- export { BlacksmithReporter } from './reporter.js';
5
- export { HashShardSequencer, shardFor } from './sequencer.js';
6
- export { ShadowAnalysis, SNAPSHOT_FILE, } from './affected.js';
7
4
  /** Beside this module; `.ts` when the plugin runs from source (its own test fixtures). */
8
5
  const recordReads = fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? './record-reads.ts' : './record-reads.js', import.meta.url));
9
6
  /** Whether this Vitest wired up the pieces `--changed` selection relies on. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@useblacksmith/vitest",
3
- "version": "0.2.1",
4
- "description": "Reports Vitest test results to Blacksmith and runs only the tests affected since the last run",
3
+ "version": "0.4.0",
4
+ "description": "Runs only the Vitest tests affected since the last successful run, with Blacksmith-backed caching",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -21,7 +21,8 @@
21
21
  "build": "tsc -p tsconfig.build.json",
22
22
  "prepack": "npm run build",
23
23
  "check:types": "tsc --noEmit",
24
- "test": "vitest run"
24
+ "test": "vitest run",
25
+ "release": "scripts/release.sh"
25
26
  },
26
27
  "peerDependencies": {
27
28
  "vitest": ">=5.1.0"
package/dist/payload.d.ts DELETED
@@ -1,56 +0,0 @@
1
- export type TestStatus = 'passed' | 'failed' | 'skipped';
2
- export interface TestError {
3
- message: string;
4
- stack: string;
5
- }
6
- export interface TestResultRecord {
7
- /** Test file, relative to the repository when the run is inside GITHUB_WORKSPACE */
8
- file: string;
9
- /** Vitest project the test ran in; null for the root project */
10
- project: string | null;
11
- /** Enclosing describe blocks, outermost first */
12
- suite: string[];
13
- name: string;
14
- status: TestStatus;
15
- /** null for tests that never ran (skipped, todo) */
16
- duration_ms: number | null;
17
- started_at: string | null;
18
- retries: number;
19
- flaky: boolean;
20
- error?: TestError;
21
- }
22
- /** A failure outside any test: a module that threw while being imported or collected, or an unhandled error */
23
- export interface RunError {
24
- /** null when the error is not attributable to a test file */
25
- file: string | null;
26
- message: string;
27
- stack: string;
28
- }
29
- export interface Payload {
30
- framework: 'vitest';
31
- framework_version: string;
32
- reporter_version: string;
33
- run: {
34
- started_at: string;
35
- finished_at: string;
36
- reason: 'passed' | 'failed' | 'interrupted';
37
- };
38
- results: TestResultRecord[];
39
- errors: RunError[];
40
- /** true when results or errors beyond the count or byte caps were dropped */
41
- truncated: boolean;
42
- }
43
- export declare const MAX_RESULTS = 50000;
44
- export declare const MAX_PAYLOAD_BYTES: number;
45
- export declare const MAX_ERROR_MESSAGE_CHARS = 4096;
46
- export declare const MAX_ERROR_STACK_CHARS = 16384;
47
- export declare function truncate(text: string, max: number): string;
48
- /**
49
- * Keeps results in order until either cap is hit, then errors in whatever
50
- * budget is left, so a payload never serializes past MAX_PAYLOAD_BYTES of
51
- * JSON (the gzip input, which is also the backend's memory ceiling).
52
- */
53
- export declare function buildPayload(results: TestResultRecord[], errors: RunError[], run: Payload['run'], versions: {
54
- framework: string;
55
- reporter: string;
56
- }, maxBytes?: number): Payload;
package/dist/payload.js DELETED
@@ -1,40 +0,0 @@
1
- export const MAX_RESULTS = 50_000;
2
- export const MAX_PAYLOAD_BYTES = 32 * 1024 * 1024;
3
- export const MAX_ERROR_MESSAGE_CHARS = 4_096;
4
- export const MAX_ERROR_STACK_CHARS = 16_384;
5
- export function truncate(text, max) {
6
- return text.length > max ? `${text.slice(0, max)}…` : text;
7
- }
8
- /**
9
- * Keeps results in order until either cap is hit, then errors in whatever
10
- * budget is left, so a payload never serializes past MAX_PAYLOAD_BYTES of
11
- * JSON (the gzip input, which is also the backend's memory ceiling).
12
- */
13
- export function buildPayload(results, errors, run, versions, maxBytes = MAX_PAYLOAD_BYTES) {
14
- let bytes = 0;
15
- let truncated = false;
16
- const keep = (items, max) => {
17
- const kept = [];
18
- for (const item of items) {
19
- const size = JSON.stringify(item).length + 1;
20
- if (kept.length >= max || bytes + size > maxBytes) {
21
- truncated = true;
22
- break;
23
- }
24
- bytes += size;
25
- kept.push(item);
26
- }
27
- return kept;
28
- };
29
- const keptResults = keep(results, MAX_RESULTS);
30
- const keptErrors = keep(errors, MAX_RESULTS);
31
- return {
32
- framework: 'vitest',
33
- framework_version: versions.framework,
34
- reporter_version: versions.reporter,
35
- run,
36
- results: keptResults,
37
- errors: keptErrors,
38
- truncated,
39
- };
40
- }
@@ -1,29 +0,0 @@
1
- import type { Reporter, SerializedError, TestModule, TestRunEndReason, Vitest } from 'vitest/node';
2
- import { type RunError, type TestResultRecord } from './payload.js';
3
- export declare const ENV_BACKEND_URL = "BLACKSMITH_BACKEND_URL";
4
- export declare const ENV_TOKEN = "BLACKSMITH_JOB_HISTORY_TOKEN";
5
- export declare const RESULTS_PATH = "/api/jobs/test-results";
6
- /**
7
- * Marks a reporter as ours across package copies: a workspace whose root and
8
- * project resolve two installs of this package has two `BlacksmithReporter`
9
- * classes, and `instanceof` would let both register.
10
- */
11
- export declare const REPORTER_BRAND: unique symbol;
12
- export declare function isBlacksmithReporter(reporter: unknown): boolean;
13
- /**
14
- * Sends every test's outcome and duration to Blacksmith in one request when
15
- * the run ends. Does nothing unless the runner injected BLACKSMITH_BACKEND_URL
16
- * and BLACKSMITH_JOB_HISTORY_TOKEN, and never changes the run's outcome.
17
- */
18
- export declare class BlacksmithReporter implements Reporter {
19
- readonly [REPORTER_BRAND] = true;
20
- private vitest;
21
- private startedAt;
22
- onInit(vitest: Vitest): void;
23
- onTestRunStart(): void;
24
- onTestRunEnd(testModules: ReadonlyArray<TestModule>, unhandledErrors: ReadonlyArray<SerializedError>, reason: TestRunEndReason): Promise<void>;
25
- private report;
26
- }
27
- export declare function collectResults(testModules: ReadonlyArray<TestModule>, workspace: string | undefined): TestResultRecord[];
28
- /** Errors a module raised while being imported or collected, outside any test (a syntax error, a top-level throw). */
29
- export declare function collectModuleErrors(testModules: ReadonlyArray<TestModule>, workspace: string | undefined): RunError[];
package/dist/reporter.js DELETED
@@ -1,149 +0,0 @@
1
- import { createRequire } from 'node:module';
2
- import path from 'node:path';
3
- import { buildPayload, MAX_ERROR_MESSAGE_CHARS, MAX_ERROR_STACK_CHARS, truncate, } from './payload.js';
4
- import { upload } from './upload.js';
5
- export const ENV_BACKEND_URL = 'BLACKSMITH_BACKEND_URL';
6
- export const ENV_TOKEN = 'BLACKSMITH_JOB_HISTORY_TOKEN';
7
- export const RESULTS_PATH = '/api/jobs/test-results';
8
- /**
9
- * Marks a reporter as ours across package copies: a workspace whose root and
10
- * project resolve two installs of this package has two `BlacksmithReporter`
11
- * classes, and `instanceof` would let both register.
12
- */
13
- export const REPORTER_BRAND = Symbol.for('@useblacksmith/vitest:reporter');
14
- export function isBlacksmithReporter(reporter) {
15
- return (typeof reporter === 'object' &&
16
- reporter !== null &&
17
- REPORTER_BRAND in reporter);
18
- }
19
- const { version: reporterVersion } = createRequire(import.meta.url)('../package.json');
20
- /**
21
- * Sends every test's outcome and duration to Blacksmith in one request when
22
- * the run ends. Does nothing unless the runner injected BLACKSMITH_BACKEND_URL
23
- * and BLACKSMITH_JOB_HISTORY_TOKEN, and never changes the run's outcome.
24
- */
25
- export class BlacksmithReporter {
26
- [REPORTER_BRAND] = true;
27
- vitest;
28
- startedAt = new Date();
29
- onInit(vitest) {
30
- this.vitest = vitest;
31
- }
32
- onTestRunStart() {
33
- this.startedAt = new Date();
34
- }
35
- async onTestRunEnd(testModules, unhandledErrors, reason) {
36
- const backendUrl = process.env[ENV_BACKEND_URL];
37
- const token = process.env[ENV_TOKEN];
38
- if (!backendUrl || !token)
39
- return;
40
- try {
41
- await this.report(backendUrl, token, testModules, unhandledErrors, reason);
42
- }
43
- catch (error) {
44
- this.vitest.logger.warn(`[blacksmith] could not report test results: ${error instanceof Error ? error.message : String(error)}`);
45
- }
46
- }
47
- async report(backendUrl, token, testModules, unhandledErrors, reason) {
48
- const workspace = process.env.GITHUB_WORKSPACE;
49
- const results = collectResults(testModules, workspace);
50
- const errors = [
51
- ...collectModuleErrors(testModules, workspace),
52
- ...unhandledErrors.map((error) => toRunError(null, error)),
53
- ];
54
- if (results.length === 0 && errors.length === 0)
55
- return;
56
- const payload = buildPayload(results, errors, {
57
- started_at: this.startedAt.toISOString(),
58
- finished_at: new Date().toISOString(),
59
- reason,
60
- }, { framework: this.vitest.version, reporter: reporterVersion });
61
- const outcome = await upload(`${backendUrl.replace(/\/+$/, '')}${RESULTS_PATH}`, token, `blacksmith-vitest/${reporterVersion}`, JSON.stringify(payload));
62
- if (!outcome.ok) {
63
- throw new Error(outcome.error);
64
- }
65
- const counts = [
66
- `${payload.results.length} test results`,
67
- payload.errors.length > 0 ? `${payload.errors.length} errors` : '',
68
- payload.truncated ? '(truncated)' : '',
69
- ];
70
- this.vitest.logger.log(`[blacksmith] reported ${counts.filter(Boolean).join(' ')}`);
71
- }
72
- }
73
- export function collectResults(testModules, workspace) {
74
- const records = [];
75
- for (const testModule of testModules) {
76
- const file = relativeFile(testModule, workspace);
77
- const project = testModule.project.name || null;
78
- for (const testCase of testModule.children.allTests()) {
79
- const result = testCase.result();
80
- if (result.state === 'pending')
81
- continue;
82
- records.push(toRecord(testCase, file, project, result.state));
83
- }
84
- }
85
- return records;
86
- }
87
- /** Errors a module raised while being imported or collected, outside any test (a syntax error, a top-level throw). */
88
- export function collectModuleErrors(testModules, workspace) {
89
- const errors = [];
90
- for (const testModule of testModules) {
91
- const file = relativeFile(testModule, workspace);
92
- for (const error of testModule.errors()) {
93
- errors.push(toRunError(file, error));
94
- }
95
- }
96
- return errors;
97
- }
98
- /**
99
- * Paths are repo-relative when the test file is inside the checkout (a
100
- * monorepo package's vitest root is not the repo root), else Vitest's
101
- * root-relative id.
102
- */
103
- function relativeFile(testModule, workspace) {
104
- if (workspace) {
105
- const relative = path.relative(workspace, testModule.moduleId);
106
- if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) {
107
- return relative.split(path.sep).join('/');
108
- }
109
- }
110
- return testModule.relativeModuleId;
111
- }
112
- function toRecord(testCase, file, project, status) {
113
- const diagnostic = testCase.diagnostic();
114
- const record = {
115
- file,
116
- project,
117
- suite: suiteChain(testCase),
118
- name: testCase.name,
119
- status,
120
- duration_ms: diagnostic?.duration ?? null,
121
- started_at: diagnostic
122
- ? new Date(diagnostic.startTime).toISOString()
123
- : null,
124
- retries: diagnostic?.retryCount ?? 0,
125
- flaky: diagnostic?.flaky ?? false,
126
- };
127
- const error = status === 'failed' ? testCase.result().errors?.[0] : undefined;
128
- if (error) {
129
- record.error = {
130
- message: truncate(error.message ?? '', MAX_ERROR_MESSAGE_CHARS),
131
- stack: truncate(error.stack ?? '', MAX_ERROR_STACK_CHARS),
132
- };
133
- }
134
- return record;
135
- }
136
- function toRunError(file, error) {
137
- return {
138
- file,
139
- message: truncate(error.message ?? '', MAX_ERROR_MESSAGE_CHARS),
140
- stack: truncate(error.stack ?? '', MAX_ERROR_STACK_CHARS),
141
- };
142
- }
143
- function suiteChain(testCase) {
144
- const chain = [];
145
- for (let parent = testCase.parent; parent.type === 'suite'; parent = parent.parent) {
146
- chain.unshift(parent.name);
147
- }
148
- return chain;
149
- }
package/dist/upload.d.ts DELETED
@@ -1,17 +0,0 @@
1
- export declare const REQUEST_TIMEOUT_MS = 10000;
2
- export declare const ATTEMPTS = 3;
3
- export declare const BACKOFF_MS: number[];
4
- export type UploadOutcome = {
5
- ok: true;
6
- status: number;
7
- bytes: number;
8
- } | {
9
- ok: false;
10
- status: number | null;
11
- error: string;
12
- };
13
- /**
14
- * One gzipped bearer POST, retried on network errors and 5xx only; a 4xx is
15
- * final because the same body would be rejected again.
16
- */
17
- export declare function upload(url: string, token: string, userAgent: string, json: string, sleep?: (ms: number) => Promise<void>): Promise<UploadOutcome>;
package/dist/upload.js DELETED
@@ -1,47 +0,0 @@
1
- import { gzipSync } from 'node:zlib';
2
- export const REQUEST_TIMEOUT_MS = 10_000;
3
- export const ATTEMPTS = 3;
4
- export const BACKOFF_MS = [1_000, 2_000];
5
- /**
6
- * One gzipped bearer POST, retried on network errors and 5xx only; a 4xx is
7
- * final because the same body would be rejected again.
8
- */
9
- export async function upload(url, token, userAgent, json, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
10
- const body = gzipSync(json);
11
- let last = { ok: false, status: null, error: 'not attempted' };
12
- for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
13
- if (attempt > 0)
14
- await sleep(BACKOFF_MS[attempt - 1] ?? 0);
15
- try {
16
- const response = await fetch(url, {
17
- method: 'POST',
18
- headers: {
19
- authorization: `Bearer ${token}`,
20
- 'content-type': 'application/json',
21
- 'content-encoding': 'gzip',
22
- 'user-agent': userAgent,
23
- },
24
- body,
25
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
26
- });
27
- if (response.ok) {
28
- return { ok: true, status: response.status, bytes: body.byteLength };
29
- }
30
- last = {
31
- ok: false,
32
- status: response.status,
33
- error: `HTTP ${response.status}`,
34
- };
35
- if (response.status < 500)
36
- return last;
37
- }
38
- catch (error) {
39
- last = {
40
- ok: false,
41
- status: null,
42
- error: error instanceof Error ? error.message : String(error),
43
- };
44
- }
45
- }
46
- return last;
47
- }