Skip to content

Commit

Permalink
feat: support ESM and web platform runtimes (#25)
Browse files Browse the repository at this point in the history
We have added or improved support for:
- EcmaScript Modules (ESM) for Node and others (in addition to CJS)
- Cloudflare Workers
- Vercel Edge Runtime
- Deno
- Browser (tested with webpack) - though you should not expose your secret key.

In Node, we still use libraries like `node-fetch` and `formdata-node`; in others, we use the native APIs.

Co-authored-by: Stainless Bot <[email protected]>
  • Loading branch information
rattrayalex and stainless-bot authored Jun 28, 2023
1 parent e74e156 commit d5552a5
Show file tree
Hide file tree
Showing 36 changed files with 1,276 additions and 693 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
node_modules
yarn-error.log
dist
/*.tgz
3 changes: 0 additions & 3 deletions .npmignore

This file was deleted.

2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
CHANGELOG.md
/ecosystem-tests
/node_modules
20 changes: 20 additions & 0 deletions _shims/agent.node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Disclaimer: modules in _shims aren't intended to be imported by SDK users.
*/

import KeepAliveAgent from 'agentkeepalive';
import type { Agent } from 'node:http';
import { AbortController as AbortControllerPolyfill } from 'abort-controller';

const defaultHttpAgent: Agent = new KeepAliveAgent({ keepAlive: true, timeout: 5 * 60 * 1000 });
const defaultHttpsAgent: Agent = new KeepAliveAgent.HttpsAgent({ keepAlive: true, timeout: 5 * 60 * 1000 });

// Polyfill global object if needed.
if (typeof AbortController === 'undefined') {
AbortController = AbortControllerPolyfill as any as typeof AbortController;
}

export const getDefaultAgent = (url: string): Agent | undefined => {
if (defaultHttpsAgent && url.startsWith('https')) return defaultHttpsAgent;
return defaultHttpAgent;
};
12 changes: 12 additions & 0 deletions _shims/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Disclaimer: modules in _shims aren't intended to be imported by SDK users.
*
* This is a stub for non-node environments.
* In node environments, it gets replaced agent.node.ts by the package export map
*/

import type { Agent } from 'node:http';

export const getDefaultAgent = (url: string): Agent | undefined => {
return undefined;
};
11 changes: 11 additions & 0 deletions _shims/fetch.node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Disclaimer: modules in _shims aren't intended to be imported by SDK users.
*/

import fetch, { Request, Response, Headers } from 'node-fetch';
import type { RequestInfo, RequestInit, BodyInit } from 'node-fetch';

export { fetch, Request, Response, Headers };
export type { RequestInfo, RequestInit, BodyInit };

export const isPolyfilled = true;
32 changes: 32 additions & 0 deletions _shims/fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Disclaimer: modules in _shims aren't intended to be imported by SDK users.
*/

import type * as nf from 'node-fetch';

const _fetch: typeof nf.default =
// @ts-ignore
fetch as any;
type _fetch = typeof nf.default;
const _Request: typeof nf.Request =
// @ts-ignore
Request as any;
type _Request = nf.Request;
const _Response: typeof nf.Response =
// @ts-ignore
Response as any;
type _Response = nf.Response;
const _Headers: typeof nf.Headers =
// @ts-ignore
Headers as any;
type _Headers = nf.Headers;

export const isPolyfilled = false;

export { _fetch as fetch, _Request as Request, _Response as Response, _Headers as Headers };

type _RequestInfo = nf.RequestInfo;
type _RequestInit = nf.RequestInit;
type _BodyInit = nf.BodyInit;

export type { _RequestInit as RequestInit, _RequestInfo as RequestInfo, _BodyInit as BodyInit };
30 changes: 30 additions & 0 deletions _shims/fileFromPath.node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Disclaimer: modules in _shims aren't intended to be imported by SDK users.
*/

import type { FilePropertyBag } from 'formdata-node';
import { fileFromPath as _fileFromPath } from 'formdata-node/file-from-path';
import type { File } from './formdata.node';

export type FileFromPathOptions = Omit<FilePropertyBag, 'lastModified'>;

let warned = false;

/**
* @deprecated use fs.createReadStream('./my/file.txt') instead
*/
export async function fileFromPath(path: string): Promise<File>;
export async function fileFromPath(path: string, filename?: string): Promise<File>;
export async function fileFromPath(path: string, options?: FileFromPathOptions): Promise<File>;
export async function fileFromPath(
path: string,
filename?: string,
options?: FileFromPathOptions,
): Promise<File>;
export async function fileFromPath(path: string, ...args: any[]): Promise<File> {
if (!warned) {
console.warn(`fileFromPath is deprecated; use fs.createReadStream(${JSON.stringify(path)}) instead`);
warned = true;
}
return await _fileFromPath(path, ...args);
}
30 changes: 30 additions & 0 deletions _shims/fileFromPath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Disclaimer: modules in _shims aren't intended to be imported by SDK users.
*
* This is a stub that gets replaced by fileFromPath.node.js for node environments
* in the package export map
*/

import type { FilePropertyBag } from 'formdata-node';
import type { File } from './formdata';

export type FileFromPathOptions = Omit<FilePropertyBag, 'lastModified'>;

/**
* This is a stub for non-node environments that just throws an error.
* In node environments, this module will be replaced by util/node/fileFromPath by the
* package import map.
*/
export async function fileFromPath(path: string): Promise<File>;
export async function fileFromPath(path: string, filename?: string): Promise<File>;
export async function fileFromPath(path: string, options?: FileFromPathOptions): Promise<File>;
export async function fileFromPath(
path: string,
filename?: string,
options?: FileFromPathOptions,
): Promise<File>;
export async function fileFromPath(): Promise<File> {
throw new Error(
'The `fileFromPath` function is only supported in Node. See the README for more details: https://github.com/anthropics/anthropic-sdk-typescript#file-uploads',
);
}
9 changes: 9 additions & 0 deletions _shims/formdata.node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Disclaimer: modules in _shims aren't intended to be imported by SDK users.
*/

import { FormData, File, Blob } from 'formdata-node';

export { FormData, File, Blob };

export const isPolyfilled = true;
22 changes: 22 additions & 0 deletions _shims/formdata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Disclaimer: modules in _shims aren't intended to be imported by SDK users.
*/

import type * as fd from 'formdata-node';

const _FormData: typeof fd.FormData =
// @ts-ignore
FormData as any;
type _FormData = fd.FormData;
const _File: typeof fd.File =
// @ts-ignore
File as any;
type _File = fd.File;
const _Blob: typeof fd.Blob =
// @ts-ignore
Blob as any;
type _Blob = fd.Blob;

export const isPolyfilled = false;

export { _FormData as FormData, _File as File, _Blob as Blob };
24 changes: 24 additions & 0 deletions _shims/getMultipartRequestOptions.node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Disclaimer: modules in _shims aren't intended to be imported by SDK users.
*/

import { FormData } from './formdata.node';
import type { RequestOptions } from '../core';
import { Readable } from 'node:stream';
import { FormDataEncoder } from 'form-data-encoder';

export async function getMultipartRequestOptions<T extends {} = Record<string, unknown>>(
form: FormData,
opts: RequestOptions<T>,
): Promise<RequestOptions<T>> {
const encoder = new FormDataEncoder(form);
const readable = Readable.from(encoder);
const body = { __multipartBody__: readable };
const headers = {
...opts.headers,
...encoder.headers,
'Content-Length': encoder.contentLength,
};

return { ...opts, body: body as any, headers };
}
13 changes: 13 additions & 0 deletions _shims/getMultipartRequestOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Disclaimer: modules in _shims aren't intended to be imported by SDK users.
*/

import { FormData } from './formdata';
import type { RequestOptions } from '../core';

export async function getMultipartRequestOptions<T extends {} = Record<string, unknown>>(
form: FormData,
opts: RequestOptions<T>,
): Promise<RequestOptions<T>> {
return { ...opts, body: { __multipartBody__: form } as any };
}
94 changes: 94 additions & 0 deletions _shims/newFileArgs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Disclaimer: modules in _shims aren't intended to be imported by SDK users.
*/

import { type BlobPart, type Uploadable, isResponseLike, isBlobLike } from './uploadable';

export type ToFileInput = Uploadable | Exclude<BlobPart, string> | AsyncIterable<BlobPart>;

export type FilePropertyBag = {
type?: string;
lastModified?: number;
};

export async function newFileArgs(
value: ToFileInput,
name?: string | null,
options: FilePropertyBag = {},
): Promise<{
bits: BlobPart[];
name: string;
options: FilePropertyBag;
}> {
if (isResponseLike(value)) {
const blob = await value.blob();
name ||= new URL(value.url).pathname.split(/[\\/]/).pop() ?? 'unknown_file';

return { bits: [blob as any], name, options };
}

const bits = await getBytes(value);

name ||= getName(value) ?? 'unknown_file';

if (!options.type) {
const type = (bits[0] as any)?.type;
if (typeof type === 'string') {
options = { ...options, type };
}
}

return { bits, name, options };
}

async function getBytes(value: ToFileInput): Promise<Array<BlobPart>> {
if (value instanceof Promise) return getBytes(await (value as any));

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(await value.arrayBuffer());
} else if (
isAsyncIterableIterator(value) // includes Readable, ReadableStream, etc.
) {
for await (const chunk of value) {
parts.push(chunk as BlobPart); // TODO, consider validating?
}
} else {
throw new Error(
`Unexpected data type: ${typeof value}; constructor: ${
value?.constructor?.name
}; props: ${propsForError(value)}`,
);
}

return parts;
}

