Skip to content
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

feat: Browser Support #13

Merged
merged 16 commits into from
Nov 14, 2024
28,273 changes: 20,685 additions & 7,588 deletions package-lock.json

Large diffs are not rendered by default.

14 changes: 8 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,18 @@
"name": "ai21",
"version": "1.0.0-rc.4",
"description": "AI21 TypeScript SDK",
"main": "./dist/index.js",
"main": "./dist/bundle.cjs.js",
"types": "./dist/index.d.ts",
"module": "./dist/bundle.es.js",
"type": "module",
"exports": {
".": {
"require": "./dist/index.js",
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
"require": "./dist/bundle.cjs.js",
"import": "./dist/bundle.es.js"
}
},
"scripts": {
"build": "tsc",
"build": "vite build",
"test": "jest --coverage --no-cache --runInBand --config jest.config.ts",
"unused-deps": "npx depcheck --json | jq '.dependencies == []'",
"clean-build": "rm -rf ./dist",
Expand Down Expand Up @@ -66,6 +66,8 @@
"tsx": "^4.19.2",
"typescript": "^4.9.5",
"typescript-eslint": "^8.13.0",
"uuid": "^11.0.3"
"uuid": "^11.0.3",
"vite": "^5.4.11",
"vite-plugin-dts": "^4.3.0"
}
}
31 changes: 17 additions & 14 deletions src/APIClient.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { AI21Error } from './errors';
import { VERSION } from './version';

import fetch from 'node-fetch';
import { HeadersInit, RequestInit } from 'node-fetch';
import { RequestOptions, FinalRequestOptions, APIResponseProps, HTTPMethod, Headers } from './types/index.js';
import {
RequestOptions,
FinalRequestOptions,
APIResponseProps,
HTTPMethod,
Headers,
UnifiedResponse,
} from './types';
import { AI21EnvConfig } from './EnvConfig';
import { handleAPIResponse } from './ResponseHandler';
import { createFetchInstance } from './runtime';
import { Fetch } from 'fetch';

