Skip to content

Commit

Permalink
chore(client): only accept standard types for file uploads (#25)
Browse files Browse the repository at this point in the history
  • Loading branch information
stainless-app[bot] committed Mar 4, 2025
1 parent 8e0ab12 commit c5e8df8
Showing 17 changed files with 267 additions and 280 deletions.
4 changes: 2 additions & 2 deletions scripts/build
Original file line number Diff line number Diff line change
@@ -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

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

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,
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
@@ -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 {
File renamed without changes.
File renamed without changes.
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
Oops, something went wrong.

0 comments on commit c5e8df8

Please sign in to comment.