├── .editorconfig ├── .eslintignore ├── .eslintrc.yml ├── .gitattributes ├── .github └── workflows │ └── build.yml ├── .gitignore ├── .nycrc.json ├── .yarnrc.yml ├── LICENSE ├── README.md ├── package.json ├── packages ├── core │ ├── package.json │ ├── readme.md │ ├── src │ │ └── index.ts │ └── tsconfig.json └── socks │ ├── package.json │ ├── readme.md │ ├── src │ └── index.ts │ └── tsconfig.json ├── tsconfig.base.json ├── tsconfig.json └── yakumo.yml /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | insert_final_newline = true 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /external 2 | 3 | dist 4 | lib 5 | tests 6 | 7 | *.js 8 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | root: true 2 | 3 | env: 4 | node: true 5 | 6 | globals: 7 | NodeJS: true 8 | 9 | extends: 10 | - '@cordisjs/eslint-config' 11 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | 3 | *.png -text 4 | *.jpg -text 5 | *.ico -text 6 | *.gif -text 7 | *.webp -text 8 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | lint: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Check out 13 | uses: actions/checkout@v4 14 | - name: Set up Node 15 | uses: actions/setup-node@v4 16 | - name: Enable Corepack 17 | run: corepack enable 18 | - name: Install 19 | run: yarn --no-immutable 20 | - name: Lint 21 | run: yarn lint 22 | 23 | build: 24 | runs-on: ubuntu-latest 25 | 26 | steps: 27 | - name: Check out 28 | uses: actions/checkout@v4 29 | - name: Set up Node 30 | uses: actions/setup-node@v4 31 | - name: Enable Corepack 32 | run: corepack enable 33 | - name: Install 34 | run: yarn --no-immutable 35 | - name: Build 36 | run: yarn build 37 | 38 | test: 39 | runs-on: ubuntu-latest 40 | 41 | strategy: 42 | fail-fast: false 43 | matrix: 44 | node-version: [20, 22] 45 | 46 | steps: 47 | - name: Check out 48 | uses: actions/checkout@v4 49 | - name: Set up Node 50 | uses: actions/setup-node@v4 51 | with: 52 | node-version: ${{ matrix.node-version }} 53 | - name: Enable Corepack 54 | run: corepack enable 55 | - name: Install 56 | run: yarn --no-immutable 57 | - name: Unit Test 58 | run: yarn test:json 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib 2 | dist 3 | 4 | /external 5 | 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/sdks 12 | !.yarn/versions 13 | 14 | yarn.lock 15 | package-lock.json 16 | pnpm-lock.yaml 17 | 18 | todo.md 19 | coverage/ 20 | node_modules/ 21 | npm-debug.log 22 | yarn-debug.log 23 | yarn-error.log 24 | package-lock.json 25 | tsconfig.temp.json 26 | tsconfig.tsbuildinfo 27 | report.*.json 28 | 29 | .eslintcache 30 | .DS_Store 31 | .idea 32 | .vscode 33 | *.suo 34 | *.ntvs* 35 | *.njsproj 36 | *.sln 37 | -------------------------------------------------------------------------------- /.nycrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": [ 3 | "**/*.spec.ts", 4 | ".yarn/**" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-present Shigma 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ./packages/core/readme.md -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@root/http", 3 | "private": true, 4 | "packageManager": "yarn@4.5.1", 5 | "type": "module", 6 | "version": "1.0.0", 7 | "workspaces": [ 8 | "external/*", 9 | "packages/*" 10 | ], 11 | "license": "MIT", 12 | "scripts": { 13 | "build": "yarn yakumo build", 14 | "lint": "eslint --cache", 15 | "test": "yarn yakumo test --import tsx", 16 | "test:text": "shx rm -rf coverage && c8 -r text yarn test", 17 | "test:json": "shx rm -rf coverage && c8 -r json yarn test", 18 | "test:html": "shx rm -rf coverage && c8 -r html yarn test" 19 | }, 20 | "devDependencies": { 21 | "@cordisjs/eslint-config": "^1.1.1", 22 | "@types/chai": "^5.2.0", 23 | "@types/chai-as-promised": "^7.1.8", 24 | "@types/node": "^22.13.10", 25 | "c8": "^10.1.3", 26 | "chai": "^5.2.0", 27 | "chai-as-promised": "^7.1.1", 28 | "esbuild": "^0.25.1", 29 | "eslint": "^8.57.0", 30 | "shx": "^0.4.0", 31 | "tsx": "npm:@cordiverse/tsx@4.19.3-fix.3", 32 | "typescript": "^5.8.2", 33 | "yakumo": "^2.0.0-alpha.6", 34 | "yakumo-esbuild": "^2.0.0-alpha.2", 35 | "yakumo-tsc": "^2.0.0-alpha.3" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cordisjs/plugin-http", 3 | "description": "Fetch-based axios-style HTTP client", 4 | "version": "1.2.0", 5 | "type": "module", 6 | "main": "lib/index.js", 7 | "types": "lib/index.d.ts", 8 | "files": [ 9 | "lib" 10 | ], 11 | "author": "Shigma ", 12 | "license": "MIT", 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/cordiverse/http.git", 16 | "directory": "packages/core" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/cordiverse/http/issues" 20 | }, 21 | "homepage": "https://github.com/cordiverse/http", 22 | "keywords": [ 23 | "http", 24 | "fetch", 25 | "axios", 26 | "https", 27 | "undici", 28 | "client", 29 | "request", 30 | "cordis", 31 | "plugin" 32 | ], 33 | "cordis": { 34 | "service": { 35 | "implements": [ 36 | "http" 37 | ] 38 | } 39 | }, 40 | "peerDependencies": { 41 | "cordis": "^4.0.0-beta.3", 42 | "undici": "^7.5.0" 43 | }, 44 | "peerDependenciesMeta": { 45 | "undici": { 46 | "optional": true 47 | } 48 | }, 49 | "devDependencies": { 50 | "@cordisjs/plugin-logger": "^1.0.0", 51 | "cordis": "^4.0.0-beta.3", 52 | "undici": "^7.5.0" 53 | }, 54 | "dependencies": { 55 | "@cordisjs/fetch-file": "^1.0.3", 56 | "cosmokit": "^1.8.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /packages/core/readme.md: -------------------------------------------------------------------------------- 1 | # @cordisjs/http 2 | 3 | Fetch-based HTTP client for [Cordis](https://cordis.io). 4 | 5 | ## Features 6 | 7 | - Browser and Node.js support 8 | - Proxy agents (HTTP / HTTPS / SOCKS) 9 | - WebSocket 10 | 11 | ## Usage 12 | 13 | ```ts 14 | import { Context } from 'cordis' 15 | import HTTP from '@cordisjs/plugin-http' 16 | 17 | const ctx = new Context() 18 | ctx.plugin(HTTP) 19 | 20 | const data = await ctx.http.get('https://example.com') 21 | const data = await ctx.http.post('https://example.com', body) 22 | const { status, data } = await ctx.http('https://example.com', { method: 'GET' }) 23 | ``` 24 | 25 | ## API 26 | 27 | ### Instance Methods 28 | 29 | #### http(url, config?) 30 | 31 | ```ts 32 | interface HTTP { 33 | (url: string | URL, config?: Config): Promise> 34 | } 35 | ``` 36 | 37 | Send a request. 38 | 39 | #### http.[get|delete|head](url, config?) 40 | 41 | ```ts 42 | interface HTTP { 43 | get: Request1 44 | delete: Request1 45 | head(url: string, config?: Config): Promise 46 | } 47 | 48 | interface Request1 { 49 | (url: string, config: Config & { responseType: K }): Promise 50 | (url: string, config?: Config): Promise 51 | } 52 | ``` 53 | 54 | Send a GET / DELETE / HEAD request. 55 | 56 | #### http.[post|put|patch](url, data, config?) 57 | 58 | ```ts 59 | interface HTTP { 60 | patch: Request2 61 | post: Request2 62 | put: Request2 63 | } 64 | 65 | interface Request2 { 66 | (url: string, data: any, config: Config & { responseType: K }): Promise 67 | (url: string, data?: any, config?: Config): Promise 68 | } 69 | ``` 70 | 71 | #### http.ws(url, config?) 72 | 73 | ```ts 74 | interface HTTP { 75 | ws(url: string | URL, config?: Config): WebSocket 76 | } 77 | ``` 78 | 79 | Open a WebSocket connection. 80 | 81 | > [!NOTE] 82 | > 83 | > Currently we will use [`ws`](https://github.com/websockets/ws) package to polyfill [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) in Node.js. 84 | > 85 | > Once Node.js has a stable WebSocket API, we will switch to it. 86 | 87 | #### http.Error.is(error) 88 | 89 | ```ts 90 | function is(error: any): error is HTTP.Error 91 | ``` 92 | 93 | ### Config 94 | 95 | ```ts 96 | interface Config { 97 | baseUrl?: string 98 | method?: Method 99 | headers?: Record 100 | redirect?: RequestRedirect 101 | keepAlive?: boolean 102 | params?: Record 103 | data?: any 104 | responseType?: keyof ResponseTypes 105 | timeout?: number 106 | } 107 | ``` 108 | 109 | #### config.baseUrl 110 | 111 | The base URL of the request. If it is set, the `url` will be resolved against it. 112 | 113 | See [URL#base](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL#base). 114 | 115 | #### config.method 116 | 117 | See [fetch#method](https://developer.mozilla.org/en-US/docs/Web/API/fetch#method). 118 | 119 | #### config.headers 120 | 121 | See [fetch#headers](https://developer.mozilla.org/en-US/docs/Web/API/fetch#headers). 122 | 123 | #### config.redirect 124 | 125 | See [fetch#redirect](https://developer.mozilla.org/en-US/docs/Web/API/fetch#redirect). 126 | 127 | #### config.keepAlive 128 | 129 | See [fetch#keepalive](https://developer.mozilla.org/en-US/docs/Web/API/fetch#keepalive). 130 | 131 | #### config.params 132 | 133 | Additional query parameters. They will be appended to the URL. 134 | 135 | #### config.data 136 | 137 | The request body. Currently support below types: 138 | 139 | - string 140 | - URLSearchParams 141 | - ArrayBuffer / ArrayBufferView 142 | - Blob 143 | - FormData 144 | - Object (will be serialized to JSON) 145 | 146 | #### config.responseType 147 | 148 | Supported response types: 149 | 150 | ```ts 151 | interface ResponseTypes { 152 | json: any 153 | text: string 154 | stream: ReadableStream 155 | blob: Blob 156 | formdata: FormData 157 | arraybuffer: ArrayBuffer 158 | } 159 | ``` 160 | 161 | #### config.timeout 162 | 163 | The request timeout in milliseconds. 164 | 165 | #### config.proxyAgent 166 | 167 | > [!NOTE] 168 | > 169 | > In order to use a proxy agent, you need to install `@cordisjs/plugin-proxy-agent`. 170 | 171 | ### Response 172 | 173 | ```ts 174 | interface Response { 175 | status: number 176 | statusText: string 177 | headers: Headers 178 | data: T 179 | } 180 | ``` 181 | 182 | #### response.status 183 | 184 | See [Response#status](https://developer.mozilla.org/en-US/docs/Web/API/Response/status). 185 | 186 | #### response.statusText 187 | 188 | See [Response#statusText](https://developer.mozilla.org/en-US/docs/Web/API/Response/statusText). 189 | 190 | #### response.headers 191 | 192 | See [Response#headers](https://developer.mozilla.org/en-US/docs/Web/API/Response/headers). 193 | 194 | #### response.data 195 | 196 | The decoded response body. 197 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Context, Inject, Service, z } from 'cordis' 2 | import { Awaitable, Binary, defineProperty, Dict, isNullable } from 'cosmokit' 3 | import { createRequire } from 'node:module' 4 | import fetchFile from '@cordisjs/fetch-file' 5 | import type {} from '@cordisjs/plugin-logger' 6 | import type { Dispatcher, RequestInit, WebSocketInit } from 'undici' 7 | 8 | declare module 'cordis' { 9 | interface Context { 10 | http: HTTP 11 | } 12 | 13 | interface Intercept { 14 | http: HTTP.Intercept 15 | } 16 | 17 | interface Events { 18 | 'http/fetch'(this: HTTP, url: URL, init: RequestInit, config: HTTP.Config, next: () => Promise): Promise 19 | 'http/websocket-init'(this: HTTP, url: URL, init: WebSocketInit, config: HTTP.Config): void 20 | } 21 | } 22 | 23 | const kHTTPError = Symbol.for('cordis.http.error') 24 | const kHTTPConfig = Symbol.for('cordis.http.config') 25 | 26 | class HTTPError extends Error { 27 | [kHTTPError] = true 28 | 29 | static is(error: any): error is HTTPError { 30 | return !!error?.[kHTTPError] 31 | } 32 | 33 | constructor(message?: string, public code?: HTTP.Error.Code, public response?: Response) { 34 | super(message) 35 | } 36 | } 37 | 38 | /** 39 | * @see https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch 40 | */ 41 | function encodeRequest(data: any): [string | null, any] { 42 | if (data instanceof URLSearchParams) return [null, data] 43 | if (data instanceof ArrayBuffer) return [null, data] 44 | if (ArrayBuffer.isView(data)) return [null, data] 45 | if (data instanceof Blob) return [null, data] 46 | if (data instanceof FormData) return [null, data] 47 | return ['application/json', JSON.stringify(data)] 48 | } 49 | 50 | export namespace HTTP { 51 | export type Method = 52 | | 'get' | 'GET' 53 | | 'delete' | 'DELETE' 54 | | 'head' | 'HEAD' 55 | | 'options' | 'OPTIONS' 56 | | 'post' | 'POST' 57 | | 'put' | 'PUT' 58 | | 'patch' | 'PATCH' 59 | | 'purge' | 'PURGE' 60 | | 'link' | 'LINK' 61 | | 'unlink' | 'UNLINK' 62 | 63 | export interface ResponseTypes { 64 | json: any 65 | text: string 66 | stream: ReadableStream 67 | blob: Blob 68 | formdata: FormData 69 | arraybuffer: ArrayBuffer 70 | headers: Headers 71 | } 72 | 73 | export interface Request1 { 74 | (url: string | URL, config: HTTP.RequestConfig & { responseType: K }): Promise 75 | (url: string | URL, config: HTTP.RequestConfig & { responseType: Decoder }): Promise 76 | (url: string | URL, config?: HTTP.RequestConfig): Promise 77 | } 78 | 79 | export interface Request2 { 80 | (url: string | URL, data: any, config: HTTP.RequestConfig & { responseType: K }): Promise 81 | (url: string | URL, data: any, config: HTTP.RequestConfig & { responseType: Decoder }): Promise 82 | (url: string | URL, data?: any, config?: HTTP.RequestConfig): Promise 83 | } 84 | 85 | export interface Intercept { 86 | baseUrl?: string 87 | headers?: Dict 88 | timeout?: number 89 | proxyAgent?: string 90 | } 91 | 92 | export interface Config extends Intercept {} 93 | 94 | export interface RequestConfig extends Config { 95 | method?: Method 96 | params?: Dict 97 | data?: any 98 | keepAlive?: boolean 99 | redirect?: RequestRedirect 100 | signal?: AbortSignal 101 | responseType?: keyof ResponseTypes | Decoder 102 | validateStatus?: (status: number) => boolean 103 | } 104 | 105 | export interface Response { 106 | url: string 107 | data: T 108 | status: number 109 | statusText: string 110 | headers: Headers 111 | } 112 | 113 | export interface AfterFetch { 114 | url: URL 115 | init: RequestInit 116 | config: RequestConfig 117 | response?: globalThis.Response 118 | error?: any 119 | } 120 | 121 | export type Decoder = (raw: globalThis.Response) => Awaitable 122 | 123 | export type Error = HTTPError 124 | 125 | export namespace Error { 126 | export type Code = 'TIMEOUT' | 'STATUS_ERROR' 127 | } 128 | } 129 | 130 | export interface FileOptions { 131 | timeout?: number | string 132 | } 133 | 134 | export interface HTTP { 135 | (url: string | URL, config?: HTTP.RequestConfig): Promise 136 | config: HTTP.Config 137 | get: HTTP.Request1 138 | delete: HTTP.Request1 139 | patch: HTTP.Request2 140 | post: HTTP.Request2 141 | put: HTTP.Request2 142 | } 143 | 144 | // we don't use `raw.ok` because it may be a 3xx redirect 145 | const validateStatus = (status: number) => status < 400 146 | 147 | @Inject('logger', false) 148 | export class HTTP extends Service { 149 | static Error = HTTPError 150 | 151 | static undici: typeof import('undici') 152 | 153 | static { 154 | const require = createRequire(import.meta.url) 155 | try { 156 | if (process.execArgv.includes('--expose-internals')) { 157 | this.undici = require('internal/deps/undici/undici') 158 | } else { 159 | this.undici = require('undici') 160 | } 161 | } catch {} 162 | 163 | for (const method of ['get', 'delete'] as const) { 164 | defineProperty(HTTP.prototype, method, async function (this: HTTP, url: string | URL, config?: HTTP.Config) { 165 | const response = await this(url, { method, validateStatus, ...config }) 166 | return this._decode(response) 167 | }) 168 | } 169 | 170 | for (const method of ['patch', 'post', 'put'] as const) { 171 | defineProperty(HTTP.prototype, method, async function (this: HTTP, url: string | URL, data?: any, config?: HTTP.Config) { 172 | const response = await this(url, { method, data, validateStatus, ...config }) 173 | return this._decode(response) 174 | }) 175 | } 176 | } 177 | 178 | static Config: z = z.object({ 179 | timeout: z.natural().role('ms').description('等待请求的最长时间。'), 180 | keepAlive: z.boolean().description('是否保持连接。'), 181 | proxyAgent: z.string().description('代理服务器地址。'), 182 | }) 183 | 184 | Config: z = z.object({ 185 | baseUrl: z.string().description('基础 URL。'), 186 | timeout: z.natural().role('ms').description('等待请求的最长时间。'), 187 | keepAlive: z.boolean().description('是否保持连接。'), 188 | proxyAgent: z.string().description('代理服务器地址。'), 189 | }) 190 | 191 | public isError = HTTPError.is 192 | 193 | private _decoders: Dict = Object.create(null) 194 | private _proxies: Dict<(url: URL) => Dispatcher> = Object.create(null) 195 | 196 | constructor(ctx: Context, public config: HTTP.Config = {}) { 197 | super(ctx, 'http') 198 | 199 | this.decoder('json', (raw) => raw.json()) 200 | this.decoder('text', (raw) => raw.text()) 201 | this.decoder('blob', (raw) => raw.blob()) 202 | this.decoder('arraybuffer', (raw) => raw.arrayBuffer()) 203 | this.decoder('formdata', (raw) => raw.formData()) 204 | this.decoder('stream', (raw) => raw.body!) 205 | this.decoder('headers', (raw) => raw.headers) 206 | 207 | this.proxy(['http', 'https'], (url) => { 208 | return new this.undici.ProxyAgent(url.href) 209 | }) 210 | 211 | // file: URL 212 | this.ctx.on('http/fetch', async (url, init, config, next) => { 213 | if (url.protocol !== 'file:') return next() 214 | if (init.method !== 'GET') { 215 | return new Response(null, { status: 405, statusText: 'Method Not Allowed' }) 216 | } 217 | return fetchFile(url, init as globalThis.RequestInit, { 218 | download: true, 219 | onError: ctx.logger?.error, 220 | }) 221 | }, { prepend: true }) 222 | 223 | // data: URL 224 | // https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/data 225 | this.ctx.on('http/fetch', async (url, init, config, next) => { 226 | // data:[][;base64], 227 | const capture = /^data:([^,]*),(.*)$/.exec(url.href) 228 | if (!capture) return next() 229 | if (init.method !== 'GET') { 230 | return new Response(null, { status: 405, statusText: 'Method Not Allowed' }) 231 | } 232 | let [, type, data] = capture 233 | let bodyInit: BodyInit = data 234 | if (type.endsWith(';base64')) { 235 | type = type.slice(0, -7) 236 | bodyInit = Binary.fromBase64(data) 237 | } else { 238 | bodyInit = decodeURIComponent(data) 239 | } 240 | return new Response(bodyInit, { 241 | status: 200, 242 | statusText: 'OK', 243 | headers: { 'content-type': type }, 244 | }) 245 | }, { prepend: true }) 246 | } 247 | 248 | get undici() { 249 | if (HTTP.undici) return HTTP.undici 250 | throw new Error('please install `undici`') 251 | } 252 | 253 | static mergeConfig = (target: HTTP.Config, source?: HTTP.Config) => ({ 254 | ...target, 255 | ...source, 256 | headers: { 257 | ...target?.headers, 258 | ...source?.headers, 259 | }, 260 | }) 261 | 262 | decoder(type: K, decoder: HTTP.Decoder) { 263 | return this.ctx.effect(() => { 264 | this._decoders[type] = decoder 265 | return () => delete this._decoders[type] 266 | }, 'ctx.http.decoder()') 267 | } 268 | 269 | proxy(name: string[], factory: (url: URL) => Dispatcher) { 270 | return this.ctx.effect(() => { 271 | for (const key of name) { 272 | this._proxies[key] = factory 273 | } 274 | return () => { 275 | for (const key of name) { 276 | delete this._proxies[key] 277 | } 278 | } 279 | }, 'ctx.http.proxy()') 280 | } 281 | 282 | extend(config: HTTP.Config = {}) { 283 | return this[Service.extend]({ 284 | config: HTTP.mergeConfig(this.config, config), 285 | }) 286 | } 287 | 288 | resolveConfig(init?: HTTP.RequestConfig): HTTP.RequestConfig { 289 | return this[Service.resolveConfig](this.config, init) 290 | } 291 | 292 | resolveURL(url: string | URL, config: HTTP.RequestConfig, isWebSocket = false) { 293 | try { 294 | url = new URL(url, config.baseUrl) 295 | } catch (error) { 296 | // prettify the error message 297 | throw new TypeError(`Invalid URL: ${url}`) 298 | } 299 | if (isWebSocket) url.protocol = url.protocol.replace(/^http/, 'ws') 300 | for (const [key, value] of Object.entries(config.params ?? {})) { 301 | if (isNullable(value)) continue 302 | url.searchParams.append(key, value) 303 | } 304 | return url 305 | } 306 | 307 | defaultDecoder(response: Response) { 308 | const type = response.headers.get('content-type') 309 | if (type?.startsWith('application/json')) { 310 | return response.json() 311 | } else if (type?.startsWith('text/')) { 312 | return response.text() 313 | } else { 314 | return response.arrayBuffer() 315 | } 316 | } 317 | 318 | async [Service.invoke](...args: any[]) { 319 | let method: HTTP.Method | undefined 320 | if (typeof args[1] === 'string' || args[1] instanceof URL) { 321 | method = args.shift() 322 | } 323 | const config = this.resolveConfig(args[1]) 324 | const url = this.resolveURL(args[0], config) 325 | method ??= config.method ?? 'GET' 326 | 327 | const controller = new AbortController() 328 | if (config.signal) { 329 | if (config.signal.aborted) { 330 | throw config.signal.reason 331 | } 332 | config.signal.addEventListener('abort', () => { 333 | controller.abort(config.signal!.reason) 334 | }) 335 | } 336 | 337 | const dispose = this.ctx.effect(() => { 338 | const timer = config.timeout && setTimeout(() => { 339 | controller.abort(new HTTPError('request timeout', 'TIMEOUT')) 340 | }, config.timeout) 341 | return () => { 342 | clearTimeout(timer) 343 | } 344 | }) 345 | controller.signal.addEventListener('abort', () => dispose()) 346 | 347 | try { 348 | const headers = new Headers(config.headers) 349 | const init: RequestInit = { 350 | method, 351 | headers, 352 | body: config.data, 353 | keepalive: config.keepAlive, 354 | redirect: config.redirect, 355 | signal: controller.signal, 356 | } 357 | 358 | if (config.data && typeof config.data === 'object') { 359 | const [type, body] = encodeRequest(config.data) 360 | init.body = body 361 | if (type && !headers.has('content-type')) { 362 | headers.append('content-type', type) 363 | } 364 | } 365 | 366 | if (config.proxyAgent) { 367 | const proxyURL = new URL(config.proxyAgent) 368 | const factory = this._proxies[proxyURL.protocol.slice(0, -1)] 369 | if (!factory) throw new Error(`Cannot resolve proxy agent ${proxyURL}`) 370 | init.dispatcher = factory(proxyURL) 371 | } 372 | 373 | const response = await this.ctx.waterfall('http/fetch', url, init, config, () => { 374 | return this.undici.fetch(url, init) as any 375 | }).catch((cause) => { 376 | if (HTTP.Error.is(cause)) throw cause 377 | const error = new HTTP.Error(`fetch ${url} failed`) 378 | error.cause = cause 379 | throw error 380 | }) 381 | 382 | response[kHTTPConfig] = config 383 | return response 384 | } finally { 385 | dispose() 386 | } 387 | } 388 | 389 | private async _decode(response: Response) { 390 | const config: HTTP.RequestConfig = response[kHTTPConfig] 391 | const validateStatus = config.validateStatus ?? (() => true) 392 | if (!validateStatus(response.status)) { 393 | throw new HTTP.Error(response.statusText, 'STATUS_ERROR', response) 394 | } 395 | 396 | if (!config.responseType) { 397 | return this.defaultDecoder(response) 398 | } 399 | 400 | let decoder: HTTP.Decoder 401 | if (typeof config.responseType === 'function') { 402 | decoder = config.responseType 403 | } else { 404 | decoder = this._decoders[config.responseType] 405 | if (!decoder) { 406 | throw new TypeError(`Unknown responseType: ${config.responseType}`) 407 | } 408 | } 409 | return decoder(response) 410 | } 411 | 412 | async head(url: string | URL, config?: HTTP.RequestConfig) { 413 | const response = await this(url, { method: 'HEAD', responseType: 'headers', ...config }) 414 | return this._decode(response) 415 | } 416 | 417 | ws(url: string | URL, _config?: HTTP.Config) { 418 | const config = this.resolveConfig(_config) 419 | url = this.resolveURL(url, config, true) 420 | const headers = new Headers(config.headers) 421 | const init: WebSocketInit = { 422 | headers, 423 | } 424 | 425 | if (config.proxyAgent) { 426 | const proxyURL = new URL(config.proxyAgent) 427 | const factory = this._proxies[proxyURL.protocol.slice(0, -1)] 428 | if (!factory) throw new Error(`Cannot resolve proxy agent ${proxyURL}`) 429 | init.dispatcher = factory(proxyURL) 430 | } 431 | 432 | this.ctx.emit(this, 'http/websocket-init', url, init, config) 433 | const socket = new this.undici.WebSocket(url, init) 434 | const dispose = this.ctx.effect(() => { 435 | return () => socket.close(1000, 'context disposed') 436 | }, 'new WebSocket()') 437 | socket.addEventListener('close', () => { 438 | dispose() 439 | }) 440 | return socket 441 | } 442 | } 443 | 444 | export default HTTP 445 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "lib", 6 | }, 7 | "include": [ 8 | "src", 9 | ], 10 | } 11 | -------------------------------------------------------------------------------- /packages/socks/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cordisjs/plugin-http-socks", 3 | "description": "Socks proxy agent support for @cordisjs/plugin-http", 4 | "version": "1.0.0", 5 | "type": "module", 6 | "main": "lib/index.js", 7 | "types": "lib/index.d.ts", 8 | "files": [ 9 | "lib" 10 | ], 11 | "author": "Shigma ", 12 | "license": "MIT", 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/cordiverse/http.git", 16 | "directory": "packages/socks" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/cordiverse/http/issues" 20 | }, 21 | "homepage": "https://github.com/cordiverse/http", 22 | "keywords": [ 23 | "client", 24 | "undici", 25 | "fetch", 26 | "axios", 27 | "socks", 28 | "http", 29 | "https", 30 | "proxy", 31 | "agent", 32 | "request", 33 | "cordis", 34 | "plugin" 35 | ], 36 | "cordis": { 37 | "service": { 38 | "required": [ 39 | "http" 40 | ] 41 | } 42 | }, 43 | "devDependencies": { 44 | "cordis": "^4.0.0-beta.3", 45 | "undici": "^7.5.0" 46 | }, 47 | "peerDependencies": { 48 | "@cordisjs/plugin-http": "^1.2.0", 49 | "cordis": "^4.0.0-beta.3" 50 | }, 51 | "dependencies": { 52 | "socks": "^2.8.4" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /packages/socks/readme.md: -------------------------------------------------------------------------------- 1 | # @cordisjs/plugin-http-socks 2 | 3 | Socks proxy agent support for [@cordisjs/plugin-http](https://github.com/cordiverse/http). 4 | -------------------------------------------------------------------------------- /packages/socks/src/index.ts: -------------------------------------------------------------------------------- 1 | // modified from https://github.com/Kaciras/fetch-socks/blob/41cec5a02c36687279ad2628f7c46327f7ff3e2d/index.ts 2 | 3 | import type {} from '@cordisjs/plugin-http' 4 | import { lookup } from 'node:dns/promises' 5 | import { Context } from 'cordis' 6 | import { SocksClient, SocksProxy } from 'socks' 7 | import type { buildConnector } from 'undici' 8 | 9 | export const name = 'http-socks' 10 | export const inject = ['http'] 11 | 12 | export function apply(ctx: Context) { 13 | ctx.http.proxy(['socks', 'socks4', 'socks4a', 'socks5', 'socks5h'], (url) => { 14 | let shouldLookup = false 15 | let type: SocksProxy['type'] 16 | 17 | // From RFC 1928, Section 3: https://tools.ietf.org/html/rfc1928#section-3 18 | // "The SOCKS service is conventionally located on TCP port 1080" 19 | const port = parseInt(url.port, 10) || 1080 20 | const host = url.hostname 21 | 22 | // figure out if we want socks v4 or v5, based on the "protocol" used. 23 | // Defaults to 5. 24 | switch (url.protocol.slice(0, -1)) { 25 | case 'socks4': 26 | shouldLookup = true 27 | // eslint-disable-next-line no-fallthrough 28 | case 'socks4a': 29 | type = 4 30 | break 31 | case 'socks5': 32 | shouldLookup = true 33 | // eslint-disable-next-line no-fallthrough 34 | case 'socks': 35 | case 'socks5h': 36 | type = 5 37 | break 38 | default: 39 | throw new Error('unreachable') 40 | } 41 | 42 | const proxy: SocksProxy = { host, port, type } 43 | if (url.username) proxy.userId = decodeURIComponent(url.username) 44 | if (url.password) proxy.password = decodeURIComponent(url.password) 45 | return new ctx.http.undici.Agent({ connect: createConnect(proxy, shouldLookup) }) 46 | }) 47 | 48 | function resolvePort(protocol: string, port: string) { 49 | return port ? Number.parseInt(port) : protocol === 'http:' ? 80 : 443 50 | } 51 | 52 | function createConnect(proxy: SocksProxy, shouldLookup: boolean, tlsOpts: buildConnector.BuildOptions = {}): buildConnector.connector { 53 | const { timeout = 10e3 } = tlsOpts 54 | const connect = ctx.http.undici.buildConnector(tlsOpts) 55 | 56 | return async (options, callback) => { 57 | let { protocol, hostname, port, httpSocket } = options 58 | 59 | try { 60 | if (shouldLookup) { 61 | hostname = (await lookup(hostname)).address 62 | } 63 | const event = await SocksClient.createConnection({ 64 | command: 'connect', 65 | proxy, 66 | timeout, 67 | destination: { 68 | host: hostname, 69 | port: resolvePort(protocol, port), 70 | }, 71 | existing_socket: httpSocket, 72 | }) 73 | httpSocket = event.socket 74 | } catch (error: any) { 75 | return callback(error, null) 76 | } 77 | 78 | if (httpSocket && protocol !== 'https:') { 79 | return callback(null, httpSocket.setNoDelay()) 80 | } 81 | 82 | return connect({ ...options, httpSocket }, callback) 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /packages/socks/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "lib", 6 | }, 7 | "include": [ 8 | "src", 9 | ], 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2024", 4 | "module": "esnext", 5 | "moduleResolution": "bundler", 6 | "declaration": true, 7 | "emitDeclarationOnly": true, 8 | "strict": true, 9 | "noImplicitAny": false, 10 | "composite": true, 11 | "incremental": true, 12 | "skipLibCheck": true, 13 | "esModuleInterop": true, 14 | "allowImportingTsExtensions": true, 15 | }, 16 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@cordisjs/plugin-http": ["packages/core/src"], 7 | "@cordisjs/plugin-http-*": ["packages/*/src"], 8 | "@cordisjs/*": ["packages/*/src"], 9 | }, 10 | }, 11 | "files": [], 12 | } 13 | -------------------------------------------------------------------------------- /yakumo.yml: -------------------------------------------------------------------------------- 1 | - id: k9knuj 2 | name: yakumo 3 | config: 4 | pipeline: 5 | build: 6 | - tsc 7 | - esbuild 8 | - id: itydft 9 | name: yakumo-esbuild 10 | - id: ep025k 11 | name: yakumo-tsc 12 | - id: ubhxpw 13 | name: yakumo/run 14 | --------------------------------------------------------------------------------