Skip to content

Commit 7280274

Browse files
authoredMar 4, 2025
chore(client): only accept standard types for file uploads (#47)
1 parent 7b95d05 commit 7280274

17 files changed

+267
-280
lines changed
 

‎scripts/build

+2-2
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ cp dist/index.d.ts dist/index.d.mts
4040
cp tsconfig.dist-src.json dist/src/tsconfig.json
4141
cp src/internal/shim-types.d.ts dist/internal/shim-types.d.ts
4242
cp src/internal/shim-types.d.ts dist/internal/shim-types.d.mts
43-
mkdir -p dist/internal/polyfill
44-
cp src/internal/polyfill/*.{mjs,js,d.ts} dist/internal/polyfill
43+
mkdir -p dist/internal/shims
44+
cp src/internal/shims/*.{mjs,js,d.ts} dist/internal/shims
4545

4646
node scripts/utils/postprocess-files.cjs
4747

‎src/client.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -726,7 +726,9 @@ export class Gitpod {
726726

727727
const timeout = setTimeout(() => controller.abort(), ms);
728728

729-
const isReadableBody = Shims.isReadableLike(options.body);
729+
const isReadableBody =
730+
((globalThis as any).ReadableStream && options.body instanceof (globalThis as any).ReadableStream) ||
731+
(typeof options.body === 'object' && options.body !== null && Symbol.asyncIterator in options.body);
730732

731733
const fetchOptions: RequestInit = {
732734
signal: controller.signal as any,

‎src/internal/polyfill/file.node.d.ts

-9
This file was deleted.

‎src/internal/polyfill/file.node.js

-17
This file was deleted.

‎src/internal/polyfill/file.node.mjs

-9
This file was deleted.

‎src/internal/shims.ts

-56
Original file line numberDiff line numberDiff line change
@@ -20,62 +20,6 @@ export function getDefaultFetch(): Fetch {
2020
);
2121
}
2222

23-
/**
24-
* A minimal copy of the NodeJS `stream.Readable` class so that we can
25-
* accept the NodeJS types in certain places, e.g. file uploads
26-
*
27-
* https://nodejs.org/api/stream.html#class-streamreadable
28-
*/
29-
export interface ReadableLike {
30-
readable: boolean;
31-
readonly readableEnded: boolean;
32-
readonly readableFlowing: boolean | null;
33-
readonly readableHighWaterMark: number;
34-
readonly readableLength: number;
35-
readonly readableObjectMode: boolean;
36-
destroyed: boolean;
37-
read(size?: number): any;
38-
pause(): this;
39-
resume(): this;
40-
isPaused(): boolean;
41-
destroy(error?: Error): this;
42-
[Symbol.asyncIterator](): AsyncIterableIterator<any>;
43-
}
44-
45-
/**
46-
* Determines if the given value looks like a NodeJS `stream.Readable`
47-
* object and that it is readable, i.e. has not been consumed.
48-
*
49-
* https://nodejs.org/api/stream.html#class-streamreadable
50-
*/
51-
export function isReadableLike(value: any) {
52-
// We declare our own class of Readable here, so it's not feasible to
53-
// do an 'instanceof' check. Instead, check for Readable-like properties.
54-
return !!value && value.readable === true && typeof value.read === 'function';
55-
}
56-
57-
/**
58-
* A minimal copy of the NodeJS `fs.ReadStream` class for usage within file uploads.
59-
*
60-
* https://nodejs.org/api/fs.html#class-fsreadstream
61-
*/
62-
export interface FsReadStreamLike extends ReadableLike {
63-
path: {}; // real type is string | Buffer but we can't reference `Buffer` here
64-
}
65-
66-
/**
67-
* Determines if the given value looks like a NodeJS `fs.ReadStream`
68-
* object.
69-
*
70-
* This just checks if the object matches our `Readable` interface
71-
* and defines a `path` property, there may be false positives.
72-
*
73-
* https://nodejs.org/api/fs.html#class-fsreadstream
74-
*/
75-
export function isFsReadStreamLike(value: any): value is FsReadStreamLike {
76-
return isReadableLike(value) && 'path' in value;
77-
}
78-
7923
type ReadableStreamArgs = ConstructorParameters<typeof ReadableStream>;
8024

8125
export function makeReadableStream(...args: ReadableStreamArgs): ReadableStream {
File renamed without changes.
File renamed without changes.
File renamed without changes.

‎src/internal/shims/file.node.d.ts

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// The infer is to make TS show it as a nice union type,
2+
// instead of literally `ConstructorParameters<typeof Blob>[0]`
3+
type FallbackBlobSource = ConstructorParameters<typeof Blob>[0] extends infer T ? T : never;
4+
/**
5+
* A [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File) provides information about files.
6+
*/
7+
declare class FallbackFile extends Blob {
8+
constructor(sources: FallbackBlobSource, fileName: string, options?: any);
9+
/**
10+
* The name of the `File`.
11+
*/
12+
readonly name: string;
13+
/**
14+
* The last modified date of the `File`.
15+
*/
16+
readonly lastModified: number;
17+
}
18+
export type File = InstanceType<typeof File>;
19+
export const File: typeof globalThis extends { File: infer fileConstructor } ? fileConstructor
20+
: typeof FallbackFile;

‎src/internal/shims/file.node.js

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
if (typeof require !== 'undefined') {
2+
if (globalThis.File) {
3+
exports.File = globalThis.File;
4+
} else {
5+
try {
6+
// Use [require][0](...) and not require(...) so bundlers don't try to bundle the
7+
// buffer module.
8+
exports.File = [require][0]('node:buffer').File;
9+
} catch (e) {}
10+
}
11+
}

‎src/internal/shims/file.node.mjs

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import * as mod from './file.node.js';
2+
export const File = globalThis.File || mod.File;

‎src/internal/to-file.ts

+152
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { File } from './shims/file.node.js';
2+
import { BlobPart, getName, makeFile, isAsyncIterable } from './uploads';
3+
import type { FilePropertyBag } from './builtin-types';
4+
5+
type BlobLikePart = string | ArrayBuffer | ArrayBufferView | BlobLike | DataView;
6+
7+
/**
8+
* Intended to match DOM Blob, node-fetch Blob, node:buffer Blob, etc.
9+
* Don't add arrayBuffer here, node-fetch doesn't have it
10+
*/
11+
interface BlobLike {
12+
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/size) */
13+
readonly size: number;
14+
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/type) */
15+
readonly type: string;
16+
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/text) */
17+
text(): Promise<string>;
18+
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/slice) */
19+
slice(start?: number, end?: number): BlobLike;
20+
}
21+
22+
/**
23+
* This check adds the arrayBuffer() method type because it is available and used at runtime
24+
*/
25+
const isBlobLike = (value: any): value is BlobLike & { arrayBuffer(): Promise<ArrayBuffer> } =>
26+
value != null &&
27+
typeof value === 'object' &&
28+
typeof value.size === 'number' &&
29+
typeof value.type === 'string' &&
30+
typeof value.text === 'function' &&
31+
typeof value.slice === 'function' &&
32+
typeof value.arrayBuffer === 'function';
33+
34+
/**
35+
* Intended to match DOM File, node:buffer File, undici File, etc.
36+
*/
37+
interface FileLike extends BlobLike {
38+
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/lastModified) */
39+
readonly lastModified: number;
40+
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/name) */
41+
readonly name?: string | undefined;
42+
}
43+
44+
/**
45+
* This check adds the arrayBuffer() method type because it is available and used at runtime
46+
*/
47+
const isFileLike = (value: any): value is FileLike & { arrayBuffer(): Promise<ArrayBuffer> } =>
48+
value != null &&
49+
typeof value === 'object' &&
50+
typeof value.name === 'string' &&
51+
typeof value.lastModified === 'number' &&
52+
isBlobLike(value);
53+
54+
/**
55+
* Intended to match DOM Response, node-fetch Response, undici Response, etc.
56+
*/
57+
export interface ResponseLike {
58+
url: string;
59+
blob(): Promise<BlobLike>;
60+
}
61+
62+
const isResponseLike = (value: any): value is ResponseLike =>
63+
value != null &&
64+
typeof value === 'object' &&
65+
typeof value.url === 'string' &&
66+
typeof value.blob === 'function';
67+
68+
export type ToFileInput =
69+
| FileLike
70+
| ResponseLike
71+
| Exclude<BlobLikePart, string>
72+
| AsyncIterable<BlobLikePart>;
73+
74+
/**
75+
* Helper for creating a {@link File} to pass to an SDK upload method from a variety of different data formats
76+
* @param value the raw content of the file. Can be an {@link Uploadable}, {@link BlobLikePart}, or {@link AsyncIterable} of {@link BlobLikePart}s
77+
* @param {string=} name the name of the file. If omitted, toFile will try to determine a file name from bits if possible
78+
* @param {Object=} options additional properties
79+
* @param {string=} options.type the MIME type of the content
80+
* @param {number=} options.lastModified the last modified timestamp
81+
* @returns a {@link File} with the given properties
82+
*/
83+
export async function toFile(
84+
value: ToFileInput | PromiseLike<ToFileInput>,
85+
name?: string | null | undefined,
86+
options?: FilePropertyBag | undefined,
87+
): Promise<File> {
88+
// If it's a promise, resolve it.
89+
value = await value;
90+
91+
// If we've been given a `File` we don't need to do anything
92+
if (isFileLike(value)) {
93+
if (File && value instanceof File) {
94+
return value;
95+
}
96+
return makeFile([await value.arrayBuffer()], value.name);
97+
}
98+
99+
if (isResponseLike(value)) {
100+
const blob = await value.blob();
101+
name ||= new URL(value.url).pathname.split(/[\\/]/).pop();
102+
103+
return makeFile(await getBytes(blob), name, options);
104+
}
105+
106+
const parts = await getBytes(value);
107+
108+
name ||= getName(value);
109+
110+
if (!options?.type) {
111+
const type = parts.find((part) => typeof part === 'object' && 'type' in part && part.type);
112+
if (typeof type === 'string') {
113+
options = { ...options, type };
114+
}
115+
}
116+
117+
return makeFile(parts, name, options);
118+
}
119+
120+
async function getBytes(value: BlobLikePart | AsyncIterable<BlobLikePart>): Promise<Array<BlobPart>> {
121+
let parts: Array<BlobPart> = [];
122+
if (
123+
typeof value === 'string' ||
124+
ArrayBuffer.isView(value) || // includes Uint8Array, Buffer, etc.
125+
value instanceof ArrayBuffer
126+
) {
127+
parts.push(value);
128+
} else if (isBlobLike(value)) {
129+
parts.push(value instanceof Blob ? value : await value.arrayBuffer());
130+
} else if (
131+
isAsyncIterable(value) // includes Readable, ReadableStream, etc.
132+
) {
133+
for await (const chunk of value) {
134+
parts.push(...(await getBytes(chunk as BlobLikePart))); // TODO, consider validating?
135+
}
136+
} else {
137+
const constructor = value?.constructor?.name;
138+
throw new Error(
139+
`Unexpected data type: ${typeof value}${
140+
constructor ? `; constructor: ${constructor}` : ''
141+
}${propsForError(value)}`,
142+
);
143+
}
144+
145+
return parts;
146+
}
147+
148+
function propsForError(value: unknown): string {
149+
if (typeof value !== 'object' || value === null) return '';
150+
const props = Object.getOwnPropertyNames(value);
151+
return `; props: [${props.map((p) => `"${p}"`).join(', ')}]`;
152+
}

0 commit comments

Comments
 (0)
Failed to load comments.