function propsForError(value: any): string {
const props = Object.getOwnPropertyNames(value);
return `[${props.map((p) => `"${p}"`).join(', ')}]`;
}

function getName(value: any): string | undefined {
return (
getStringFromMaybeBuffer(value.name) ||
getStringFromMaybeBuffer(value.filename) ||
// For fs.ReadStream
getStringFromMaybeBuffer(value.path)?.split(/[\\/]/).pop()
);
}

const getStringFromMaybeBuffer = (x: string | Buffer | unknown): string | undefined => {
if (typeof x === 'string') return x;
if (typeof Buffer !== 'undefined' && x instanceof Buffer) return String(x);
return undefined;
};

const isAsyncIterableIterator = (value: any): value is AsyncIterableIterator<unknown> =>
value != null && typeof value === 'object' && typeof value[Symbol.asyncIterator] === 'function';
25 changes: 25 additions & 0 deletions _shims/toFile.node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Disclaimer: modules in _shims aren't intended to be imported by SDK users.
*/

import { File } from 'formdata-node';
import { type ToFileInput, newFileArgs, type FilePropertyBag } from './newFileArgs';
import type { Uploadable, BlobPart, FileLike } from './uploadable.node'; // eslint-disable-line

/**
* Helper for creating a {@link File} to pass to an SDK upload method from a variety of different data formats
* @param bits the raw content of the file. Can be an {@link Uploadable}, {@link BlobPart}, or {@link AsyncIterable} of {@link BlobPart}s
* @param 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(
bits: ToFileInput,
name?: string | null | undefined,
options: FilePropertyBag | undefined = {},
): Promise<FileLike> {
const args = await newFileArgs(bits, name, options);
return new File(args.bits, args.name, args.options);
}
26 changes: 26 additions & 0 deletions _shims/toFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/// <reference lib="dom" />;

/**
* Disclaimer: modules in _shims aren't intended to be imported by SDK users.
*/

