From 717f7b47e7656e34f82b4fb2abf00f150581390c Mon Sep 17 00:00:00 2001 From: joyqi Date: Fri, 17 Mar 2023 17:47:05 +0800 Subject: [PATCH] Remove all dependencies and create a native and simple http client --- README.md | 8 +- package.json | 5 -- pnpm-lock.yaml | 122 ++---------------------------- src/index.ts | 45 +++++------ src/polyfill/fetch.ts | 5 -- src/polyfill/formdata.ts | 5 -- src/polyfill/readfile.ts | 5 -- src/request/feature/data.ts | 36 +++++++++ src/request/feature/form.ts | 76 +++++++++++++++++++ src/request/feature/headers.ts | 30 ++++++++ src/request/feature/index.ts | 20 +++++ src/request/feature/method.ts | 20 +++++ src/request/feature/signal.ts | 24 ++++++ src/request/index.ts | 134 +++++++++++++++++++++++++++++++++ src/v1/audio.ts | 21 +++--- src/v1/files.ts | 13 ++-- src/v1/images.ts | 21 +++--- 17 files changed, 397 insertions(+), 193 deletions(-) delete mode 100644 src/polyfill/fetch.ts delete mode 100644 src/polyfill/formdata.ts delete mode 100644 src/polyfill/readfile.ts create mode 100644 src/request/feature/data.ts create mode 100644 src/request/feature/form.ts create mode 100644 src/request/feature/headers.ts create mode 100644 src/request/feature/index.ts create mode 100644 src/request/feature/method.ts create mode 100644 src/request/feature/signal.ts create mode 100644 src/request/index.ts diff --git a/README.md b/README.md index 5024643..89abaf7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,12 @@ # node-openai -An elegant Node.js library written in TypeScript for the OpenAI API. +**An elegant Node.js library written in TypeScript for the OpenAI API. Pure JavaScript, no dependencies. Works in Node.js and the browser.** + +[![npm](https://img.shields.io/npm/v/node-openai.svg)](https://www.npmjs.com/package/node-openai) +[![npm](https://img.shields.io/npm/dt/node-openai.svg)](https://www.npmjs.com/package/node-openai) +[![GitHub](https://img.shields.io/github/license/joyqi/node-openai.svg)](https://github.com/joyqi/node-openai/blob/master/LICENSE) +![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/joyqi/node-openai/ci.yml) +![Libraries.io dependency status for GitHub repo](https://img.shields.io/librariesio/github/joyqi/node-openai) - [Installation](#installation) - [Features](#features) diff --git a/package.json b/package.json index 5c61a09..a21012c 100644 --- a/package.json +++ b/package.json @@ -46,10 +46,5 @@ "ts-node": "latest", "tsc-esm-fix": "latest", "typescript": "latest" - }, - "dependencies": { - "axios": "^1.3.4", - "form-data": "^4.0.0", - "node-fetch": "^3.3.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e70c92..5e8da30 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,27 +4,19 @@ specifiers: '@types/mocha': latest '@types/node': latest assert: latest - axios: ^1.3.4 - form-data: ^4.0.0 mocha: latest - node-fetch: ^3.3.1 ts-node: latest tsc-esm-fix: latest typescript: latest -dependencies: - axios: 1.3.4 - form-data: 4.0.0 - node-fetch: 3.3.1 - devDependencies: '@types/mocha': 10.0.1 '@types/node': 18.15.3 assert: 2.0.0 mocha: 10.2.0 - ts-node: 10.9.1_cbfmry4sbbh4vatmdrsmatfg5a + ts-node: 10.9.1_sxidjv3cojnrggmso45tj7hldi tsc-esm-fix: 2.20.12 - typescript: 4.9.5 + typescript: 5.0.2 packages: @@ -190,25 +182,11 @@ packages: util: 0.12.5 dev: true - /asynckit/0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - dev: false - /available-typed-arrays/1.0.5: resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==} engines: {node: '>= 0.4'} dev: true - /axios/1.3.4: - resolution: {integrity: sha512-toYm+Bsyl6VC5wSkfkbbNB6ROv7KY93PEBBL6xyDczaIHasAiv4wPqQ/c4RjoQzipxRD2W5g21cOqQulZ7rHwQ==} - dependencies: - follow-redirects: 1.15.2 - form-data: 4.0.0 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - dev: false - /balanced-match/1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} dev: true @@ -330,13 +308,6 @@ packages: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} dev: true - /combined-stream/1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - dependencies: - delayed-stream: 1.0.0 - dev: false - /concat-map/0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} dev: true @@ -345,11 +316,6 @@ packages: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} dev: true - /data-uri-to-buffer/4.0.1: - resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} - engines: {node: '>= 12'} - dev: false - /debug/4.3.4_supports-color@8.1.1: resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} engines: {node: '>=6.0'} @@ -394,11 +360,6 @@ packages: object-keys: 1.1.1 dev: true - /delayed-stream/1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - dev: false - /diff/4.0.2: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} @@ -462,14 +423,6 @@ packages: reusify: 1.0.4 dev: true - /fetch-blob/3.2.0: - resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} - engines: {node: ^12.20 || >= 14.13} - dependencies: - node-domexception: 1.0.0 - web-streams-polyfill: 3.2.1 - dev: false - /fill-range/7.0.1: resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} engines: {node: '>=8'} @@ -498,38 +451,12 @@ packages: hasBin: true dev: true - /follow-redirects/1.15.2: - resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - dev: false - /for-each/0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} dependencies: is-callable: 1.2.7 dev: true - /form-data/4.0.0: - resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} - engines: {node: '>= 6'} - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - mime-types: 2.1.35 - dev: false - - /formdata-polyfill/4.0.10: - resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} - engines: {node: '>=12.20.0'} - dependencies: - fetch-blob: 3.2.0 - dev: false - /fs-extra/11.1.0: resolution: {integrity: sha512-0rcTq621PD5jM/e0a3EJoGC/1TC5ZBCERW82LQuwfGnCa1V8w7dpYH1yNu+SLb6E5dkeCBzKEyLGlFrnr+dUyw==} engines: {node: '>=14.14'} @@ -897,18 +824,6 @@ packages: picomatch: 2.3.1 dev: true - /mime-db/1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - dev: false - - /mime-types/2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - dependencies: - mime-db: 1.52.0 - dev: false - /min-indent/1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -978,20 +893,6 @@ packages: hasBin: true dev: true - /node-domexception/1.0.0: - resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} - engines: {node: '>=10.5.0'} - dev: false - - /node-fetch/3.3.1: - resolution: {integrity: sha512-cRVc/kyto/7E5shrWca1Wsea4y6tL9iYJE5FBCius3JQfb/4P4I295PfhgbJQBLTx6lATE4z+wK0rPM4VS2uow==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - dependencies: - data-uri-to-buffer: 4.0.1 - fetch-blob: 3.2.0 - formdata-polyfill: 4.0.10 - dev: false - /normalize-package-data/3.0.3: resolution: {integrity: sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==} engines: {node: '>=10'} @@ -1099,10 +1000,6 @@ packages: engines: {node: '>=8.6'} dev: true - /proxy-from-env/1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - dev: false - /queue-microtask/1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} dev: true @@ -1274,7 +1171,7 @@ packages: engines: {node: '>=12'} dev: true - /ts-node/10.9.1_cbfmry4sbbh4vatmdrsmatfg5a: + /ts-node/10.9.1_sxidjv3cojnrggmso45tj7hldi: resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} hasBin: true peerDependencies: @@ -1300,7 +1197,7 @@ packages: create-require: 1.1.1 diff: 4.0.2 make-error: 1.3.6 - typescript: 4.9.5 + typescript: 5.0.2 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 dev: true @@ -1331,9 +1228,9 @@ packages: engines: {node: '>=14.16'} dev: true - /typescript/4.9.5: - resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} - engines: {node: '>=4.2.0'} + /typescript/5.0.2: + resolution: {integrity: sha512-wVORMBGO/FAs/++blGNeAVdbNKtIh1rbBL2EyQ1+J9lClJ93KiiKe8PmFIVdXhHcyv44SL9oglmfeSsndo0jRw==} + engines: {node: '>=12.20'} hasBin: true dev: true @@ -1363,11 +1260,6 @@ packages: spdx-expression-parse: 3.0.1 dev: true - /web-streams-polyfill/3.2.1: - resolution: {integrity: sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==} - engines: {node: '>= 8'} - dev: false - /which-typed-array/1.1.9: resolution: {integrity: sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==} engines: {node: '>= 0.4'} diff --git a/src/index.ts b/src/index.ts index 8ab71a2..973809f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,26 +1,24 @@ import * as v1 from "./v1"; -import fetchPolyfill from "./polyfill/fetch"; -import FormDataPolyfill from "./polyfill/formdata"; -import { RequestInit as NodeRequestInit } from "node-fetch"; +import { Init, request } from "./request"; // ApiConfig defines the configuration options for the OpenAI API export type ApiConfig = { apiKey: string; organization?: string; endpoint?: string; + options?: ApiInit; }; -type RequestConfig = RequestInit & NodeRequestInit & { - data?: any -}; +export type ApiInit = Omit; // ApiVersion defines the version of the OpenAI API export type ApiVersion = "v1" | "v2"; -export type ApiClient = (path: string, options: RequestConfig, direct?: boolean) => Promise; +export type ApiClient = (path: string, options: Init, direct?: boolean) => Promise; // OpenAI is the main class for the OpenAI API export class OpenAI { + constructor(private config: ApiConfig) {} v1() { @@ -75,37 +73,30 @@ export class OpenAI { // Generate a client for the given version of the OpenAI API private makeClient(version: ApiVersion): ApiClient { - return async (path: string, options: RequestConfig, direct = false) => { - const headers: any = { + return async (path: string, options: Init, direct = false) => { + if (this.config.options) { + options = Object.assign(this.config.options, options); + } + + const headers: Record = { Authorization: `Bearer ${this.config.apiKey}`, }; - if (options.data) { - options.body = JSON.stringify(options.data); - delete options.data; - headers["Content-Type"] = "application/json"; - } else if (options.body && options.body instanceof FormDataPolyfill) { - headers["Content-Type"] = "multipart/form-data"; + if (this.config.organization) { + headers["OpenAI-Organization"] = this.config.organization; } options.headers = Object.assign(headers, options.headers || {}); const endpoint = this.config.endpoint || "https://api.openai.com"; const url = `${endpoint}/${version}/${path}`; - const response = await fetchPolyfill(url, options); + const response = await request(url, options, direct ? "original" : "json"); - if (!direct && !response.headers.get("content-type")?.match(/^application\/json/)) { - throw new Error(`Unexpected Content-Type: ${response.headers.get("content-type")}`); - } else if (direct) { - return response.body; - } else { - const data = await response.json(); - if (response.status != 200) { - throw new Error(direct ? response.statusText : data.error.message); - } else { - return data; - } + if (response.status !== 200) { + throw new Error(direct ? response.statusText : response.body.error.message); } + + return response.body; } } } diff --git a/src/polyfill/fetch.ts b/src/polyfill/fetch.ts deleted file mode 100644 index 0b2032a..0000000 --- a/src/polyfill/fetch.ts +++ /dev/null @@ -1,5 +0,0 @@ -import fetchPolyfill from 'node-fetch'; - -const final = typeof fetch === 'undefined' ? fetchPolyfill : fetch; - -export default final; \ No newline at end of file diff --git a/src/polyfill/formdata.ts b/src/polyfill/formdata.ts deleted file mode 100644 index 9b4f752..0000000 --- a/src/polyfill/formdata.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { FormData as FormDataPolyfill } from 'node-fetch'; - -const final = typeof FormData === 'undefined' ? FormDataPolyfill : FormData; - -export default final; diff --git a/src/polyfill/readfile.ts b/src/polyfill/readfile.ts deleted file mode 100644 index c9ff69c..0000000 --- a/src/polyfill/readfile.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { fileFrom } from 'node-fetch'; - -export default async (file: string | File) => { - return typeof file === 'string' ? await fileFrom(file) : file; -} \ No newline at end of file diff --git a/src/request/feature/data.ts b/src/request/feature/data.ts new file mode 100644 index 0000000..cbf09cc --- /dev/null +++ b/src/request/feature/data.ts @@ -0,0 +1,36 @@ +import { ClientRequest } from "http"; +import { Feature, Options, Init } from ".."; + +declare module ".." { + interface Init { + data?: any; + } +} + +export class DataFeature implements Feature { + forRequest(init: Init, options: Options) { + if (init.data) { + options.headers = { + ...options.headers, + "Content-Type": "application/json", + }; + } + } + + async forRequestClient(init: Init, client: ClientRequest) { + if (init.data) { + client.write(JSON.stringify(init.data)); + } + } + + forFetch(init: Init, options: RequestInit) { + if (init.data) { + options.headers = { + ...options.headers, + "Content-Type": "application/json", + }; + + options.body = JSON.stringify(init.data); + } + } +} \ No newline at end of file diff --git a/src/request/feature/form.ts b/src/request/feature/form.ts new file mode 100644 index 0000000..076c744 --- /dev/null +++ b/src/request/feature/form.ts @@ -0,0 +1,76 @@ +import { ClientRequest } from "http"; +import { Feature, Options, Init, StreamFile } from ".."; +import { ReadStream } from "fs"; + +declare module ".." { + interface Init { + form?: Form; + } +} + +type Form = { + [key: string]: string | StreamFile; +}; + +async function uploadFile(file: StreamFile, client: ClientRequest): Promise { + return new Promise((resolve, reject) => { + const stream = file.stream as ReadStream; + + stream.on("error", reject); + stream.on("end", () => { + client.write("\r\n"); + resolve(); + }); + stream.pipe(client, { end: false }); + }); +} + +export class FormFeature implements Feature { + forRequest(init: Init, options: Options) {} + + async forRequestClient(init: Init, client: ClientRequest) { + if (init.form) { + const boundary = "--------------------------" + Date.now().toString(16); + client.setHeader("Content-Type", `multipart/form-data; boundary=${boundary}`); + + for (const key in init.form) { + const value = init.form[key]; + client.write(`--${boundary}\r\n`); + client.write(`Content-Disposition: form-data; name="${key}"`); + + if (typeof value === "string") { + client.write(`\r\n\r\n${value}\r\n`); + } else { + client.write(`; filename="${value.name}"\r\n`); + client.write(`Content-Type: application/octet-stream\r\n\r\n`); + await uploadFile(value, client); + } + } + + client.write(`--${boundary}--\r\n`); + } + } + + forFetch(init: Init, options: RequestInit) { + if (init.form) { + options.headers = { + ...options.headers, + "Content-Type": "multipart/form-data", + }; + + const formData = new FormData(); + + for (const key in init.form) { + const value = init.form[key]; + + if (typeof value === "string") { + formData.append(key, value); + } else { + formData.append(key, value.stream as Blob, value.name); + } + } + + options.body = formData; + } + } +} \ No newline at end of file diff --git a/src/request/feature/headers.ts b/src/request/feature/headers.ts new file mode 100644 index 0000000..c6ee648 --- /dev/null +++ b/src/request/feature/headers.ts @@ -0,0 +1,30 @@ +import { ClientRequest } from "http"; +import { Feature, Options, Init } from ".."; + +declare module ".." { + interface Init { + headers?: Record; + } +} + +export class HeadersFeature implements Feature { + forRequest(init: Init, options: Options) { + if (init.headers) { + options.headers = { + ...options.headers, + ...init.headers, + } + } + } + + async forRequestClient(init: Init, client: ClientRequest) {} + + forFetch(init: Init, options: RequestInit) { + if (init.headers) { + options.headers = { + ...options.headers, + ...init.headers, + } + } + } +} \ No newline at end of file diff --git a/src/request/feature/index.ts b/src/request/feature/index.ts new file mode 100644 index 0000000..6c60eec --- /dev/null +++ b/src/request/feature/index.ts @@ -0,0 +1,20 @@ +import { HeadersFeature } from './headers'; +import { DataFeature } from './data'; +import { FormFeature } from './form'; +import { MethodFeature } from './method'; +import { SignalFeature } from './signal'; +import { Feature } from '..'; + +const features = [ + new HeadersFeature(), + new DataFeature(), + new FormFeature(), + new MethodFeature(), + new SignalFeature(), +]; + +export default async function requestFeature(fn: (feature: Feature) => Promise) { + for (const feature of features) { + await fn(feature); + } +} \ No newline at end of file diff --git a/src/request/feature/method.ts b/src/request/feature/method.ts new file mode 100644 index 0000000..225889c --- /dev/null +++ b/src/request/feature/method.ts @@ -0,0 +1,20 @@ +import { ClientRequest } from "http"; +import { Feature, Options, Init } from ".."; + +declare module ".." { + interface Init { + method?: string; + } +} + +export class MethodFeature implements Feature { + forRequest(init: Init, options: Options) { + options.method = init.method || "GET"; + } + + async forRequestClient(init: Init, client: ClientRequest) {} + + forFetch(init: Init, options: RequestInit) { + options.method = init.method || "GET"; + } +} \ No newline at end of file diff --git a/src/request/feature/signal.ts b/src/request/feature/signal.ts new file mode 100644 index 0000000..e06653a --- /dev/null +++ b/src/request/feature/signal.ts @@ -0,0 +1,24 @@ +import { ClientRequest } from "http"; +import { Feature, Options, Init } from ".."; + +declare module ".." { + interface Init { + signal?: AbortSignal; + } +} + +export class SignalFeature implements Feature { + forRequest(init: Init, options: Options) { + if (init.signal) { + options.signal = init.signal; + } + } + + async forRequestClient(init: Init, client: ClientRequest) {} + + forFetch(init: Init, options: RequestInit) { + if (init.signal) { + options.signal = init.signal; + } + } +} \ No newline at end of file diff --git a/src/request/index.ts b/src/request/index.ts new file mode 100644 index 0000000..ebb23e7 --- /dev/null +++ b/src/request/index.ts @@ -0,0 +1,134 @@ +import { RequestOptions as HttpsRequestOptions } from "https"; +import { RequestOptions as HttpRequestOptions, ClientRequest, IncomingMessage } from "http"; +import requestFeature from "./feature"; +import { ReadStream } from "fs"; + +export type Options = HttpsRequestOptions | HttpRequestOptions; + +export interface Init { +} + +export type Format = 'json' | 'text' | 'original'; + +export interface Feature { + forRequest: (init: Init, options: Options) => void; + forRequestClient: (init: Init, client: ClientRequest) => Promise; + forFetch: (init: Init, options: RequestInit) => void; +} + +export type Response = { + status: number; + statusText: string; + headers: Record; + body: ReadableStream | IncomingMessage | any; +}; + +export class StreamFile { + public name: string; + + public stream: Blob | ReadStream; + + constructor(file: string | File) { + if (typeof file === "string") { + const { createReadStream } = require("fs"); + this.stream = createReadStream(file); + this.name = this.basename(file); + } else { + this.stream = file; + this.name = this.basename(file.name); + } + } + + private basename(path: string): string { + return path.split(/[\\/]/).pop() || ""; + } +} + +export function readFile(file: string | File) { + return new StreamFile(file); +} + +export async function request(url: string, init: Init, format: Format): Promise { + const response: Response = { + status: 200, + statusText: "OK", + headers: {}, + body: null, + }; + + if (typeof fetch === "function") { + const options: RequestInit = {}; + await requestFeature(async (feature) => feature.forFetch(init, options)); + + const resp = await fetch(url, options); + response.status = resp.status; + response.statusText = resp.statusText; + response.headers = Object.fromEntries(resp.headers.entries()); + + if (format === "json") { + if (!resp.headers.get("content-type")?.match(/^application\/json/)) { + throw new Error(`Unexpected Content-Type: ${resp.headers.get("content-type")}`); + } + + response.body = await resp.json(); + } else if (format === "text") { + if (!resp.headers.get("content-type")?.match(/^text\//)) { + throw new Error(`Unexpected Content-Type: ${resp.headers.get("content-type")}`); + } + + response.body = await resp.text(); + } else { + response.body = resp; + } + + return response; + } else { + const { request: requestClient } = await import("https"); + const { request: httpRequestClient } = await import("http"); + + const isHttps = url.startsWith("https://"); + const client = isHttps ? requestClient : httpRequestClient; + + const options: Options = {}; + await requestFeature(async (feature) => feature.forRequest(init, options)); + const req = client(url, options); + await requestFeature(async (feature) => feature.forRequestClient(init, req)); + + return new Promise((resolve, reject) => { + req.on("response", (res: IncomingMessage) => { + response.status = res.statusCode || 200; + response.statusText = res.statusMessage || "OK"; + response.headers = res.headers; + + if (format === "original") { + response.body = res; + resolve(response); + } else { + const chunks: Uint8Array[] = []; + res.on("data", (chunk: Uint8Array) => chunks.push(chunk)); + res.on("end", () => { + const data = Buffer.concat(chunks).toString(); + if (format === "json") { + if (!res.headers["content-type"]?.match(/^application\/json/)) { + return reject(new Error(`Unexpected Content-Type: ${res.headers["content-type"]}`)); + } + + response.body = JSON.parse(data); + } else { + if (!res.headers["content-type"]?.match(/^text\//)) { + return reject(new Error(`Unexpected Content-Type: ${res.headers["content-type"]}`)); + } + + response.body = data; + } + + resolve(response); + }); + } + }); + + req.on("error", reject); + req.end(); + }); + } +} \ No newline at end of file diff --git a/src/v1/audio.ts b/src/v1/audio.ts index 4588f5e..e5837ab 100644 --- a/src/v1/audio.ts +++ b/src/v1/audio.ts @@ -1,6 +1,5 @@ import { ApiClient } from ".."; -import FormDataPolyfill from "../polyfill/formdata"; -import readFile from "../polyfill/readfile"; +import { readFile } from "../request"; type AudioResponseFormat = 'json' | 'text' | 'srt' | 'verbose_json' | 'vtt'; @@ -25,28 +24,26 @@ type Audio = Partial<{ export function createAudioTranscription(client: ApiClient) { return async (request: CreateAudioTranscriptionRequest, file: string | File): Promise