Skip to content

chore(client): only accept standard types for file uploads #47

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions scripts/build
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ cp dist/index.d.ts dist/index.d.mts
cp tsconfig.dist-src.json dist/src/tsconfig.json
cp src/internal/shim-types.d.ts dist/internal/shim-types.d.ts
cp src/internal/shim-types.d.ts dist/internal/shim-types.d.mts
mkdir -p dist/internal/polyfill
cp src/internal/polyfill/*.{mjs,js,d.ts} dist/internal/polyfill
mkdir -p dist/internal/shims
cp src/internal/shims/*.{mjs,js,d.ts} dist/internal/shims

node scripts/utils/postprocess-files.cjs

Expand Down
4 changes: 3 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -726,7 +726,9 @@ export class Gitpod {

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

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

const fetchOptions: RequestInit = {
signal: controller.signal as any,
Expand Down
9 changes: 0 additions & 9 deletions src/internal/polyfill/file.node.d.ts

This file was deleted.

17 changes: 0 additions & 17 deletions src/internal/polyfill/file.node.js

This file was deleted.

9 changes: 0 additions & 9 deletions src/internal/polyfill/file.node.mjs

This file was deleted.

56 changes: 0 additions & 56 deletions src/internal/shims.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,62 +20,6 @@ export function getDefaultFetch(): Fetch {
);
}

/**
* A minimal copy of the NodeJS `stream.Readable` class so that we can
* accept the NodeJS types in certain places, e.g. file uploads
*
* https://nodejs.org/api/stream.html#class-streamreadable
*/
export interface ReadableLike {
readable: boolean;
readonly readableEnded: boolean;
readonly readableFlowing: boolean | null;
readonly readableHighWaterMark: number;
readonly readableLength: number;
readonly readableObjectMode: boolean;
destroyed: boolean;
read(size?: number): any;
pause(): this;
resume(): this;
isPaused(): boolean;
destroy(error?: Error): this;
[Symbol.asyncIterator](): AsyncIterableIterator<any>;
}

/**
* Determines if the given value looks like a NodeJS `stream.Readable`
* object and that it is readable, i.e. has not been consumed.
*
* https://nodejs.org/api/stream.html#class-streamreadable
*/
export function isReadableLike(value: any) {
// We declare our own class of Readable here, so it's not feasible to
// do an 'instanceof' check. Instead, check for Readable-like properties.
return !!value && value.readable === true && typeof value.read === 'function';
}

/**
* A minimal copy of the NodeJS `fs.ReadStream` class for usage within file uploads.
*
* https://nodejs.org/api/fs.html#class-fsreadstream
*/
export interface FsReadStreamLike extends ReadableLike {
path: {}; // real type is string | Buffer but we can't reference `Buffer` here
}

/**
* Determines if the given value looks like a NodeJS `fs.ReadStream`
* object.
*
* This just checks if the object matches our `Readable` interface
* and defines a `path` property, there may be false positives.
*
* https://nodejs.org/api/fs.html#class-fsreadstream
*/
export function isFsReadStreamLike(value: any): value is FsReadStreamLike {
return isReadableLike(value) && 'path' in value;
}

type ReadableStreamArgs = ConstructorParameters<typeof ReadableStream>;

export function makeReadableStream(...args: ReadableStreamArgs): ReadableStream {
Expand Down
File renamed without changes.
20 changes: 20 additions & 0 deletions src/internal/shims/file.node.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// The infer is to make TS show it as a nice union type,
// instead of literally `ConstructorParameters<typeof Blob>[0]`
type FallbackBlobSource = ConstructorParameters<typeof Blob>[0] extends infer T ? T : never;
/**
* A [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File) provides information about files.
*/
declare class FallbackFile extends Blob {
constructor(sources: FallbackBlobSource, fileName: string, options?: any);
/**
* The name of the `File`.
*/
readonly name: string;
/**
* The last modified date of the `File`.
*/
readonly lastModified: number;
}
export type File = InstanceType<typeof File>;
export const File: typeof globalThis extends { File: infer fileConstructor } ? fileConstructor
: typeof FallbackFile;
11 changes: 11 additions & 0 deletions src/internal/shims/file.node.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
if (typeof require !== 'undefined') {
if (globalThis.File) {
exports.File = globalThis.File;
} else {
try {
// Use [require][0](...) and not require(...) so bundlers don't try to bundle the
// buffer module.
exports.File = [require][0]('node:buffer').File;
} catch (e) {}
}
}
2 changes: 2 additions & 0 deletions src/internal/shims/file.node.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import * as mod from './file.node.js';
export const File = globalThis.File || mod.File;
152 changes: 152 additions & 0 deletions src/internal/to-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { File } from './shims/file.node.js';
import { BlobPart, getName, makeFile, isAsyncIterable } from './uploads';
import type { FilePropertyBag } from './builtin-types';

type BlobLikePart = string | ArrayBuffer | ArrayBufferView | BlobLike | DataView;

/**
* Intended to match DOM Blob, node-fetch Blob, node:buffer Blob, etc.
* Don't add arrayBuffer here, node-fetch doesn't have it
*/
interface BlobLike {
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/size) */
readonly size: number;
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/type) */
readonly type: string;
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/text) */
text(): Promise<string>;
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/slice) */
slice(start?: number, end?: number): BlobLike;
}

