├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ ├── cd.yml │ └── ci.yml ├── .gitignore ├── .husky ├── .gitignore ├── pre-commit └── prepare-commit-msg ├── .prettierignore ├── .prettierrc.json ├── README.md ├── index.ts ├── lib ├── errors.ts ├── handleRetry.ts ├── handleTimeout.ts ├── merge.ts └── utils.ts ├── package-lock.json ├── package.json └── test ├── handleRetry.test.ts ├── handleTimeout.test.ts ├── index.js ├── index.test.ts ├── merge.test.ts └── utils.test.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | *.d.ts 2 | dist 3 | .rpt2_cache/ 4 | .rts2_cache_cjs/ 5 | .rts2_cache_js/ 6 | .rts2_cache_es/ 7 | .rts2_cache_umd/ 8 | node_modules 9 | .idea 10 | 11 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = { 3 | env: { 4 | browser: true, 5 | es2021: true, 6 | }, 7 | extends: [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "prettier", 11 | ], 12 | overrides: [], 13 | parser: "@typescript-eslint/parser", 14 | parserOptions: { 15 | ecmaVersion: "latest", 16 | sourceType: "module", 17 | }, 18 | plugins: ["@typescript-eslint"], 19 | rules: {}, 20 | }; 21 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: CD 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - next 8 | - beta 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-18.04 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | - name: Setup Node.js 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: 18 21 | - name: Installing dependencies 22 | run: npm ci 23 | - name: Lint 24 | run: npm run lint 25 | - name: Build 26 | run: npm run build 27 | - name: Test 28 | run: npm run test 29 | - name: Release 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | NPM_TOKEN: ${{ secrets.TRUEWORK_TEAM_NPM_TOKEN }} 33 | run: npx semantic-release 34 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - "**" 7 | 8 | jobs: 9 | build: 10 | name: Pre-release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v3 15 | - name: Setup Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: 18 19 | - name: Installing dependencies 20 | run: npm ci 21 | - name: Lint 22 | run: npm run lint 23 | - name: Build 24 | run: npm run build 25 | - name: Test 26 | run: npm run test 27 | - name: Release 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | NPM_TOKEN: ${{ secrets.TRUEWORK_TEAM_NPM_TOKEN }} 31 | run: npx semantic-release -d 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | .rpt2_cache/ 3 | .rts2_cache_cjs/ 4 | .rts2_cache_js/ 5 | .rts2_cache_es/ 6 | .rts2_cache_umd/ 7 | node_modules 8 | .idea 9 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run format && npm run test 5 | -------------------------------------------------------------------------------- /.husky/prepare-commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | exec < /dev/tty && node_modules/.bin/cz --hook || true 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | .rpt2_cache/ 3 | .rts2_cache_cjs/ 4 | .rts2_cache_js/ 5 | .rts2_cache_es/ 6 | .rts2_cache_umd/ 7 | node_modules 8 | node_modules/* 9 | .idea 10 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gretchen ![npm](https://img.shields.io/npm/v/gretchen) [![](https://badgen.net/bundlephobia/minzip/gretchen)](https://bundlephobia.com/result?p=gretchen) 2 | 3 | Making `fetch` happen in TypeScript. 4 | 5 | > Looking for more info? Check out [our blog post](https://medium.com/@estrattonbailey/introducing-gretchen-making-fetch-happen-in-typescript-87ab0bd66027?source=friends_link&sk=884da87efacd2db29d670a04f6651f60). 6 | 7 | ## Features 8 | 9 | - **safe:** will not throw on non-200 responses 10 | - **precise:** allows for typing of both success & error responses 11 | - **resilient:** configurable retries & timeout 12 | - **smart:** respects `Retry-After` header 13 | - **small:** won't break your bundle 14 | 15 | ### Install 16 | 17 | ```bash 18 | npm i gretchen --save 19 | ``` 20 | 21 | ### Browser support 22 | 23 | `gretchen` targets all modern browsers. For IE11 support, you'll need to polyfill 24 | `fetch`, `Promise`, and `Object.assign`. For Node.js, you'll need `fetch` and 25 | `AbortController`. 26 | 27 | ### Quick links 28 | 29 | - [Usage](#usage) 30 | - [Making a request](#making-a-request) 31 | - [Options](#options) 32 | - [Retrying requests](#retrying-requests) 33 | - [Timeouts](#timeouts) 34 | - [Response handling](#response-handling) 35 | - [Hooks](#hooks) 36 | - [Creating instances](#creating-instances) 37 | - [Usage with TypeScript](#usage-with-typescript) 38 | - [Why?](#why) 39 | - [Credits](#credits) 40 | - [License](#license) 41 | 42 | # Usage 43 | 44 | With `fetch`, you might do something like this: 45 | 46 | ```js 47 | const request = await fetch("/api/user/12"); 48 | const user = await request.json(); 49 | ``` 50 | 51 | With `gretchen`, it's very similar: 52 | 53 | ```js 54 | import { gretch } from "gretchen"; 55 | 56 | const { data: user } = await gretch("/api/user/12").json(); 57 | ``` 58 | 59 | 👉 `gretchen` aims to provide just enough abstraction to provide ease of use 60 | without sacrificing flexibility. 61 | 62 | ## Making a request 63 | 64 | Using `gretchen` is very similar to using `fetch`. It too defaults to `GET`, and 65 | sets the `credentials` header to `same-origin`. 66 | 67 | ```js 68 | const request = gretch("/api/user/12"); 69 | ``` 70 | 71 | To parse a response body, simply call any of the standard `fetch` [body interface 72 | methods](https://developer.mozilla.org/en-US/docs/Web/API/Response#Body_Interface_Methods_2): 73 | 74 | ```js 75 | const response = await request.json(); 76 | ``` 77 | 78 | The slight diversion from native `fetch` here is to allow users to do this in 79 | one shot: 80 | 81 | ```js 82 | const response = await gretch("/api/user/12").json(); 83 | ``` 84 | 85 | In addition to the _body interface methods_ you're familiar with, there's also a 86 | `flush()` method. This resolves the request _without_ parsing the body (or 87 | errors), which results in slight performance gains. This method returns a 88 | slightly different response object, see below for more details. 89 | 90 | ```js 91 | const response = await gretch("/api/user/authenticated").flush(); 92 | ``` 93 | 94 | ### Options 95 | 96 | To make different types of requests or edit headers and other request config, 97 | pass a options object: 98 | 99 | ```js 100 | const response = await gretch("/api/user/12", { 101 | credentials: "include", 102 | headers: { 103 | "Tracking-ID": "abcde12345", 104 | }, 105 | }).json(); 106 | ``` 107 | 108 | Configuring requests bodies should look familiar as well: 109 | 110 | ```js 111 | const response = await gretch("/api/user/12", { 112 | method: "PATCH", 113 | body: JSON.stringify({ 114 | name: "Megan Rapinoe", 115 | occupation: "President of the United States", 116 | }), 117 | }).json(); 118 | ``` 119 | 120 | For convenience, there’s also a `json` shorthand. We’ll take care of 121 | stringifying the body and applying the `Content-Type` header: 122 | 123 | ```js 124 | const response = await gretch("/api/user/12", { 125 | method: "PATCH", 126 | json: { 127 | email: "m.rapinoe@gmail.com", 128 | }, 129 | }).json(); 130 | ``` 131 | 132 | #### Retrying requests 133 | 134 | `gretchen` will automatically attempt to retry _some_ types of requests if they 135 | return certain error codes. Below are the configurable options and their 136 | defaults: 137 | 138 | - `attempts` - a `number` of retries to attempt before failing. Defaults to `2`. 139 | - `codes` - an `array` of `number` status codes that indicate a retry-able 140 | request. Defaults to `[ 408, 413, 429 ]`. 141 | - `methods` - an `array` of `string`s indicating which request methods should be 142 | retry-able. Defaults to `[ "GET" ]`. 143 | - `delay` - a `number` in milliseconds used to exponentially back-off the delay 144 | time between requests. Defaults to `6`. Example: first delay is 6ms, second 145 | 36ms, third 216ms, and so on. 146 | 147 | These options can be set using the configuration object: 148 | 149 | ```js 150 | const response = await gretch("/api/user/12", { 151 | retry: { 152 | attempts: 3, 153 | }, 154 | }).json(); 155 | ``` 156 | 157 | #### Timeouts 158 | 159 | By default, `gretchen` will time out requests after 10 seconds and retry them, unless otherwise configured. To configure timeout, pass a value in milliseconds: 160 | 161 | ```js 162 | const response = await gretch("/api/user/12", { 163 | timeout: 20000, 164 | }).json(); 165 | ``` 166 | 167 | ## Response handling 168 | 169 | `gretchen`'s thin abstraction layer returns a specialized structure from a 170 | request. In TypeScript terms, it employs a _discriminated union_ for ease of 171 | typing. More on that later. 172 | 173 | ```js 174 | const { url, status, error, data, response } = await gretch( 175 | "/api/user/12" 176 | ).json(); 177 | ``` 178 | 179 | `url` and `status` here are what they say they are: properties of the `Response` 180 | returned from the request. 181 | 182 | #### `data` 183 | 184 | If the response returns a `body` and you elect to parse it i.e. `.json()`, it 185 | will be populated here. 186 | 187 | #### `error` 188 | 189 | And instead of throwing errors `gretchen` will populate the `error` prop with 190 | any errors that occur **_or_** `body`ies returned from non-success (`4xx`) 191 | responses. 192 | 193 | Examples of `error` usage: 194 | 195 | - a `/login` endpoint returns `401` and includes a message for the user 196 | - an endpoint times out and an `HTTPTimeout` error is returned 197 | - an unknown network error occurs during the request 198 | 199 | #### `response` 200 | 201 | `gretchen` also provides the full `response` object in case you need it. 202 | 203 | #### Usage with `flush` 204 | 205 | As mentioned above, `gretchen` also provides a `flush()` method to resolve a 206 | request without parsing the body or errors. This results in a slightly different 207 | response object. 208 | 209 | ```js 210 | const { url, status, response } = await gretch( 211 | "/api/user/authenticated" 212 | ).flush(); 213 | ``` 214 | 215 | ## Hooks 216 | 217 | `gretchen` uses the concept of "hooks" to tap into the request lifecycle. Hooks 218 | are good for code that needs to run on every request, like adding tracking 219 | headers and logging errors. 220 | 221 | Hooks should be defined as an array. That way you can compose multiple hooks 222 | per-request, and define and merge default hooks when [creating 223 | instances](#creating-instances). 224 | 225 | #### `before` 226 | 227 | The `before` hook runs just prior to the request being made. You can even modify 228 | the request directly, like to add headers. The `before` hook is passed the `Request` 229 | object, and the full options object. 230 | 231 | ```js 232 | const response = await gretch("/api/user/12", { 233 | hooks: { 234 | before: [ 235 | (request, options) => { 236 | request.headers.set("Tracking-ID", "abcde"); 237 | }, 238 | ], 239 | }, 240 | }).json(); 241 | ``` 242 | 243 | #### `after` 244 | 245 | The `after` runs after the request has resolved and any body interface methods 246 | have been called. It has the opportunity to read the `gretchen` response. It 247 | _cannot_ modify it. This is mostly useful for logging. 248 | 249 | ```js 250 | const response = await gretch("/api/user/12", { 251 | hooks: { 252 | after: [ 253 | ({ url, status, data, error }, options) => { 254 | sentry.captureMessage(`${url} returned ${status}`); 255 | }, 256 | ], 257 | }, 258 | }).json(); 259 | ``` 260 | 261 | ## Creating instances 262 | 263 | `gretchen` also exports a `create` method that allows you to configure default 264 | options. This is useful if you want to attach something like logging to every 265 | request made with the returned instance. 266 | 267 | ```js 268 | import { create } from "gretchen"; 269 | 270 | const gretch = create({ 271 | headers: { 272 | "X-Powered-By": "gretchen", 273 | }, 274 | hooks: { 275 | after({ error }) { 276 | if (error) sentry.captureException(error); 277 | }, 278 | }, 279 | }); 280 | 281 | await gretch("/api/user/12").json(); 282 | ``` 283 | 284 | ### Base URLs 285 | 286 | Another common use case for creating a separate instance is to specify a 287 | `baseURL` for all requests. The `baseURL` will then be resolved against the base 288 | URL of the page, allowing support for both absolute and relative `baseURL` 289 | values. 290 | 291 | In the example below, assume requests are being made from a page located at 292 | `https://www.mysite.com`. 293 | 294 | Functionally, this: 295 | 296 | ```js 297 | const gretch = create({ 298 | baseURL: "https://www.mysite.com/api", 299 | }); 300 | ``` 301 | 302 | Is equivalent to this: 303 | 304 | ```js 305 | const gretch = create({ 306 | baseURL: "/api", 307 | }); 308 | ``` 309 | 310 | So this request: 311 | 312 | ```js 313 | await gretch("/user/12").json(); 314 | ``` 315 | 316 | Will resolve to `https://www.mysite.com/api/user/12`. 317 | 318 | **Note:** if a `baseURL` is specified, URLs will be normalized in order to 319 | concatenate them i.e. a leading slash – `/user/12` vs `user/12` – will not 320 | impact how the request is resolved. 321 | 322 | ## Usage with TypeScript 323 | 324 | `gretchen` is written in TypeScript and employs a _discriminated union_ to allow 325 | you to type and consume both the success and error responses returned by your 326 | API. 327 | 328 | To do so, pass your data types directly to the `gretch` call: 329 | 330 | ```typescript 331 | type Success = { 332 | name: string; 333 | occupation: string; 334 | }; 335 | 336 | type Error = { 337 | code: number; 338 | errors: string[]; 339 | }; 340 | 341 | const response = await gretch("/api/user/12").json(); 342 | ``` 343 | 344 | Then, you can safely use the responses: 345 | 346 | ```typescript 347 | if (response.error) { 348 | const { 349 | code, // number 350 | errors, // array of strings 351 | } = response.error; // typeof Error 352 | } else if (response.data) { 353 | const { 354 | name, // string 355 | occupation, // string 356 | } = response.data; // typeof Success 357 | } 358 | ``` 359 | 360 | # Why? 361 | 362 | There are a lot of options out there for requesting data. But most modern 363 | `fetch` implementations rely on throwing errors. For type-safety, we wanted 364 | something that would allow us to type the response, no matter what. We also 365 | wanted to bake in a few opinions of our own, although the API is flexible enough 366 | for most other applications. 367 | 368 | ### Credits 369 | 370 | This library was inspired by [ky](https://github.com/sindresorhus/ky), [fetch-retry](https://github.com/zeit/fetch-retry), and others. 371 | 372 | ### License 373 | 374 | MIT License © [Truework](https://truework.com) 375 | 376 |
377 | 378 | ![cheap movie reference](https://user-images.githubusercontent.com/4732330/73581652-928c6100-444f-11ea-8796-7cdc77271d06.png) 379 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import { HTTPError } from "./lib/errors"; 2 | import { 3 | handleRetry, 4 | defaultRetryOptions, 5 | RetryOptions, 6 | } from "./lib/handleRetry"; 7 | import { handleTimeout } from "./lib/handleTimeout"; 8 | import { normalizeURL } from "./lib/utils"; 9 | import { merge } from "./lib/merge"; 10 | 11 | export type DefaultGretchResponse = any; 12 | export type DefaultGretchError = any; 13 | 14 | export type MergeableObject = 15 | | { 16 | [k: string]: MergeableObject; 17 | } 18 | | Partial 19 | | any[]; 20 | 21 | export type GretchResponse = 22 | | { 23 | url: string; 24 | status: number; 25 | data: undefined; 26 | error: A; 27 | response: Response; 28 | } 29 | | { 30 | url: string; 31 | status: number; 32 | data: T; 33 | error: undefined; 34 | response: Response; 35 | }; 36 | 37 | export type GretchBeforeHook = (request: Request, opts: GretchOptions) => void; 38 | export type GretchAfterHook = ( 39 | response: GretchResponse, 40 | opts: GretchOptions 41 | ) => void; 42 | 43 | export type GretchHooks = { 44 | before?: GretchBeforeHook | GretchBeforeHook[]; 45 | after?: GretchAfterHook | GretchAfterHook[]; 46 | }; 47 | 48 | export type GretchOptions = { 49 | baseURL?: string; 50 | json?: { [key: string]: any }; 51 | retry?: RetryOptions | boolean; 52 | timeout?: number; 53 | onException?: (e: Error) => void; 54 | hooks?: GretchHooks; 55 | headers?: { [key: string]: any } & RequestInit["headers"]; 56 | [key: string]: any; 57 | } & RequestInit; 58 | 59 | export type GretchInstance = { 60 | flush(): Promise<{ url: string; status: number; response: Response }>; 61 | arrayBuffer: () => Promise>; 62 | blob: () => Promise>; 63 | formData: () => Promise>; 64 | json: () => Promise>; 65 | text: () => Promise>; 66 | }; 67 | 68 | export function gretch( 69 | url: string, 70 | opts: GretchOptions = {} 71 | ): GretchInstance { 72 | const { 73 | method = "GET", 74 | baseURL, 75 | json, 76 | retry = defaultRetryOptions, 77 | timeout = 10000, 78 | hooks = {}, 79 | ...rest 80 | } = opts; 81 | const options: RequestInit = { 82 | method, 83 | headers: {}, 84 | ...(rest as RequestInit), 85 | }; 86 | const controller = 87 | typeof AbortController !== "undefined" ? new AbortController() : null; 88 | 89 | if (controller) { 90 | options.signal = controller.signal; 91 | } 92 | 93 | if (json) { 94 | options.headers = { 95 | "Content-Type": "application/json", 96 | ...options.headers, 97 | }; 98 | 99 | options.body = JSON.stringify(json); 100 | } 101 | 102 | const normalizedURL = 103 | baseURL !== undefined ? normalizeURL(url, { baseURL }) : url; 104 | const request = new Request(normalizedURL, options); 105 | 106 | if (hooks.before) 107 | [].concat(hooks.before).forEach((hook) => hook(request, opts)); 108 | 109 | const fetcher = () => 110 | timeout 111 | ? handleTimeout(fetch(request), timeout, controller) 112 | : fetch(request); 113 | 114 | const sent = 115 | retry === false 116 | ? fetcher() 117 | : handleRetry(fetcher, method, retry as Partial); 118 | 119 | const instance = { 120 | async flush() { 121 | const response = await sent; 122 | return { 123 | url: normalizedURL, 124 | status: response.status, 125 | response, 126 | }; 127 | }, 128 | }; 129 | ["json", "text", "formData", "arrayBuffer", "blob"].forEach((key) => { 130 | instance[key] = async () => { 131 | let response: Response; 132 | let status = 500; 133 | let resolved: T | A; 134 | let error; 135 | let data; 136 | 137 | try { 138 | response = await sent; 139 | status = response.status || 500; 140 | 141 | if (status !== 204) { 142 | resolved = await response[key](); 143 | } 144 | 145 | if (response.ok) { 146 | data = resolved as T; 147 | } else { 148 | error = (resolved || new HTTPError(response)) as A; 149 | } 150 | } catch (e) { 151 | error = (e || `You tried to make fetch happen, but it didn't.`) as any; 152 | } 153 | 154 | const res: GretchResponse = { 155 | url: normalizedURL, 156 | status, 157 | data, 158 | error, 159 | response, 160 | }; 161 | 162 | if (hooks.after) 163 | [].concat(hooks.after).forEach((hook) => hook({ ...res }, opts)); 164 | 165 | return res; 166 | }; 167 | }); 168 | 169 | return instance as GretchInstance; 170 | } 171 | 172 | export function create(defaultOpts: GretchOptions = {}) { 173 | return function wrappedGretch< 174 | T = DefaultGretchResponse, 175 | A = DefaultGretchError 176 | >(url: string, opts: GretchOptions = {}): GretchInstance { 177 | return gretch(url, merge(defaultOpts, opts)); 178 | }; 179 | } 180 | -------------------------------------------------------------------------------- /lib/errors.ts: -------------------------------------------------------------------------------- 1 | export interface HTTPError extends Error { 2 | name: string; 3 | status: number; 4 | url: string; 5 | } 6 | 7 | export interface HTTPTimeout extends Error { 8 | name: string; 9 | url: string; 10 | } 11 | 12 | export class HTTPError extends Error { 13 | constructor(response: Response) { 14 | super(response.statusText); 15 | this.name = "HTTPError"; 16 | this.status = response.status; 17 | this.url = response.url; 18 | } 19 | } 20 | 21 | export class HTTPTimeout extends Error { 22 | constructor() { 23 | super("Request timed out"); 24 | this.name = "HTTPTimeout"; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /lib/handleRetry.ts: -------------------------------------------------------------------------------- 1 | import { HTTPTimeout } from "./errors"; 2 | 3 | export type RetryOptions = { 4 | attempts?: number; 5 | codes?: number[]; 6 | methods?: string[]; 7 | delay?: number; 8 | }; 9 | 10 | export const defaultRetryOptions: RetryOptions = { 11 | attempts: 2, 12 | codes: [408, 413, 429], 13 | methods: ["GET"], 14 | delay: 6, 15 | }; 16 | 17 | export async function handleRetry( 18 | request: () => Promise, 19 | method: string, 20 | retryOptions: Partial 21 | ) { 22 | const res = await request(); 23 | const { status, headers } = res; 24 | const retryAfter = headers.get("Retry-After"); 25 | 26 | const { attempts, codes, methods, delay } = { 27 | ...defaultRetryOptions, 28 | ...retryOptions, 29 | }; 30 | 31 | const codesMatch = 32 | codes.indexOf(status) > -1 || (status >= 500 && status < 600); 33 | const methodsMatch = methods.indexOf(method) > -1; 34 | 35 | if (codesMatch && methodsMatch) { 36 | if (attempts === 0 || res instanceof HTTPTimeout) { 37 | return res; 38 | } 39 | 40 | await new Promise((r) => { 41 | setTimeout(r, retryAfter ? parseInt(retryAfter, 10) * 1000 : delay); 42 | }); 43 | 44 | return handleRetry(request, method, { 45 | attempts: attempts - 1, 46 | codes: codes, 47 | methods: methods, 48 | delay: delay * delay, 49 | }); 50 | } 51 | 52 | return res; 53 | } 54 | -------------------------------------------------------------------------------- /lib/handleTimeout.ts: -------------------------------------------------------------------------------- 1 | import { HTTPTimeout } from "./errors"; 2 | 3 | export async function handleTimeout( 4 | request: Promise, 5 | ms = 10000, 6 | controller?: AbortController 7 | ): Promise { 8 | return new Promise((resolve, reject) => { 9 | const timer = setTimeout(() => { 10 | if (controller) { 11 | controller.abort(); 12 | } 13 | 14 | reject(new HTTPTimeout()); 15 | }, ms); 16 | 17 | request 18 | .then(resolve) 19 | .catch(reject) 20 | .then(() => clearTimeout(timer)); 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /lib/merge.ts: -------------------------------------------------------------------------------- 1 | import { GretchOptions, MergeableObject } from "../index"; 2 | 3 | function headersToObj(headers: Headers) { 4 | const o = {}; 5 | 6 | headers.forEach((v, k) => { 7 | o[k] = v; 8 | }); 9 | 10 | return o; 11 | } 12 | 13 | export function merge( 14 | a: MergeableObject = {}, 15 | b: MergeableObject = {} 16 | ): GretchOptions { 17 | const c = { ...a }; 18 | 19 | for (const k of Object.keys(b)) { 20 | const v = b[k]; 21 | 22 | if (typeof v === "object") { 23 | if (k === "headers") { 24 | c[k] = merge( 25 | headersToObj(new Headers(a[k])), 26 | headersToObj(new Headers(v)) 27 | ); 28 | } else if (v.pop && a[k].pop) { 29 | c[k] = [...(a[k] || []), ...v]; 30 | } else if (typeof a[k] === "object" && !a[k].pop) { 31 | c[k] = merge(a[k], v); 32 | } else { 33 | c[k] = v; 34 | } 35 | } else { 36 | c[k] = v; 37 | } 38 | } 39 | 40 | return c; 41 | } 42 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | const protocolRegEx = /https?:\/\//; 2 | 3 | export function normalizeURL(url: string, { baseURL }: { baseURL: string }) { 4 | if (protocolRegEx.test(url) || !baseURL) return url; 5 | 6 | const [protocol] = baseURL.match(protocolRegEx) || [""]; 7 | const path = (baseURL.replace(protocol, "") + "/").replace(/\/\//, "/"); 8 | 9 | return protocol + (path + url).replace(/\/\//, "/"); 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gretchen", 3 | "version": "1.2.0", 4 | "description": "Making fetch happen in Typescript.", 5 | "source": "index.ts", 6 | "main": "dist/gretchen.js", 7 | "modern": "dist/gretchen.modern.js", 8 | "module": "dist/gretchen.esm.js", 9 | "unpkg": "dist/gretchen.iife.js", 10 | "types": "dist/index.d.ts", 11 | "files": [ 12 | "dist" 13 | ], 14 | "scripts": { 15 | "build": "microbundle", 16 | "watch": "microbundle watch", 17 | "test": "node test", 18 | "lint": "npx eslint .", 19 | "format": "npx eslint --fix . && npx prettier --write .", 20 | "prepare": "npx husky install && npx commitizen init cz-conventional-changelog --save-dev --save-exact" 21 | }, 22 | "keywords": [ 23 | "fetch", 24 | "fetching", 25 | "http", 26 | "https", 27 | "request", 28 | "requests", 29 | "get", 30 | "url", 31 | "ajax", 32 | "api", 33 | "got", 34 | "axios", 35 | "node-fetch", 36 | "typescript", 37 | "tiny", 38 | "small", 39 | "micro", 40 | "microjs" 41 | ], 42 | "repository": { 43 | "type": "git", 44 | "url": "git+ssh://git@github.com/truework/gretchen.git" 45 | }, 46 | "author": "truework", 47 | "license": "MIT", 48 | "bugs": { 49 | "url": "https://github.com/truework/gretchen/issues" 50 | }, 51 | "homepage": "https://github.com/truework/gretchen#readme", 52 | "devDependencies": { 53 | "@typescript-eslint/eslint-plugin": "^5.55.0", 54 | "@typescript-eslint/parser": "^5.55.0", 55 | "baretest": "^2.0.0", 56 | "commitizen": "^4.2.4", 57 | "core-js": "^3.7.0", 58 | "cross-fetch": "^3.0.4", 59 | "cz-conventional-changelog": "^3.3.0", 60 | "esbuild-register": "^2.6.0", 61 | "eslint": "^8.36.0", 62 | "eslint-config-prettier": "^8.7.0", 63 | "husky": "^6.0.0", 64 | "microbundle": "^0.13.3", 65 | "prettier": "2.8.4", 66 | "semantic-release": "^17.2.2", 67 | "typescript": "^4.3.3" 68 | }, 69 | "config": { 70 | "commitizen": { 71 | "path": "./node_modules/cz-conventional-changelog" 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /test/handleRetry.test.ts: -------------------------------------------------------------------------------- 1 | import "cross-fetch/polyfill"; 2 | import { createServer } from "http"; 3 | 4 | import { handleRetry } from "../lib/handleRetry"; 5 | import { handleTimeout } from "../lib/handleTimeout"; 6 | 7 | export default (test, assert) => { 8 | test("works", async () => { 9 | let i = 0; 10 | const server = createServer((req, res) => { 11 | if (i++ < 2) { 12 | res.writeHead(500); 13 | res.end(); 14 | } else { 15 | res.end("ha"); 16 | } 17 | }); 18 | 19 | await new Promise((r) => { 20 | server.listen(async () => { 21 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 22 | // @ts-ignore 23 | const { port } = server.address(); 24 | 25 | const raw = await handleRetry( 26 | () => fetch(`http://127.0.0.1:${port}`), 27 | "GET", 28 | {} 29 | ); 30 | const res = await raw.text(); 31 | 32 | assert.equal(res, "ha"); 33 | 34 | server.close(); 35 | 36 | r(0); 37 | }); 38 | }); 39 | }); 40 | 41 | test("retries fail", async () => { 42 | let i = 0; 43 | const server = createServer((req, res) => { 44 | if (i++ < 2) { 45 | res.writeHead(500); 46 | res.end(); 47 | } else { 48 | res.end("ha"); 49 | } 50 | }); 51 | 52 | await new Promise((r) => { 53 | server.listen(async () => { 54 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 55 | // @ts-ignore 56 | const { port } = server.address(); 57 | 58 | const raw = await handleRetry( 59 | () => fetch(`http://127.0.0.1:${port}`), 60 | "GET", 61 | { attempts: 1 } 62 | ); 63 | assert.equal(raw.status, 500); 64 | 65 | server.close(); 66 | 67 | r(0); 68 | }); 69 | }); 70 | }); 71 | 72 | test("respect 0 retries config", async () => { 73 | let i = 0; 74 | const server = createServer((req, res) => { 75 | if (i++ < 2) { 76 | res.writeHead(500); 77 | res.end(); 78 | } else { 79 | res.end("ha"); 80 | } 81 | }); 82 | 83 | await new Promise((r) => { 84 | server.listen(async () => { 85 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 86 | // @ts-ignore 87 | const { port } = server.address(); 88 | 89 | const raw = await handleRetry( 90 | () => fetch(`http://127.0.0.1:${port}`), 91 | "GET", 92 | { attempts: 0 } 93 | ); 94 | assert.equal(raw.status, 500); 95 | assert.equal(i, 1); 96 | 97 | server.close(); 98 | 99 | r(0); 100 | }); 101 | }); 102 | }); 103 | 104 | test("retries for specified status codes", async () => { 105 | let i = 0; 106 | const server = createServer((req, res) => { 107 | if (i++ < 2) { 108 | res.writeHead(400); 109 | res.end(); 110 | } else { 111 | res.end("ha"); 112 | } 113 | }); 114 | 115 | await new Promise((r) => { 116 | server.listen(async () => { 117 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 118 | // @ts-ignore 119 | const { port } = server.address(); 120 | 121 | const raw = await handleRetry( 122 | () => fetch(`http://127.0.0.1:${port}`), 123 | "GET", 124 | { codes: [400] } 125 | ); 126 | const res = await raw.text(); 127 | 128 | assert.equal(res, "ha"); 129 | 130 | server.close(); 131 | 132 | r(0); 133 | }); 134 | }); 135 | }); 136 | 137 | test("retries for specified methods", async () => { 138 | let i = 0; 139 | const server = createServer((req, res) => { 140 | if (i++ < 2) { 141 | res.writeHead(500); 142 | res.end(); 143 | } else { 144 | res.end("ha"); 145 | } 146 | }); 147 | 148 | await new Promise((r) => { 149 | server.listen(async () => { 150 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 151 | // @ts-ignore 152 | const { port } = server.address(); 153 | 154 | const raw = await handleRetry( 155 | () => fetch(`http://127.0.0.1:${port}`), 156 | "POST", 157 | { methods: ["POST"] } 158 | ); 159 | const res = await raw.text(); 160 | 161 | assert.equal(res, "ha"); 162 | 163 | server.close(); 164 | 165 | r(0); 166 | }); 167 | }); 168 | }); 169 | 170 | test("works with timeout", async () => { 171 | const server = createServer((req, res) => { 172 | setTimeout(() => { 173 | res.end("ha"); 174 | }, 1000); 175 | }); 176 | 177 | await new Promise((r) => { 178 | server.listen(async () => { 179 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 180 | // @ts-ignore 181 | const { port } = server.address(); 182 | 183 | const request = () => 184 | handleTimeout(fetch(`http://127.0.0.1:${port}`), 500); 185 | 186 | try { 187 | await handleRetry(request, "GET", {}); 188 | } catch (e) { 189 | assert.equal(e.name, "HTTPTimeout"); 190 | } 191 | 192 | server.close(); 193 | 194 | r(0); 195 | }); 196 | }); 197 | }); 198 | 199 | test("respects Retry-After header", async () => { 200 | let i = 0; 201 | const server = createServer((req, res) => { 202 | if (i++ < 2) { 203 | res.writeHead(500, { 204 | "Retry-After": 1, 205 | }); 206 | res.end(); 207 | } else { 208 | res.end("ha"); 209 | } 210 | }); 211 | 212 | await new Promise((r) => { 213 | server.listen(async () => { 214 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 215 | // @ts-ignore 216 | const { port } = server.address(); 217 | 218 | const then = Date.now(); 219 | 220 | const raw = await handleRetry( 221 | () => fetch(`http://127.0.0.1:${port}`), 222 | "GET", 223 | {} 224 | ); 225 | 226 | const now = Date.now(); 227 | 228 | // retried too fast 229 | if (now - then < 1000) { 230 | throw Error("fail"); 231 | } 232 | 233 | const res = await raw.text(); 234 | 235 | assert.equal(res, "ha"); 236 | 237 | server.close(); 238 | 239 | r(0); 240 | }); 241 | }); 242 | }); 243 | }; 244 | -------------------------------------------------------------------------------- /test/handleTimeout.test.ts: -------------------------------------------------------------------------------- 1 | import "cross-fetch/polyfill"; 2 | import { createServer } from "http"; 3 | 4 | import { handleTimeout } from "../lib/handleTimeout"; 5 | 6 | export default (test, assert) => { 7 | test("will timeout", async () => { 8 | const server = createServer((req, res) => { 9 | setTimeout(() => { 10 | res.end("ha"); 11 | }, 1000); 12 | }); 13 | 14 | await new Promise((r) => { 15 | server.listen(async () => { 16 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 17 | // @ts-ignore 18 | const { port } = server.address(); 19 | 20 | try { 21 | await handleTimeout(fetch(`http://127.0.0.1:${port}`), 500); 22 | } catch (e) { 23 | assert.equal(e.name, "HTTPTimeout"); 24 | } 25 | 26 | server.close(); 27 | 28 | r(0); 29 | }); 30 | }); 31 | }); 32 | 33 | test("won't timeout", async () => { 34 | const server = createServer((req, res) => { 35 | setTimeout(() => { 36 | res.end("ha"); 37 | }, 1000); 38 | }); 39 | 40 | await new Promise((r) => { 41 | server.listen(async () => { 42 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 43 | // @ts-ignore 44 | const { port } = server.address(); 45 | 46 | const raw = await handleTimeout(fetch(`http://127.0.0.1:${port}`)); 47 | const res = await raw.text(); 48 | 49 | assert.equal(res, "ha"); 50 | 51 | server.close(); 52 | 53 | r(0); 54 | }); 55 | }); 56 | }); 57 | }; 58 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | console.time("test"); 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-var-requires 5 | require("esbuild-register/dist/node").register(); 6 | 7 | // eslint-disable-next-line @typescript-eslint/no-var-requires 8 | const test = require("baretest")("gretchen"); 9 | // eslint-disable-next-line @typescript-eslint/no-var-requires 10 | const assert = require("assert"); 11 | // eslint-disable-next-line @typescript-eslint/no-var-requires 12 | require("./utils.test").default(test, assert); 13 | // eslint-disable-next-line @typescript-eslint/no-var-requires 14 | require("./handleRetry.test").default(test, assert); 15 | // eslint-disable-next-line @typescript-eslint/no-var-requires 16 | require("./handleTimeout.test").default(test, assert); 17 | // eslint-disable-next-line @typescript-eslint/no-var-requires 18 | require("./index.test").default(test, assert); 19 | // eslint-disable-next-line @typescript-eslint/no-var-requires 20 | require("./merge.test").default(test, assert); 21 | 22 | process.on("unhandledRejection", (e) => { 23 | console.error(e); 24 | process.exit(1); 25 | }); 26 | 27 | !(async function () { 28 | // eslint-disable-next-line @typescript-eslint/no-empty-function 29 | test.before(() => {}); 30 | 31 | // eslint-disable-next-line @typescript-eslint/no-empty-function 32 | test.after(() => {}); 33 | 34 | await test.run(); 35 | 36 | console.timeEnd("test"); 37 | })(); 38 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import "cross-fetch/polyfill"; 2 | import { createServer } from "http"; 3 | 4 | import { gretch, create } from "../index"; 5 | 6 | export default (test, assert) => { 7 | test("successful request", async () => { 8 | const server = createServer((req, res) => { 9 | res.end("ha"); 10 | }); 11 | 12 | await new Promise((r) => { 13 | server.listen(async () => { 14 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 15 | // @ts-ignore 16 | const { port } = server.address(); 17 | 18 | const res = await gretch(`http://127.0.0.1:${port}`).text(); 19 | 20 | if (res.data) { 21 | assert.equal(res.data, "ha"); 22 | } 23 | 24 | server.close(); 25 | 26 | r(0); 27 | }); 28 | }); 29 | }); 30 | 31 | test("retry request", async () => { 32 | let i = 0; 33 | const server = createServer((req, res) => { 34 | if (i++ < 2) { 35 | res.writeHead(500); 36 | res.end(); 37 | } else { 38 | res.end("ha"); 39 | } 40 | }); 41 | 42 | await new Promise((r) => { 43 | server.listen(async () => { 44 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 45 | // @ts-ignore 46 | const { port } = server.address(); 47 | 48 | const res = await gretch(`http://127.0.0.1:${port}`).text(); 49 | 50 | if (res.data) { 51 | assert.equal(res.data, "ha"); 52 | } 53 | 54 | server.close(); 55 | 56 | r(0); 57 | }); 58 | }); 59 | }); 60 | 61 | test("retry fails, returns generic error", async () => { 62 | let i = 0; 63 | const server = createServer((req, res) => { 64 | if (i++ < 2) { 65 | res.writeHead(500); 66 | res.end(); 67 | } else { 68 | res.end("ha"); 69 | } 70 | }); 71 | 72 | await new Promise((r) => { 73 | server.listen(async () => { 74 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 75 | // @ts-ignore 76 | const { port } = server.address(); 77 | 78 | const res = await gretch(`http://127.0.0.1:${port}`, { 79 | retry: { 80 | attempts: 1, 81 | }, 82 | }).text(); 83 | 84 | assert.equal(res.error.name, "HTTPError"); 85 | 86 | server.close(); 87 | 88 | r(0); 89 | }); 90 | }); 91 | }); 92 | 93 | test("request timeout, returns generic error", async () => { 94 | const server = createServer((req, res) => { 95 | setTimeout(() => { 96 | res.end("ha"); 97 | }, 1000); 98 | }); 99 | 100 | await new Promise((r) => { 101 | server.listen(async () => { 102 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 103 | // @ts-ignore 104 | const { port } = server.address(); 105 | 106 | const res = await gretch(`http://127.0.0.1:${port}`, { 107 | timeout: 500, 108 | }).text(); 109 | 110 | assert.equal(res.error.name, "HTTPTimeout"); 111 | 112 | server.close(); 113 | 114 | r(0); 115 | }); 116 | }); 117 | }); 118 | 119 | test("json posts", async () => { 120 | const server = createServer((req, res) => { 121 | const data = []; 122 | req.on("data", (chunk) => data.push(chunk)); 123 | req.on("end", () => { 124 | const body = JSON.parse(data[0].toString("utf8")); 125 | assert.equal(body.foo, true); 126 | res.end(JSON.stringify({ success: true })); 127 | }); 128 | }); 129 | 130 | await new Promise((r) => { 131 | server.listen(async () => { 132 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 133 | // @ts-ignore 134 | const { port } = server.address(); 135 | 136 | const res = await gretch<{ success: boolean }>( 137 | `http://127.0.0.1:${port}`, 138 | { 139 | method: "POST", 140 | json: { 141 | foo: true, 142 | }, 143 | } 144 | ).json(); 145 | 146 | assert.equal(res.data.success, true); 147 | 148 | server.close(); 149 | 150 | r(0); 151 | }); 152 | }); 153 | }); 154 | 155 | test("won't parse 204 status", async () => { 156 | const server = createServer((req, res) => { 157 | const body = { message: "foo" }; 158 | res.writeHead(204, { 159 | "Content-Type": "application/json", 160 | "Content-Length": JSON.stringify(body).length, 161 | }); 162 | res.end(JSON.stringify(body)); 163 | }); 164 | 165 | await new Promise((r) => { 166 | server.listen(async () => { 167 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 168 | // @ts-ignore 169 | const { port } = server.address(); 170 | 171 | const res = await gretch(`http://127.0.0.1:${port}`).json(); 172 | 173 | assert(!res.data); 174 | assert(!res.error); 175 | 176 | server.close(); 177 | 178 | r(0); 179 | }); 180 | }); 181 | }); 182 | 183 | test("returns data as error", async () => { 184 | const server = createServer((req, res) => { 185 | const body = { message: "foo" }; 186 | res.writeHead(400, { 187 | "Content-Type": "application/json", 188 | "Content-Length": JSON.stringify(body).length, 189 | }); 190 | res.end(JSON.stringify(body)); 191 | }); 192 | 193 | await new Promise((r) => { 194 | server.listen(async () => { 195 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 196 | // @ts-ignore 197 | const { port } = server.address(); 198 | 199 | const res = await gretch(`http://127.0.0.1:${port}`).json(); 200 | 201 | if (res.error) { 202 | assert.equal(res.error.message, "foo"); 203 | } 204 | 205 | server.close(); 206 | 207 | r(0); 208 | }); 209 | }); 210 | }); 211 | 212 | test(`body exists, will fail to parse non-json`, async () => { 213 | const server = createServer((req, res) => { 214 | res.writeHead(200, { 215 | "Content-Type": "application/json", 216 | }); 217 | res.end("hey"); 218 | }); 219 | 220 | await new Promise((r) => { 221 | server.listen(async () => { 222 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 223 | // @ts-ignore 224 | const { port } = server.address(); 225 | 226 | const res = await gretch(`http://127.0.0.1:${port}`).json(); 227 | 228 | assert(!!res.error); 229 | 230 | server.close(); 231 | 232 | r(0); 233 | }); 234 | }); 235 | }); 236 | 237 | test(`won't parse body if 204 and json()`, async () => { 238 | const server = createServer((req, res) => { 239 | res.writeHead(204, { 240 | "Content-Type": "application/json", 241 | }); 242 | res.end(JSON.stringify({ foo: true })); 243 | }); 244 | 245 | await new Promise((r) => { 246 | server.listen(async () => { 247 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 248 | // @ts-ignore 249 | const { port } = server.address(); 250 | 251 | const res = await gretch(`http://127.0.0.1:${port}`).json(); 252 | 253 | assert(!res.data); 254 | assert(!res.error); 255 | 256 | server.close(); 257 | 258 | r(0); 259 | }); 260 | }); 261 | }); 262 | 263 | test(`hooks`, async () => { 264 | const server = createServer((req, res) => { 265 | res.writeHead(200); 266 | res.end(); 267 | }); 268 | 269 | await new Promise((r) => { 270 | server.listen(async () => { 271 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 272 | // @ts-ignore 273 | const { port } = server.address(); 274 | 275 | let hooks = 0; 276 | 277 | await gretch(`http://127.0.0.1:${port}`, { 278 | timeout: 50000, 279 | hooks: { 280 | before(request, opts) { 281 | assert(request.url); 282 | assert(opts.timeout); 283 | hooks++; 284 | }, 285 | after: [ 286 | (response, opts) => { 287 | assert(response.status); 288 | assert(opts.timeout); 289 | hooks++; 290 | }, 291 | () => { 292 | hooks++; 293 | }, 294 | ], 295 | }, 296 | }).json(); 297 | 298 | assert(hooks === 3); 299 | 300 | server.close(); 301 | 302 | r(0); 303 | }); 304 | }); 305 | }); 306 | 307 | test(`create`, async () => { 308 | const server = createServer((req, res) => { 309 | res.writeHead(200); 310 | res.end(); 311 | }); 312 | 313 | await new Promise((r) => { 314 | server.listen(async () => { 315 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 316 | // @ts-ignore 317 | const { port } = server.address(); 318 | 319 | const wrappedGretch = create({ 320 | headers: { 321 | Foo: "Bar", 322 | }, 323 | }); 324 | 325 | await wrappedGretch(`http://127.0.0.1:${port}`, { 326 | hooks: { 327 | before(request, opts) { 328 | assert.equal(opts.headers.Foo, "Bar"); 329 | }, 330 | }, 331 | }).json(); 332 | 333 | server.close(); 334 | 335 | r(0); 336 | }); 337 | }); 338 | }); 339 | 340 | test(`create with baseURL`, async () => { 341 | const wrappedGretch = create({ 342 | baseURL: `http://www.foo.com`, 343 | }); 344 | 345 | const res = await wrappedGretch("api").json(); 346 | 347 | assert.equal(res.url, `http://www.foo.com/api`); 348 | }); 349 | 350 | test(`create with baseURL, override per request`, async () => { 351 | const wrappedGretch = create({ 352 | baseURL: `http://www.foo.com`, 353 | }); 354 | 355 | const res = await wrappedGretch("/api", { 356 | baseURL: `http://www.bar.com`, 357 | }).json(); 358 | 359 | assert.equal(res.url, `http://www.bar.com/api`); 360 | }); 361 | 362 | test(`body not parsed with flush`, async () => { 363 | const server = createServer((req, res) => { 364 | const body = { message: "foo" }; 365 | res.writeHead(200, { 366 | "Content-Type": "application/json", 367 | "Content-Length": JSON.stringify(body).length, 368 | }); 369 | res.end(JSON.stringify(body)); 370 | }); 371 | 372 | await new Promise((r) => { 373 | server.listen(async () => { 374 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 375 | // @ts-ignore 376 | const { port } = server.address(); 377 | 378 | const res = await gretch(`http://127.0.0.1:${port}`).flush(); 379 | 380 | assert(!!res.url); 381 | assert(!!res.status); 382 | assert(!!res.response); 383 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 384 | // @ts-ignore 385 | assert(!res.error); 386 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 387 | // @ts-ignore 388 | assert(!res.data); 389 | 390 | server.close(); 391 | 392 | r(0); 393 | }); 394 | }); 395 | }); 396 | }; 397 | -------------------------------------------------------------------------------- /test/merge.test.ts: -------------------------------------------------------------------------------- 1 | import { merge } from "../lib/merge"; 2 | 3 | export default (test, assert) => { 4 | test("merges primitives", () => { 5 | const o = merge( 6 | { 7 | str: "in", 8 | bool: false, 9 | int: 0, 10 | arr: ["in"], 11 | obj: { 12 | prop: "in", 13 | }, 14 | }, 15 | { 16 | str: "out", 17 | bool: true, 18 | int: 1, 19 | arr: ["out"], 20 | obj: { 21 | prop: "out", 22 | }, 23 | } 24 | ); 25 | 26 | assert.equal(o.str, "out"); 27 | assert.equal(o.bool, true); 28 | assert.equal(o.int, 1); 29 | assert.deepEqual(o.arr, ["in", "out"]); 30 | assert.equal(o.obj.prop, "out"); 31 | }); 32 | 33 | test("merges headers", () => { 34 | const o = merge( 35 | { 36 | headers: new Headers({ 37 | "X-In": "in", 38 | "X-Header": "in", 39 | }), 40 | }, 41 | { 42 | headers: { 43 | "X-Out": "out", 44 | "X-Header": "out", 45 | }, 46 | } 47 | ); 48 | 49 | assert.equal(o.headers["x-header"], "out"); 50 | assert.equal(o.headers["x-in"], "in"); 51 | assert.equal(o.headers["x-out"], "out"); 52 | }); 53 | 54 | test("overwrites mixed values", () => { 55 | const o = merge( 56 | { 57 | timeout: 100, 58 | retry: false, 59 | hooks: { 60 | // eslint-disable-next-line @typescript-eslint/no-empty-function 61 | after() {}, 62 | }, 63 | }, 64 | { 65 | timeout: 200, 66 | retry: { 67 | attempts: 3, 68 | }, 69 | hooks: { 70 | // eslint-disable-next-line @typescript-eslint/no-empty-function 71 | after: [() => {}], 72 | }, 73 | } 74 | ); 75 | 76 | assert.equal(o.timeout, 200); 77 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 78 | // @ts-ignore 79 | assert.equal(o.retry.attempts, 3); 80 | assert(Array.isArray(o.hooks.after)); 81 | }); 82 | 83 | test("merges hooks", () => { 84 | const o = merge( 85 | { 86 | hooks: { 87 | // eslint-disable-next-line @typescript-eslint/no-empty-function 88 | before() {}, 89 | }, 90 | }, 91 | { 92 | hooks: { 93 | // eslint-disable-next-line @typescript-eslint/no-empty-function 94 | after() {}, 95 | }, 96 | } 97 | ); 98 | 99 | assert(typeof o.hooks.before === "function"); 100 | assert(typeof o.hooks.after === "function"); 101 | }); 102 | 103 | test("clones reference object", () => { 104 | const defaults = { 105 | prop: "default", 106 | }; 107 | 108 | const o = merge(defaults, { 109 | prop: "out", 110 | }); 111 | 112 | assert.equal(defaults.prop, "default"); 113 | assert.equal(o.prop, "out"); 114 | }); 115 | }; 116 | -------------------------------------------------------------------------------- /test/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { normalizeURL } from "../lib/utils"; 2 | 3 | export default (test, assert) => { 4 | test("normalizeURL", () => { 5 | const normalized = "https://www.foo.com/api/v1/user"; 6 | const baseURL = "https://www.foo.com"; 7 | 8 | const normal = normalizeURL("api/v1/user", { baseURL }); 9 | assert.equal(normal, normalized); 10 | 11 | const trailingSlash = normalizeURL("api/v1/user", { 12 | baseURL: baseURL + "/", 13 | }); 14 | assert.equal(trailingSlash, normalized); 15 | 16 | const leadingSlash = normalizeURL("/api/v1/user", { baseURL }); 17 | assert.equal(leadingSlash, normalized); 18 | 19 | // no slashes 20 | const noSlashes = normalizeURL("api/v1/user", { baseURL }); 21 | assert.equal(noSlashes, normalized); 22 | 23 | const root = normalizeURL("", { baseURL }); 24 | assert.equal(root, baseURL + "/"); 25 | 26 | const absoluteOverride = normalizeURL("https://www.bar.com/api/v1/user", { 27 | baseURL: "http://localhost:8080", 28 | }); 29 | assert.equal(absoluteOverride, "https://www.bar.com/api/v1/user"); 30 | 31 | const relativePath = normalizeURL("v1/user", { baseURL: "/api" }); 32 | assert.equal(relativePath, "/api/v1/user"); 33 | }); 34 | }; 35 | --------------------------------------------------------------------------------