Skip to content

Commit ae9f2b7

Browse files
authored
Merge pull request #1324 from jakebailey/remove-form-data
Replace form-data with Node streams
2 parents b40452e + 3fb79d8 commit ae9f2b7

5 files changed

Lines changed: 359 additions & 119 deletions

File tree

‎package-lock.json‎

Lines changed: 6 additions & 101 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@
5050
"azure-devops-node-api": "^12.5.0",
5151
"cockatiel": "^3.2.1",
5252
"commander": "^12.1.0",
53-
"form-data": "^4.0.6",
5453
"hosted-git-info": "^4.1.0",
5554
"jsonc-parser": "^3.3.1",
5655
"leven": "^3.1.0",

‎src/multipart.ts‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { Readable } from 'stream';
2+
3+
export interface MultipartPart {
4+
name: string;
5+
filename: string;
6+
stream: Readable;
7+
}
8+
9+
function escapeHeaderParameter(value: string): string {
10+
return value.replace(/\r/g, '%0D').replace(/\n/g, '%0A').replace(/"/g, '%22');
11+
}
12+
13+
export function createMultipartStream(parts: readonly MultipartPart[], boundary: string): Readable {
14+
const lineBreak = '\r\n';
15+
16+
for (const part of parts) {
17+
// A part can fail before it is reached. Readable stores the error and its async iterator
18+
// rethrows it when the part is consumed; this listener just keeps it from being unhandled.
19+
part.stream.on('error', () => {});
20+
}
21+
22+
const destroyParts = () => {
23+
for (const part of parts) {
24+
part.stream.destroy();
25+
}
26+
};
27+
28+
const stream = Readable.from(
29+
(async function* () {
30+
try {
31+
for (const part of parts) {
32+
yield `--${boundary}${lineBreak}` +
33+
`Content-Disposition: attachment; name=${escapeHeaderParameter(part.name)}; filename="${escapeHeaderParameter(part.filename)}"${lineBreak}` +
34+
`Content-Type: application/octet-stream${lineBreak}${lineBreak}`;
35+
yield* part.stream;
36+
yield lineBreak;
37+
}
38+
39+
yield `--${boundary}--${lineBreak}`;
40+
} finally {
41+
// Parts after a failed or abandoned one are never consumed, so release them here.
42+
destroyParts();
43+
}
44+
})(),
45+
// Without this the stream emits 'close' once it ends, which makes consumers that treat
46+
// 'close' as "the body is complete" end the underlying request a second time.
47+
{ autoDestroy: false }
48+
);
49+
50+
// The generator body, and therefore its `finally`, never runs if the stream is destroyed
51+
// before anything is read from it.
52+
stream.on('close', destroyParts);
53+
54+
return stream;
55+
}
56+
57+
export function runWithStreamError<T>(stream: Readable, operation: () => Promise<T>): Promise<T> {
58+
return new Promise<T>((resolve, reject) => {
59+
// This listener is deliberately never removed. An error arriving after the operation has
60+
// settled would otherwise be an unhandled 'error' event, which terminates the process.
61+
// Settling a promise more than once is a no-op, so the first outcome wins.
62+
stream.on('error', reject);
63+
Promise.resolve().then(operation).then(resolve, reject);
64+
});
65+
}

‎src/publish.ts‎

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@ import { ManifestPackage, ManifestPublish } from './manifest';
88
import { readVSIXPackage } from './zip';
99
import { validatePublisher } from './validation';
1010
import { GalleryApi } from 'azure-devops-node-api/GalleryApi';
11-
import FormData from 'form-data';
1211
import { basename, join } from 'path';
1312
import { tmpdir } from 'os';
1413
import { IterableBackoff, handleWhen, retry } from 'cockatiel';
1514
import { getAzureCredentialAccessToken } from './auth';
1615
import { getOIDCCredential } from './oidc';
16+
import { createMultipartStream, runWithStreamError } from './multipart';
1717

1818
async function withTemporaryPackage<T>(fn: (packagePath: string) => Promise<T>): Promise<T> {
1919
const directory = await fs.promises.mkdtemp(join(tmpdir(), 'vsce-'));
@@ -213,7 +213,6 @@ export interface IInternalPublishOptions {
213213
async function _publish(packagePath: string, sigzipPath: string | undefined, manifest: ManifestPublish, options: IInternalPublishOptions) {
214214
const pat = await getPAT(manifest.publisher, options);
215215
const api = await getGalleryAPI(pat);
216-
const packageStream = fs.createReadStream(packagePath);
217216
const name = `${manifest.publisher}.${manifest.name}`;
218217
const description = options.target
219218
? `${name} (${options.target}) v${manifest.version}`
@@ -254,10 +253,10 @@ async function _publish(packagePath: string, sigzipPath: string | undefined, man
254253
}
255254

256255
if (sigzipPath) {
257-
await _publishSignedPackage(api, basename(packagePath), packageStream, basename(sigzipPath), fs.createReadStream(sigzipPath), manifest);
256+
await _publishSignedPackage(api, packagePath, sigzipPath, manifest);
258257
} else {
259258
try {
260-
await api.updateExtension(undefined, packageStream, manifest.publisher, manifest.name);
259+
await api.updateExtension(undefined, fs.createReadStream(packagePath), manifest.publisher, manifest.name);
261260
} catch (err: any) {
262261
if (err.statusCode === 409) {
263262
if (options.skipDuplicate) {
@@ -273,9 +272,9 @@ async function _publish(packagePath: string, sigzipPath: string | undefined, man
273272
}
274273
} else {
275274
if (sigzipPath) {
276-
await _publishSignedPackage(api, basename(packagePath), packageStream, basename(sigzipPath), fs.createReadStream(sigzipPath), manifest);
275+
await _publishSignedPackage(api, packagePath, sigzipPath, manifest);
277276
} else {
278-
await api.createExtension(undefined, packageStream);
277+
await api.createExtension(undefined, fs.createReadStream(packagePath));
279278
}
280279
}
281280
} catch (err: any) {
@@ -295,25 +294,34 @@ async function _publish(packagePath: string, sigzipPath: string | undefined, man
295294
log.done(`Published ${description}.`);
296295
}
297296

298-
async function _publishSignedPackage(api: GalleryApi, packageName: string, packageStream: fs.ReadStream, sigzipName: string, sigzipStream: fs.ReadStream, manifest: ManifestPublish) {
297+
async function _publishSignedPackage(api: GalleryApi, packagePath: string, sigzipPath: string, manifest: ManifestPublish) {
299298
const extensionType = 'Visual Studio Code';
300-
const form = new FormData();
301-
const lineBreak = '\r\n';
302-
form.setBoundary('0f411892-ef48-488f-89d3-4f0546e84723');
303-
form.append('vsix', packageStream, {
304-
header: `--${form.getBoundary()}${lineBreak}Content-Disposition: attachment; name=vsix; filename=\"${packageName}\"${lineBreak}Content-Type: application/octet-stream${lineBreak}${lineBreak}`
305-
});
306-
form.append('sigzip', sigzipStream, {
307-
header: `--${form.getBoundary()}${lineBreak}Content-Disposition: attachment; name=sigzip; filename=\"${sigzipName}\"${lineBreak}Content-Type: application/octet-stream${lineBreak}${lineBreak}`
308-
});
309299

310300
const publishWithRetry = retry(handleWhen(err => err.message.includes('timeout')), {
311301
maxAttempts: 3,
312302
backoff: new IterableBackoff([5_000, 10_000, 20_000])
313303
});
314304

315305
return await publishWithRetry.execute(async () => {
316-
return await api.publishExtensionWithPublisherSignature(undefined, form, manifest.publisher, manifest.name, extensionType);
306+
const form = createMultipartStream(
307+
[
308+
{ name: 'vsix', filename: basename(packagePath), stream: fs.createReadStream(packagePath) },
309+
{ name: 'sigzip', filename: basename(sigzipPath), stream: fs.createReadStream(sigzipPath) },
310+
],
311+
'0f411892-ef48-488f-89d3-4f0546e84723'
312+
);
313+
314+
try {
315+
return await runWithStreamError(form, () =>
316+
api.publishExtensionWithPublisherSignature(undefined, form, manifest.publisher, manifest.name, extensionType)
317+
);
318+
} finally {
319+
// Release the file handles when the request did not consume the whole form, which
320+
// otherwise keeps the package locked until the process exits.
321+
if (!form.readableEnded) {
322+
form.destroy();
323+
}
324+
}
317325
});
318326
}
319327

0 commit comments

Comments
 (0)