├── .husky ├── pre-commit └── pre-push ├── .npmrc ├── .npmignore ├── .gitignore ├── source ├── index.ts ├── Stack.ts ├── polyfill.ts ├── HTTPClient.ts ├── utility.ts └── HTTPRequest.ts ├── test ├── tsconfig.json ├── custom-JSDOM.ts ├── Stack.spec.ts ├── Client.spec.ts ├── Request-Client.spec.ts ├── XMLHttpRequest.ts └── utility.spec.ts ├── .github ├── pr-badge.yml ├── workflows │ └── main.yml └── settings.yml ├── .vscode └── launch.json ├── tsconfig.json ├── package.json └── ReadMe.md /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm test 2 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | npm run build 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers = false 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test/ 2 | .parcel-cache/ 3 | docs/ 4 | .husky/ 5 | .github/ 6 | .vscode/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | package-lock.json 3 | yarn.lock 4 | .parcel-cache/ 5 | dist/ 6 | docs/ 7 | .vscode/settings.json -------------------------------------------------------------------------------- /source/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Stack'; 2 | export * from './HTTPRequest'; 3 | export * from './HTTPClient'; 4 | export * from './utility'; 5 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ES6" 5 | }, 6 | "include": ["*.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /test/custom-JSDOM.ts: -------------------------------------------------------------------------------- 1 | import JSDOMEnvironment from 'jest-environment-jsdom'; 2 | 3 | export default class extends JSDOMEnvironment { 4 | constructor(config, context) { 5 | super(config, context); 6 | 7 | this.global.fetch = fetch; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.github/pr-badge.yml: -------------------------------------------------------------------------------- 1 | - icon: visualstudio 2 | label: 'GitHub.dev' 3 | message: 'PR-$prNumber' 4 | color: 'blue' 5 | url: 'https://github.dev/$owner/$repo/pull/$prNumber' 6 | 7 | - icon: github 8 | label: 'GitHub codespaces' 9 | message: 'PR-$prNumber' 10 | color: 'black' 11 | url: 'https://codespaces.new/$owner/$repo/pull/$prNumber' 12 | 13 | - icon: git 14 | label: 'GitPod.io' 15 | message: 'PR-$prNumber' 16 | color: 'orange' 17 | url: 'https://gitpod.io/?autostart=true#https://github.com/$owner/$repo/pull/$prNumber' 18 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Jest", 6 | "type": "node", 7 | "request": "launch", 8 | "port": 9229, 9 | "runtimeArgs": [ 10 | "--inspect-brk", 11 | "${workspaceRoot}/node_modules/jest/bin/jest.js", 12 | "--runInBand" 13 | ], 14 | "console": "integratedTerminal", 15 | "internalConsoleOptions": "neverOpen" 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ESNext", 4 | "moduleResolution": "Bundler", 5 | "esModuleInterop": true, 6 | "downlevelIteration": true, 7 | "lib": ["ESNext", "DOM", "DOM.Iterable"], 8 | "declaration": true, 9 | "skipLibCheck": true, 10 | "outDir": "dist/" 11 | }, 12 | "include": ["source/**/*"], 13 | "typedocOptions": { 14 | "name": "KoAJAX", 15 | "excludeExternals": true, 16 | "excludePrivate": true, 17 | "plugin": ["typedoc-plugin-mdn-links"], 18 | "highlightLanguages": [ 19 | "powershell", 20 | "diff", 21 | "html", 22 | "json", 23 | "javascript", 24 | "jsx", 25 | "typescript", 26 | "tsx" 27 | ] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /source/Stack.ts: -------------------------------------------------------------------------------- 1 | const { push } = Array.prototype; 2 | 3 | export type Middleware = ( 4 | context: C, 5 | next: () => Promise 6 | ) => Promise | any; 7 | 8 | export class Stack { 9 | length = 0; 10 | 11 | use(...middlewares: Middleware[]) { 12 | push.apply(this, middlewares); 13 | 14 | return this; 15 | } 16 | 17 | execute(context?: C, depth = 0) { 18 | const middleware: Middleware | undefined = this[depth]; 19 | 20 | if (middleware instanceof Function) 21 | return middleware( 22 | context, 23 | this.execute.bind(this, context, ++depth) 24 | ); 25 | } 26 | 27 | mount(condition: (context: C) => boolean, stack: Stack) { 28 | return this.use((context, next) => 29 | condition(context) ? stack.execute(context) : next() 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /source/polyfill.ts: -------------------------------------------------------------------------------- 1 | import 'core-js/es/object/from-entries'; 2 | import 'core-js/es/promise/with-resolvers'; 3 | import 'core-js/es/string/match-all'; 4 | import 'core-js/full/array/from-async'; 5 | 6 | export async function polyfill(origin = 'http://127.0.0.1') { 7 | if (globalThis.XMLHttpRequest) return; 8 | 9 | const { JSDOM } = await import('jsdom'); 10 | 11 | const { window } = await JSDOM.fromURL(origin); 12 | 13 | for (const key of [ 14 | 'Node', 15 | 'Document', 16 | 'document', 17 | 'HTMLElement', 18 | 'HTMLFormElement', 19 | 'SVGElement', 20 | 'DOMParser', 21 | 'XMLSerializer', 22 | 'FormData', 23 | 'TextEncoder', 24 | 'EventTarget', 25 | 'AbortSignal', 26 | 'ReadableStream', 27 | 'ArrayBuffer', 28 | 'Blob', 29 | 'XMLHttpRequest', 30 | 'FileReader' 31 | ]) 32 | globalThis[key] ||= window[key]; 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI & CD 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | jobs: 7 | Build-and-Publish: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | contents: write 11 | id-token: write 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - uses: pnpm/action-setup@v4 16 | with: 17 | version: 9 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: 22 21 | registry-url: https://registry.npmjs.org 22 | cache: pnpm 23 | - name: Install Dependencies 24 | run: pnpm i --frozen-lockfile 25 | 26 | - name: Build & Publish 27 | run: npm publish --access public --provenance 28 | env: 29 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 30 | 31 | - name: Update document 32 | uses: peaceiris/actions-gh-pages@v4 33 | with: 34 | publish_dir: ./docs 35 | personal_token: ${{ secrets.GITHUB_TOKEN }} 36 | force_orphan: true 37 | -------------------------------------------------------------------------------- /test/Stack.spec.ts: -------------------------------------------------------------------------------- 1 | import { Stack } from '../source'; 2 | 3 | describe('Stack', () => { 4 | const stack = new Stack<{ count: number }>(), 5 | list: number[] = []; 6 | 7 | it('should append Middlewares', () => { 8 | stack.use( 9 | async (context, next) => { 10 | list.push(context.count++); 11 | 12 | await next(); 13 | 14 | list.push(context.count++); 15 | }, 16 | context => list.push(context.count++) 17 | ); 18 | 19 | expect(stack.length).toBe(2); 20 | }); 21 | 22 | it('should execute Middlewares in order', async () => { 23 | await stack.execute({ count: 0 }); 24 | 25 | expect(list).toEqual([0, 1, 2]); 26 | }); 27 | 28 | it('should catch Errors by next()', async () => { 29 | const stack = new Stack<{ error: Error }>(), 30 | context = {} as { error: Error }; 31 | 32 | stack.use( 33 | async (context, next) => { 34 | try { 35 | await next(); 36 | } catch (error) { 37 | context.error = error; 38 | } 39 | }, 40 | () => { 41 | throw Error('test'); 42 | } 43 | ); 44 | 45 | await stack.execute(context); 46 | 47 | expect(context.error).toEqual(Error('test')); 48 | }); 49 | 50 | it('should execute only one Stack Path while Stacks nested', async () => { 51 | const stack = new Stack<{ deep: boolean }>(), 52 | list: number[] = []; 53 | 54 | stack.use(async (_, next) => { 55 | list.push(0); 56 | 57 | await next(); 58 | 59 | list.push(1); 60 | }); 61 | 62 | stack.mount( 63 | ({ deep }) => deep, 64 | new Stack().use(() => list.push(2)) 65 | ); 66 | 67 | stack.use(() => list.push(3)); 68 | 69 | await stack.execute({ deep: true }); 70 | 71 | expect(list).toEqual([0, 2, 1]); 72 | 73 | list.length = 0; 74 | 75 | await stack.execute({ deep: false }); 76 | 77 | expect(list).toEqual([0, 3, 1]); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /test/Client.spec.ts: -------------------------------------------------------------------------------- 1 | import './XMLHttpRequest'; 2 | 3 | import { HTTPClient } from '../source'; 4 | 5 | describe('HTTP Client', () => { 6 | const client = new HTTPClient({ responseType: 'json' }); 7 | 8 | it('should return Data while Status is less then 300', async () => { 9 | const { headers, body } = await client.get('/200'); 10 | 11 | expect(headers).toEqual({ 'Content-Type': 'application/json' }); 12 | expect(body).toEqual({ message: 'Hello, World!' }); 13 | }); 14 | 15 | it('should throw Error while Status is greater then 300', async () => { 16 | try { 17 | await client.get('/404'); 18 | } catch (error) { 19 | expect({ ...error }).toEqual({ 20 | request: { 21 | method: 'GET', 22 | path: 'http://localhost/404', 23 | headers: {} 24 | }, 25 | response: { 26 | status: 404, 27 | statusText: 'Not Found', 28 | headers: { 'Content-Type': 'application/json' }, 29 | body: { message: 'Hello, Error!' } 30 | } 31 | }); 32 | } 33 | }); 34 | 35 | it('should serialize JSON automatically', async () => { 36 | const { body } = await client.post('/201', { test: 'example' }); 37 | 38 | expect(body).toEqual({ test: 'example' }); 39 | }); 40 | 41 | it('should invoke Custom Middlewares', async () => { 42 | const data: any[] = []; 43 | 44 | client.use(async ({ request: { path }, response }, next) => { 45 | data.push(path); 46 | 47 | await next(); 48 | 49 | data.push(response.status); 50 | }); 51 | 52 | await client.get('/200'); 53 | 54 | expect(data).toEqual(['/200', 200]); 55 | }); 56 | 57 | it('should throw Abort Error as Abort Signal emitted', async () => { 58 | const controller = new AbortController(); 59 | 60 | setTimeout(() => controller.abort()); 61 | 62 | try { 63 | await client.get('/200', {}, { signal: controller.signal }); 64 | } catch (error) { 65 | expect(error.message).toBe('signal is aborted without reason'); 66 | } 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "koajax", 3 | "version": "3.1.2", 4 | "license": "LGPL-3.0", 5 | "author": "shiy2008@gmail.com", 6 | "description": "HTTP Client based on Koa-like middlewares", 7 | "keywords": [ 8 | "http", 9 | "request", 10 | "client", 11 | "ajax", 12 | "koa", 13 | "middleware" 14 | ], 15 | "homepage": "https://web-cell.dev/KoAJAX/", 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/EasyWebApp/KoAJAX.git" 19 | }, 20 | "bugs": { 21 | "url": "https://github.com/EasyWebApp/KoAJAX/issues" 22 | }, 23 | "source": "source/index.ts", 24 | "types": "dist/index.d.ts", 25 | "main": "dist/index.js", 26 | "module": "dist/index.esm.js", 27 | "dependencies": { 28 | "@swc/helpers": "^0.5.15", 29 | "regenerator-runtime": "^0.14.1", 30 | "web-streams-polyfill": "^4.1.0", 31 | "web-utility": "^4.4.3" 32 | }, 33 | "peerDependencies": { 34 | "core-js": ">=3", 35 | "jsdom": ">=21" 36 | }, 37 | "devDependencies": { 38 | "@parcel/packager-ts": "~2.14.1", 39 | "@parcel/transformer-typescript-types": "~2.14.1", 40 | "@types/jest": "^29.5.14", 41 | "@types/jsdom": "^21.1.7", 42 | "@types/node": "^22.13.11", 43 | "abortcontroller-polyfill": "^1.7.8", 44 | "core-js": "^3.41.0", 45 | "cross-env": "^7.0.3", 46 | "husky": "^9.1.7", 47 | "jest": "^29.7.0", 48 | "jest-environment-jsdom": "^29.7.0", 49 | "jsdom": "^26.0.0", 50 | "lint-staged": "^15.5.0", 51 | "open-cli": "^8.0.0", 52 | "parcel": "~2.14.1", 53 | "prettier": "^3.5.3", 54 | "ts-jest": "^29.2.6", 55 | "ts-node": "^10.9.2", 56 | "typedoc": "^0.28.1", 57 | "typedoc-plugin-mdn-links": "^5.0.1", 58 | "typescript": "~5.8.2" 59 | }, 60 | "prettier": { 61 | "singleQuote": true, 62 | "trailingComma": "none", 63 | "arrowParens": "avoid", 64 | "tabWidth": 4 65 | }, 66 | "lint-staged": { 67 | "*.{md,ts,json,yml}": "prettier --write" 68 | }, 69 | "jest": { 70 | "preset": "ts-jest", 71 | "testEnvironment": "./test/custom-JSDOM.ts" 72 | }, 73 | "browserslist": "> 0.5%, last 2 versions, not dead, IE 11", 74 | "targets": { 75 | "main": { 76 | "optimize": true 77 | }, 78 | "types": false 79 | }, 80 | "scripts": { 81 | "prepare": "husky", 82 | "test": "lint-staged && cross-env NODE_OPTIONS=--unhandled-rejections=warn jest --detectOpenHandles", 83 | "pack-dist": "rm -rf dist/ && tsc --emitDeclarationOnly && parcel build", 84 | "pack-docs": "rm -rf docs/ && typedoc source/", 85 | "build": "npm run pack-dist && npm run pack-docs", 86 | "help": "npm run pack-docs && open-cli docs/index.html", 87 | "prepublishOnly": "npm test && npm run build" 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /test/Request-Client.spec.ts: -------------------------------------------------------------------------------- 1 | import { Blob, ReadableStream } from './XMLHttpRequest'; 2 | 3 | import { requestFetch, requestXHR } from '../source'; 4 | 5 | describe('HTTP Request', () => { 6 | it('should return a Promise & an Observable with fetch()', async () => { 7 | const { download, response } = requestFetch<{ id: number }>({ 8 | path: 'https://jsonplaceholder.typicode.com/posts/1', 9 | responseType: 'json' 10 | }); 11 | expect(Symbol.asyncIterator in download).toBeTruthy(); 12 | 13 | const { loaded, total } = (await Array.fromAsync(download)).at(-1); 14 | 15 | expect(loaded).toBeGreaterThanOrEqual(total); 16 | 17 | const { body } = await response; 18 | 19 | expect(body).toMatchObject({ id: 1 }); 20 | }); 21 | 22 | it('should return a Promise & 2 Observable with fetch() & Readable Stream', async () => { 23 | const blob = new Blob([JSON.stringify({ title: 'KoAJAX' })], { 24 | type: 'application/json' 25 | }); 26 | const { upload, download, response } = requestFetch<{ title: string }>({ 27 | method: 'POST', 28 | path: 'https://jsonplaceholder.typicode.com/posts', 29 | headers: { 30 | 'Content-Type': blob.type, 31 | 'Content-Length': blob.size + '' 32 | }, 33 | body: ReadableStream.from(blob.stream()), 34 | responseType: 'json' 35 | }); 36 | expect(Symbol.asyncIterator in upload!).toBeTruthy(); 37 | expect(Symbol.asyncIterator in download).toBeTruthy(); 38 | 39 | const { loaded, total } = (await Array.fromAsync(upload!)).at(-1); 40 | 41 | expect(loaded).toBeGreaterThanOrEqual(total); 42 | 43 | const { body } = await response; 44 | 45 | expect(body).toMatchObject({ title: 'KoAJAX' }); 46 | }); 47 | 48 | it('should return a Promise & 2 Observable with XHR', async () => { 49 | const { upload, download, response } = requestXHR({ 50 | path: '/200', 51 | responseType: 'json' 52 | }); 53 | 54 | expect(Symbol.asyncIterator in upload!).toBeTruthy(); 55 | expect(Symbol.asyncIterator in download).toBeTruthy(); 56 | 57 | expect(await response).toEqual({ 58 | status: 200, 59 | statusText: 'OK', 60 | headers: { 'Content-Type': 'application/json' }, 61 | body: { message: 'Hello, World!' } 62 | }); 63 | }); 64 | 65 | it('should send a Readable Stream with XHR', async () => { 66 | const blob = new Blob([JSON.stringify({ name: 'KoAJAX' })], { 67 | type: 'application/json' 68 | }); 69 | const { response } = requestXHR({ 70 | method: 'POST', 71 | path: '/201', 72 | body: ReadableStream.from(blob.stream()), 73 | responseType: 'json' 74 | }); 75 | expect(await response).toEqual({ 76 | status: 201, 77 | statusText: 'Created', 78 | headers: { 'Content-Type': 'application/json' }, 79 | body: { name: 'KoAJAX' } 80 | }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /test/XMLHttpRequest.ts: -------------------------------------------------------------------------------- 1 | import { AbortController } from 'abortcontroller-polyfill/dist/cjs-ponyfill'; 2 | import { Blob } from 'buffer'; 3 | import { ReadableStream } from 'web-streams-polyfill'; 4 | 5 | export { Blob, ReadableStream }; 6 | 7 | import '../source/polyfill'; 8 | import { Request } from '../source'; 9 | 10 | export class XMLHttpRequest extends EventTarget { 11 | readyState = 0; 12 | 13 | onreadystatechange?: () => any; 14 | onerror?: () => any; 15 | 16 | responseURL: string; 17 | responseType: XMLHttpRequestResponseType; 18 | 19 | #updateReadyState(state: number) { 20 | this.readyState = state; 21 | this.onreadystatechange?.(); 22 | } 23 | 24 | overrideMimeType(type: string) {} 25 | 26 | open(method: Request['method'], URI: string) { 27 | this.responseURL = URI; 28 | 29 | this.#updateReadyState(1); 30 | } 31 | 32 | setRequestHeader() {} 33 | 34 | upload = new EventTarget(); 35 | 36 | status: number; 37 | statusText: string; 38 | responseText: string; 39 | response: any; 40 | 41 | send(body: Request['body']) { 42 | setTimeout(() => this.#updateReadyState(2)); 43 | 44 | setTimeout(() => this.#updateReadyState(3)); 45 | 46 | setTimeout(() => this.#mockResponse(body)); 47 | } 48 | 49 | async #mockResponse(body: Request['body']) { 50 | if (this.readyState > 3) return; 51 | 52 | this.status = Number(this.responseURL.split('/').slice(-1)[0]); 53 | 54 | switch (this.status) { 55 | case 200: { 56 | this.statusText = 'OK'; 57 | this.response = { message: 'Hello, World!' }; 58 | break; 59 | } 60 | case 201: { 61 | this.statusText = 'Created'; 62 | this.response = 63 | typeof body === 'string' 64 | ? JSON.parse(body) 65 | : body instanceof Blob 66 | ? await body.text() 67 | : body; 68 | break; 69 | } 70 | case 404: { 71 | this.statusText = 'Not Found'; 72 | this.response = { message: 'Hello, Error!' }; 73 | } 74 | } 75 | 76 | if (this.responseType === 'json') 77 | this.responseText = JSON.stringify(this.response); 78 | else this.response = JSON.stringify(this.response); 79 | 80 | this.#updateReadyState(4); 81 | } 82 | 83 | abort() { 84 | this.status = 0; 85 | this.#updateReadyState(4); 86 | } 87 | 88 | getResponseHeader(name: string) { 89 | return 'application/json'; 90 | } 91 | 92 | getAllResponseHeaders() { 93 | return 'content-type: application/json'; 94 | } 95 | } 96 | 97 | global.AbortController = AbortController; 98 | // @ts-ignore 99 | global.ReadableStream = ReadableStream; 100 | // @ts-ignore 101 | // https://github.com/jsdom/jsdom/issues/2555#issuecomment-1864762292 102 | global.Blob = Blob; 103 | // @ts-ignore 104 | global.XMLHttpRequest = XMLHttpRequest; 105 | -------------------------------------------------------------------------------- /.github/settings.yml: -------------------------------------------------------------------------------- 1 | # These settings are synced to GitHub by https://probot.github.io/apps/settings/ 2 | 3 | repository: 4 | allow_merge_commit: false 5 | 6 | delete_branch_on_merge: true 7 | 8 | enable_vulnerability_alerts: true 9 | 10 | labels: 11 | - name: bug 12 | color: '#d73a4a' 13 | description: Something isn't working 14 | 15 | - name: documentation 16 | color: '#0075ca' 17 | description: Improvements or additions to documentation 18 | 19 | - name: duplicate 20 | color: '#cfd3d7' 21 | description: This issue or pull request already exists 22 | 23 | - name: enhancement 24 | color: '#a2eeef' 25 | description: Some improvements 26 | 27 | - name: feature 28 | color: '#16b33f' 29 | description: New feature or request 30 | 31 | - name: good first issue 32 | color: '#7057ff' 33 | description: Good for newcomers 34 | 35 | - name: help wanted 36 | color: '#008672' 37 | description: Extra attention is needed 38 | 39 | - name: invalid 40 | color: '#e4e669' 41 | description: This doesn't seem right 42 | 43 | - name: question 44 | color: '#d876e3' 45 | description: Further information is requested 46 | 47 | - name: wontfix 48 | color: '#ffffff' 49 | description: This will not be worked on 50 | 51 | branches: 52 | - name: master 53 | # https://docs.github.com/en/rest/reference/repos#update-branch-protection 54 | protection: 55 | # Required. Require at least one approving review on a pull request, before merging. Set to null to disable. 56 | required_pull_request_reviews: 57 | # The number of approvals required. (1-6) 58 | required_approving_review_count: 1 59 | # Dismiss approved reviews automatically when a new commit is pushed. 60 | dismiss_stale_reviews: true 61 | # Blocks merge until code owners have reviewed. 62 | require_code_owner_reviews: true 63 | # Specify which users and teams can dismiss pull request reviews. 64 | # Pass an empty dismissal_restrictions object to disable. 65 | # User and team dismissal_restrictions are only available for organization-owned repositories. 66 | # Omit this parameter for personal repositories. 67 | dismissal_restrictions: 68 | # users: [] 69 | # teams: [] 70 | # Required. Require status checks to pass before merging. Set to null to disable 71 | required_status_checks: 72 | # Required. Require branches to be up to date before merging. 73 | strict: true 74 | # Required. The list of status checks to require in order to merge into this branch 75 | contexts: [] 76 | # Required. Enforce all configured restrictions for administrators. 77 | # Set to true to enforce required status checks for repository administrators. 78 | # Set to null to disable. 79 | enforce_admins: true 80 | # Prevent merge commits from being pushed to matching branches 81 | required_linear_history: true 82 | # Required. Restrict who can push to this branch. 83 | # Team and user restrictions are only available for organization-owned repositories. 84 | # Set to null to disable. 85 | restrictions: null 86 | -------------------------------------------------------------------------------- /test/utility.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | encodeBase64, 3 | makeFormData, 4 | parseHeaders, 5 | readAs, 6 | serializeNode 7 | } from '../source'; 8 | 9 | describe('HTTP utility', () => { 10 | describe('Parse Headers', () => { 11 | it('should parse Link header to Object', () => { 12 | const meta = parseHeaders( 13 | 'link:' + 14 | [ 15 | '; rel="next"', 16 | '; rel="last"' 17 | ] 18 | ); 19 | expect(meta).toEqual({ 20 | Link: { 21 | next: { 22 | rel: 'next', 23 | URI: 'https://api.github.com/search/code?q=addClass+user%3Amozilla&page=2' 24 | }, 25 | last: { 26 | rel: 'last', 27 | URI: 'https://api.github.com/search/code?q=addClass+user%3Amozilla&page=34' 28 | } 29 | } 30 | }); 31 | }); 32 | }); 33 | 34 | describe('Form Data', () => { 35 | it('should make a Form Data instance from a Plain Object', () => { 36 | const formData = makeFormData({ 37 | a: 1, 38 | b: [2, 3, null, undefined], 39 | c: new Blob() 40 | }); 41 | const entries = [...formData]; 42 | 43 | expect(entries.filter(([key]) => key === 'a')).toEqual([ 44 | ['a', '1'] 45 | ]); 46 | expect(entries.filter(([key]) => key === 'b')).toEqual([ 47 | ['b', '2'], 48 | ['b', '3'] 49 | ]); 50 | expect(entries.find(([key]) => key === 'c')?.[1]).toBeInstanceOf( 51 | Blob 52 | ); 53 | }); 54 | }); 55 | 56 | describe('Serialize DOM nodes', () => { 57 | it('should serialize Input fields to String', () => { 58 | document.body.innerHTML = ` 59 |
60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 |
`; 70 | 71 | const [form] = document.forms; 72 | 73 | const result1 = serializeNode(form); 74 | 75 | expect(result1.data + '').toBe('test=1%2C3&example=4'); 76 | expect(result1.contentType).toBe( 77 | 'application/x-www-form-urlencoded' 78 | ); 79 | form.enctype = 'text/plain'; 80 | 81 | const result2 = serializeNode(form); 82 | 83 | expect(result2.data + '').toBe('test=1,3\nexample=4'); 84 | expect(result2.contentType).toBe('text/plain'); 85 | }); 86 | }); 87 | 88 | describe('Blob', () => { 89 | const text = '😂'; 90 | const blob = new Blob([text], { type: 'text/plain' }); 91 | 92 | it('should read a Blob as a Text', async () => { 93 | const text = await readAs(blob, 'text').result; 94 | 95 | expect(text).toBe('😂'); 96 | }); 97 | 98 | it('should encode an Unicode string or blob to a Base64 string', async () => { 99 | expect(await encodeBase64(text)).toBe('8J+Ygg=='); 100 | expect(await encodeBase64(blob)).toBe('8J+Ygg=='); 101 | }); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /source/HTTPClient.ts: -------------------------------------------------------------------------------- 1 | import { Stack, Middleware } from './Stack'; 2 | import { 3 | Request, 4 | Response, 5 | RequestOptions, 6 | request, 7 | BodyRequestMethods, 8 | HTTPError 9 | } from './HTTPRequest'; 10 | import { ProgressData, serialize } from './utility'; 11 | 12 | const { splice } = Array.prototype; 13 | 14 | export interface Context { 15 | request: Request; 16 | response: Response; 17 | } 18 | 19 | export interface ClientOptions extends RequestOptions { 20 | baseURI?: string; 21 | baseRequest?: typeof request; 22 | } 23 | 24 | export type MethodOptions = Omit< 25 | Request, 26 | 'method' | 'path' | 'headers' | 'body' 27 | >; 28 | 29 | export interface DownloadOptions 30 | extends Pick { 31 | chunkSize?: number; 32 | range?: [number?, number?]; 33 | } 34 | 35 | export interface TransferProgress extends ProgressData { 36 | percent: number; 37 | buffer: ArrayBuffer; 38 | } 39 | 40 | export class HTTPClient extends Stack { 41 | baseURI: string; 42 | baseRequest: typeof request; 43 | options: RequestOptions; 44 | 45 | constructor({ 46 | baseURI = globalThis.document?.baseURI, 47 | baseRequest = request, 48 | ...options 49 | }: ClientOptions = {}) { 50 | super(); 51 | 52 | this.baseURI = baseURI; 53 | this.baseRequest = baseRequest; 54 | this.options = options; 55 | 56 | super.use(this.defaultWare); 57 | 58 | super.use(async ({ request: data, response }) => { 59 | data.path = new URL(data.path + '', this.baseURI) + ''; 60 | 61 | Object.assign( 62 | response, 63 | await this.baseRequest({ ...options, ...data }).response 64 | ); 65 | }); 66 | } 67 | 68 | defaultWare: Middleware = async ({ request, response }, next) => { 69 | const { method = 'GET', headers = {}, body } = request; 70 | 71 | if (method in BodyRequestMethods && body && typeof body === 'object') { 72 | const { contentType, data } = serialize( 73 | body, 74 | headers['Content-Type'] 75 | ); 76 | if (contentType) headers['Content-Type'] = contentType; 77 | request.body = data; 78 | } 79 | await next(); 80 | 81 | if (response.status > 299) 82 | throw new HTTPError(response.statusText, request, response); 83 | }; 84 | 85 | use(...middlewares: Middleware[]) { 86 | splice.call(this, -2, 0, ...middlewares); 87 | 88 | return this; 89 | } 90 | 91 | async request(data: T['request']): Promise> { 92 | const context = { 93 | request: { ...data, headers: { ...data.headers } }, 94 | response: {} 95 | } as T; 96 | 97 | await this.execute(context); 98 | 99 | return context.response; 100 | } 101 | 102 | async head( 103 | path: Request['path'], 104 | headers?: Request['headers'], 105 | options?: MethodOptions 106 | ) { 107 | const { headers: data } = await this.request({ 108 | method: 'HEAD', 109 | path, 110 | headers, 111 | ...options 112 | }); 113 | return data; 114 | } 115 | 116 | get( 117 | path: Request['path'], 118 | headers?: Request['headers'], 119 | options?: MethodOptions 120 | ) { 121 | return this.request({ method: 'GET', path, headers, ...options }); 122 | } 123 | 124 | post( 125 | path: Request['path'], 126 | body?: Request['body'], 127 | headers?: Request['headers'], 128 | options?: MethodOptions 129 | ) { 130 | return this.request({ 131 | method: 'POST', 132 | path, 133 | headers, 134 | body, 135 | ...options 136 | }); 137 | } 138 | 139 | put( 140 | path: Request['path'], 141 | body?: Request['body'], 142 | headers?: Request['headers'], 143 | options?: MethodOptions 144 | ) { 145 | return this.request({ 146 | method: 'PUT', 147 | path, 148 | headers, 149 | body, 150 | ...options 151 | }); 152 | } 153 | 154 | patch( 155 | path: Request['path'], 156 | body?: Request['body'], 157 | headers?: Request['headers'], 158 | options?: MethodOptions 159 | ) { 160 | return this.request({ 161 | method: 'PATCH', 162 | path, 163 | headers, 164 | body, 165 | ...options 166 | }); 167 | } 168 | 169 | delete( 170 | path: Request['path'], 171 | body?: Request['body'], 172 | headers?: Request['headers'], 173 | options?: MethodOptions 174 | ) { 175 | return this.request({ 176 | method: 'DELETE', 177 | path, 178 | headers, 179 | body, 180 | ...options 181 | }); 182 | } 183 | 184 | async *download( 185 | path: Request['path'], 186 | { 187 | headers, 188 | chunkSize = 1024 ** 2, 189 | range: [start = 0, end = Infinity] = [], 190 | ...options 191 | }: DownloadOptions = {} 192 | ): AsyncGenerator { 193 | var total = 0; 194 | 195 | function setEndAsHeader(length: number) { 196 | total = length; 197 | 198 | if (end === Infinity) end = total; 199 | } 200 | 201 | try { 202 | const { 'Content-Length': length } = await this.head( 203 | path, 204 | headers, 205 | options 206 | ); 207 | setEndAsHeader(+length); 208 | } catch (error) { 209 | console.error(error); 210 | } 211 | 212 | for ( 213 | let i = start, j = i - 1 + chunkSize; 214 | i < end; 215 | i = j + 1, j += chunkSize 216 | ) { 217 | const { 218 | status, 219 | headers: { 'Content-Range': range }, 220 | body 221 | } = await this.get( 222 | path, 223 | { ...headers, Range: `bytes=${i}-${j}` }, 224 | options 225 | ); 226 | const totalBytes = +(range as string)?.split('/').pop(); 227 | 228 | if (totalBytes) setEndAsHeader(totalBytes); 229 | 230 | if (status !== 206) { 231 | yield { total, loaded: total, percent: 100, buffer: body }; 232 | break; 233 | } 234 | const loaded = i + body.byteLength; 235 | 236 | yield { 237 | total, 238 | loaded, 239 | percent: +((loaded / total) * 100).toFixed(2), 240 | buffer: body 241 | }; 242 | } 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /ReadMe.md: -------------------------------------------------------------------------------- 1 | # KoAJAX 2 | 3 | **HTTP Client** based on [Koa-like middlewares][1] 4 | 5 | [![NPM Dependency](https://img.shields.io/librariesio/github/EasyWebApp/KoAJAX.svg)][2] 6 | [![CI & CD](https://github.com/EasyWebApp/KoAJAX/actions/workflows/main.yml/badge.svg)][3] 7 | [![](https://data.jsdelivr.com/v1/package/npm/koajax/badge?style=rounded)][4] 8 | 9 | [![NPM](https://nodei.co/npm/koajax.png?downloads=true&downloadRank=true&stars=true)][5] 10 | 11 | ## Feature 12 | 13 | ### Request Body 14 | 15 | Automatic Serialized types: 16 | 17 | 1. Pure text: `string` 18 | 2. Form encoding: `URLSearchParams`, `FormData` 19 | 3. DOM object: `Node` 20 | 4. JSON object: `Object` 21 | 5. Binary data: `Blob`, `ArrayBuffer`, TypedArray, `DataView` 22 | 6. Stream object: `ReadableStream` 23 | 24 | ### Response Body 25 | 26 | Automatic Parsed type: 27 | 28 | 1. HTML/XML: `Document` 29 | 2. JSON: `Object` 30 | 3. Binary data: `ArrayBuffer` 31 | 32 | ## Usage 33 | 34 | ### Browser 35 | 36 | #### Installation 37 | 38 | ```powershell 39 | npm install koajax 40 | ``` 41 | 42 | #### `index.html` 43 | 44 | ```html 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | ``` 53 | 54 | ### Node.js 55 | 56 | #### Installation 57 | 58 | ```powershell 59 | npm install koajax core-js jsdom 60 | ``` 61 | 62 | #### `index.ts` 63 | 64 | ```javascript 65 | import { polyfill } from 'koajax/source/polyfill'; 66 | 67 | import { HTTPClient } from 'koajax'; 68 | 69 | const origin = 'https://your-target-origin.com'; 70 | 71 | polyfill(origin).then(() => { 72 | const client = new HTTPClient({ 73 | baseURI: `${origin}/api`, 74 | responseType: 'json' 75 | }); 76 | const { body } = await client.get('test/interface'); 77 | 78 | console.log(body); 79 | }); 80 | ``` 81 | 82 | #### Execution 83 | 84 | ```powershell 85 | npx tsx index.ts 86 | ``` 87 | 88 | ### Non-polyfillable runtimes 89 | 90 | 1. https://github.com/idea2app/KoAJAX-Taro-adapter 91 | 92 | ## Example 93 | 94 | ### RESTful API with Token-based Authorization 95 | 96 | ```javascript 97 | import { HTTPClient } from 'koajax'; 98 | 99 | var token = ''; 100 | 101 | export const client = new HTTPClient().use( 102 | async ({ request: { method, path, headers }, response }, next) => { 103 | if (token) headers['Authorization'] = 'token ' + token; 104 | 105 | await next(); 106 | 107 | if (method === 'POST' && path.startsWith('/session')) 108 | token = response.headers.Token; 109 | } 110 | ); 111 | 112 | client.get('/path/to/your/API').then(console.log); 113 | ``` 114 | 115 | ### Up/Download files 116 | 117 | #### Single HTTP request based on XMLHTTPRequest `progress` events 118 | 119 | (based on [Async Generator][6]) 120 | 121 | ```javascript 122 | import { request } from 'koajax'; 123 | 124 | document.querySelector('input[type="file"]').onchange = async ({ 125 | target: { files } 126 | }) => { 127 | for (const file of files) { 128 | const { upload, download, response } = request({ 129 | method: 'POST', 130 | path: '/files', 131 | body: file, 132 | responseType: 'json' 133 | }); 134 | 135 | for await (const { loaded } of upload) 136 | console.log(`Upload ${file.name} : ${(loaded / file.size) * 100}%`); 137 | 138 | const { body } = await response; 139 | 140 | console.log(`Upload ${file.name} : ${body.url}`); 141 | } 142 | }; 143 | ``` 144 | 145 | #### Single HTTP request based on Fetch `duplex` streams 146 | 147 | > This experimental feature has [some limitations][7]. 148 | 149 | ```diff 150 | -import { request } from 'koajax'; 151 | +import { requestFetch } from 'koajax'; 152 | 153 | document.querySelector('input[type="file"]').onchange = async ({ 154 | target: { files } 155 | }) => { 156 | for (const file of files) { 157 | - const { upload, download, response } = request({ 158 | + const { upload, download, response } = requestFetch({ 159 | method: 'POST', 160 | path: '/files', 161 | + headers: { 162 | + 'Content-Type': file.type, 163 | + 'Content-Length': file.size + '' 164 | + }, 165 | - body: file, 166 | + body: file.stream(), 167 | responseType: 'json' 168 | }); 169 | 170 | for await (const { loaded } of upload) 171 | console.log(`Upload ${file.name} : ${(loaded / file.size) * 100}%`); 172 | 173 | const { body } = await response; 174 | 175 | console.log(`Upload ${file.name} : ${body.url}`); 176 | } 177 | }; 178 | ``` 179 | 180 | #### Multiple HTTP requests based on `Range` header 181 | 182 | ```powershell 183 | npm i native-file-system-adapter # Web standard API polyfill 184 | ``` 185 | 186 | ```javascript 187 | import { showSaveFilePicker } from 'native-file-system-adapter'; 188 | import { HTTPClient } from 'koajax'; 189 | 190 | const bufferClient = new HTTPClient({ responseType: 'arraybuffer' }); 191 | 192 | document.querySelector('#download').onclick = async () => { 193 | const fileURL = 'https://your.server/with/Range/header/supported/file.zip'; 194 | const suggestedName = new URL(fileURL).pathname.split('/').pop(); 195 | 196 | const fileHandle = await showSaveFilePicker({ suggestedName }); 197 | const writer = await fileHandle.createWritable(), 198 | stream = bufferClient.download(fileURL); 199 | 200 | try { 201 | for await (const { total, loaded, percent, buffer } of stream) { 202 | await writer.write(buffer); 203 | 204 | console.table({ total, loaded, percent }); 205 | } 206 | window.alert(`File ${fileHandle.name} downloaded successfully!`); 207 | } finally { 208 | await writer.close(); 209 | } 210 | }; 211 | ``` 212 | 213 | ### Global Error fallback 214 | 215 | ```powershell 216 | npm install browser-unhandled-rejection # Web standard API polyfill 217 | ``` 218 | 219 | ```javascript 220 | import { auto } from 'browser-unhandled-rejection'; 221 | import { HTTPError } from 'koajax'; 222 | 223 | auto(); 224 | 225 | window.addEventListener('unhandledrejection', ({ reason }) => { 226 | if (!(reason instanceof HTTPError)) return; 227 | 228 | const { message } = reason.response.body; 229 | 230 | if (message) window.alert(message); 231 | }); 232 | ``` 233 | 234 | ### Read Files 235 | 236 | (based on [Async Generator][6]) 237 | 238 | ```javascript 239 | import { readAs } from 'koajax'; 240 | 241 | document.querySelector('input[type="file"]').onchange = async ({ 242 | target: { files } 243 | }) => { 244 | for (const file of files) { 245 | const { progress, result } = readAs(file, 'dataURL'); 246 | 247 | for await (const { loaded } of progress) 248 | console.log( 249 | `Loading ${file.name} : ${(loaded / file.size) * 100}%` 250 | ); 251 | 252 | const URI = await result; 253 | 254 | console.log(`Loaded ${file.name} : ${URI}`); 255 | } 256 | }; 257 | ``` 258 | 259 | [1]: https://github.com/koajs/koa#middleware 260 | [2]: https://libraries.io/npm/koajax 261 | [3]: https://github.com/EasyWebApp/KoAJAX/actions/workflows/main.yml 262 | [4]: https://www.jsdelivr.com/package/npm/koajax 263 | [5]: https://nodei.co/npm/koajax/ 264 | [6]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for-await...of#Iterating_over_async_generators 265 | [7]: https://developer.chrome.com/docs/capabilities/web-apis/fetch-streaming-requests#restrictions 266 | -------------------------------------------------------------------------------- /source/utility.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createAsyncIterator, 3 | likeArray, 4 | isTypedArray, 5 | stringifyDOM, 6 | formToJSON 7 | } from 'web-utility'; 8 | 9 | export function polyfillProgressEvent() { 10 | return (globalThis.ProgressEvent ||= class ProgressEvent< 11 | T extends EventTarget = EventTarget 12 | > extends Event { 13 | declare target: T | null; 14 | 15 | lengthComputable: boolean; 16 | total: number; 17 | loaded: number; 18 | 19 | constructor( 20 | type: string, 21 | { lengthComputable, total, loaded, ...meta }: ProgressEventInit = {} 22 | ) { 23 | super(type, meta); 24 | 25 | this.lengthComputable = lengthComputable; 26 | this.total = total; 27 | this.loaded = loaded; 28 | } 29 | }); 30 | } 31 | 32 | export async function parseDocument(text: string, contentType = '') { 33 | const [type] = contentType?.split(';') || []; 34 | 35 | return new DOMParser().parseFromString( 36 | text, 37 | (type as DOMParserSupportedType) || 'text/html' 38 | ); 39 | } 40 | 41 | export function makeFormData(data: Record) { 42 | const formData = new FormData(); 43 | 44 | for (const [key, value] of Object.entries(data)) { 45 | const list = ( 46 | typeof value !== 'string' && likeArray(value) ? value : [value] 47 | ) as ArrayLike; 48 | 49 | for (const item of Array.from(list)) 50 | if (item != null) 51 | if (typeof item === 'object') 52 | formData.append(key, item, (item as File).name); 53 | else formData.append(key, item); 54 | } 55 | return formData; 56 | } 57 | 58 | export function serializeNode(root: Node): { 59 | contentType: string; 60 | data: string | URLSearchParams | FormData; 61 | } { 62 | var contentType: string; 63 | 64 | if (!(root instanceof HTMLFormElement)) 65 | return { 66 | contentType: 67 | root instanceof SVGElement 68 | ? 'image/svg' 69 | : root instanceof Document || root instanceof HTMLElement 70 | ? 'text/html' 71 | : 'application/xml', 72 | data: stringifyDOM(root) 73 | }; 74 | 75 | if (root.querySelector('input[type="file"][name]')) 76 | return { 77 | contentType: 'multipart/form-data', 78 | data: new FormData(root) 79 | }; 80 | const data = formToJSON>(root); 81 | 82 | switch ((contentType = root.enctype)) { 83 | case 'text/plain': 84 | return { 85 | contentType, 86 | data: Object.entries(data) 87 | .map(([name, value]) => `${name}=${value}`) 88 | .join('\n') 89 | }; 90 | case 'application/x-www-form-urlencoded': 91 | return { contentType, data: new URLSearchParams(data) }; 92 | default: 93 | return { 94 | contentType: 'application/json', 95 | data: JSON.stringify(data) 96 | }; 97 | } 98 | } 99 | 100 | export function serialize( 101 | data: T, 102 | contentType?: string 103 | ): { 104 | data: T | BodyInit; 105 | contentType?: string; 106 | } { 107 | const [type] = contentType?.split(';') || []; 108 | 109 | switch (type) { 110 | case 'application/x-www-form-urlencoded': 111 | return { 112 | contentType, 113 | data: new URLSearchParams(data as Record) 114 | }; 115 | case 'multipart/form-data': 116 | return { data: makeFormData(data) }; 117 | case 'application/json': 118 | return { contentType, data: JSON.stringify(data) }; 119 | case 'text/html': 120 | case 'application/xml': 121 | case 'image/svg': 122 | return { contentType, data: stringifyDOM(data as Node) }; 123 | } 124 | if (type) return { data, contentType }; 125 | 126 | try { 127 | if (data instanceof URLSearchParams) 128 | return { 129 | contentType: 'application/x-www-form-urlencoded', 130 | data 131 | }; 132 | } catch {} 133 | 134 | try { 135 | if (data instanceof FormData) return { data }; 136 | } catch {} 137 | 138 | try { 139 | if (data instanceof Node) return serializeNode(data); 140 | } catch {} 141 | 142 | try { 143 | if ( 144 | isTypedArray(data) || 145 | data instanceof ArrayBuffer || 146 | data instanceof DataView || 147 | data instanceof Blob || 148 | data instanceof ReadableStream 149 | ) 150 | return { 151 | contentType: 'application/octet-stream', 152 | data 153 | }; 154 | } catch {} 155 | 156 | try { 157 | return { 158 | contentType: 'application/json', 159 | data: JSON.stringify(data) 160 | }; 161 | } catch {} 162 | 163 | throw new Error('Unserialized Object needs a specific Content-Type'); 164 | } 165 | 166 | export type ProgressEventTarget = Pick< 167 | XMLHttpRequestEventTarget & FileReader, 168 | 'dispatchEvent' | 'addEventListener' | 'removeEventListener' 169 | >; 170 | export type ProgressData = Pick; 171 | 172 | export const streamFromProgress = (target: T) => 173 | createAsyncIterator>( 174 | ({ next, complete, error }) => { 175 | const handleProgress = ({ loaded, total }: ProgressEvent) => { 176 | next({ loaded, total }); 177 | 178 | if (loaded >= total) complete(); 179 | }; 180 | target.addEventListener('progress', handleProgress); 181 | target.addEventListener('error', error); 182 | 183 | return () => { 184 | target.removeEventListener('progress', handleProgress); 185 | target.removeEventListener('error', error); 186 | }; 187 | } 188 | ); 189 | export async function* emitStreamProgress( 190 | stream: import('web-streams-polyfill').ReadableStream, 191 | total: number, 192 | eventTarget: ProgressEventTarget 193 | ): AsyncGenerator { 194 | var loaded = 0; 195 | 196 | polyfillProgressEvent(); 197 | 198 | for await (const chunk of stream) { 199 | yield chunk; 200 | 201 | loaded += (chunk as Uint8Array).byteLength; 202 | 203 | const event = new ProgressEvent('progress', { 204 | lengthComputable: isNaN(total), 205 | loaded, 206 | total 207 | }); 208 | eventTarget.dispatchEvent(event); 209 | } 210 | } 211 | 212 | export enum FileMethod { 213 | text = 'readAsText', 214 | dataURL = 'readAsDataURL', 215 | binaryString = 'readAsBinaryString', 216 | arrayBuffer = 'readAsArrayBuffer' 217 | } 218 | 219 | export function readAs( 220 | file: Blob, 221 | method: keyof typeof FileMethod, 222 | encoding?: string 223 | ) { 224 | const reader = new FileReader(); 225 | const result = new Promise((resolve, reject) => { 226 | reader.onerror = reject; 227 | reader.onload = () => resolve(reader.result); 228 | 229 | reader[FileMethod[method]](file, encoding); 230 | }); 231 | return { progress: streamFromProgress(reader), result }; 232 | } 233 | 234 | const DataURI = /^data:(.+?\/(.+?))?(;base64)?,([\s\S]+)/; 235 | /** 236 | * @param raw - Binary data 237 | * 238 | * @return Base64 encoded data 239 | */ 240 | export async function encodeBase64(raw: string | Blob) { 241 | if (raw instanceof Blob) { 242 | const text = await readAs(raw, 'dataURL').result; 243 | 244 | return (DataURI.exec(text as string) || '')[4]; 245 | } 246 | const text = encodeURIComponent(raw).replace(/%([0-9A-F]{2})/g, (_, p1) => 247 | String.fromCharCode(+('0x' + p1)) 248 | ); 249 | return btoa(text); 250 | } 251 | -------------------------------------------------------------------------------- /source/HTTPRequest.ts: -------------------------------------------------------------------------------- 1 | import type { ReadableStream } from 'web-streams-polyfill'; 2 | import { parseJSON } from 'web-utility'; 3 | 4 | import { 5 | emitStreamProgress, 6 | parseDocument, 7 | ProgressData, 8 | ProgressEventTarget, 9 | streamFromProgress 10 | } from './utility'; 11 | 12 | export enum BodyRequestMethods { 13 | POST = 'POST', 14 | PUT = 'PUT', 15 | PATCH = 'PATCH', 16 | DELETE = 'DELETE' 17 | } 18 | 19 | export interface RequestOptions { 20 | withCredentials?: boolean; 21 | timeout?: number; 22 | responseType?: XMLHttpRequestResponseType; 23 | } 24 | 25 | export interface Request extends RequestOptions { 26 | method?: 'HEAD' | 'GET' | keyof typeof BodyRequestMethods; 27 | path: string | URL; 28 | headers?: HeadersInit; 29 | body?: BodyInit | HTMLFormElement | T; 30 | signal?: AbortSignal; 31 | } 32 | 33 | export interface Response { 34 | status: number; 35 | statusText: string; 36 | headers: Record; 37 | body?: B; 38 | } 39 | 40 | export class HTTPError extends URIError { 41 | constructor( 42 | message: string, 43 | public request: Request, 44 | public response: Response 45 | ) { 46 | super(message); 47 | } 48 | } 49 | 50 | export type LinkHeader = Record< 51 | string, 52 | { URI: string; rel: string; title?: string } 53 | >; 54 | 55 | export const headerParser = { 56 | Link: (value: string): LinkHeader => 57 | Object.fromEntries( 58 | Array.from( 59 | value.matchAll(/<(\S+?)>; rel="(\w+)"(?:; title="(.*?)")?/g), 60 | ([_, URI, rel, title]) => [rel, { rel, URI, title }] 61 | ) 62 | ) 63 | }; 64 | 65 | export const parseHeaders = (raw: string): Response['headers'] => 66 | Object.fromEntries( 67 | Array.from( 68 | raw.trim().matchAll(/^([\w-]+):\s*(.*)/gm), 69 | ([_, key, value]) => { 70 | key = key.replace(/(^[a-z]|-[a-z])/g, char => 71 | char.toUpperCase() 72 | ); 73 | return [key, headerParser[key]?.(value) ?? value]; 74 | } 75 | ) 76 | ); 77 | export function parseBody(raw: string, contentType: string): T { 78 | if (contentType.includes('json')) return parseJSON(raw); 79 | 80 | if (contentType.match(/html|xml/)) 81 | try { 82 | return parseDocument(raw, contentType) as T; 83 | } catch {} 84 | 85 | if (contentType.includes('text')) return raw as T; 86 | 87 | return new TextEncoder().encode(raw).buffer as T; 88 | } 89 | 90 | export interface RequestResult { 91 | response: Promise>; 92 | upload?: AsyncGenerator; 93 | download: AsyncGenerator; 94 | } 95 | 96 | export function requestXHR({ 97 | method = 'GET', 98 | path, 99 | headers = {}, 100 | body, 101 | signal, 102 | ...rest 103 | }: Request): RequestResult { 104 | const request = new XMLHttpRequest(); 105 | const header = new Headers(headers); 106 | const bodyPromise = 107 | body instanceof globalThis.ReadableStream 108 | ? Array.fromAsync(body as ReadableStream).then( 109 | parts => new Blob(parts) 110 | ) 111 | : Promise.resolve(body); 112 | const abort = () => request.abort(); 113 | 114 | signal?.addEventListener('abort', abort); 115 | 116 | const response = new Promise>((resolve, reject) => { 117 | request.onreadystatechange = () => { 118 | const { readyState, status, statusText, responseType } = request; 119 | 120 | if (readyState !== 4 || (!status && !signal?.aborted)) return; 121 | 122 | resolve({ 123 | status, 124 | statusText, 125 | headers: parseHeaders(request.getAllResponseHeaders()), 126 | body: 127 | responseType && responseType !== 'text' 128 | ? request.response 129 | : request.responseText 130 | }); 131 | }; 132 | request.onerror = request.ontimeout = reject; 133 | 134 | const [MIMEType] = header.get('Accept')?.split(',') || [ 135 | rest.responseType === 'document' 136 | ? 'application/xhtml+xml' 137 | : rest.responseType === 'json' 138 | ? 'application/json' 139 | : '' 140 | ]; 141 | if (MIMEType) request.overrideMimeType(MIMEType); 142 | 143 | request.open(method, path + ''); 144 | 145 | for (const [key, value] of header) request.setRequestHeader(key, value); 146 | 147 | Object.assign(request, rest); 148 | 149 | bodyPromise.then(body => request.send(body)); 150 | }).then(({ body, ...meta }) => { 151 | signal?.throwIfAborted(); 152 | 153 | const contentType = request.getResponseHeader('Content-Type') || ''; 154 | 155 | if (typeof body === 'string' && !contentType.includes('text')) 156 | body = parseBody(body, contentType); 157 | 158 | return { ...meta, body }; 159 | }); 160 | 161 | response.finally(() => signal?.removeEventListener('abort', abort)); 162 | 163 | return { 164 | response, 165 | upload: streamFromProgress(request.upload), 166 | download: streamFromProgress(request) 167 | }; 168 | } 169 | 170 | export function requestFetch({ 171 | path, 172 | method, 173 | headers, 174 | withCredentials, 175 | body, 176 | signal, 177 | timeout, 178 | responseType 179 | }: Request): RequestResult { 180 | const signals = [signal, timeout && AbortSignal.timeout(timeout)].filter( 181 | Boolean 182 | ); 183 | headers = 184 | headers instanceof Headers 185 | ? Object.fromEntries(headers.entries()) 186 | : headers instanceof Array 187 | ? Object.fromEntries(headers) 188 | : headers; 189 | headers = 190 | responseType === 'text' 191 | ? { ...headers, Accept: 'text/plain' } 192 | : responseType === 'json' 193 | ? { ...headers, Accept: 'application/json' } 194 | : responseType === 'document' 195 | ? { 196 | ...headers, 197 | Accept: 'text/html, application/xhtml+xml, application/xml' 198 | } 199 | : responseType === 'arraybuffer' || responseType === 'blob' 200 | ? { ...headers, Accept: 'application/octet-stream' } 201 | : headers; 202 | const isStream = body instanceof globalThis.ReadableStream; 203 | var upload: AsyncGenerator | undefined; 204 | 205 | if (isStream) { 206 | const uploadProgress = new EventTarget(); 207 | 208 | body = globalThis.ReadableStream['from']( 209 | emitStreamProgress( 210 | body as ReadableStream, 211 | +headers['Content-Length'], 212 | uploadProgress 213 | ) 214 | ) as ReadableStream; 215 | 216 | upload = streamFromProgress(uploadProgress); 217 | } 218 | const downloadProgress = new EventTarget(); 219 | 220 | const response = fetch(path + '', { 221 | method, 222 | headers, 223 | credentials: withCredentials ? 'include' : 'omit', 224 | body, 225 | signal: signals[0] && AbortSignal.any(signals), 226 | // @ts-expect-error https://developer.chrome.com/docs/capabilities/web-apis/fetch-streaming-requests 227 | duplex: isStream ? 'half' : undefined 228 | }).then(response => 229 | parseResponse(response, responseType, downloadProgress) 230 | ); 231 | return { response, upload, download: streamFromProgress(downloadProgress) }; 232 | } 233 | 234 | export async function parseResponse( 235 | { status, statusText, headers, body }: globalThis.Response, 236 | responseType: Request['responseType'], 237 | downloadProgress: ProgressEventTarget 238 | ): Promise> { 239 | const stream = globalThis.ReadableStream['from']( 240 | emitStreamProgress( 241 | body as ReadableStream, 242 | +headers.get('Content-Length'), 243 | downloadProgress 244 | ) 245 | ) as ReadableStream; 246 | 247 | const contentType = headers.get('Content-Type') || ''; 248 | 249 | const header = parseHeaders( 250 | [...headers].map(([key, value]) => `${key}: ${value}`).join('\n') 251 | ); 252 | const rBody = 253 | status === 204 254 | ? undefined 255 | : await parseFetchBody(stream, contentType, responseType); 256 | 257 | return { status, statusText, headers: header, body: rBody }; 258 | } 259 | 260 | export async function parseFetchBody( 261 | stream: ReadableStream, 262 | contentType: string, 263 | responseType: Request['responseType'] 264 | ) { 265 | const blob = new Blob(await Array.fromAsync(stream), { type: contentType }); 266 | 267 | if (responseType === 'blob') return blob as B; 268 | 269 | if (responseType === 'arraybuffer') return blob.arrayBuffer() as B; 270 | 271 | const text = await blob.text(); 272 | 273 | if (responseType === 'text') return text as B; 274 | 275 | return parseBody(text, contentType); 276 | } 277 | 278 | export const request = 279 | typeof globalThis.XMLHttpRequest === 'function' ? requestXHR : requestFetch; 280 | --------------------------------------------------------------------------------