@sous-io/sous 0.2.14 → 0.2.16
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/docs/markdown/commands.md +64 -13
- package/docs/markdown/repositories-authoring.md +53 -0
- package/docs/markdown/repositories-consuming.md +32 -1
- package/package.json +1 -1
- package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
- package/src/commands/namespace/list.ts +42 -21
- package/src/commands/namespace/show.ts +32 -12
- package/src/commands/recipe/list.ts +40 -23
- package/src/commands/recipe/show.ts +28 -6
- package/src/commands/repo/link.ts +400 -1
- package/src/commands/repo/list.ts +67 -10
- package/src/commands/repo/search.ts +68 -15
- package/src/commands/subscription/list.ts +98 -19
- package/src/lib/build-preparation.ts +44 -1
- package/src/lib/repos/catalog-display.ts +101 -2
- package/src/lib/repos/catalog-inputs.ts +145 -17
- package/src/lib/repos/catalog.ts +92 -5
- package/src/lib/repos/freshness.ts +56 -0
- package/src/lib/repos/git-clone.ts +468 -7
- package/src/lib/repos/providers/github.ts +1 -0
- package/src/lib/repos/providers/gitlab.ts +1 -0
- package/src/lib/repos/providers/http.ts +7 -2
- package/src/lib/repos/providers/index-cache.ts +56 -17
- package/src/lib/repos/providers/provider.ts +2 -0
- package/src/lib/repos/subscription-service.ts +138 -9
- package/src/utils/flags.ts +24 -0
|
@@ -8,15 +8,18 @@
|
|
|
8
8
|
* is pinned, the links map and the store for a recipe's own files, and
|
|
9
9
|
* `recipeOutputs` for where a content kind lands.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
* fetched is left out of the catalog and named separately, so a
|
|
13
|
-
* is safe offline.
|
|
11
|
+
* By default nothing here downloads anything: a repository whose index has
|
|
12
|
+
* never been fetched is left out of the catalog and named separately, so a
|
|
13
|
+
* browsing command is safe offline. Asked for the latest, it reads each index
|
|
14
|
+
* from upstream instead and writes none of it to the cache; a repository that
|
|
15
|
+
* cannot be reached is read from the cache and named as not checked.
|
|
14
16
|
*/
|
|
15
17
|
|
|
16
18
|
import type { Settings, VarScope } from "../settings.js";
|
|
17
19
|
import type { SubscriptionService } from "./subscription-service.js";
|
|
18
20
|
import type { CatalogInputs, CatalogRepo } from "./catalog.js";
|
|
19
|
-
import {
|
|
21
|
+
import type { IndexFile } from "./formats/index-file.js";
|
|
22
|
+
import { linkedPathFor, readEffectiveLinks } from "./links.js";
|
|
20
23
|
import { mapLinkedRecipes, readRecipeManifestIn } from "./locked-recipes.js";
|
|
21
24
|
import { WRITABLE_CONTENT_KINDS, destinationsFor } from "./recipe-targets.js";
|
|
22
25
|
import type { WritableContentKind } from "./recipe-targets.js";
|
|
@@ -37,8 +40,94 @@ export type CatalogInputsOptions = {
|
|
|
37
40
|
scope?: VarScope;
|
|
38
41
|
/** The environment to read; decides where the store and the links map are. */
|
|
39
42
|
env?: NodeJS.ProcessEnv;
|
|
43
|
+
/**
|
|
44
|
+
* The indexes to read, when the caller has already gathered them (with
|
|
45
|
+
* `readTrustedIndexes`, say, to read upstream). The cached indexes otherwise.
|
|
46
|
+
*/
|
|
47
|
+
indexes?: TrustedIndexes;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Where to read the trusted repositories' indexes from. */
|
|
51
|
+
export type TrustedIndexesOptions = {
|
|
52
|
+
/** Read each index from upstream rather than the cache, writing none of it. */
|
|
53
|
+
latest?: boolean;
|
|
40
54
|
};
|
|
41
55
|
|
|
56
|
+
/** One trusted repository's index, and where it was read from. */
|
|
57
|
+
export type TrustedIndex = {
|
|
58
|
+
/** The repository's short name. */
|
|
59
|
+
name: string;
|
|
60
|
+
/** Where it lives, as the project's config records it. */
|
|
61
|
+
url?: string;
|
|
62
|
+
/** The index that was read. */
|
|
63
|
+
index: IndexFile;
|
|
64
|
+
/** Whether it came from upstream just now or from the cache. */
|
|
65
|
+
source: "upstream" | "cache";
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Every trusted repository's index sous could read, and what it could not. */
|
|
69
|
+
export type TrustedIndexes = {
|
|
70
|
+
/** The indexes, by repository short name, sorted. */
|
|
71
|
+
repos: TrustedIndex[];
|
|
72
|
+
/**
|
|
73
|
+
* Trusted repositories with no index at all: never fetched, and (when the
|
|
74
|
+
* latest was asked for) not reachable either. Sorted.
|
|
75
|
+
*/
|
|
76
|
+
notFetched: string[];
|
|
77
|
+
/**
|
|
78
|
+
* Repositories the latest was asked for that could not be reached, and were
|
|
79
|
+
* read from the cache instead. Sorted; always empty when reading the cache.
|
|
80
|
+
*/
|
|
81
|
+
notChecked: string[];
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Reads the index of every repository this project trusts. From the cache by
|
|
86
|
+
* default, which downloads nothing. With `latest`, from upstream, all at once,
|
|
87
|
+
* and nothing fetched is written to the cache: only a command that resolves
|
|
88
|
+
* versions changes what the cache holds. A repository upstream cannot answer
|
|
89
|
+
* for is read from the cache and named in `notChecked`.
|
|
90
|
+
*
|
|
91
|
+
* @param service - The subscription service for this project.
|
|
92
|
+
* @param options - Whether to read upstream.
|
|
93
|
+
*/
|
|
94
|
+
export async function readTrustedIndexes(
|
|
95
|
+
service: SubscriptionService,
|
|
96
|
+
options: TrustedIndexesOptions = {}
|
|
97
|
+
): Promise<TrustedIndexes> {
|
|
98
|
+
if (options.latest !== true) return cachedTrustedIndexes(service);
|
|
99
|
+
|
|
100
|
+
const trusted = service.currentRepos();
|
|
101
|
+
const names = Object.keys(trusted).sort();
|
|
102
|
+
const answers = await Promise.allSettled(names.map((name) => service.upstreamIndex(name)));
|
|
103
|
+
|
|
104
|
+
const result: TrustedIndexes = { repos: [], notFetched: [], notChecked: [] };
|
|
105
|
+
names.forEach((name, position) => {
|
|
106
|
+
const url = trusted[name]?.url;
|
|
107
|
+
const answer = answers[position]!;
|
|
108
|
+
if (answer.status === "fulfilled") {
|
|
109
|
+
result.repos.push({
|
|
110
|
+
name,
|
|
111
|
+
...(url === undefined ? {} : { url }),
|
|
112
|
+
index: answer.value,
|
|
113
|
+
source: "upstream",
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Upstream could not answer, so the cached copy stands in and says so. A
|
|
119
|
+
// repository with no cached copy either is named once, as never fetched.
|
|
120
|
+
const cached = service.cachedIndex(name);
|
|
121
|
+
if (cached === undefined) {
|
|
122
|
+
result.notFetched.push(name);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
result.notChecked.push(name);
|
|
126
|
+
result.repos.push({ name, ...(url === undefined ? {} : { url }), index: cached, source: "cache" });
|
|
127
|
+
});
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
|
|
42
131
|
/** The catalog's inputs, plus what could not be read. */
|
|
43
132
|
export type CatalogContext = {
|
|
44
133
|
/** What the catalog functions read. */
|
|
@@ -48,6 +137,11 @@ export type CatalogContext = {
|
|
|
48
137
|
* could be listed. Sorted.
|
|
49
138
|
*/
|
|
50
139
|
notFetched: string[];
|
|
140
|
+
/**
|
|
141
|
+
* Repositories the latest was asked for that could not be reached, listed
|
|
142
|
+
* from the cache instead. Sorted.
|
|
143
|
+
*/
|
|
144
|
+
notChecked: string[];
|
|
51
145
|
};
|
|
52
146
|
|
|
53
147
|
/**
|
|
@@ -60,25 +154,23 @@ export type CatalogContext = {
|
|
|
60
154
|
export function catalogContextFor(options: CatalogInputsOptions): CatalogContext {
|
|
61
155
|
const { service } = options;
|
|
62
156
|
const env = options.env ?? process.env;
|
|
157
|
+
const indexes = options.indexes ?? cachedTrustedIndexes(service);
|
|
63
158
|
|
|
64
|
-
const repos: CatalogRepo[] =
|
|
65
|
-
|
|
159
|
+
const repos: CatalogRepo[] = indexes.repos.map((entry) => ({
|
|
160
|
+
name: entry.name,
|
|
161
|
+
...(entry.url === undefined ? {} : { url: entry.url }),
|
|
162
|
+
index: entry.index,
|
|
163
|
+
}));
|
|
66
164
|
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (index === undefined) {
|
|
71
|
-
notFetched.push(name);
|
|
72
|
-
continue;
|
|
73
|
-
}
|
|
74
|
-
const url = trusted[name]?.url;
|
|
75
|
-
repos.push({ name, ...(url === undefined ? {} : { url }), index });
|
|
76
|
-
}
|
|
165
|
+
const links = readEffectiveLinks(options.sousDir, env);
|
|
166
|
+
const linked: Record<string, string> = {};
|
|
167
|
+
for (const [name, link] of Object.entries(links)) linked[name] = link.path;
|
|
77
168
|
|
|
78
169
|
const inputs: CatalogInputs = {
|
|
79
170
|
repos,
|
|
80
171
|
lock: service.lockService.read(),
|
|
81
172
|
subscriptions: Object.keys(service.allSubscriptions()).sort(),
|
|
173
|
+
linked,
|
|
82
174
|
readManifest: (recipe) => {
|
|
83
175
|
const directory = recipeFilesDirectory({
|
|
84
176
|
service,
|
|
@@ -105,7 +197,43 @@ export function catalogContextFor(options: CatalogInputsOptions): CatalogContext
|
|
|
105
197
|
},
|
|
106
198
|
};
|
|
107
199
|
|
|
108
|
-
return { inputs, notFetched };
|
|
200
|
+
return { inputs, notFetched: indexes.notFetched, notChecked: indexes.notChecked };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The catalog's inputs, reading the indexes the way the options say: from the
|
|
205
|
+
* cache, or with `latest` from upstream without writing to the cache.
|
|
206
|
+
*
|
|
207
|
+
* @param options - The subscription service, the project's directory and config,
|
|
208
|
+
* and whether to read upstream.
|
|
209
|
+
*/
|
|
210
|
+
export async function loadCatalogContext(
|
|
211
|
+
options: Omit<CatalogInputsOptions, "indexes"> & TrustedIndexesOptions
|
|
212
|
+
): Promise<CatalogContext> {
|
|
213
|
+
const indexes = await readTrustedIndexes(options.service, {
|
|
214
|
+
...(options.latest === undefined ? {} : { latest: options.latest }),
|
|
215
|
+
});
|
|
216
|
+
return catalogContextFor({ ...options, indexes });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Every trusted repository's cached index, read synchronously.
|
|
221
|
+
*
|
|
222
|
+
* @param service - The subscription service for this project.
|
|
223
|
+
*/
|
|
224
|
+
function cachedTrustedIndexes(service: SubscriptionService): TrustedIndexes {
|
|
225
|
+
const trusted = service.currentRepos();
|
|
226
|
+
const result: TrustedIndexes = { repos: [], notFetched: [], notChecked: [] };
|
|
227
|
+
for (const name of Object.keys(trusted).sort()) {
|
|
228
|
+
const index = service.cachedIndex(name);
|
|
229
|
+
if (index === undefined) {
|
|
230
|
+
result.notFetched.push(name);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const url = trusted[name]?.url;
|
|
234
|
+
result.repos.push({ name, ...(url === undefined ? {} : { url }), index, source: "cache" });
|
|
235
|
+
}
|
|
236
|
+
return result;
|
|
109
237
|
}
|
|
110
238
|
|
|
111
239
|
/** Which recipe, at which version, in which of this project's repositories. */
|
package/src/lib/repos/catalog.ts
CHANGED
|
@@ -78,6 +78,11 @@ export type CatalogInputs = {
|
|
|
78
78
|
lock: Lockfile;
|
|
79
79
|
/** The ref keys the project subscribes to: namespaces, and `namespace/recipe`. */
|
|
80
80
|
subscriptions: string[];
|
|
81
|
+
/**
|
|
82
|
+
* The repositories read from a linked working copy instead of the store, by
|
|
83
|
+
* short name, each with the checkout's path.
|
|
84
|
+
*/
|
|
85
|
+
linked?: Record<string, string>;
|
|
81
86
|
/**
|
|
82
87
|
* Reads one published recipe's manifest, when its files are on this machine.
|
|
83
88
|
* Returning undefined means "not available", and the recipe is described from
|
|
@@ -119,6 +124,11 @@ export type RecipeListing = {
|
|
|
119
124
|
latest?: string;
|
|
120
125
|
/** The version this project's lockfile pins, when it pins one. */
|
|
121
126
|
pinned?: string;
|
|
127
|
+
/**
|
|
128
|
+
* The linked checkout builds read this recipe from instead of the pinned
|
|
129
|
+
* version, when the project pins it and its repository is linked.
|
|
130
|
+
*/
|
|
131
|
+
linkedPath?: string;
|
|
122
132
|
/** True when the project subscribes to this recipe, or to the whole namespace holding it. */
|
|
123
133
|
subscribed: boolean;
|
|
124
134
|
/** The recipe's one-paragraph summary, when its index carries one. */
|
|
@@ -212,6 +222,11 @@ export type RecipeDetail = {
|
|
|
212
222
|
latest?: string;
|
|
213
223
|
/** The version this project's lockfile pins, when it pins one. */
|
|
214
224
|
pinned?: string;
|
|
225
|
+
/**
|
|
226
|
+
* The linked checkout builds read this recipe from instead of the pinned
|
|
227
|
+
* version, when the project pins it and its repository is linked.
|
|
228
|
+
*/
|
|
229
|
+
linkedPath?: string;
|
|
215
230
|
/** True when the project subscribes to this recipe, or to the whole namespace holding it. */
|
|
216
231
|
subscribed: boolean;
|
|
217
232
|
/**
|
|
@@ -275,7 +290,7 @@ export function listRecipes(inputs: CatalogInputs): RecipeListing[] {
|
|
|
275
290
|
|
|
276
291
|
for (const repo of inputs.repos) {
|
|
277
292
|
for (const key of Object.keys(repo.index.recipes)) {
|
|
278
|
-
listings.push(recipeListing(key, repo, inputs
|
|
293
|
+
listings.push(recipeListing(key, repo, inputs, subscriptions));
|
|
279
294
|
}
|
|
280
295
|
}
|
|
281
296
|
|
|
@@ -305,7 +320,7 @@ export function describeNamespace(inputs: CatalogInputs, ref: string): Namespace
|
|
|
305
320
|
...(declared.description === undefined ? {} : { description: declared.description }),
|
|
306
321
|
subscribed: coverageOf(found.namespace, found.repo.index, subscriptions),
|
|
307
322
|
recipes: recipeKeysIn(found.repo.index, found.namespace).map((key) =>
|
|
308
|
-
recipeListing(key, found.repo, inputs
|
|
323
|
+
recipeListing(key, found.repo, inputs, subscriptions)
|
|
309
324
|
),
|
|
310
325
|
};
|
|
311
326
|
}
|
|
@@ -320,7 +335,7 @@ export function describeNamespace(inputs: CatalogInputs, ref: string): Namespace
|
|
|
320
335
|
export function describeRecipe(inputs: CatalogInputs, ref: string): RecipeDetail {
|
|
321
336
|
const found = resolveRecipeRef(inputs, ref);
|
|
322
337
|
const subscriptions = new Set(inputs.subscriptions);
|
|
323
|
-
const listing = recipeListing(found.key, found.repo, inputs
|
|
338
|
+
const listing = recipeListing(found.key, found.repo, inputs, subscriptions);
|
|
324
339
|
const entry = found.repo.index.recipes[found.key]!;
|
|
325
340
|
|
|
326
341
|
const describing = listing.pinned ?? listing.latest;
|
|
@@ -355,6 +370,75 @@ export function describeRecipe(inputs: CatalogInputs, ref: string): RecipeDetail
|
|
|
355
370
|
};
|
|
356
371
|
}
|
|
357
372
|
|
|
373
|
+
/**
|
|
374
|
+
* The same inputs, narrowed to what this project has installed: each
|
|
375
|
+
* repository's index keeps only the recipes the lockfile pins from that
|
|
376
|
+
* repository, and only the namespaces holding one of them. Every listing and
|
|
377
|
+
* every ref lookup over the result therefore sees only installed recipes. A
|
|
378
|
+
* recipe's published version history is kept whole, so the installed version
|
|
379
|
+
* still sits among the others.
|
|
380
|
+
*
|
|
381
|
+
* narrowToInstalled(inputs)
|
|
382
|
+
* // -> the index of "r" holds "a/x" only, when the lockfile pins "a/x" from "r"
|
|
383
|
+
*
|
|
384
|
+
* @param inputs - The catalog's inputs.
|
|
385
|
+
*/
|
|
386
|
+
export function narrowToInstalled(inputs: CatalogInputs): CatalogInputs {
|
|
387
|
+
const repos = inputs.repos.map((repo) => {
|
|
388
|
+
const recipes = Object.fromEntries(
|
|
389
|
+
Object.entries(repo.index.recipes).filter(
|
|
390
|
+
([key]) => inputs.lock.recipes[key]?.repo === repo.name
|
|
391
|
+
)
|
|
392
|
+
);
|
|
393
|
+
const held = new Set(Object.keys(recipes).map((key) => key.slice(0, key.indexOf("/"))));
|
|
394
|
+
const namespaces = Object.fromEntries(
|
|
395
|
+
Object.entries(repo.index.namespaces).filter(([namespace]) => held.has(namespace))
|
|
396
|
+
);
|
|
397
|
+
return { ...repo, index: { ...repo.index, recipes, namespaces } };
|
|
398
|
+
});
|
|
399
|
+
return { ...inputs, repos };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Looks a ref up among the installed recipes only, so an ambiguity between an
|
|
404
|
+
* installed recipe and one the project does not use settles itself. A ref that
|
|
405
|
+
* names something published but not installed is an error saying exactly that,
|
|
406
|
+
* rather than the "no repository publishes" error the narrowed lookup alone
|
|
407
|
+
* would raise.
|
|
408
|
+
*
|
|
409
|
+
* describeInstalled(inputs, "workflow/task-files", describeRecipe, "recipe")
|
|
410
|
+
* // -> the recipe, when the lockfile pins it; otherwise an error saying the
|
|
411
|
+
* // project has not installed it
|
|
412
|
+
*
|
|
413
|
+
* @param inputs - The catalog's inputs, not yet narrowed.
|
|
414
|
+
* @param ref - The ref being looked up.
|
|
415
|
+
* @param describe - The lookup to run: `describeNamespace` or `describeRecipe`.
|
|
416
|
+
* @param what - What the ref names, for the error.
|
|
417
|
+
*/
|
|
418
|
+
export function describeInstalled<T>(
|
|
419
|
+
inputs: CatalogInputs,
|
|
420
|
+
ref: string,
|
|
421
|
+
describe: (inputs: CatalogInputs, ref: string) => T,
|
|
422
|
+
what: "namespace" | "recipe"
|
|
423
|
+
): T {
|
|
424
|
+
try {
|
|
425
|
+
return describe(narrowToInstalled(inputs), ref);
|
|
426
|
+
} catch {
|
|
427
|
+
// The full lookup either explains the ref better (ambiguous, unknown) or
|
|
428
|
+
// proves it is published and simply not installed.
|
|
429
|
+
describe(inputs, ref);
|
|
430
|
+
throw what === "namespace"
|
|
431
|
+
? new ConfigError(
|
|
432
|
+
`This project has installed no recipe from the namespace '${ref}', so there is ` +
|
|
433
|
+
`nothing to show with '--installed'.`
|
|
434
|
+
)
|
|
435
|
+
: new ConfigError(
|
|
436
|
+
`This project has not installed the recipe '${ref}', so there is nothing to ` +
|
|
437
|
+
`show with '--installed'.`
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
358
442
|
// --- Resolving a ref ----------------------------------------------------------------------------
|
|
359
443
|
|
|
360
444
|
/** One namespace a ref resolved to. */
|
|
@@ -488,15 +572,16 @@ function coverageOf(
|
|
|
488
572
|
*
|
|
489
573
|
* @param key - The recipe key.
|
|
490
574
|
* @param repo - The repository publishing it.
|
|
491
|
-
* @param
|
|
575
|
+
* @param inputs - The lockfile and the linked repositories.
|
|
492
576
|
* @param subscriptions - The ref keys the project subscribes to.
|
|
493
577
|
*/
|
|
494
578
|
function recipeListing(
|
|
495
579
|
key: string,
|
|
496
580
|
repo: CatalogRepo,
|
|
497
|
-
|
|
581
|
+
inputs: Pick<CatalogInputs, "lock" | "linked">,
|
|
498
582
|
subscriptions: Set<string>
|
|
499
583
|
): RecipeListing {
|
|
584
|
+
const lock = inputs.lock;
|
|
500
585
|
const entry = repo.index.recipes[key]!;
|
|
501
586
|
const namespace = key.slice(0, key.indexOf("/"));
|
|
502
587
|
const name = key.slice(namespace.length + 1);
|
|
@@ -507,6 +592,7 @@ function recipeListing(
|
|
|
507
592
|
// the recipe came from this repository.
|
|
508
593
|
const locked = lock.recipes[key];
|
|
509
594
|
const pinned = locked !== undefined && locked.repo === repo.name ? locked.version : undefined;
|
|
595
|
+
const linkedPath = pinned === undefined ? undefined : inputs.linked?.[repo.name];
|
|
510
596
|
|
|
511
597
|
return {
|
|
512
598
|
key,
|
|
@@ -515,6 +601,7 @@ function recipeListing(
|
|
|
515
601
|
repo: repo.name,
|
|
516
602
|
...(latest === undefined ? {} : { latest }),
|
|
517
603
|
...(pinned === undefined ? {} : { pinned }),
|
|
604
|
+
...(linkedPath === undefined ? {} : { linkedPath }),
|
|
518
605
|
subscribed: subscriptions.has(key) || subscriptions.has(namespace),
|
|
519
606
|
...(entry.description === undefined ? {} : { description: entry.description }),
|
|
520
607
|
};
|
|
@@ -206,3 +206,59 @@ export function effectiveRangeForHolders(
|
|
|
206
206
|
const combined = constraints.join(" ");
|
|
207
207
|
return semver.validRange(combined) === null ? undefined : combined;
|
|
208
208
|
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* How long a build waits for a repository that it is only checking so it can
|
|
212
|
+
* say whether a newer version exists: three seconds. Past that the check is
|
|
213
|
+
* abandoned, quietly, and the build goes on with what the cache already knew.
|
|
214
|
+
*/
|
|
215
|
+
export const NEWER_VERSION_CHECK_TIMEOUT_MS = 3000;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Runs a piece of upstream work with a deadline. The work is handed an abort
|
|
219
|
+
* signal that fires at the deadline, so a request that honors it is cancelled
|
|
220
|
+
* rather than left to keep the process alive; either way the returned promise
|
|
221
|
+
* rejects at the deadline, and the timer never holds the process open itself.
|
|
222
|
+
*
|
|
223
|
+
* await withDeadline((signal) => fetchSomething(signal), 3000);
|
|
224
|
+
* // -> the result, or an error saying the repository did not answer in time
|
|
225
|
+
*
|
|
226
|
+
* @param work - The work to run, given the signal that cancels it.
|
|
227
|
+
* @param milliseconds - How long to wait.
|
|
228
|
+
*/
|
|
229
|
+
export async function withDeadline<T>(
|
|
230
|
+
work: (signal: AbortSignal) => Promise<T>,
|
|
231
|
+
milliseconds: number
|
|
232
|
+
): Promise<T> {
|
|
233
|
+
const controller = new AbortController();
|
|
234
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
235
|
+
|
|
236
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
237
|
+
timer = setTimeout(() => {
|
|
238
|
+
controller.abort();
|
|
239
|
+
reject(
|
|
240
|
+
new Error(
|
|
241
|
+
`The repository did not answer within ${formatSeconds(milliseconds)}, so sous ` +
|
|
242
|
+
`stopped waiting for it.`
|
|
243
|
+
)
|
|
244
|
+
);
|
|
245
|
+
}, milliseconds);
|
|
246
|
+
timer.unref?.();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
return await Promise.race([work(controller.signal), deadline]);
|
|
251
|
+
} finally {
|
|
252
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* A duration in plain words, such as "3 seconds" or "1 second".
|
|
258
|
+
*
|
|
259
|
+
* @param milliseconds - The duration.
|
|
260
|
+
*/
|
|
261
|
+
function formatSeconds(milliseconds: number): string {
|
|
262
|
+
const seconds = Math.round((milliseconds / 1000) * 10) / 10;
|
|
263
|
+
return seconds === 1 ? "1 second" : `${seconds} seconds`;
|
|
264
|
+
}
|