@jsenv/package-publish 1.11.49 → 1.11.51

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/package-publish",
3
- "version": "1.11.49",
3
+ "version": "1.11.51",
4
4
  "type": "module",
5
5
  "description": "Publish package to one or many registry.",
6
6
  "repository": {
@@ -23,7 +23,7 @@
23
23
  "/src/"
24
24
  ],
25
25
  "dependencies": {
26
- "@jsenv/filesystem": "4.15.19",
26
+ "@jsenv/filesystem": "4.15.20",
27
27
  "@jsenv/humanize": "1.7.8",
28
28
  "semver": "7.8.5"
29
29
  },
@@ -8,7 +8,7 @@ export const fetchLatestInRegistry = async ({
8
8
  token,
9
9
  }) => {
10
10
  const requestUrl = `${registryUrl}/${packageName}`;
11
- const response = await fetch(requestUrl, {
11
+ const response = await fetchWithRetryOnTransientError(requestUrl, {
12
12
  method: "GET",
13
13
  headers: {
14
14
  // "user-agent": "jsenv",
@@ -38,6 +38,40 @@ export const fetchLatestInRegistry = async ({
38
38
  return packageObject.versions[packageObject["dist-tags"].latest];
39
39
  };
40
40
 
41
+ // The registry (or a middlebox) sometimes closes a keep-alive socket while a
42
+ // request is in flight, surfacing as "fetch failed" with ECONNRESET. Fetching a
43
+ // whole workspace fires many requests at once so one reset per run is common.
44
+ // These failures are transient by nature and cannot be prevented client-side,
45
+ // so retry a couple of times before giving up.
46
+ const TRANSIENT_NETWORK_ERROR_CODES = [
47
+ "ECONNRESET",
48
+ "ETIMEDOUT",
49
+ "EPIPE",
50
+ "EAI_AGAIN",
51
+ "UND_ERR_SOCKET",
52
+ ];
53
+ const fetchWithRetryOnTransientError = async (url, options) => {
54
+ let attemptCount = 0;
55
+ while (true) {
56
+ attemptCount++;
57
+ try {
58
+ return await fetch(url, options);
59
+ } catch (e) {
60
+ const errorCode = e.cause?.code;
61
+ if (
62
+ attemptCount >= 3 ||
63
+ !TRANSIENT_NETWORK_ERROR_CODES.includes(errorCode)
64
+ ) {
65
+ throw e;
66
+ }
67
+ const delay = attemptCount * 500;
68
+ await new Promise((resolve) => {
69
+ setTimeout(resolve, delay);
70
+ });
71
+ }
72
+ }
73
+ };
74
+
41
75
  const writeUnexpectedResponseStatus = ({
42
76
  requestUrl,
43
77
  responseStatus,
@@ -64,7 +64,7 @@ export const publish = async ({
64
64
  }),
65
65
  );
66
66
  try {
67
- const reason = await new Promise((resolve, reject) => {
67
+ const publishResult = await new Promise((resolve, reject) => {
68
68
  const command = exec(
69
69
  "npm publish --no-workspaces",
70
70
  {
@@ -105,6 +105,24 @@ export const publish = async ({
105
105
  success: true,
106
106
  reason: "already-published",
107
107
  });
108
+ } else if (error.message.includes("previously staged version")) {
109
+ // The registry accepted a tarball for that version (from a run
110
+ // interrupted before npm confirmed, or one whose PUT it took
111
+ // without exposing the version yet) and refuses any further
112
+ // publish of it until it becomes visible. The publish did go
113
+ // through, so wait for the version to land.
114
+ resolve(
115
+ waitForStagedVersionToLand({
116
+ logger,
117
+ registryUrl,
118
+ packageName: packageObject.name,
119
+ packageVersion: packageObject.version,
120
+ token,
121
+ }).then(() => ({
122
+ success: true,
123
+ reason: "already-published",
124
+ })),
125
+ );
108
126
  }
109
127
  // github publish conflict
110
128
  else if (
@@ -138,14 +156,11 @@ export const publish = async ({
138
156
  });
139
157
  }
140
158
  });
141
- if (reason === "already-published") {
159
+ if (publishResult.reason === "already-published") {
142
160
  publishTask.setRightText(`(already published)`);
143
161
  }
144
162
  publishTask.done();
145
- return {
146
- success: true,
147
- reason,
148
- };
163
+ return publishResult;
149
164
  } finally {
150
165
  restoreProcessEnv();
151
166
  restorePackageFile();
@@ -183,3 +198,75 @@ const computeRegistryKey = (packageName) => {
183
198
  }
184
199
  return `registry`;
185
200
  };
201
+
202
+ // A version becomes available in two steps: npm stages it, then the registry
203
+ // makes it visible. While it is staged the registry answers 409 to a publish of
204
+ // that same version, and once staged that version can never be published again,
205
+ // so waiting for it is the only way through.
206
+ const STAGED_VERSION_TIMEOUT_MS = 120_000;
207
+ const STAGED_VERSION_POLL_INTERVAL_MS = 5_000;
208
+
209
+ const waitForStagedVersionToLand = async ({
210
+ logger,
211
+ registryUrl,
212
+ packageName,
213
+ packageVersion,
214
+ token,
215
+ }) => {
216
+ logger.info(
217
+ `${packageName}@${packageVersion} is staged on ${registryUrl}, waiting for the registry to publish it`,
218
+ );
219
+ const msBeforeTimeout = Date.now() + STAGED_VERSION_TIMEOUT_MS;
220
+ while (true) {
221
+ const versionIsInRegistry = await checkVersionInRegistry({
222
+ registryUrl,
223
+ packageName,
224
+ packageVersion,
225
+ token,
226
+ });
227
+ if (versionIsInRegistry) {
228
+ return;
229
+ }
230
+ if (Date.now() > msBeforeTimeout) {
231
+ throw new Error(
232
+ `${packageName}@${packageVersion} is staged on ${registryUrl} but did not get published. Bump the version, a staged version cannot be published again.`,
233
+ );
234
+ }
235
+ await new Promise((resolve) => {
236
+ setTimeout(resolve, STAGED_VERSION_POLL_INTERVAL_MS);
237
+ });
238
+ }
239
+ };
240
+
241
+ const checkVersionInRegistry = async ({
242
+ registryUrl,
243
+ packageName,
244
+ packageVersion,
245
+ token,
246
+ }) => {
247
+ let response;
248
+ try {
249
+ response = await fetch(`${registryUrl}/${packageName}`, {
250
+ headers: {
251
+ "accept":
252
+ "application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*",
253
+ // the registry is served by a cache; without this the version can stay
254
+ // invisible long after it landed
255
+ "cache-control": "no-cache",
256
+ ...(token
257
+ ? {
258
+ authorization: `token ${token}`,
259
+ }
260
+ : {}),
261
+ },
262
+ });
263
+ } catch {
264
+ // a network hiccup is one more reason for the version not to be there yet
265
+ return false;
266
+ }
267
+ if (response.status !== 200) {
268
+ return false;
269
+ }
270
+ const packageObject = await response.json();
271
+ return Boolean(packageObject.versions[packageVersion]);
272
+ };