const validatePositiveInteger = (name: string, n: unknown): number => {
if (typeof n !== 'number' || !Number.isInteger(n)) {
Expand All @@ -21,19 +27,23 @@ export abstract class APIClient {
protected baseURL: string;
protected maxRetries: number;
protected timeout: number;
protected fetch: Fetch;

constructor({
baseURL,
maxRetries = AI21EnvConfig.MAX_RETRIES,
timeout = AI21EnvConfig.TIMEOUT_SECONDS,
fetch = createFetchInstance(),
}: {
baseURL: string;
maxRetries?: number | undefined;
timeout: number | undefined;
fetch?: Fetch;
}) {
this.baseURL = baseURL;
this.maxRetries = validatePositiveInteger('maxRetries', maxRetries);
this.timeout = validatePositiveInteger('timeout', timeout);
this.fetch = fetch;
}
get<Req, Rsp>(path: string, opts?: RequestOptions<Req>): Promise<Rsp> {
return this.makeRequest('get', path, opts);
Expand Down Expand Up @@ -81,31 +91,24 @@ export abstract class APIClient {
};

return this.performRequest(options as FinalRequestOptions).then(
(response) => handleAPIResponse<Rsp>(response) as Rsp,
(response) => this.fetch.handleResponse<Rsp>(response) as Rsp,
);
}

private async performRequest(options: FinalRequestOptions): Promise<APIResponseProps> {
const controller = new AbortController();
const url = `${this.baseURL}${options.path}`;

const headers = {
...this.defaultHeaders(options),
...options.headers,
};

const response = await fetch(url, {
method: options.method,
headers: headers as HeadersInit,
signal: controller.signal as RequestInit['signal'],
body: options.body ? JSON.stringify(options.body) : undefined,
});
const response = await this.fetch.call(url, { ...options, headers });

if (!response.ok) {
throw new AI21Error(`Request failed with status ${response.status}. ${await response.text()}`);
}

return { response, options, controller };
return { response: response as UnifiedResponse, options };
}

protected isRunningInBrowser(): boolean {
Expand Down
24 changes: 0 additions & 24 deletions src/ResponseHandler.ts

This file was deleted.

36 changes: 25 additions & 11 deletions src/Streaming/SSEDecoder.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { Response as NodeResponse } from 'node-fetch';
import { Readable } from 'stream';
import { SSE_DATA_PREFIX } from './Consts';
import { StreamingDecodeError } from '../errors';
import { UnifiedResponse } from 'types';

export interface SSEDecoder {
decode(line: string): string | null;
iterLines(response: NodeResponse): AsyncIterableIterator<string>;
iterLines(response: UnifiedResponse): AsyncIterableIterator<string>;
}

export class DefaultSSEDecoder implements SSEDecoder {
abstract class BaseSSEDecoder implements SSEDecoder {
decode(line: string): string | null {
if (!line) return null;

Expand All @@ -19,14 +18,9 @@ export class DefaultSSEDecoder implements SSEDecoder {
throw new StreamingDecodeError(`Invalid SSE line: ${line}`);
}

async *iterLines(response: NodeResponse): AsyncIterableIterator<string> {
if (!response.body) {
throw new Error('Response body is null');
}

const webReadableStream = Readable.toWeb(response.body as Readable);
const reader = webReadableStream.getReader();
abstract iterLines(response: UnifiedResponse): AsyncIterableIterator<string>;

async *_iterLines(reader: ReadableStreamDefaultReader<Uint8Array>): AsyncIterableIterator<string> {
let buffer = '';

try {
Expand Down Expand Up @@ -55,3 +49,23 @@ export class DefaultSSEDecoder implements SSEDecoder {
}
}
}

export class BrowserSSEDecoder extends BaseSSEDecoder {
async *iterLines(response: UnifiedResponse): AsyncIterableIterator<string> {
if (!response.body) {
throw new Error('Response body is null');
}

const body = response.body as ReadableStream<Uint8Array>;
yield* this._iterLines(body.getReader());
}
}

export class NodeSSEDecoder extends BaseSSEDecoder {
async *iterLines(response: UnifiedResponse): AsyncIterableIterator<string> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const readerStream = (await import('stream/web')).ReadableStream as any;
const reader = readerStream.from(response.body).getReader();
yield* this._iterLines(reader);
}
}
10 changes: 5 additions & 5 deletions src/Streaming/Stream.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Response as NodeResponse } from 'node-fetch';
import { DefaultSSEDecoder } from './SSEDecoder';
import { BrowserSSEDecoder } from './SSEDecoder';
import { SSEDecoder } from './SSEDecoder';
import { SSE_DONE_MSG } from './Consts';
import { StreamingDecodeError } from '../errors';
import { UnifiedResponse } from '../types';

function getStreamMessage<T>(chunk: string): T {
try {
Expand All @@ -17,15 +17,15 @@ export class Stream<T> implements AsyncIterableIterator<T> {
private iterator: AsyncIterableIterator<T>;

constructor(
private response: NodeResponse,
private response: UnifiedResponse,
decoder?: SSEDecoder,
) {
this.decoder = decoder || new DefaultSSEDecoder();
this.decoder = decoder || new BrowserSSEDecoder();
this.iterator = this.stream();
}

private async *stream(): AsyncIterableIterator<T> {
for await (const chunk of this.decoder.iterLines(this.response)) {
for await (const chunk of this.decoder.iterLines(this.response as Response)) {
if (chunk === SSE_DONE_MSG) break;
yield getStreamMessage(chunk);
}
Expand Down
1 change: 1 addition & 0 deletions src/Streaming/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { Stream } from './Stream';
export { BrowserSSEDecoder, NodeSSEDecoder, SSEDecoder } from './SSEDecoder';
25 changes: 25 additions & 0 deletions src/fetch/BaseFetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { AI21Error } from '../errors';
import { Stream } from '../Streaming';
import { FinalRequestOptions, UnifiedResponse } from '../types';
import { APIResponseProps } from '../types/API';

export type APIResponse<T> = {
data?: T;
response: UnifiedResponse;
};
export abstract class Fetch {
abstract call(url: string, options: FinalRequestOptions): Promise<UnifiedResponse>;
async handleResponse<T>({ response, options }: APIResponseProps) {
if (options.stream) {
if (!response.body) {
throw new AI21Error('Response body is null');
}

return this.handleStream<T>(response);
}

const contentType = response.headers.get('content-type');
return contentType?.includes('application/json') ? await response.json() : null;
}
abstract handleStream<T>(response: UnifiedResponse): Stream<T>;
}
20 changes: 20 additions & 0 deletions src/fetch/BrowserFetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { FinalRequestOptions, UnifiedResponse } from 'types';
import { Fetch } from './BaseFetch';
import { Stream, BrowserSSEDecoder } from '../Streaming';

export class BrowserFetch extends Fetch {
call(url: string, options: FinalRequestOptions): Promise<UnifiedResponse> {
const controller = new AbortController();

return fetch(url, {
method: options?.method,
headers: options?.headers as HeadersInit,
body: options.body ? JSON.stringify(options.body) : undefined,
signal: controller.signal,
});
}

handleStream<T>(response: UnifiedResponse): Stream<T> {
return new Stream<T>(response as Response, new BrowserSSEDecoder());
}
}
21 changes: 21 additions & 0 deletions src/fetch/NodeFetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { FinalRequestOptions, UnifiedResponse } from 'types';
import { Fetch } from './BaseFetch';
import { Stream, NodeSSEDecoder } from '../Streaming';

export class NodeFetch extends Fetch {
async call(url: string, options: FinalRequestOptions): Promise<UnifiedResponse> {
const nodeFetchModule = await import('node-fetch');
const nodeFetch = nodeFetchModule.default;

return nodeFetch(url, {
method: options?.method,
headers: options.headers as Record<string, string>,
body: options?.body ? JSON.stringify(options.body) : undefined,
});
}

handleStream<T>(response: UnifiedResponse): Stream<T> {
type NodeRespose = import('node-fetch').Response;
return new Stream<T>(response as NodeRespose, new NodeSSEDecoder());
}
}
3 changes: 3 additions & 0 deletions src/fetch/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { Fetch } from './BaseFetch';
export { BrowserFetch } from './BrowserFetch';
export { NodeFetch } from './NodeFetch';
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export { AI21 } from './AI21';
export { VERSION } from './version';
export { RequestOptions } from './types';
export * from './types';
export { AI21Error, MissingAPIKeyError } from './errors';
export { Stream } from './Streaming';
export { APIResource } from './APIResource';
Expand Down
28 changes: 28 additions & 0 deletions src/runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { BrowserFetch, Fetch, NodeFetch } from './fetch';

export const isBrowser = typeof window !== 'undefined' && typeof window.document !== 'undefined';

/**
* A Web Worker is a background thread that runs JavaScript code separate from the main thread.
* Web Workers enable concurrent processing by:
* - Running CPU-intensive tasks without blocking the UI
* - Performing background operations like data fetching and processing
* - Operating independently from the main window context
*/
export const isWebWorker =
typeof self === 'object' &&
typeof self?.importScripts === 'function' &&
(self.constructor?.name === 'DedicatedWorkerGlobalScope' ||
self.constructor?.name === 'ServiceWorkerGlobalScope' ||
self.constructor?.name === 'SharedWorkerGlobalScope');

export const isNode =
typeof process !== 'undefined' && Boolean(process.version) && Boolean(process.versions?.node);

export function createFetchInstance(): Fetch {
if (isBrowser || isWebWorker) {
return new BrowserFetch();
}

return new NodeFetch();
}
14 changes: 5 additions & 9 deletions src/types/API.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
import { BlobLike } from 'formdata-node';
import { Response } from 'node-fetch';
import { Readable } from 'stream';

export type HTTPMethod = 'get' | 'post' | 'put' | 'patch' | 'delete';

export type APIResponseProps = {
response: Response;
response: CrossPlatformResponse;
options: FinalRequestOptions;
controller: AbortController;
controller?: AbortController;
};

export type RequestOptions<
Req = unknown | Record<string, unknown> | Readable | BlobLike | ArrayBufferView | ArrayBuffer,
> = {
export type RequestOptions<Req = unknown | Record<string, unknown> | ArrayBufferView | ArrayBuffer> = {
method?: HTTPMethod;
path?: string;
query?: Req | undefined;
Expand All @@ -31,3 +25,5 @@ export type FinalRequestOptions = RequestOptions & {

export type DefaultQuery = Record<string, unknown>;
export type Headers = Record<string, string | null | undefined>;
export type CrossPlatformResponse = Response | import('node-fetch').Response;
export type CrossPlatformReadableStream = ReadableStream<Uint8Array> | import('stream/web').ReadableStream;
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,5 @@ export {
type HTTPMethod,
type DefaultQuery,
type Headers,
type CrossPlatformResponse as UnifiedResponse,
} from './API';
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"extendedDiagnostics": true,
"strict": true,
"target": "ES6",
"module": "CommonJS",
"module": "ESNext",
"moduleResolution": "node",
"esModuleInterop": true,
"skipLibCheck": true,
Expand Down
Loading
Loading