/**
* This check adds the arrayBuffer() method type because it is available and used at runtime
*/
const isBlobLike = (value: any): value is BlobLike & { arrayBuffer(): Promise<ArrayBuffer> } =>
value != null &&
typeof value === 'object' &&
typeof value.size === 'number' &&
typeof value.type === 'string' &&
typeof value.text === 'function' &&
typeof value.slice === 'function' &&
typeof value.arrayBuffer === 'function';

/**
* Intended to match DOM File, node:buffer File, undici File, etc.
*/
interface FileLike extends BlobLike {
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/lastModified) */
readonly lastModified: number;
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/name) */
readonly name?: string | undefined;
}

/**
* This check adds the arrayBuffer() method type because it is available and used at runtime
*/
const isFileLike = (value: any): value is FileLike & { arrayBuffer(): Promise<ArrayBuffer> } =>
value != null &&
typeof value === 'object' &&
typeof value.name === 'string' &&
typeof value.lastModified === 'number' &&
isBlobLike(value);

/**
* Intended to match DOM Response, node-fetch Response, undici Response, etc.
*/
export interface ResponseLike {
url: string;
blob(): Promise<BlobLike>;
}

const isResponseLike = (value: any): value is ResponseLike =>
value != null &&
typeof value === 'object' &&
typeof value.url === 'string' &&
typeof value.blob === 'function';

export type ToFileInput =
| FileLike
| ResponseLike
| Exclude<BlobLikePart, string>
| AsyncIterable<BlobLikePart>;

/**
* Helper for creating a {@link File} to pass to an SDK upload method from a variety of different data formats
* @param value the raw content of the file. Can be an {@link Uploadable}, {@link BlobLikePart}, or {@link AsyncIterable} of {@link BlobLikePart}s
* @param {string=} name the name of the file. If omitted, toFile will try to determine a file name from bits if possible
* @param {Object=} options additional properties
* @param {string=} options.type the MIME type of the content
* @param {number=} options.lastModified the last modified timestamp
* @returns a {@link File} with the given properties
*/
export async function toFile(
value: ToFileInput | PromiseLike<ToFileInput>,
name?: string | null | undefined,
options?: FilePropertyBag | undefined,
): Promise<File> {
// If it's a promise, resolve it.
value = await value;

// If we've been given a `File` we don't need to do anything
if (isFileLike(value)) {
if (File && value instanceof File) {
return value;
}
return makeFile([await value.arrayBuffer()], value.name);
}

if (isResponseLike(value)) {
const blob = await value.blob();
name ||= new URL(value.url).pathname.split(/[\\/]/).pop();

return makeFile(await getBytes(blob), name, options);
}

const parts = await getBytes(value);

name ||= getName(value);

if (!options?.type) {
const type = parts.find((part) => typeof part === 'object' && 'type' in part && part.type);
if (typeof type === 'string') {
options = { ...options, type };
}
}

return makeFile(parts, name, options);
}

async function getBytes(value: BlobLikePart | AsyncIterable<BlobLikePart>): Promise<Array<BlobPart>> {
let parts: Array<BlobPart> = [];
if (
typeof value === 'string' ||
ArrayBuffer.isView(value) || // includes Uint8Array, Buffer, etc.
value instanceof ArrayBuffer
) {
parts.push(value);
} else if (isBlobLike(value)) {
parts.push(value instanceof Blob ? value : await value.arrayBuffer());
} else if (
isAsyncIterable(value) // includes Readable, ReadableStream, etc.
) {
for await (const chunk of value) {
parts.push(...(await getBytes(chunk as BlobLikePart))); // TODO, consider validating?
}
} else {
const constructor = value?.constructor?.name;
throw new Error(
`Unexpected data type: ${typeof value}${
constructor ? `; constructor: ${constructor}` : ''
}${propsForError(value)}`,
);
}

return parts;
}

function propsForError(value: unknown): string {
if (typeof value !== 'object' || value === null) return '';
const props = Object.getOwnPropertyNames(value);
return `; props: [${props.map((p) => `"${p}"`).join(', ')}]`;
}
Loading