@valbuild/cli 0.132.1 → 0.134.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/CHANGELOG.md +113 -0
- package/cli/dist/valbuild-cli-cli.cjs.dev.js +1428 -1
- package/cli/dist/valbuild-cli-cli.cjs.prod.js +1428 -1
- package/cli/dist/valbuild-cli-cli.esm.js +1426 -1
- package/package.json +7 -5
- package/src/cli.ts +51 -0
- package/src/debug/context.ts +1 -2
- package/src/publish/artifacts.ts +194 -0
- package/src/publish/client.ts +177 -0
- package/src/publish/contentApi.ts +210 -0
- package/src/publish/contentHost.ts +160 -0
- package/src/publish/credentials.test.ts +348 -0
- package/src/publish/credentials.ts +258 -0
- package/src/publish/fakeContent.ts +482 -0
- package/src/publish/protocol.ts +174 -0
- package/src/publish/publishWireContract.test.ts +292 -0
- package/src/publish/runPublish.test.ts +536 -0
- package/src/publish/runPublish.ts +633 -0
- package/src/publish.ts +112 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@valbuild/cli",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.134.0",
|
|
5
5
|
"description": "Val CLI tools",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -24,11 +24,13 @@
|
|
|
24
24
|
"jszip": "^3.10.1",
|
|
25
25
|
"meow": "^9.0.0",
|
|
26
26
|
"picocolors": "^1.1.1",
|
|
27
|
-
"
|
|
27
|
+
"zod": "^4.4.3",
|
|
28
|
+
"zod-validation-error": "^5.0.0",
|
|
29
|
+
"@valbuild/core": "0.134.0",
|
|
28
30
|
"@valbuild/eslint-plugin": "0.125.0",
|
|
29
|
-
"@valbuild/language-server": "0.
|
|
30
|
-
"@valbuild/
|
|
31
|
-
"@valbuild/
|
|
31
|
+
"@valbuild/language-server": "0.134.0",
|
|
32
|
+
"@valbuild/shared": "0.134.0",
|
|
33
|
+
"@valbuild/server": "0.134.0"
|
|
32
34
|
},
|
|
33
35
|
"peerDependencies": {
|
|
34
36
|
"prettier": "*",
|
package/src/cli.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { login } from "./login";
|
|
|
9
9
|
import { lsp } from "./lsp";
|
|
10
10
|
import { debug } from "./debug";
|
|
11
11
|
import { deleteUnappliablePatches } from "./deleteUnappliablePatches";
|
|
12
|
+
import { publish } from "./publish";
|
|
12
13
|
|
|
13
14
|
async function main(): Promise<void> {
|
|
14
15
|
const { input, flags, showHelp } = meow(
|
|
@@ -24,6 +25,7 @@ async function main(): Promise<void> {
|
|
|
24
25
|
login
|
|
25
26
|
files
|
|
26
27
|
connect
|
|
28
|
+
publish
|
|
27
29
|
versions
|
|
28
30
|
lsp
|
|
29
31
|
debug
|
|
@@ -48,6 +50,32 @@ async function main(): Promise<void> {
|
|
|
48
50
|
Options:
|
|
49
51
|
--root [root], -r [root] Set project root directory (default process.cwd())
|
|
50
52
|
|
|
53
|
+
Command: publish
|
|
54
|
+
Description: publish this project's build through content.val.build.
|
|
55
|
+
Declares the build's artifacts, uploads the ones content does not already hold,
|
|
56
|
+
and has content verify it by building and rendering a canary before the site
|
|
57
|
+
changes. Authenticates with VAL_PROJECT_TOKEN, or with the "val login" token in
|
|
58
|
+
.val/pat.json (which needs the project, as "<org>/<project>", in val.config or
|
|
59
|
+
VAL_PROJECT). Never as a flag: an argument is visible to anyone who can list
|
|
60
|
+
processes, and it is kept in shell history and in every CI log.
|
|
61
|
+
Options:
|
|
62
|
+
--root [root], -r [root] Set project root directory (default process.cwd())
|
|
63
|
+
--artifacts [dir] The built artifacts (default <root>/.val/publish). The path
|
|
64
|
+
of each file under it is its artifact key: server, client,
|
|
65
|
+
css, rsc, layer, or a path under chunk/server, chunk/client,
|
|
66
|
+
chunk/rsc, asset, public
|
|
67
|
+
--commit [sha] The commit this build is of (default VAL_GIT_COMMIT,
|
|
68
|
+
else GITHUB_SHA, else git HEAD)
|
|
69
|
+
--branch [name] The branch content commits saves to (default VAL_GIT_BRANCH,
|
|
70
|
+
else GITHUB_REF_NAME, else the current git branch)
|
|
71
|
+
--layer-rev [rev] Name the dependency layer content already holds, when this
|
|
72
|
+
build does not send one
|
|
73
|
+
--build-hash [hash] Identify the build (default: a hash of its artifacts).
|
|
74
|
+
Declaring the same one twice resumes that publish
|
|
75
|
+
--links-own-css The app links its own CSS (--no-links-own-css for the
|
|
76
|
+
opposite; omit it when the build did not say)
|
|
77
|
+
--dry-run Verify, and stop before the site changes
|
|
78
|
+
|
|
51
79
|
Command: list-unused-files
|
|
52
80
|
Description: EXPERIMENTAL.
|
|
53
81
|
List files that are in the configured files directory (files.directory, default public/val) but not in use by any Val module.
|
|
@@ -112,6 +140,18 @@ async function main(): Promise<void> {
|
|
|
112
140
|
out: {
|
|
113
141
|
type: "string",
|
|
114
142
|
},
|
|
143
|
+
artifacts: {
|
|
144
|
+
type: "string",
|
|
145
|
+
},
|
|
146
|
+
layerRev: {
|
|
147
|
+
type: "string",
|
|
148
|
+
},
|
|
149
|
+
buildHash: {
|
|
150
|
+
type: "string",
|
|
151
|
+
},
|
|
152
|
+
linksOwnCss: {
|
|
153
|
+
type: "boolean",
|
|
154
|
+
},
|
|
115
155
|
commit: {
|
|
116
156
|
type: "string",
|
|
117
157
|
},
|
|
@@ -181,6 +221,17 @@ async function main(): Promise<void> {
|
|
|
181
221
|
yes: flags.yes,
|
|
182
222
|
verbose: flags.verbose,
|
|
183
223
|
});
|
|
224
|
+
case "publish":
|
|
225
|
+
return publish({
|
|
226
|
+
root: flags.root,
|
|
227
|
+
artifacts: flags.artifacts,
|
|
228
|
+
commit: flags.commit,
|
|
229
|
+
branch: flags.branch,
|
|
230
|
+
layerRev: flags.layerRev,
|
|
231
|
+
buildHash: flags.buildHash,
|
|
232
|
+
linksOwnCss: flags.linksOwnCss,
|
|
233
|
+
dryRun: flags.dryRun,
|
|
234
|
+
});
|
|
184
235
|
case "login":
|
|
185
236
|
return login({
|
|
186
237
|
root: flags.root,
|
package/src/debug/context.ts
CHANGED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import zlib from "zlib";
|
|
5
|
+
import { DeclaredArtifact } from "./protocol";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Where an artifact's bytes are, and what it is called on the wire.
|
|
9
|
+
*
|
|
10
|
+
* The key namespace is flat and is content's: `server`, `client`, `css`,
|
|
11
|
+
* `rsc`, `layer`, and paths under `chunk/server/`, `chunk/client/`,
|
|
12
|
+
* `chunk/rsc/`, `asset/` and `public/`. Content validates it and answers with
|
|
13
|
+
* every problem at once, so this does not re-implement the rules - a second,
|
|
14
|
+
* slightly different copy of them here is how a CLI comes to refuse a publish
|
|
15
|
+
* the service would have taken.
|
|
16
|
+
*/
|
|
17
|
+
export type Artifact = DeclaredArtifact & { file: string };
|
|
18
|
+
|
|
19
|
+
export type CollectedArtifacts = {
|
|
20
|
+
artifacts: Artifact[];
|
|
21
|
+
totalBytes: number;
|
|
22
|
+
skipped: string[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The artifacts to publish, read from a directory laid out by key.
|
|
27
|
+
*
|
|
28
|
+
* The path under the directory IS the artifact key: a file at `public/app.css`
|
|
29
|
+
* is the artifact `public/app.css`, and `server` is the server bundle. Nothing
|
|
30
|
+
* is renamed on the way - an asset is addressed by the path the built code
|
|
31
|
+
* imports it at, so a normalised name produces a bundle whose imports resolve
|
|
32
|
+
* to nothing, at runtime, in the isolate.
|
|
33
|
+
*
|
|
34
|
+
* **This is the seam with the build.** `val publish` uploads artifacts; it does
|
|
35
|
+
* not produce them. Whatever builds the project - today the platform's own
|
|
36
|
+
* builder, which is where the wire, rolldown and dependency-layer steps live -
|
|
37
|
+
* writes this directory, and this reads it.
|
|
38
|
+
*/
|
|
39
|
+
export async function collectArtifacts(
|
|
40
|
+
dir: string,
|
|
41
|
+
): Promise<CollectedArtifacts> {
|
|
42
|
+
const artifacts: Artifact[] = [];
|
|
43
|
+
const skipped: string[] = [];
|
|
44
|
+
let totalBytes = 0;
|
|
45
|
+
|
|
46
|
+
const walk = async (current: string): Promise<void> => {
|
|
47
|
+
const entries = await fs.promises.readdir(current, { withFileTypes: true });
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
const absolute = path.join(current, entry.name);
|
|
50
|
+
const key = path.relative(dir, absolute).split(path.sep).join("/");
|
|
51
|
+
if (entry.isDirectory()) {
|
|
52
|
+
await walk(absolute);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (entry.isSymbolicLink()) {
|
|
56
|
+
// A symlinked file is published as its bytes; a symlinked directory is
|
|
57
|
+
// not walked, because one usually comes with a cycle, and a publisher
|
|
58
|
+
// that hangs is worse than one that says what it left out.
|
|
59
|
+
const stat = await fs.promises.stat(absolute).catch(() => null);
|
|
60
|
+
if (stat === null) {
|
|
61
|
+
skipped.push(`${key} (broken symlink)`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (!stat.isFile()) {
|
|
65
|
+
skipped.push(
|
|
66
|
+
`${key} (symlink to a ${stat.isDirectory() ? "directory" : "special file"})`,
|
|
67
|
+
);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
} else if (!entry.isFile()) {
|
|
71
|
+
skipped.push(`${key} (not a regular file)`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const { sha256, bytes } = await hashFile(absolute);
|
|
75
|
+
artifacts.push({ key, sha256, bytes, file: absolute });
|
|
76
|
+
totalBytes += bytes;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
await walk(dir);
|
|
81
|
+
// Sorted so two publishes of one build declare the same list in the same
|
|
82
|
+
// order, which is what makes the build hash below reproducible.
|
|
83
|
+
artifacts.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
84
|
+
return { artifacts, totalBytes, skipped };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function hashFile(
|
|
88
|
+
absolute: string,
|
|
89
|
+
): Promise<{ sha256: string; bytes: number }> {
|
|
90
|
+
const hash = crypto.createHash("sha256");
|
|
91
|
+
let bytes = 0;
|
|
92
|
+
// Streamed: an artifact may be 128 MB, and reading one into a Buffer to hash
|
|
93
|
+
// it is a needless way to run a CI runner out of memory.
|
|
94
|
+
const stream = fs.createReadStream(absolute);
|
|
95
|
+
await new Promise<void>((resolve, reject) => {
|
|
96
|
+
stream.on("data", (chunk) => {
|
|
97
|
+
hash.update(chunk);
|
|
98
|
+
bytes += chunk.length;
|
|
99
|
+
});
|
|
100
|
+
stream.on("end", resolve);
|
|
101
|
+
stream.on("error", reject);
|
|
102
|
+
});
|
|
103
|
+
return { sha256: hash.digest("hex"), bytes };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The build's own hash, when the build did not say.
|
|
108
|
+
*
|
|
109
|
+
* Content uses it for idempotency - declaring the same `buildHash` twice
|
|
110
|
+
* returns the same publish, which is what makes a retried CI job resume rather
|
|
111
|
+
* than start again - so it has to be a function of the build and nothing else.
|
|
112
|
+
* Every artifact's key and hash, in order: two runs of one build agree, and
|
|
113
|
+
* any change to any artifact is a different publish.
|
|
114
|
+
*/
|
|
115
|
+
export function buildHashOf(artifacts: Artifact[]): string {
|
|
116
|
+
const hash = crypto.createHash("sha256");
|
|
117
|
+
for (const artifact of artifacts) {
|
|
118
|
+
hash.update(`${artifact.key} ${artifact.sha256}\n`);
|
|
119
|
+
}
|
|
120
|
+
return hash.digest("hex");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Which dependency layer this build was built against.
|
|
125
|
+
*
|
|
126
|
+
* A layer is sent or named, never neither: the loader reads the head's layer
|
|
127
|
+
* by rev, and a publish that declares no layer at all loses every dependency
|
|
128
|
+
* on the next render. When the layer is being uploaded, its rev is inside it -
|
|
129
|
+
* it is gzipped JSON with a `rev` - so there is nothing to ask the caller for.
|
|
130
|
+
* When it is not, the build knows which stored layer it used, and says so with
|
|
131
|
+
* `--layer-rev`.
|
|
132
|
+
*/
|
|
133
|
+
export async function layerRevOf(
|
|
134
|
+
artifacts: Artifact[],
|
|
135
|
+
): Promise<
|
|
136
|
+
| { status: "ok"; layerRev: string | null }
|
|
137
|
+
| { status: "error"; message: string }
|
|
138
|
+
> {
|
|
139
|
+
const layer = artifacts.find((artifact) => artifact.key === "layer");
|
|
140
|
+
if (!layer) {
|
|
141
|
+
return { status: "ok", layerRev: null };
|
|
142
|
+
}
|
|
143
|
+
const bytes = await fs.promises.readFile(layer.file);
|
|
144
|
+
let parsed: unknown;
|
|
145
|
+
try {
|
|
146
|
+
parsed = JSON.parse(zlib.gunzipSync(bytes).toString("utf-8"));
|
|
147
|
+
} catch (err) {
|
|
148
|
+
return {
|
|
149
|
+
status: "error",
|
|
150
|
+
message:
|
|
151
|
+
`The layer artifact is not gzipped JSON: ${
|
|
152
|
+
err instanceof Error ? err.message : String(err)
|
|
153
|
+
}\n\n` +
|
|
154
|
+
"A layer is `{ rev, worker, browser, ... }`, gzipped. Pass --layer-rev to\n" +
|
|
155
|
+
"name a layer content already holds instead of sending one.",
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const rev =
|
|
159
|
+
typeof parsed === "object" && parsed !== null
|
|
160
|
+
? Reflect.get(parsed, "rev")
|
|
161
|
+
: undefined;
|
|
162
|
+
if (typeof rev !== "string" || rev === "") {
|
|
163
|
+
return {
|
|
164
|
+
status: "error",
|
|
165
|
+
message:
|
|
166
|
+
"The layer artifact has no rev, and a layer that is sent has to say which\n" +
|
|
167
|
+
"it is: that is the name the loader stores and later reuses it by.",
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return { status: "ok", layerRev: rev };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Where the artifacts are, if the caller did not say. */
|
|
174
|
+
export const DEFAULT_ARTIFACTS_DIR = ".val/publish";
|
|
175
|
+
|
|
176
|
+
export function resolveArtifactsDir(options: {
|
|
177
|
+
root: string;
|
|
178
|
+
dir?: string;
|
|
179
|
+
}): { status: "ok"; dir: string } | { status: "error"; message: string } {
|
|
180
|
+
const named = options.dir ?? DEFAULT_ARTIFACTS_DIR;
|
|
181
|
+
const dir = path.resolve(options.root, named);
|
|
182
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
|
183
|
+
return {
|
|
184
|
+
status: "error",
|
|
185
|
+
message:
|
|
186
|
+
`No artifacts to publish: ${dir} is not a directory.\n\n` +
|
|
187
|
+
"Build the project first, then point at what it produced:\n\n" +
|
|
188
|
+
" npx val publish --artifacts <directory>\n\n" +
|
|
189
|
+
"The path of each file under it is its artifact key - `server`, `client`,\n" +
|
|
190
|
+
"`public/...`, `chunk/client/...`, `layer`.",
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return { status: "ok", dir };
|
|
194
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import { getJson, postJson } from "./contentHost";
|
|
3
|
+
import {
|
|
4
|
+
ArtifactsResponse,
|
|
5
|
+
DeclareBody,
|
|
6
|
+
DeclareResponse,
|
|
7
|
+
PromoteResponse,
|
|
8
|
+
StatusResponse,
|
|
9
|
+
UploadSlot,
|
|
10
|
+
VerifyResponse,
|
|
11
|
+
parseArtifacts,
|
|
12
|
+
parseDeclare,
|
|
13
|
+
parsePromote,
|
|
14
|
+
parseStatus,
|
|
15
|
+
parseVerify,
|
|
16
|
+
} from "./protocol";
|
|
17
|
+
|
|
18
|
+
export type PublishClient = {
|
|
19
|
+
declare(body: DeclareBody): Promise<DeclareResponse>;
|
|
20
|
+
confirmArtifacts(publishId: string): Promise<ArtifactsResponse>;
|
|
21
|
+
verify(publishId: string): Promise<VerifyResponse>;
|
|
22
|
+
promote(publishId: string): Promise<PromoteResponse>;
|
|
23
|
+
status(publishId: string): Promise<StatusResponse>;
|
|
24
|
+
upload(slot: UploadSlot, file: string): Promise<void>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function createPublishClient(options: {
|
|
28
|
+
host: string;
|
|
29
|
+
token: string;
|
|
30
|
+
fetchImpl?: typeof fetch;
|
|
31
|
+
}): PublishClient {
|
|
32
|
+
const { host, token } = options;
|
|
33
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
34
|
+
// Every call, and nothing else: not a cookie, not the project's api key, not
|
|
35
|
+
// a personal access token.
|
|
36
|
+
const headers = { Authorization: `Bearer ${token}` };
|
|
37
|
+
const publishUrl = (publishId: string, step?: string) =>
|
|
38
|
+
`${host}/v1/publish/${encodeURIComponent(publishId)}${step ? `/${step}` : ""}`;
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
declare: async (body) =>
|
|
42
|
+
parseDeclare(
|
|
43
|
+
await postJson({ url: `${host}/v1/publish`, headers, body, fetchImpl }),
|
|
44
|
+
),
|
|
45
|
+
confirmArtifacts: async (publishId) =>
|
|
46
|
+
parseArtifacts(
|
|
47
|
+
await postJson({
|
|
48
|
+
url: publishUrl(publishId, "artifacts"),
|
|
49
|
+
headers,
|
|
50
|
+
fetchImpl,
|
|
51
|
+
}),
|
|
52
|
+
),
|
|
53
|
+
verify: async (publishId) =>
|
|
54
|
+
parseVerify(
|
|
55
|
+
await postJson({
|
|
56
|
+
url: publishUrl(publishId, "verify"),
|
|
57
|
+
headers,
|
|
58
|
+
fetchImpl,
|
|
59
|
+
}),
|
|
60
|
+
),
|
|
61
|
+
promote: async (publishId) =>
|
|
62
|
+
parsePromote(
|
|
63
|
+
await postJson({
|
|
64
|
+
url: publishUrl(publishId, "promote"),
|
|
65
|
+
headers,
|
|
66
|
+
fetchImpl,
|
|
67
|
+
}),
|
|
68
|
+
),
|
|
69
|
+
status: async (publishId) =>
|
|
70
|
+
parseStatus(
|
|
71
|
+
await getJson({ url: publishUrl(publishId), headers, fetchImpl }),
|
|
72
|
+
),
|
|
73
|
+
upload: (slot, file) => putArtifact(slot, file, fetchImpl),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Object storage refused the bytes, or could not be reached. */
|
|
78
|
+
export class UploadError extends Error {
|
|
79
|
+
readonly statusCode: number;
|
|
80
|
+
readonly key: string;
|
|
81
|
+
constructor(statusCode: number, key: string, message: string) {
|
|
82
|
+
super(message);
|
|
83
|
+
this.name = "UploadError";
|
|
84
|
+
this.statusCode = statusCode;
|
|
85
|
+
this.key = key;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A slot's permission to write has run out.
|
|
91
|
+
*
|
|
92
|
+
* Not a failed publish: declaring again mints fresh slots and the publish
|
|
93
|
+
* carries on from where it was, which is why this is its own kind of failure
|
|
94
|
+
* rather than an error message.
|
|
95
|
+
*/
|
|
96
|
+
export function isExpiredSlot(err: UploadError): boolean {
|
|
97
|
+
return err.statusCode === 403 || err.statusCode === 401;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function putArtifact(
|
|
101
|
+
slot: UploadSlot,
|
|
102
|
+
file: string,
|
|
103
|
+
fetchImpl: typeof fetch,
|
|
104
|
+
): Promise<void> {
|
|
105
|
+
/*
|
|
106
|
+
* Read, rather than streamed.
|
|
107
|
+
*
|
|
108
|
+
* `ContentLength` is signed into the slot's URL, so the store rejects a body
|
|
109
|
+
* of a different size - and a streamed body goes out chunked, which is a
|
|
110
|
+
* different size as far as the signature is concerned. An artifact is capped
|
|
111
|
+
* at 128 MB by the API, which is the bound this trades against.
|
|
112
|
+
*/
|
|
113
|
+
const body = await fs.promises.readFile(file);
|
|
114
|
+
let res: Response;
|
|
115
|
+
try {
|
|
116
|
+
res = await fetchImpl(slot.url, {
|
|
117
|
+
method: slot.method,
|
|
118
|
+
headers: slot.headers,
|
|
119
|
+
body,
|
|
120
|
+
});
|
|
121
|
+
} catch (err) {
|
|
122
|
+
throw new UploadError(
|
|
123
|
+
0,
|
|
124
|
+
slot.key,
|
|
125
|
+
`${slot.key}: ${err instanceof Error ? err.message : String(err)}`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (!res.ok) {
|
|
129
|
+
// The store answers XML, and a whole error document in a CI log buries the
|
|
130
|
+
// line that matters. The status is the actionable half.
|
|
131
|
+
throw new UploadError(
|
|
132
|
+
res.status,
|
|
133
|
+
slot.key,
|
|
134
|
+
`${slot.key}: object storage answered ${res.status} ${res.statusText}`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Run `worker` over `items`, `limit` at a time.
|
|
141
|
+
*
|
|
142
|
+
* A project can have hundreds of small artifacts, so one at a time is minutes
|
|
143
|
+
* of latency and all at once is hundreds of open sockets on a CI runner. The
|
|
144
|
+
* first failure stops the pool: there is no point uploading the rest of a
|
|
145
|
+
* publish that is not going to be promoted.
|
|
146
|
+
*/
|
|
147
|
+
export async function pool<T>(
|
|
148
|
+
items: T[],
|
|
149
|
+
limit: number,
|
|
150
|
+
worker: (item: T) => Promise<void>,
|
|
151
|
+
): Promise<void> {
|
|
152
|
+
let next = 0;
|
|
153
|
+
let failure: unknown = undefined;
|
|
154
|
+
const run = async (): Promise<void> => {
|
|
155
|
+
for (;;) {
|
|
156
|
+
if (failure !== undefined) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const index = next++;
|
|
160
|
+
if (index >= items.length) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
await worker(items[index]);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
failure = err;
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
await Promise.all(
|
|
172
|
+
Array.from({ length: Math.min(limit, items.length) }, () => run()),
|
|
173
|
+
);
|
|
174
|
+
if (failure !== undefined) {
|
|
175
|
+
throw failure;
|
|
176
|
+
}
|
|
177
|
+
}
|