diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index dcd1e2d..0000000 --- a/.eslintrc.js +++ /dev/null @@ -1,29 +0,0 @@ -module.exports = { - root: true, - env: { - node: true, - es6: true - }, - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - 'prettier', - 'plugin:prettier/recommended' - ], - rules: { - 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', - 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', - '@typescript-eslint/no-unused-vars': 'error', - '@typescript-eslint/no-explicit-any': 0, - '@typescript-eslint/explicit-function-return-type': [ - 'error', - { - allowExpressions: true - } - ], - 'object-shorthand': ['error', 'always', { avoidQuotes: true }] - }, - parserOptions: { - parser: '@typescript-eslint/parser' - } -}; diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 1ff9245..1807e39 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -4,28 +4,28 @@ name: Tests on: - push: - branches: [ master ] - pull_request: - branches: [ master ] + push: + branches: [master] + pull_request: + branches: [master] jobs: - build: + build: + runs-on: ubuntu-latest - runs-on: ubuntu-latest + strategy: + max-parallel: 2 + matrix: + node-version: [20, 22, 23] + # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ - strategy: - matrix: - node-version: [16, 18, 20] - # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ - - steps: - - uses: actions/checkout@v4 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - - run: npm install -d - - run: npm run lint - - run: npm test - - run: npm run test-integration + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - run: npm install -d + - run: npm run lint + - run: npm test + - run: npm run test-integration diff --git a/.mocharc.json b/.mocharc.json new file mode 100644 index 0000000..2ffcc8b --- /dev/null +++ b/.mocharc.json @@ -0,0 +1,6 @@ +{ + "require": "tsx", + "spec": [ + "tests/**/*.ts" + ] +} \ No newline at end of file diff --git a/.eslintignore b/.prettierignore similarity index 84% rename from .eslintignore rename to .prettierignore index f26b6e1..c8a0d98 100644 --- a/.eslintignore +++ b/.prettierignore @@ -1,5 +1,3 @@ - .DS_Store -.idea node_modules/ built/ package-lock.json diff --git a/.prettierrc b/.prettierrc index cbdf283..c380d6e 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,8 +1,8 @@ { - "singleQuote": true, - "trailingComma": "none", - "htmlWhitespaceSensitivity": "ignore", - "tabWidth": 4, - "semi": true, - "arrowParens": "avoid" -} \ No newline at end of file + "singleQuote": true, + "trailingComma": "none", + "htmlWhitespaceSensitivity": "ignore", + "tabWidth": 4, + "semi": true, + "arrowParens": "avoid" +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 6069cc8..96772bd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,12 @@ { "editor.formatOnSave": true, "editor.codeActionsOnSave": { - "source.fixAll": true + "source.fixAll": "explicit" + }, + "[md]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" } } diff --git a/README.md b/README.md index e98d5ae..c176830 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # cloudconvert-node -This is the official Node.js SDK v2 for the [CloudConvert](https://cloudconvert.com/api/v2) **API v2**. +This is the official Node.js SDK for the [CloudConvert](https://cloudconvert.com/api/v2) API v2. [![Node.js Run Tests](https://github.com/cloudconvert/cloudconvert-node/actions/workflows/run-tests.yml/badge.svg)](https://github.com/cloudconvert/cloudconvert-node/actions/workflows/run-tests.yml) [![npm](https://img.shields.io/npm/v/cloudconvert.svg)](https://www.npmjs.com/package/cloudconvert) @@ -86,15 +86,15 @@ const job = await cloudConvert.jobs.create({ } }); -const uploadTask = job.tasks.filter(task => task.name === 'upload-my-file')[0]; +const uploadTask = job.tasks.find(task => task.name === 'upload-my-file'); const inputFile = fs.createReadStream('./file.pdf'); await cloudConvert.tasks.upload(uploadTask, inputFile, 'file.pdf'); ``` -> **Note on custom streams**: -The length of the stream needs to be known prior to uploading. The SDK automatically detects the file size of file-based read streams. If you are using a custom stream, you need to pass a `filesize` as fourth parameter to the `upload()` method. +> **Note on custom streams**: +The length of the stream needs to be known prior to uploading. The SDK tries to automatically detect the file size of file-based read streams. If you are using a custom stream, you might need to pass a `fileSize` as fourth parameter to the `upload()` method. ## Websocket Events @@ -242,5 +242,5 @@ and even auto-fix as many things as possible by running ## Resources -- [API v2 Documentation](https://cloudconvert.com/api/v2) -- [CloudConvert Blog](https://cloudconvert.com/blog) +- [API v2 Documentation](https://cloudconvert.com/api/v2) +- [CloudConvert Blog](https://cloudconvert.com/blog) diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..d6f306f --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,55 @@ +import { defineConfig, globalIgnores } from 'eslint/config'; +import typescriptEslint from '@typescript-eslint/eslint-plugin'; +import globals from 'globals'; +import tsParser from '@typescript-eslint/parser'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import js from '@eslint/js'; +import { FlatCompat } from '@eslint/eslintrc'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all +}); + +export default defineConfig([ + globalIgnores([ + '**/node_modules/', + '**/built/', + '**/package-lock.json', + '**/package.json', + 'tests/unit/requests/', + 'tests/unit/responses/', + '.vscode/', + 'tsconfig.json', + '.mocharc.json' + ]), + { + extends: compat.extends( + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended' + ), + + plugins: { + '@typescript-eslint': typescriptEslint + }, + + languageOptions: { + globals: { + ...globals.browser, + ...globals.node + }, + + parser: tsParser, + ecmaVersion: 'latest', + sourceType: 'module' + }, + + rules: { + '@typescript-eslint/no-explicit-any': 'off' + } + } +]); diff --git a/lib/CloudConvert.ts b/lib/CloudConvert.ts index 518cf50..51677ad 100644 --- a/lib/CloudConvert.ts +++ b/lib/CloudConvert.ts @@ -1,24 +1,149 @@ -import axios, { type AxiosInstance } from 'axios'; -import io from 'socket.io-client'; +import { statSync } from 'node:fs'; +import { basename } from 'node:path'; +import { Readable } from 'node:stream'; +import { io, type Socket } from 'socket.io-client'; +import { version } from '../package.json'; import JobsResource, { type JobEventData } from './JobsResource'; +import SignedUrlResource from './SignedUrlResource'; import TasksResource, { type JobTaskEventData, type TaskEventData } from './TasksResource'; import UsersResource from './UsersResource'; import WebhooksResource from './WebhooksResource'; -import { version } from '../package.json'; -import SignedUrlResource from './SignedUrlResource'; + +export type UploadFileSource = + | Blob + | Uint8Array + | Iterable + | AsyncIterable + | NodeJS.ReadableStream; + +async function* unifySources( + data: UploadFileSource +): AsyncIterable { + if (data instanceof Uint8Array) { + yield data; + return; + } + + if (data instanceof Blob) { + yield data.bytes(); + return; + } + + if (Symbol.iterator in data) { + yield* data; + return; + } + + if (Symbol.asyncIterator in data) { + for await (const chunk of data) { + if (typeof chunk === 'string') + throw new Error( + 'bad file data, received string but expected Uint8Array' + ); + yield chunk; + } + return; + } +} + +function guessNameAndSize( + source: UploadFileSource, + fileName?: string, + fileSize?: number +): { name: string; size: number } { + const path = + 'path' in source && typeof source.path === 'string' + ? source.path + : undefined; + const name = + fileName ?? (path !== undefined ? basename(path) : undefined) ?? 'file'; + const size = + fileSize ?? + (source instanceof Uint8Array ? source.byteLength : undefined) ?? + (source instanceof Blob ? source.size : undefined) ?? + (path !== undefined + ? statSync(path, { throwIfNoEntry: false })?.size + : undefined); + if (size === undefined) { + throw new Error( + 'Could not determine the number of bytes, specify it explicitly when calling `upload`' + ); + } + return { name, size }; +} + +export class UploadFile { + private readonly attributes: Array<[key: string, value: unknown]> = []; + private readonly data: AsyncIterable; + private readonly filename?: string; + private readonly fileSize: number; + constructor(data: UploadFileSource, filename?: string, fileSize?: number) { + this.data = unifySources(data); + const { name, size } = guessNameAndSize(data, filename, fileSize); + this.filename = name; + this.fileSize = size; + } + add(key: string, value: unknown) { + this.attributes.push([key, value]); + } + toMultiPart(boundary: string): { + size: number; + stream: AsyncIterable; + } { + const enc = new TextEncoder(); + const prefix: Uint8Array[] = []; + const suffix: Uint8Array[] = []; + + // Start multipart/form-data protocol + prefix.push(enc.encode(`--${boundary}\r\n`)); + // Send all attributes + const separator = enc.encode(`\r\n--${boundary}\r\n`); + let first = true; + for (const [key, value] of this.attributes) { + if (value == null) continue; + if (!first) prefix.push(separator); + prefix.push( + enc.encode( + `content-disposition:form-data;name="${key}"\r\n\r\n${value}` + ) + ); + first = false; + } + // Send file + if (!first) prefix.push(separator); + prefix.push( + enc.encode( + `content-disposition:form-data;name="file";filename=${this.filename}\r\ncontent-type:application/octet-stream\r\n\r\n` + ) + ); + const data = this.data; + // End multipart/form-data protocol + suffix.push(enc.encode(`\r\n--${boundary}--\r\n`)); + + const size = + prefix.reduce((sum, arr) => sum + arr.byteLength, 0) + + this.fileSize + + suffix.reduce((sum, arr) => sum + arr.byteLength, 0); + async function* concat() { + yield* prefix; + yield* data; + yield* suffix; + } + return { size, stream: concat() }; + } +} export default class CloudConvert { - private socket: SocketIOClient.Socket | undefined; + private socket: Socket | undefined; private subscribedChannels: Map | undefined; public readonly apiKey: string; public readonly useSandbox: boolean; public readonly region: string | null; - public axios!: AxiosInstance; public tasks!: TasksResource; public jobs!: JobsResource; public users!: UsersResource; @@ -30,25 +155,6 @@ export default class CloudConvert { this.useSandbox = useSandbox; this.region = region; - this.createAxiosInstance(); - this.createResources(); - } - - createAxiosInstance(): void { - this.axios = axios.create({ - baseURL: this.useSandbox - ? 'https://api.sandbox.cloudconvert.com/v2/' - : `https://${ - this.region ? this.region + '.' : '' - }api.cloudconvert.com/v2/`, - headers: { - Authorization: `Bearer ${this.apiKey}`, - 'User-Agent': `cloudconvert-node/v${version} (https://github.com/cloudconvert/cloudconvert-node)` - } - }); - } - - createResources(): void { this.tasks = new TasksResource(this); this.jobs = new JobsResource(this); this.users = new UsersResource(this); @@ -56,6 +162,73 @@ export default class CloudConvert { this.signedUrls = new SignedUrlResource(); } + async call( + method: 'GET' | 'POST' | 'DELETE', + route: string, + parameters?: UploadFile | object, + options?: { presigned?: boolean; flat?: boolean } + ) { + const baseURL = this.useSandbox + ? 'https://api.sandbox.cloudconvert.com/v2/' + : `https://${ + this.region ? this.region + '.' : '' + }api.cloudconvert.com/v2/`; + return await this.callWithBase( + baseURL, + method, + route, + parameters, + options + ); + } + + async callWithBase( + baseURL: string, + method: 'GET' | 'POST' | 'DELETE', + route: string, + parameters?: UploadFile | object, + options?: { presigned?: boolean; flat?: boolean } + ) { + const presigned = options?.presigned ?? false; + const flat = options?.flat ?? false; + const url = new URL(route, baseURL); + const { contentLength, contentType, search, body } = prepareParameters( + method, + parameters + ); + if (search !== undefined) { + url.search = search; + } + const headers = { + 'User-Agent': `cloudconvert-node/v${version} (https://github.com/cloudconvert/cloudconvert-node)`, + ...(!presigned ? { Authorization: `Bearer ${this.apiKey}` } : {}), + ...(contentLength ? { 'Content-Length': contentLength } : {}), + ...(contentType ? { 'Content-Type': contentType } : {}) + }; + const res = await fetch(url, { + method, + headers, + body, + // @ts-expect-error incorrect types in @types/node@20 + duplex: 'half' + }); + if (!res.ok) { + // @ts-expect-error cause not present in types yet + throw new Error(res.statusText, { cause: res }); + } + + if ( + !res.headers + .get('content-type') + ?.toLowerCase() + .includes('application/json') + ) { + return undefined; + } + const json = await res.json(); + return flat ? json : json.data; + } + subscribe( channel: string, event: string, @@ -65,13 +238,11 @@ export default class CloudConvert { | ((event: JobTaskEventData) => void) ): void { if (!this.socket) { - this.socket = io.connect( + this.socket = io( this.useSandbox ? 'https://socketio.sandbox.cloudconvert.com' : 'https://socketio.cloudconvert.com', - { - transports: ['websocket'] - } + { transports: ['websocket'] } ); this.subscribedChannels = new Map(); } @@ -79,11 +250,7 @@ export default class CloudConvert { if (!this.subscribedChannels?.get(channel)) { this.socket.emit('subscribe', { channel, - auth: { - headers: { - Authorization: `Bearer ${this.apiKey}` - } - } + auth: { headers: { Authorization: `Bearer ${this.apiKey}` } } }); this.subscribedChannels?.set(channel, true); } @@ -103,3 +270,44 @@ export default class CloudConvert { this.socket?.close(); } } + +function prepareParameters( + method: 'GET' | 'POST' | 'DELETE', + data?: UploadFile | object +): { + contentLength?: string; + contentType?: string; + body?: string | ReadableStream; + search?: string; +} { + if (data === undefined) { + return {}; + } + + if (method === 'GET') { + // abort early if all data needs to go into the search params + const entries = Object.entries(data ?? {}); + return { search: new URLSearchParams(entries).toString() }; + } + + if (data instanceof UploadFile) { + const boundary = `----------${Array.from(Array(32)) + .map(() => Math.random().toString(36)[2] || 0) + .join('')}`; + const { size, stream } = data.toMultiPart(boundary); + return { + contentLength: size.toString(), + contentType: `multipart/form-data; boundary=${boundary}`, + body: asyncIterableToReadableStream(stream) + }; + } + + return { contentType: 'application/json', body: JSON.stringify(data) }; +} + +function asyncIterableToReadableStream( + it: AsyncIterable +): ReadableStream { + const r = Readable.from(it); + return Readable.toWeb(r) as ReadableStream; +} diff --git a/lib/JobsResource.ts b/lib/JobsResource.ts index ada71e4..2eec40a 100644 --- a/lib/JobsResource.ts +++ b/lib/JobsResource.ts @@ -36,58 +36,47 @@ export default class JobsResource { this.cloudConvert = cloudConvert; } - async get(id: string, query = null): Promise { - const response = await this.cloudConvert.axios.get(`jobs/${id}`, { - params: query || {} - }); - return response.data.data; + async get(id: string, query?: object): Promise { + return await this.cloudConvert.call('GET', `jobs/${id}`, query); } async wait(id: string): Promise { - const response = await this.cloudConvert.axios.get(`jobs/${id}`, { - baseURL: this.cloudConvert.useSandbox - ? 'https://sync.api.sandbox.cloudconvert.com/v2/' - : `https://${ - this.cloudConvert.region - ? this.cloudConvert.region + '.' - : '' - }sync.api.cloudconvert.com/v2/` - }); - return response.data.data; + const baseURL = this.cloudConvert.useSandbox + ? 'https://sync.api.sandbox.cloudconvert.com/v2/' + : `https://${ + this.cloudConvert.region ? this.cloudConvert.region + '.' : '' + }sync.api.cloudconvert.com/v2/`; + return await this.cloudConvert.callWithBase( + baseURL, + 'GET', + `jobs/${id}` + ); } - async all( - query: { - 'filter[status]'?: JobStatus; - 'filter[tag]'?: string; - include?: string; - per_page?: number; - page?: number; - } | null = null - ): Promise { - const response = await this.cloudConvert.axios.get('jobs', { - params: query || {} - }); - return response.data.data; + async all(query?: { + 'filter[status]'?: JobStatus; + 'filter[tag]'?: string; + include?: string; + per_page?: number; + page?: number; + }): Promise { + return await this.cloudConvert.call('GET', 'jobs', query); } // See below for an explanation on how this type signature works - async create(data: JobTemplate | null = null): Promise { - const response = await this.cloudConvert.axios.post('jobs', data, { - maxBodyLength: Infinity - }); - return response.data.data; + async create(data?: JobTemplate): Promise { + return await this.cloudConvert.call('POST', 'jobs', data); } async delete(id: string): Promise { - await this.cloudConvert.axios.delete(`jobs/${id}`); + await this.cloudConvert.call('DELETE', `jobs/${id}`); } async subscribeEvent( id: string, event: string, callback: (event: JobEventData) => void - ): Promise { + ) { this.cloudConvert.subscribe( `private-job.${id}`, `job.${event}`, @@ -95,11 +84,11 @@ export default class JobsResource { ); } - async subscribeTaskEvent( + subscribeTaskEvent( id: string, event: string, callback: (event: TaskEventData) => void - ): Promise { + ) { this.cloudConvert.subscribe( `private-job.${id}.tasks`, `task.${event}`, diff --git a/lib/TasksResource.ts b/lib/TasksResource.ts index 4c197bf..81deb30 100644 --- a/lib/TasksResource.ts +++ b/lib/TasksResource.ts @@ -1,8 +1,8 @@ -import FormData, { type Stream } from 'form-data'; -import CloudConvert from './CloudConvert'; +import CloudConvert, { + UploadFile, + type UploadFileSource +} from './CloudConvert'; import { type JobTask } from './JobsResource'; -import axios from 'axios'; -import { ReadStream, statSync } from 'fs'; export type TaskEvent = 'created' | 'updated' | 'finished' | 'failed'; export type TaskStatus = 'waiting' | 'processing' | 'finished' | 'error'; @@ -546,71 +546,53 @@ export default class TasksResource { this.cloudConvert = cloudConvert; } - async get( - id: string, - query: { include: string } | null = null - ): Promise { - const response = await this.cloudConvert.axios.get(`tasks/${id}`, { - params: query || {} - }); - return response.data.data; + async get(id: string, query?: { include?: string }): Promise { + return await this.cloudConvert.call('GET', `tasks/${id}`, query); } async wait(id: string): Promise { - const response = await this.cloudConvert.axios.get(`tasks/${id}`, { - baseURL: this.cloudConvert.useSandbox - ? 'https://sync.api.sandbox.cloudconvert.com/v2/' - : `https://${ - this.cloudConvert.region - ? this.cloudConvert.region + '.' - : '' - }sync.api.cloudconvert.com/v2/` - }); - return response.data.data; + const baseURL = this.cloudConvert.useSandbox + ? 'https://sync.api.sandbox.cloudconvert.com/v2/' + : `https://${ + this.cloudConvert.region ? this.cloudConvert.region + '.' : '' + }sync.api.cloudconvert.com/v2/`; + return await this.cloudConvert.callWithBase( + baseURL, + 'GET', + `tasks/${id}` + ); } async cancel(id: string): Promise { - const response = await this.cloudConvert.axios.post( - `tasks/${id}/cancel` - ); - return response.data.data; + return await this.cloudConvert.call('POST', `tasks/${id}/cancel`); } - async all( - query: { - 'filter[job_id]'?: string; - 'filter[status]'?: TaskStatus; - 'filter[operation]'?: Operation['operation']; - per_page?: number; - page?: number; - } | null = null - ): Promise { - const response = await this.cloudConvert.axios.get('tasks', { - params: query || {} - }); - return response.data.data; + async all(query?: { + 'filter[job_id]'?: string; + 'filter[status]'?: TaskStatus; + 'filter[operation]'?: Operation['operation']; + per_page?: number; + page?: number; + }): Promise { + return await this.cloudConvert.call('GET', 'tasks', query); } async create( operation: O, - data: Extract['data'] | null = null + data?: Extract['data'] ): Promise { - const response = await this.cloudConvert.axios.post( - operation, - data - ); - return response.data.data; + return await this.cloudConvert.call('POST', operation, data); } async delete(id: string): Promise { - await this.cloudConvert.axios.delete(`tasks/${id}`); + await this.cloudConvert.call('DELETE', `tasks/${id}`); } async upload( task: Task | JobTask, - stream: Stream, - filename: string | null = null, - size: number | null = null + stream: UploadFileSource, + filename?: string, + fileSize?: number ): Promise { if (task.operation !== 'import/upload') { throw new Error('The task operation is not import/upload'); @@ -620,34 +602,17 @@ export default class TasksResource { throw new Error('The task is not ready for uploading'); } - const formData = new FormData(); - + const uploadFile = new UploadFile(stream, filename, fileSize); for (const parameter in task.result.form.parameters) { - formData.append(parameter, task.result.form.parameters[parameter]); + uploadFile.add(parameter, task.result.form.parameters[parameter]); } - const fileOptions: { filename?: string; knownLength?: number } = {}; - - if (filename) { - fileOptions.filename = filename; - } - if (size) { - fileOptions.knownLength = size; - } else if (stream instanceof ReadStream) { - fileOptions.knownLength = statSync(stream.path).size; - } - formData.append('file', stream, fileOptions); - - return await axios.post(task.result.form.url, formData, { - maxContentLength: Infinity, - maxBodyLength: Infinity, - headers: { - ...(formData.hasKnownLength() - ? { 'Content-Length': formData.getLengthSync() } - : {}), - ...formData.getHeaders() - } - }); + return await this.cloudConvert.call( + 'POST', + task.result.form.url, + uploadFile, + { presigned: true, flat: true } + ); } async subscribeEvent( diff --git a/lib/UsersResource.ts b/lib/UsersResource.ts index 07d5fbf..32ba6d9 100644 --- a/lib/UsersResource.ts +++ b/lib/UsersResource.ts @@ -18,8 +18,7 @@ export default class UsersResource { } async me(): Promise { - const response = await this.cloudConvert.axios.get('users/me'); - return response.data.data; + return await this.cloudConvert.call('GET', 'users/me'); } async subscribeJobEvent( diff --git a/package.json b/package.json index 5587053..12538e3 100644 --- a/package.json +++ b/package.json @@ -15,32 +15,39 @@ "bugs": { "url": "https://github.com/cloudconvert/cloudconvert-node/issues" }, + "engines": { + "node": ">=20.11.19" + }, "dependencies": { - "axios": "^0.28.1", - "form-data": "^4.0.0", - "socket.io-client": "^4.6.1" + "socket.io-client": "^4.7.4" }, "devDependencies": { - "@types/node": "^18.14.0", - "@types/socket.io-client": "^1.4.36", - "@typescript-eslint/eslint-plugin": "^5.53.0", - "@typescript-eslint/parser": "^5.53.0", - "chai": "^4.3.7", - "eslint": "^8.34.0", - "eslint-config-prettier": "^8.6.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "^9.24.0", + "@types/chai": "^5.2.1", + "@types/mocha": "^10.0.10", + "@types/node": "^20.11.19", + "@types/socket.io-client": "^3.0.0", + "@typescript-eslint/eslint-plugin": "^8.30.1", + "@typescript-eslint/parser": "^8.30.1", + "chai": "^5.0.3", + "eslint": "^9.24.0", + "eslint-config-prettier": "^10.1.2", "eslint-config-typescript": "^3.0.0", - "eslint-plugin-prettier": "^4.2.1", - "esm": "^3.2.25", - "mocha": "^8.4.0", - "nock": "^13.3.0", - "prettier": "^2.8.4", - "typescript": "^4.9.5" + "eslint-plugin-prettier": "^5.1.3", + "globals": "^16.0.0", + "mocha": "^11.1.0", + "nock": "^14.0.3", + "prettier": "3.5.3", + "tsx": "^4.19.3", + "typescript": "^5.3.3" }, "scripts": { "prepare": "npm run build", "build": "tsc", - "test": "mocha --require esm tests/unit", - "test-integration": "mocha --require esm tests/integration", + "test": "mocha --require tsx tests/unit/*.ts", + "test-integration": "mocha --require tsx tests/integration/*.ts", + "fmt": "prettier . --write", "lint": "eslint --ext .ts --ext .js --ext .json ." } } diff --git a/tests/integration/ApiKey.js b/tests/integration/ApiKey.ts similarity index 100% rename from tests/integration/ApiKey.js rename to tests/integration/ApiKey.ts diff --git a/tests/integration/JobsResourceTest.js b/tests/integration/JobsResourceTest.ts similarity index 50% rename from tests/integration/JobsResourceTest.js rename to tests/integration/JobsResourceTest.ts index 7dcd87f..14c529d 100644 --- a/tests/integration/JobsResourceTest.js +++ b/tests/integration/JobsResourceTest.ts @@ -1,31 +1,30 @@ import CloudConvert from '../../built/lib/CloudConvert.js'; import { assert } from 'chai'; import * as fs from 'fs'; +import { Readable } from 'node:stream'; +import { type ReadableStream } from 'node:stream/web'; import * as os from 'os'; -import apiKey from './ApiKey'; -import axios from 'axios'; +import apiKey from './ApiKey.js'; describe('JobsResource', () => { + let cloudConvert: CloudConvert; + let tmpPath: string; + beforeEach(() => { - this.cloudConvert = new CloudConvert(apiKey, true); + cloudConvert = new CloudConvert(apiKey, true); }); describe('create()', () => { beforeEach(() => { - this.tmpPath = os.tmpdir() + '/tmp.png'; + tmpPath = os.tmpdir() + '/tmp.png'; }); it('test upload and download files', async () => { - let job = await this.cloudConvert.jobs.create({ + let job = await cloudConvert.jobs.create({ tag: 'integration-test-upload-download', tasks: { - 'import-it': { - operation: 'import/upload' - }, - 'export-it': { - input: 'import-it', - operation: 'export/url' - } + 'import-it': { operation: 'import/upload' }, + 'export-it': { input: 'import-it', operation: 'export/url' } } }); @@ -37,55 +36,48 @@ describe('JobsResource', () => { __dirname + '/../integration/files/input.png' ); - await this.cloudConvert.tasks.upload(uploadTask, stream); + await cloudConvert.tasks.upload(uploadTask, stream); - job = await this.cloudConvert.jobs.wait(job.id); + job = await cloudConvert.jobs.wait(job.id); assert.equal(job.status, 'finished'); // download export file - const file = this.cloudConvert.jobs.getExportUrls(job)[0]; + const file = cloudConvert.jobs.getExportUrls(job)[0]; assert.equal(file.filename, 'input.png'); - const writer = fs.createWriteStream(this.tmpPath); + const writer = fs.createWriteStream(tmpPath); - const response = await axios(file.url, { - responseType: 'stream' - }); + const response = (await fetch(file.url!)).body as ReadableStream; - response.data.pipe(writer); + Readable.fromWeb(response).pipe(writer); - await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { writer.on('finish', resolve); writer.on('error', reject); }); // check file size - const stat = fs.statSync(this.tmpPath); + const stat = fs.statSync(tmpPath); assert.equal(stat.size, 46937); - await this.cloudConvert.jobs.delete(job.id); + await cloudConvert.jobs.delete(job.id); }).timeout(30000); afterEach(() => { - fs.unlinkSync(this.tmpPath); + fs.unlinkSync(tmpPath); }); }); describe('subscribeEvent()', () => { it('test listening for finished event', async () => { - let job = await this.cloudConvert.jobs.create({ + const job = await cloudConvert.jobs.create({ tag: 'integration-test-socket', tasks: { - 'import-it': { - operation: 'import/upload' - }, - 'export-it': { - input: 'import-it', - operation: 'export/url' - } + 'import-it': { operation: 'import/upload' }, + 'export-it': { input: 'import-it', operation: 'export/url' } } }); @@ -97,23 +89,22 @@ describe('JobsResource', () => { __dirname + '/../integration/files/input.png' ); - this.cloudConvert.tasks.upload(uploadTask, stream); + setTimeout(() => { + // for testing, we need to slow down the upload. otherwise we might miss the event because the job finishes too fast + cloudConvert.tasks.upload(uploadTask, stream); + }, 1000); const event = await new Promise(resolve => { - this.cloudConvert.jobs.subscribeEvent( - job.id, - 'finished', - resolve - ); + cloudConvert.jobs.subscribeEvent(job.id, 'finished', resolve); }); assert.equal(event.job.status, 'finished'); - await this.cloudConvert.jobs.delete(job.id); + await cloudConvert.jobs.delete(job.id); }).timeout(30000); afterEach(() => { - this.cloudConvert.closeSocket(); + cloudConvert.closeSocket(); }); }); }); diff --git a/tests/integration/TasksResourceTest.js b/tests/integration/TasksResourceTest.ts similarity index 61% rename from tests/integration/TasksResourceTest.js rename to tests/integration/TasksResourceTest.ts index e30fd5b..9570be8 100644 --- a/tests/integration/TasksResourceTest.js +++ b/tests/integration/TasksResourceTest.ts @@ -1,16 +1,18 @@ import CloudConvert from '../../built/lib/CloudConvert.js'; import { assert } from 'chai'; import * as fs from 'fs'; -import apiKey from './ApiKey'; +import apiKey from './ApiKey.js'; describe('TasksResource', () => { + let cloudConvert: CloudConvert; + beforeEach(() => { - this.cloudConvert = new CloudConvert(apiKey, true); + cloudConvert = new CloudConvert(apiKey, true); }); describe('upload()', () => { it('uploads input.png', async () => { - let task = await this.cloudConvert.tasks.create('import/upload', { + let task = await cloudConvert.tasks.create('import/upload', { name: 'upload-test' }); @@ -18,14 +20,14 @@ describe('TasksResource', () => { __dirname + '/../integration/files/input.png' ); - await this.cloudConvert.tasks.upload(task, stream); + await cloudConvert.tasks.upload(task, stream); - task = await this.cloudConvert.tasks.wait(task.id); + task = await cloudConvert.tasks.wait(task.id); assert.equal(task.status, 'finished'); assert.equal(task.result.files[0].filename, 'input.png'); - await this.cloudConvert.tasks.delete(task.id); + await cloudConvert.tasks.delete(task.id); }).timeout(30000); }); }); diff --git a/tests/integration/UsersResourceTest.js b/tests/integration/UsersResourceTest.ts similarity index 73% rename from tests/integration/UsersResourceTest.js rename to tests/integration/UsersResourceTest.ts index 9230c80..a1d3df0 100644 --- a/tests/integration/UsersResourceTest.js +++ b/tests/integration/UsersResourceTest.ts @@ -1,15 +1,17 @@ import CloudConvert from '../../built/lib/CloudConvert.js'; -import apiKey from './ApiKey'; +import apiKey from './ApiKey.js'; import { assert } from 'chai'; describe('UsersResource', () => { + let cloudConvert: CloudConvert; + beforeEach(() => { - this.cloudConvert = new CloudConvert(apiKey, true); + cloudConvert = new CloudConvert(apiKey, true); }); describe('me()', () => { it('should fetch the current user', async () => { - const data = await this.cloudConvert.users.me(); + const data = await cloudConvert.users.me(); console.log(data); diff --git a/tests/unit/JobsResourceTest.js b/tests/unit/JobsResourceTest.ts similarity index 79% rename from tests/unit/JobsResourceTest.js rename to tests/unit/JobsResourceTest.ts index e1b37bd..99f63e0 100644 --- a/tests/unit/JobsResourceTest.js +++ b/tests/unit/JobsResourceTest.ts @@ -3,23 +3,23 @@ import { assert } from 'chai'; import nock from 'nock'; describe('JobsResource', () => { + let cloudConvert: CloudConvert; + beforeEach(() => { - this.cloudConvert = new CloudConvert('test'); + cloudConvert = new CloudConvert('test'); }); describe('all()', () => { it('should fetch all jobs', async () => { nock('https://api.cloudconvert.com', { - reqheaders: { - Authorization: 'Bearer test' - } + reqheaders: { Authorization: 'Bearer test' } }) .get('/v2/jobs') .replyWithFile(200, __dirname + '/responses/jobs.json', { 'Content-Type': 'application/json' }); - const data = await this.cloudConvert.jobs.all(); + const data = await cloudConvert.jobs.all(); assert.isArray(data); assert.equal(data[0].id, 'bd7d06b4-60fb-472b-b3a3-9034b273df07'); @@ -36,7 +36,7 @@ describe('JobsResource', () => { 'Content-Type': 'application/json' }); - const data = await this.cloudConvert.jobs.get( + const data = await cloudConvert.jobs.get( 'cd82535b-0614-4b23-bbba-b24ab0e892f7' ); @@ -48,15 +48,12 @@ describe('JobsResource', () => { describe('create()', () => { it('should send the create request', async () => { nock('https://api.cloudconvert.com') - .post('/v2/jobs', { - tag: 'test', - tasks: {} - }) + .post('/v2/jobs', { tag: 'test', tasks: {} }) .replyWithFile(200, __dirname + '/responses/job_created.json', { 'Content-Type': 'application/json' }); - const data = await this.cloudConvert.jobs.create({ + const data = await cloudConvert.jobs.create({ tag: 'test', tasks: {} }); @@ -72,7 +69,7 @@ describe('JobsResource', () => { .delete('/v2/jobs/2f901289-c9fe-4c89-9c4b-98be526bdfbf') .reply(204); - await this.cloudConvert.jobs.delete( + await cloudConvert.jobs.delete( '2f901289-c9fe-4c89-9c4b-98be526bdfbf' ); }); @@ -86,17 +83,15 @@ describe('JobsResource', () => { .replyWithFile( 200, __dirname + '/responses/job_finished.json', - { - 'Content-Type': 'application/json' - } + { 'Content-Type': 'application/json' } ); - const job = await this.cloudConvert.jobs.get( + const job = await cloudConvert.jobs.get( 'b2e4eb2b-a744-4da2-97cd-776d393532a8', { include: 'tasks' } ); - const exportUrls = this.cloudConvert.jobs.getExportUrls(job); + const exportUrls = cloudConvert.jobs.getExportUrls(job); assert.isArray(exportUrls); assert.lengthOf(exportUrls, 1); diff --git a/tests/unit/SignedUrlResourceTest.js b/tests/unit/SignedUrlResourceTest.ts similarity index 88% rename from tests/unit/SignedUrlResourceTest.js rename to tests/unit/SignedUrlResourceTest.ts index 4b91f39..12f336d 100644 --- a/tests/unit/SignedUrlResourceTest.js +++ b/tests/unit/SignedUrlResourceTest.ts @@ -2,8 +2,10 @@ import CloudConvert from '../../built/lib/CloudConvert.js'; import { assert } from 'chai'; describe('SignedUrlResource', () => { + let cloudConvert: CloudConvert; + beforeEach(() => { - this.cloudConvert = new CloudConvert('test'); + cloudConvert = new CloudConvert('test'); }); describe('create()', () => { @@ -25,9 +27,9 @@ describe('SignedUrlResource', () => { inline: true } } - }; + } as const; - const url = this.cloudConvert.signedUrls.sign( + const url = cloudConvert.signedUrls.sign( base, signingSecret, job, diff --git a/tests/unit/TasksResourceTest.js b/tests/unit/TasksResourceTest.ts similarity index 81% rename from tests/unit/TasksResourceTest.js rename to tests/unit/TasksResourceTest.ts index 0bbcf5b..1f334ff 100644 --- a/tests/unit/TasksResourceTest.js +++ b/tests/unit/TasksResourceTest.ts @@ -4,23 +4,23 @@ import * as fs from 'fs'; import nock from 'nock'; describe('TasksResource', () => { + let cloudConvert: CloudConvert; + beforeEach(() => { - this.cloudConvert = new CloudConvert('test'); + cloudConvert = new CloudConvert('test'); }); describe('all()', () => { it('should fetch all tasks', async () => { nock('https://api.cloudconvert.com', { - reqheaders: { - Authorization: 'Bearer test' - } + reqheaders: { Authorization: 'Bearer test' } }) .get('/v2/tasks') .replyWithFile(200, __dirname + '/responses/tasks.json', { 'Content-Type': 'application/json' }); - const data = await this.cloudConvert.tasks.all(); + const data = await cloudConvert.tasks.all(); assert.isArray(data); assert.equal(data[0].id, '73df1e16-fd8b-47a1-a156-f197babde91a'); @@ -36,7 +36,7 @@ describe('TasksResource', () => { 'Content-Type': 'application/json' }); - const data = await this.cloudConvert.tasks.get( + const data = await cloudConvert.tasks.get( '4c80f1ae-5b3a-43d5-bb58-1a5c4eb4e46b' ); @@ -59,7 +59,7 @@ describe('TasksResource', () => { { 'Content-Type': 'application/json' } ); - const data = await this.cloudConvert.tasks.create('convert', { + const data = await cloudConvert.tasks.create('convert', { name: 'test', url: 'http://invalid.url', filename: 'test.file' @@ -76,7 +76,7 @@ describe('TasksResource', () => { .delete('/v2/tasks/2f901289-c9fe-4c89-9c4b-98be526bdfbf') .reply(204); - await this.cloudConvert.tasks.delete( + await cloudConvert.tasks.delete( '2f901289-c9fe-4c89-9c4b-98be526bdfbf' ); }); @@ -85,30 +85,28 @@ describe('TasksResource', () => { describe('upload()', () => { it('should send the upload request', async () => { nock('https://api.cloudconvert.com') - .post('/v2/import/upload', {}) + .post('/v2/import/upload') .replyWithFile( 200, __dirname + '/responses/upload_task_created.json', { 'Content-Type': 'application/json' } ); - const task = await this.cloudConvert.tasks.create('import/upload'); + const task = await cloudConvert.tasks.create('import/upload'); nock('https://upload.sandbox.cloudconvert.com', { - reqheaders: { - 'Content-Type': /multipart\/form-data/i - } + reqheaders: { 'Content-Type': /multipart\/form-data/i } }) .post( '/storage.de1.cloud.ovh.net/v1/AUTH_b2cffe8f45324c2bba39e8db1aedb58f/cloudconvert-files-sandbox/8aefdb39-34c8-4c7a-9f2e-1751686d615e/?s=jNf7hn3zox1iZfZY6NirNA&e=1559588529' ) .reply(201); - const stream = fs.createReadStream( + const blob = await fs.openAsBlob( __dirname + '/../integration/files/input.png' ); - await this.cloudConvert.tasks.upload(task, stream); + await cloudConvert.tasks.upload(task, blob); }); }); }); diff --git a/tests/unit/UsersResourceTest.js b/tests/unit/UsersResourceTest.ts similarity index 82% rename from tests/unit/UsersResourceTest.js rename to tests/unit/UsersResourceTest.ts index 5aa192d..ca1785b 100644 --- a/tests/unit/UsersResourceTest.js +++ b/tests/unit/UsersResourceTest.ts @@ -3,8 +3,10 @@ import { assert } from 'chai'; import nock from 'nock'; describe('UsersResource', () => { + let cloudConvert: CloudConvert; + beforeEach(() => { - this.cloudConvert = new CloudConvert('test'); + cloudConvert = new CloudConvert('test'); }); describe('me()', () => { @@ -15,7 +17,7 @@ describe('UsersResource', () => { 'Content-Type': 'application/json' }); - const data = await this.cloudConvert.users.me(); + const data = await cloudConvert.users.me(); assert.isObject(data); assert.equal(data.id, 1); diff --git a/tests/unit/WebhooksResourceTest.js b/tests/unit/WebhooksResourceTest.ts similarity index 63% rename from tests/unit/WebhooksResourceTest.js rename to tests/unit/WebhooksResourceTest.ts index 982a1dd..897d5be 100644 --- a/tests/unit/WebhooksResourceTest.js +++ b/tests/unit/WebhooksResourceTest.ts @@ -3,8 +3,10 @@ import { assert } from 'chai'; import * as fs from 'fs'; describe('WebhooksResource', () => { + let cloudConvert: CloudConvert; + beforeEach(() => { - this.cloudConvert = new CloudConvert('test'); + cloudConvert = new CloudConvert('test'); }); describe('verify()', () => { @@ -13,22 +15,15 @@ describe('WebhooksResource', () => { const signature = '576b653f726c85265a389532988f483b5c7d7d5f40cede5f5ddf9c3f02934f35'; const payloadString = fs.readFileSync( - __dirname + '/requests/webhook_job_finished_payload.json' + __dirname + '/requests/webhook_job_finished_payload.json', + 'utf-8' ); assert.isFalse( - this.cloudConvert.webhooks.verify( - payloadString, - 'invalid', - secret - ) + cloudConvert.webhooks.verify(payloadString, 'invalid', secret) ); assert.isTrue( - this.cloudConvert.webhooks.verify( - payloadString, - signature, - secret - ) + cloudConvert.webhooks.verify(payloadString, signature, secret) ); }); }); diff --git a/tsconfig.json b/tsconfig.json index 48b582f..4430a0c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,9 @@ { "compilerOptions": { - "charset": "utf8", "declaration": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "noUncheckedIndexedAccess": true, - "keyofStringsOnly": true, "module": "commonjs", "newLine": "lf", "noFallthroughCasesInSwitch": true,