import { type ToFileInput, newFileArgs, type FilePropertyBag } from './newFileArgs';
import type { FileLike, Uploadable } from './uploadable'; // eslint-disable-line

/**
* Helper for creating a {@link File} to pass to an SDK upload method from a variety of different data formats
* @param bits the raw content of the file. Can be an {@link Uploadable}, {@link BlobPart}, or {@link AsyncIterable} of {@link BlobPart}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(
bits: ToFileInput,
name?: string | null | undefined,
options: FilePropertyBag | undefined = {},
): Promise<FileLike> {
const args = await newFileArgs(bits, name, options);
return new File(args.bits as BlobPart[], args.name, args.options);
}
21 changes: 21 additions & 0 deletions _shims/uploadable.node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Disclaimer: modules in _shims aren't intended to be imported by SDK users.
*/

import { ReadStream } from 'node:fs';
import {
BlobPart,
Uploadable,
BlobLike,
FileLike,
ResponseLike,
isBlobLike,
isFileLike,
isResponseLike,
} from './uploadable';

export { BlobPart, BlobLike, FileLike, ResponseLike, isBlobLike, Uploadable };

export const isUploadable = (value: any): value is Uploadable => {
return isFileLike(value) || isResponseLike(value) || value instanceof ReadStream;
};
Loading

0 comments on commit d5552a5

Please sign in to comment.