@d1g1tal/transportr 2.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,491 +1,846 @@
1
- # transportr
2
-
3
- [![npm version](https://img.shields.io/npm/v/@d1g1tal/transportr?color=blue)](https://www.npmjs.com/package/@d1g1tal/transportr)
4
- [![npm downloads](https://img.shields.io/npm/dm/@d1g1tal/transportr)](https://www.npmjs.com/package/@d1g1tal/transportr)
5
- [![CI](https://github.com/D1g1talEntr0py/transportr/actions/workflows/ci.yml/badge.svg)](https://github.com/D1g1talEntr0py/transportr/actions/workflows/ci.yml)
6
- [![codecov](https://codecov.io/gh/D1g1talEntr0py/transportr/graph/badge.svg)](https://codecov.io/gh/D1g1talEntr0py/transportr)
7
- [![License: MIT](https://img.shields.io/github/license/D1g1talEntr0py/transportr)](https://github.com/D1g1talEntr0py/transportr/blob/main/LICENSE)
8
- [![Node.js](https://img.shields.io/node/v/@d1g1tal/transportr)](https://nodejs.org)
9
- [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
10
-
11
- A TypeScript Fetch API wrapper providing type-safe HTTP requests with advanced abort/timeout handling, event-driven architecture, and automatic content-type based response processing.
12
-
13
- ## Features
14
-
15
- - **Type-safe** — Full TypeScript support with strict types, branded JSON strings, and typed headers
16
- - **Automatic response handling** — Content-type based response parsing (JSON, HTML, XML, images, streams, etc.)
17
- - **Abort & timeout management** — Per-request timeouts, `AbortController` integration, and `abortAll()` for cleanup
18
- - **Event-driven** — Global and instance-level lifecycle events (`configured`, `success`, `error`, `complete`, etc.)
19
- - **Retry logic** — Configurable retry with exponential backoff, status code filtering, and method filtering
20
- - **Request deduplication** — Identical in-flight GET/HEAD requests share a single fetch
21
- - **Lifecycle hooks** — `beforeRequest`, `afterResponse`, `beforeError` hooks at global, instance, and per-request levels
22
- - **XSRF/CSRF protection** — Automatic cookie-to-header token injection
23
- - **HTML selectors** — Extract specific elements from HTML responses with CSS selectors
24
- - **FormData auto-detection** — Automatically handles FormData, Blob, ArrayBuffer, and stream bodies
25
-
26
- ## Requirements
27
-
28
- - **Node.js** 20.0.0 or a modern browser with native [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) and `AbortController` support
29
- - `jsdom` is an **optional peer dependency** — only needed for HTML/XML/DOM features in Node.js. Install it separately if you use `getHtml()`, `getXml()`, `getHtmlFragment()`, `getScript()`, `getStylesheet()`, or `getImage()` in a non-browser environment:
30
-
31
- ```bash
32
- pnpm add jsdom
33
- ```
34
-
35
- ## Installation
36
-
37
- ```bash
38
- # With pnpm:
39
- pnpm add @d1g1tal/transportr
40
-
41
- # Or with npm:
42
- npm install @d1g1tal/transportr
43
-
44
- # Or with yarn
45
- yarn add @d1g1tal/transportr
46
- ```
47
-
48
- ## Quick Start
49
-
50
- Only the main module is required — the submodule constants (`HttpRequestHeader`, `HttpMediaType`, etc.) are optional conveniences. Anywhere a constant is used, a plain string works just as well.
51
-
52
- Out of the box, every instance defaults to:
53
- - `Content-Type: application/json; charset=utf-8`
54
- - `Accept: application/json; charset=utf-8`
55
- - `timeout`: 30 000 ms
56
- - `cache`: `no-store`
57
- - `credentials`: `same-origin`
58
- - `mode`: `cors`
59
-
60
- ```typescript
61
- import { Transportr } from '@d1g1tal/transportr';
62
-
63
- const api = new Transportr('https://api.example.com');
64
-
65
- // GET JSON — default Accept header is already application/json
66
- const data = await api.getJson('/users/1');
67
-
68
- // POST with JSON body automatically serialized, no Content-Type needed
69
- const created = await api.post('/users', { body: { name: 'Alice' } });
70
-
71
- // GET with search params
72
- const results = await api.getJson('/search', { searchParams: { q: 'term', page: 1 } });
73
-
74
- // Typed response using generics
75
- interface User { id: number; name: string; }
76
- const user = await api.get<User>('/users/1');
77
-
78
- // Plain strings work anywhere — constants are just for convenience
79
- const api2 = new Transportr('https://api.example.com', {
80
- headers: { 'authorization': 'Bearer token', 'accept-language': 'en-US' }
81
- });
82
- ```
83
-
84
- ## Browser / CDN Usage
85
-
86
- The package is published as pure ESM and works directly in modern browsers no bundler required. All dependencies (`@d1g1tal/media-type`, `@d1g1tal/subscribr`, DOMPurify) are bundled into the output, so there are no external module URLs to manage. `jsdom` is not needed in a browser environment.
87
-
88
- ### With an import map (recommended)
89
-
90
- An [import map](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script/type/importmap) mirrors the package's named submodule exports and keeps your code identical to the Node.js form — use bare specifiers exactly as you would in a bundled project:
91
-
92
- ```html
93
- <script type="importmap">
94
- {
95
- "imports": {
96
- "@d1g1tal/transportr": "https://cdn.jsdelivr.net/npm/@d1g1tal/transportr/dist/transportr.js",
97
- "@d1g1tal/transportr/headers": "https://cdn.jsdelivr.net/npm/@d1g1tal/transportr/dist/headers.js",
98
- "@d1g1tal/transportr/methods": "https://cdn.jsdelivr.net/npm/@d1g1tal/transportr/dist/methods.js",
99
- "@d1g1tal/transportr/media-types": "https://cdn.jsdelivr.net/npm/@d1g1tal/transportr/dist/media-types.js",
100
- "@d1g1tal/transportr/response-headers": "https://cdn.jsdelivr.net/npm/@d1g1tal/transportr/dist/response-headers.js"
101
- }
102
- }
103
- </script>
104
-
105
- <script type="module">
106
- import { Transportr } from '@d1g1tal/transportr';
107
- import { HttpRequestHeader } from '@d1g1tal/transportr/headers';
108
- import { HttpRequestMethod } from '@d1g1tal/transportr/methods';
109
- import { HttpMediaType } from '@d1g1tal/transportr/media-types';
110
- import { HttpResponseHeader } from '@d1g1tal/transportr/response-headers';
111
-
112
- const api = new Transportr('https://api.example.com', {
113
- headers: {
114
- [HttpRequestHeader.AUTHORIZATION]: 'Bearer token',
115
- [HttpRequestHeader.ACCEPT]: HttpMediaType.JSON
116
- }
117
- });
118
-
119
- const data = await api.getJson('/users/1');
120
- console.log(data);
121
- </script>
122
- ```
123
-
124
- ### Without an import map
125
-
126
- The CDN resolves the `"."` entry in `exports` automatically, so no explicit file path is needed for the main module. Submodules use their CDN paths directly:
127
-
128
- ```html
129
- <script type="module">
130
- import { Transportr } from 'https://cdn.jsdelivr.net/npm/@d1g1tal/transportr';
131
- import { HttpRequestHeader } from 'https://cdn.jsdelivr.net/npm/@d1g1tal/transportr/dist/headers.js';
132
- import { HttpMediaType } from 'https://cdn.jsdelivr.net/npm/@d1g1tal/transportr/dist/media-types.js';
133
-
134
- const api = new Transportr('https://api.example.com', {
135
- headers: {
136
- [HttpRequestHeader.AUTHORIZATION]: 'Bearer token',
137
- [HttpRequestHeader.ACCEPT]: HttpMediaType.JSON
138
- }
139
- });
140
-
141
- const data = await api.getJson('/users/1');
142
- console.log(data);
143
- </script>
144
- ```
145
-
146
- Import map support is available in all browsers covered by this project's `browserslist` configuration (Chrome 89+, Firefox 108+, Safari 16.4+).
147
-
148
- ## API
149
-
150
- ### Constructor
151
-
152
- ```typescript
153
- new Transportr(url?: URL | string | RequestOptions, options?: RequestOptions)
154
- ```
155
-
156
- Creates a new instance. When `url` is omitted, defaults to `globalThis.location.origin`.
157
-
158
- ```typescript
159
- // With base URL
160
- const api = new Transportr('https://api.example.com/v2');
161
-
162
- // With URL and default options
163
- const api = new Transportr('https://api.example.com', {
164
- timeout: 10000,
165
- headers: { 'Authorization': 'Bearer token' }
166
- });
167
-
168
- // With options only (uses current origin)
169
- const api = new Transportr({ timeout: 5000 });
170
- ```
171
-
172
- ### Request Methods
173
-
174
- | Method | Description |
175
- |--------|-------------|
176
- | `get(path?, options?)` | GET request with auto content-type handling |
177
- | `post(path?, options?)` | POST request |
178
- | `put(path?, options?)` | PUT request |
179
- | `patch(path?, options?)` | PATCH request |
180
- | `delete(path?, options?)` | DELETE request |
181
- | `head(path?, options?)` | HEAD request |
182
- | `options(path?, options?)` | OPTIONS request (returns allowed methods) |
183
- | `request(path?, options?)` | Raw request returning `TypedResponse<T>` |
184
-
185
- ### Typed Response Methods
186
-
187
- | Method | Returns | Accept Header |
188
- |--------|---------|---------------|
189
- | `getJson(path?, options?)` | `Json` | `application/json` |
190
- | `getHtml(path?, options?, selector?)` | `Document \| Element` | `text/html` |
191
- | `getHtmlFragment(path?, options?, selector?)` | `DocumentFragment \| Element` | `text/html` |
192
- | `getXml(path?, options?)` | `Document` | `application/xml` |
193
- | `getScript(path?, options?)` | `void` (injected into DOM) | `application/javascript` |
194
- | `getStylesheet(path?, options?)` | `void` (injected into DOM) | `text/css` |
195
- | `getBlob(path?, options?)` | `Blob` | `application/octet-stream` |
196
- | `getImage(path?, options?)` | `HTMLImageElement` | `image/*` |
197
- | `getBuffer(path?, options?)` | `ArrayBuffer` | `application/octet-stream` |
198
- | `getStream(path?, options?)` | `ReadableStream` | `application/octet-stream` |
199
-
200
- ### Request Options
201
-
202
- ```typescript
203
- type RequestOptions = {
204
- headers?: RequestHeaders;
205
- searchParams?: URLSearchParams | string | Record<string, string | number | boolean>;
206
- timeout?: number; // Default: 30000ms
207
- global?: boolean; // Emit global events (default: true)
208
- body?: BodyInit | JsonObject; // Auto-serialized for JSON content-type
209
- retry?: number | RetryOptions;
210
- dedupe?: boolean; // Deduplicate identical GET/HEAD requests
211
- xsrf?: boolean | XsrfOptions;
212
- hooks?: HooksOptions;
213
- // ...all standard RequestInit properties (cache, credentials, mode, etc.)
214
- };
215
- ```
216
-
217
- ### Retry
218
-
219
- ```typescript
220
- // Simple: retry up to 3 times with default settings
221
- await api.get('/data', { retry: 3 });
222
-
223
- // Advanced configuration
224
- await api.get('/data', {
225
- retry: {
226
- limit: 3,
227
- statusCodes: [408, 413, 429, 500, 502, 503, 504],
228
- methods: ['GET', 'PUT', 'HEAD', 'DELETE', 'OPTIONS'],
229
- delay: 300, // ms before first retry
230
- backoffFactor: 2 // exponential backoff multiplier
231
- }
232
- });
233
- ```
234
-
235
- ### Request Deduplication
236
-
237
- When `dedupe: true`, identical in-flight GET/HEAD requests share a single fetch call. Each consumer receives a cloned response.
238
-
239
- ```typescript
240
- // Only one fetch call is made
241
- const [a, b] = await Promise.all([
242
- api.get('/data', { dedupe: true }),
243
- api.get('/data', { dedupe: true })
244
- ]);
245
- ```
246
-
247
- ### Lifecycle Hooks
248
-
249
- Hooks run in order: global → instance → per-request.
250
-
251
- ```typescript
252
- // Global hooks (all instances)
253
- Transportr.addHooks({
254
- beforeRequest: [async (options, url) => {
255
- options.headers.set('X-Request-ID', crypto.randomUUID());
256
- return options;
257
- }],
258
- afterResponse: [async (response, options) => response],
259
- beforeError: [(error) => error]
260
- });
261
-
262
- // Instance hooks
263
- api.addHooks({
264
- afterResponse: [async (response) => {
265
- console.log(`Response: ${response.status}`);
266
- return response;
267
- }]
268
- });
269
-
270
- // Per-request hooks
271
- await api.get('/data', {
272
- hooks: { beforeRequest: [async (opts) => opts] }
273
- });
274
- ```
275
-
276
- ### Events
277
-
278
- ```typescript
279
- // Global events (all instances)
280
- const reg = Transportr.register(Transportr.RequestEvents.SUCCESS, (event, data) => {
281
- console.log('Request succeeded:', data);
282
- });
283
-
284
- // Instance events
285
- const reg = api.register(Transportr.RequestEvents.ERROR, (event, error) => {
286
- console.error('Request failed:', error);
287
- });
288
-
289
- // Unregister
290
- api.unregister(reg); // Returns `this` for chaining
291
- ```
292
-
293
- **Event lifecycle**: `configured` → `success | error | aborted | timeout` → `complete` → `all-complete`
294
-
295
- Additional events: `retry` (emitted on each retry attempt)
296
-
297
- ### Error Handling
298
-
299
- Non-2xx responses throw an error with `name === 'HttpError'`. Aborted and timed-out requests also produce an `HttpError` with synthetic status codes.
300
-
301
- ```typescript
302
- import type { HttpError } from '@d1g1tal/transportr';
303
-
304
- try {
305
- const user = await api.getJson('/users/1');
306
- } catch (error) {
307
- if (error instanceof Error && error.name === 'HttpError') {
308
- const httpError = error as unknown as HttpError;
309
- console.error(httpError.statusCode); // HTTP status code
310
- console.error(httpError.statusText); // HTTP status text
311
- console.error(httpError.entity); // parsed response body (if any)
312
- console.error(httpError.url?.href); // request URL
313
- console.error(httpError.method); // HTTP method used
314
- console.error(httpError.timing); // { start, end, duration } in ms
315
- }
316
- }
317
- ```
318
-
319
- **Synthetic status codes for non-HTTP failures:**
320
-
321
- | Code | Text | Cause |
322
- |------|------|-------|
323
- | `499` | `Aborted` | Cancelled via `controller.abort()` or `Transportr.abortAll()` |
324
- | `504` | `Request Timeout` | `timeout` option exceeded |
325
-
326
- ### Abort & Timeout
327
-
328
- ```typescript
329
- // Per-request timeout
330
- await api.get('/slow', { timeout: 5000 });
331
-
332
- // Manual abort via AbortController
333
- const controller = new AbortController();
334
- api.get('/data', { signal: controller.signal });
335
- controller.abort();
336
-
337
- // Abort all in-flight requests
338
- Transportr.abortAll();
339
- ```
340
-
341
- ### XSRF/CSRF Protection
342
-
343
- ```typescript
344
- // Default: reads 'XSRF-TOKEN' cookie, sets 'X-XSRF-TOKEN' header
345
- await api.post('/data', { body: payload, xsrf: true });
346
-
347
- // Custom cookie/header names
348
- await api.post('/data', {
349
- body: payload,
350
- xsrf: { cookieName: 'MY-CSRF', headerName: 'X-MY-CSRF' }
351
- });
352
- ```
353
-
354
- ### HTML Selector Support
355
-
356
- ```typescript
357
- // Get a specific element from HTML response
358
- const nav = await api.getHtml('/page', {}, 'nav.main');
359
- const item = await api.getHtmlFragment('/partial', {}, '.item:first-child');
360
- ```
361
-
362
- ### FormData & Raw Bodies
363
-
364
- FormData, Blob, ArrayBuffer, ReadableStream, TypedArray, and URLSearchParams are sent as-is. The `Content-Type` header is automatically removed so the runtime can set it (e.g., multipart boundary for FormData).
365
-
366
- ```typescript
367
- const form = new FormData();
368
- form.append('file', fileBlob, 'photo.jpg');
369
- await api.post('/upload', { body: form });
370
- ```
371
-
372
- ### Custom Content-Type Handlers
373
-
374
- ```typescript
375
- // Register a custom handler (takes priority over built-in)
376
- Transportr.registerContentTypeHandler('csv', async (response) => {
377
- const text = await response.text();
378
- return text.split('\n').map(row => row.split(','));
379
- });
380
-
381
- // Remove a handler
382
- Transportr.unregisterContentTypeHandler('csv');
383
- ```
384
-
385
- ### Cleanup
386
-
387
- ```typescript
388
- // Tear down a single instance
389
- api.destroy();
390
-
391
- // Tear down all global state
392
- Transportr.unregisterAll();
393
-
394
- // Clear only global hooks without aborting in-flight requests
395
- Transportr.clearHooks();
396
- ```
397
-
398
- ### Method Chaining
399
-
400
- Instance methods `unregister()`, `addHooks()`, and `clearHooks()` return `this`:
401
-
402
- ```typescript
403
- api
404
- .addHooks({ beforeRequest: [myHook] })
405
- .clearHooks()
406
- .addHooks({ afterResponse: [logHook] });
407
- ```
408
-
409
- ### Instance Properties
410
-
411
- | Property | Type | Description |
412
- |----------|------|-------------|
413
- | `baseUrl` | `URL` | The base URL used for all requests from this instance |
414
-
415
- ### Static Properties
416
-
417
- | Property | Description |
418
- |----------|-------------|
419
- | `Transportr.CredentialsPolicy` | Credentials policy constants |
420
- | `Transportr.RequestModes` | Request mode constants |
421
- | `Transportr.RequestPriorities` | Request priority constants |
422
- | `Transportr.RedirectPolicies` | Redirect policy constants |
423
- | `Transportr.ReferrerPolicy` | Referrer policy constants |
424
- | `Transportr.RequestEvents` | Event name constants |
425
-
426
- ### Submodule Imports
427
-
428
- HTTP constant objects are available as named submodule imports. Each is a tree-shakeable, side-effect-free object of string constants — useful for avoiding magic strings and getting autocomplete.
429
-
430
- #### `@d1g1tal/transportr/headers`
431
-
432
- Request header name constants.
433
-
434
- ```typescript
435
- import { HttpRequestHeader } from '@d1g1tal/transportr/headers';
436
-
437
- const api = new Transportr('https://api.example.com', {
438
- headers: {
439
- [HttpRequestHeader.AUTHORIZATION]: 'Bearer token',
440
- [HttpRequestHeader.CONTENT_TYPE]: 'application/json',
441
- [HttpRequestHeader.ACCEPT_LANGUAGE]: 'en-US'
442
- }
443
- });
444
- ```
445
-
446
- #### `@d1g1tal/transportr/methods`
447
-
448
- HTTP method string constants.
449
-
450
- ```typescript
451
- import { HttpRequestMethod } from '@d1g1tal/transportr/methods';
452
-
453
- const response = await api.request('/data', { method: HttpRequestMethod.PATCH });
454
- ```
455
-
456
- #### `@d1g1tal/transportr/media-types`
457
-
458
- MIME type string constants covering common content types (JSON, HTML, XML, CSS, images, audio, video, and more).
459
-
460
- ```typescript
461
- import { HttpMediaType } from '@d1g1tal/transportr/media-types';
462
-
463
- const api = new Transportr('https://api.example.com', {
464
- headers: { [HttpRequestHeader.ACCEPT]: HttpMediaType.JSON }
465
- });
466
-
467
- // Use as a content-type value
468
- await api.post('/upload', {
469
- body: csvData,
470
- headers: { [HttpRequestHeader.CONTENT_TYPE]: HttpMediaType.CSV }
471
- });
472
- ```
473
-
474
- #### `@d1g1tal/transportr/response-headers`
475
-
476
- Response header name constants — useful when reading headers from a response.
477
-
478
- ```typescript
479
- import { HttpResponseHeader } from '@d1g1tal/transportr/response-headers';
480
-
481
- const reg = api.register(Transportr.RequestEvents.SUCCESS, (event, data) => {
482
- const response = data as Response;
483
- const etag = response.headers.get(HttpResponseHeader.ETAG);
484
- const retryAfter = response.headers.get(HttpResponseHeader.RETRY_AFTER);
485
- const location = response.headers.get(HttpResponseHeader.LOCATION);
486
- });
487
- ```
488
-
489
- ## License
490
-
491
- [ISC](LICENSE)
1
+ # transportr
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@d1g1tal/transportr?color=blue)](https://www.npmjs.com/package/@d1g1tal/transportr)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@d1g1tal/transportr)](https://www.npmjs.com/package/@d1g1tal/transportr)
5
+ [![CI](https://github.com/D1g1talEntr0py/transportr/actions/workflows/ci.yml/badge.svg)](https://github.com/D1g1talEntr0py/transportr/actions/workflows/ci.yml)
6
+ [![codecov](https://codecov.io/gh/D1g1talEntr0py/transportr/graph/badge.svg)](https://codecov.io/gh/D1g1talEntr0py/transportr)
7
+ [![License: MIT](https://img.shields.io/github/license/D1g1talEntr0py/transportr)](https://github.com/D1g1talEntr0py/transportr/blob/main/LICENSE)
8
+ [![Node.js](https://img.shields.io/node/v/@d1g1tal/transportr)](https://nodejs.org)
9
+ [![TypeScript](https://img.shields.io/badge/TypeScript-4.5-blue?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
10
+
11
+ A TypeScript Fetch API wrapper providing type-safe HTTP requests with advanced abort/timeout handling, event-driven architecture, and automatic content-type based response processing.
12
+
13
+ ## Features
14
+
15
+ - **Type-safe** — Full TypeScript support with strict types, branded JSON strings, and typed headers
16
+ - **Automatic response handling** — Content-type based response parsing (JSON, HTML, XML, images, streams, etc.)
17
+ - **Abort & timeout management** — Per-request timeouts, `AbortController` integration, and `abortAll()` for cleanup
18
+ - **Event-driven** — Global and instance-level lifecycle events (`configured`, `success`, `error`, `complete`, etc.)
19
+ - **Retry logic** — Configurable retry with exponential backoff, status code filtering, and method filtering
20
+ - **Request deduplication** — Identical in-flight GET/HEAD requests share a single fetch
21
+ - **Lifecycle hooks** — `beforeRequest`, `afterResponse`, `beforeError` hooks at global, instance, and per-request levels
22
+ - **XSRF/CSRF protection** — Automatic cookie-to-header token injection
23
+ - **HTML selectors** — Extract specific elements from HTML responses with CSS selectors
24
+ - **FormData auto-detection** — Automatically handles FormData, Blob, ArrayBuffer, and stream bodies
25
+ - **Streaming** — SSE (`getEventStream`) and NDJSON (`getJsonStream`) as `AsyncIterable`
26
+ - **Progress tracking** — Download and upload progress callbacks with loaded/total/percentage
27
+ - **Safe results** — `unwrap: false` option returns `Result<T>` tuples instead of throwing
28
+ - **Concurrent helpers** `Transportr.all()` and `Transportr.race()` with auto-abort for race losers
29
+
30
+ ## Why Transportr?
31
+
32
+ The HTTP client space in JavaScript/TypeScript is crowded. Here's where Transportr fits and why it may be the right choice for your project.
33
+
34
+ ### The Competition at a Glance
35
+
36
+ | Library | Minified | Gzipped | Engine | Request API | Philosophy |
37
+ |---------|----------|---------|--------|-------------|------------|
38
+ | axios | 35.4 kB | 13.9 kB | ≥ 10 | XMLHttpRequest / http | Kitchen-sink, XHR-based |
39
+ | ky | 13.7 kB | 4.9 kB | ≥ 18 | Fetch | Tiny fetch wrapper, browser-first |
40
+ | ofetch | 9.3 kB | 3.8 kB | ≥ 18 | Fetch | Universal, minimal API |
41
+ | wretch | 4.8 kB | 1.9 kB | ≥ 14 | Fetch | Fluent-chain, middleware-based |
42
+ | got | — | ~43 kB | ≥ 18 | http/https | Node-only, feature-rich |
43
+ | transportr | 27 kB | 8.5 kB* | ≥ 20 | Fetch | Content-type-aware, event-driven |
44
+
45
+ Sizes from [Bundlephobia](https://bundlephobia.com). \*Transportr bundles `@d1g1tal/media-type`, `@d1g1tal/subscribr`, and DOMPurify. Optional `jsdom` peer dependency is **not** included — only needed for HTML/XML/DOM features in Node.js.
46
+
47
+ ### What Every Fetch Wrapper Gives You
48
+
49
+ All of these libraries wrap `fetch` and add roughly the same core set of features:
50
+
51
+ - JSON body serialization + parsing
52
+ - Error on non-2xx responses
53
+ - Request timeout
54
+ - Base URL configuration
55
+ - TypeScript types
56
+
57
+ If you only need those basics, any of them will do. ky at 3.5 kB is a perfectly fine choice.
58
+
59
+ ### What Only Transportr Gives You
60
+
61
+ #### 1. Content-Type-Aware Response Handling
62
+
63
+ No other HTTP client knows *what it fetched* and processes it accordingly. Transportr maps response `Content-Type` directly to strongly-typed return values:
64
+
65
+ ```typescript
66
+ const api = new Transportr('https://example.com');
67
+
68
+ // Returns Promise<Json>parsed and typed
69
+ const data = await api.getJson('/api/users');
70
+
71
+ // Returns Promise<Document> fully parsed, DOMPurify-sanitized HTML
72
+ const page = await api.getHtml('/page');
73
+
74
+ // Returns Promise<Document> sanitized and parsed XML
75
+ const feed = await api.getXml('/feed.xml');
76
+
77
+ // Returns Promise<DocumentFragment> — isolated fragment, no full document
78
+ const fragment = await api.getHtmlFragment('/partial');
79
+
80
+ // Returns Promise<void> script fetched, verified, injected into <head>
81
+ await api.getScript('https://cdn.example.com/widget.js');
82
+
83
+ // Returns Promise<void> — stylesheet fetched and injected into <head>
84
+ await api.getStylesheet('https://cdn.example.com/theme.css');
85
+
86
+ // Returns Promise<HTMLImageElement>decoded, memory-safe
87
+ const img = await api.getImage('/assets/photo.webp');
88
+
89
+ // Returns Promise<ReadableStream> — for large payloads
90
+ const stream = await api.getStream('/export/data.csv');
91
+ ```
92
+
93
+ The type system enforces what you get back. You can't accidentally call `.querySelector()` on a JSON response.
94
+
95
+ #### 2. Automatic DOMPurify Sanitization
96
+
97
+ `getHtml()`, `getXml()`, and `getHtmlFragment()` sanitize the response through **DOMPurify before parsing**. You don't have to remember to sanitize — it's built into the transport layer.
98
+
99
+ This is the correct place to sanitize: as close to the network boundary as possible, before the content ever reaches a parser or your application code.
100
+
101
+ #### 3. HTML Selector Extraction
102
+
103
+ Fetch a page and get back exactly the element you want, not the whole document:
104
+
105
+ ```typescript
106
+ // Returns Promise<Element | null>
107
+ const nav = await api.getHtml('/page', {}, 'nav.main');
108
+ const price = await api.getHtmlFragment('/product', {}, '.price');
109
+ ```
110
+
111
+ Useful for partial page loading, widget hydration, and scraping structured content without a separate HTML parsing step.
112
+
113
+ #### 4. Script & Stylesheet Injection with Cleanup
114
+
115
+ `getScript()` and `getStylesheet()` use `URL.createObjectURL()` to inject remote assets, then automatically revoke the object URL after load/error. No memory leaks, no dangling blob URLs:
116
+
117
+ ```typescript
118
+ // Loads, verifies, injects, and cleans up automatically
119
+ await api.getScript('https://partner.example.com/sdk.js');
120
+ await api.getStylesheet('https://cdn.example.com/theme.css');
121
+ ```
122
+
123
+ This is a real pattern for micro-frontend loaders and dynamic plugin systems. No other HTTP client handles it.
124
+
125
+ #### 5. Full Lifecycle Event System
126
+
127
+ Transportr has a two-tier event system (global + per-instance) with a defined lifecycle:
128
+
129
+ ```
130
+ configured success | error | aborted | timeout → complete → all-complete
131
+ ```
132
+
133
+ Register handlers globally (across all instances) or per-instance:
134
+
135
+ ```typescript
136
+ // All requests, all instances
137
+ Transportr.register(Transportr.RequestEvent.SUCCESS, (event, data) => {
138
+ analytics.track('api_success', { url: data.url });
139
+ });
140
+
141
+ // Just this instance
142
+ api.register(Transportr.RequestEvent.TIMEOUT, (event, error) => {
143
+ toast.error('Request timed out');
144
+ });
145
+ ```
146
+
147
+ ky has hooks. ofetch has hooks. Neither has a persistent, named event system you can subscribe to and unsubscribe from independently.
148
+
149
+ #### 6. Request Deduplication
150
+
151
+ Identical in-flight GET/HEAD requests share a single fetch. Each caller receives a cloned `Response`:
152
+
153
+ ```typescript
154
+ // Only one network request — both get independent Response clones
155
+ const [a, b] = await Promise.all([
156
+ api.get('/config', { dedupe: true }),
157
+ api.get('/config', { dedupe: true }),
158
+ ]);
159
+ ```
160
+
161
+ This is commonly left to userland caching or state management. Transportr makes it a first-class option.
162
+
163
+ #### 7. `abortAll()` for Clean Teardown
164
+
165
+ Cancel every in-flight request across all instances in one call:
166
+
167
+ ```typescript
168
+ // Route change, unmount, logout kill everything
169
+ Transportr.abortAll();
170
+ ```
171
+
172
+ Useful in SPAs for route transitions or session expiry. ky and ofetch require manual `AbortController` management per-request.
173
+
174
+ #### 8. Structured Hook Layers
175
+
176
+ Hooks run in a deterministic order: **global instance → per-request**. This lets you separate concerns cleanly:
177
+
178
+ ```typescript
179
+ Transportr.addHooks({ beforeRequest: [addRequestId] }); // Always runs
180
+ api.addHooks({ afterResponse: [logLatency] }); // Runs for this API
181
+ await api.get('/data', { hooks: { beforeError: [notify] } }); // Only this call
182
+ ```
183
+
184
+ ### Feature Comparison
185
+
186
+ | Feature | transportr | ky | ofetch | wretch | axios |
187
+ |---------|:----------:|:--:|:------:|:------:|:-----:|
188
+ | JSON request/response | ✅ | ✅ | ✅ | ✅ | ✅ |
189
+ | TypeScript (first-class) | | | ✅ | ✅ | ✅ |
190
+ | Timeout | | | | | ✅ |
191
+ | Retry with backoff | | | | ✅ | ⚠️ plugin |
192
+ | SSE / NDJSON streaming | | | ❌ | ❌ | ❌ |
193
+ | Download / upload progress | | | | | ✅ |
194
+ | Safe result tuples | | | | | ❌ |
195
+ | Concurrent helpers with auto-abort | | | ❌ | ❌ | ❌ |
196
+ | Request deduplication | | | ❌ | ❌ | ❌ |
197
+ | Abort all in-flight | | | ❌ | ❌ | ❌ |
198
+ | Lifecycle event system | | | ❌ | ❌ | ❌ |
199
+ | HTML response → `Document` | ✅ | ❌ | ❌ | ❌ | ❌ |
200
+ | XML response → `Document` | ✅ | ❌ | ❌ | ❌ | ❌ |
201
+ | HTML fragment with selector | ✅ | ❌ | ❌ | ❌ | ❌ |
202
+ | Auto DOMPurify sanitization | ✅ | ❌ | ❌ | ❌ | ❌ |
203
+ | Script injection + cleanup | ✅ | ❌ | ❌ | ❌ | ❌ |
204
+ | Stylesheet injection + cleanup | ✅ | ❌ | ❌ | ❌ | ❌ |
205
+ | Image → `HTMLImageElement` | | | | | ❌ |
206
+ | XSRF/CSRF protection | ✅ | ❌ | ❌ | ⚠️ plugin | ✅ |
207
+ | `beforeRequest` hooks | | ✅ | ✅ | ✅ | ✅ |
208
+ | `afterResponse` hooks | | | ✅ | ✅ | ✅ |
209
+ | Global + instance hook layers | ✅ | ❌ | ❌ | ❌ | ❌ |
210
+ | Branded JSON types | ✅ | ❌ | ❌ | ❌ | ❌ |
211
+ | Custom content-type handlers | ✅ | ❌ | ❌ | ❌ | ❌ |
212
+
213
+ ### When to Choose Transportr
214
+
215
+ **Choose Transportr if you are building:**
216
+
217
+ - A **micro-frontend loader** that fetches and injects remote scripts or stylesheets
218
+ - An **SSR/ISR application** that fetches HTML partials and extracts fragments server-side
219
+ - A **content aggregator** that deals with mixed response types (JSON, HTML, XML, images)
220
+ - A **dashboard or scraper** that parses HTML with CSS selectors and needs built-in sanitization
221
+ - An application where **abort-all on route change** or **request deduplication** are requirements
222
+ - A project that wants **typed lifecycle events** rather than ad-hoc error handling
223
+
224
+ **Choose ky or ofetch if you are building:**
225
+
226
+ - A pure JSON API client where bundle size is the primary constraint
227
+ - A project that has no DOM-manipulation requirements
228
+ - A project that already has its own event/observability layer
229
+
230
+ ## Requirements
231
+
232
+ - **Node.js** ≥ 20.0.0 or a modern browser with native [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) and `AbortController` support
233
+ - `jsdom` is an **optional peer dependency** — only needed for HTML/XML/DOM features in Node.js. Install it separately if you use `getHtml()`, `getXml()`, `getHtmlFragment()`, `getScript()`, `getStylesheet()`, or `getImage()` in a non-browser environment:
234
+
235
+ ```bash
236
+ pnpm add jsdom
237
+ ```
238
+
239
+ ## Installation
240
+
241
+ ```bash
242
+ # With pnpm:
243
+ pnpm add @d1g1tal/transportr
244
+
245
+ # Or with npm:
246
+ npm install @d1g1tal/transportr
247
+
248
+ # Or with yarn
249
+ yarn add @d1g1tal/transportr
250
+ ```
251
+
252
+ ## Quick Start
253
+
254
+ Only the main module is required — the submodule constants (`RequestHeader`, `ContentType`, etc.) are optional conveniences. Anywhere a constant is used, a plain string works just as well.
255
+
256
+ Out of the box, every instance defaults to:
257
+ - `Content-Type: application/json; charset=utf-8`
258
+ - `Accept: application/json; charset=utf-8`
259
+ - `timeout`: 30 000 ms
260
+ - `cache`: `no-store`
261
+ - `credentials`: `same-origin`
262
+ - `mode`: `cors`
263
+
264
+ ```typescript
265
+ import { Transportr } from '@d1g1tal/transportr';
266
+
267
+ const api = new Transportr('https://api.example.com');
268
+
269
+ // GET JSON — default Accept header is already application/json
270
+ const data = await api.getJson('/users/1');
271
+
272
+ // POST with JSON body automatically serialized, no Content-Type needed
273
+ const created = await api.post('/users', { body: { name: 'Alice' } });
274
+
275
+ // GET with search params
276
+ const results = await api.getJson('/search', { searchParams: { q: 'term', page: 1 } });
277
+
278
+ // Typed response using generics
279
+ interface User { id: number; name: string; }
280
+ const user = await api.get<User>('/users/1');
281
+
282
+ // Plain strings work anywhere — constants are just for convenience
283
+ const api2 = new Transportr('https://api.example.com', {
284
+ headers: { 'authorization': 'Bearer token', 'accept-language': 'en-US' }
285
+ });
286
+ ```
287
+
288
+ ## Browser / CDN Usage
289
+
290
+ The package is published as pure ESM and works directly in modern browsers — no bundler required. All dependencies (`@d1g1tal/media-type`, `@d1g1tal/subscribr`, DOMPurify) are bundled into the output, so there are no external module URLs to manage. `jsdom` is not needed in a browser environment.
291
+
292
+ ### With an import map (recommended)
293
+
294
+ An [import map](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script/type/importmap) mirrors the package's named submodule exports and keeps your code identical to the Node.js form — use bare specifiers exactly as you would in a bundled project:
295
+
296
+ ```html
297
+ <script type="importmap">
298
+ {
299
+ "imports": {
300
+ "@d1g1tal/transportr": "https://cdn.jsdelivr.net/npm/@d1g1tal/transportr/dist/transportr.js",
301
+ "@d1g1tal/transportr/request-header": "https://cdn.jsdelivr.net/npm/@d1g1tal/transportr/dist/request-header.js",
302
+ "@d1g1tal/transportr/request-method": "https://cdn.jsdelivr.net/npm/@d1g1tal/transportr/dist/request-method.js",
303
+ "@d1g1tal/transportr/content-type": "https://cdn.jsdelivr.net/npm/@d1g1tal/transportr/dist/content-type.js",
304
+ "@d1g1tal/transportr/response-header": "https://cdn.jsdelivr.net/npm/@d1g1tal/transportr/dist/response-header.js"
305
+ }
306
+ }
307
+ </script>
308
+
309
+ <script type="module">
310
+ import { Transportr } from '@d1g1tal/transportr';
311
+ import { RequestHeader } from '@d1g1tal/transportr/request-header';
312
+ import { RequestMethod } from '@d1g1tal/transportr/request-method';
313
+ import { ContentType } from '@d1g1tal/transportr/content-type';
314
+ import { ResponseHeader } from '@d1g1tal/transportr/response-header';
315
+
316
+ const api = new Transportr('https://api.example.com', {
317
+ headers: {
318
+ [RequestHeader.AUTHORIZATION]: 'Bearer token',
319
+ [RequestHeader.ACCEPT]: ContentType.JSON
320
+ }
321
+ });
322
+
323
+ const data = await api.getJson('/users/1');
324
+ console.log(data);
325
+ </script>
326
+ ```
327
+
328
+ ### Without an import map
329
+
330
+ The CDN resolves the `"."` entry in `exports` automatically, so no explicit file path is needed for the main module. Submodules use their CDN paths directly:
331
+
332
+ ```html
333
+ <script type="module">
334
+ import { Transportr } from 'https://cdn.jsdelivr.net/npm/@d1g1tal/transportr';
335
+ import { RequestHeader } from 'https://cdn.jsdelivr.net/npm/@d1g1tal/transportr/dist/request-header.js';
336
+ import { ContentType } from 'https://cdn.jsdelivr.net/npm/@d1g1tal/transportr/dist/content-type.js';
337
+
338
+ const api = new Transportr('https://api.example.com', {
339
+ headers: {
340
+ [RequestHeader.AUTHORIZATION]: 'Bearer token',
341
+ [RequestHeader.ACCEPT]: ContentType.JSON
342
+ }
343
+ });
344
+
345
+ const data = await api.getJson('/users/1');
346
+ console.log(data);
347
+ </script>
348
+ ```
349
+
350
+ Import map support is available in all browsers covered by this project's `browserslist` configuration (Chrome 89+, Firefox 108+, Safari 16.4+).
351
+
352
+ ## Migrating from v2
353
+
354
+ v3.0 contains breaking renames plus new features. The renames are all find-and-replace — no behavior changed.
355
+
356
+ ### Submodule paths & exported symbols
357
+
358
+ | Before (v2) | After (v3) |
359
+ |---|---|
360
+ | `@d1g1tal/transportr/headers` | `@d1g1tal/transportr/request-header` |
361
+ | `@d1g1tal/transportr/methods` | `@d1g1tal/transportr/request-method` |
362
+ | `@d1g1tal/transportr/media-types` | `@d1g1tal/transportr/content-type` |
363
+ | `@d1g1tal/transportr/response-headers` | `@d1g1tal/transportr/response-header` |
364
+ | `HttpRequestHeader` | `RequestHeader` |
365
+ | `HttpRequestMethod` | `RequestMethod` |
366
+ | `HttpMediaType` | `ContentType` |
367
+ | `HttpResponseHeader` | `ResponseHeader` |
368
+
369
+ ### Static properties on `Transportr`
370
+
371
+ | Before (v2) | After (v3) |
372
+ |---|---|
373
+ | `Transportr.RequestEvents` | `Transportr.RequestEvent` |
374
+ | `Transportr.RequestModes` | `Transportr.RequestMode` |
375
+ | `Transportr.RequestPriorities` | `Transportr.RequestPriority` |
376
+ | `Transportr.RedirectPolicies` | `Transportr.RedirectPolicy` |
377
+
378
+ ### New in v3
379
+
380
+ - **Streaming responses** — `getEventStream()` returns an `AsyncIterable<ServerSentEvent>` for SSE endpoints; `getJsonStream<T>()` returns an `AsyncIterable<T>` for NDJSON feeds.
381
+ - **Progress tracking** — `onDownloadProgress` and `onUploadProgress` callbacks in request options provide `{ loaded, total, percentage }` updates.
382
+ - **Safe results** — Pass `unwrap: false` per-request or in the constructor to get `Result<T>` tuples (`[true, data]` or `[false, error]`) instead of thrown errors.
383
+ - **Concurrent helpers** — `Transportr.all()` for parallel requests; `Transportr.race()` races requests and auto-aborts the losers.
384
+ - **Typed events** — `register()` now narrows the event data type based on the event name.
385
+
386
+ ## API
387
+
388
+ ### Constructor
389
+
390
+ ```typescript
391
+ new Transportr(url?: URL | string | RequestOptions, options?: RequestOptions)
392
+ ```
393
+
394
+ Creates a new instance. When `url` is omitted, defaults to `globalThis.location.origin`.
395
+
396
+ ```typescript
397
+ // With base URL
398
+ const api = new Transportr('https://api.example.com/v2');
399
+
400
+ // With URL and default options
401
+ const api = new Transportr('https://api.example.com', {
402
+ timeout: 10000,
403
+ headers: { 'Authorization': 'Bearer token' }
404
+ });
405
+
406
+ // With options only (uses current origin)
407
+ const api = new Transportr({ timeout: 5000 });
408
+ ```
409
+
410
+ ### Updating Instance Options
411
+
412
+ Update any option that was set at construction time, without creating a new instance. `configure()` accepts the same shape as the constructor options. Headers and searchParams are **merged** onto existing defaults; all other options **overwrite** the current value; hooks are appended.
413
+
414
+ ```typescript
415
+ const api = new Transportr('https://api.example.com', {
416
+ timeout: 30000,
417
+ credentials: 'same-origin'
418
+ });
419
+
420
+ // After login inject auth token and tighten timeout
421
+ api.configure({
422
+ timeout: 10000,
423
+ credentials: 'include',
424
+ headers: { 'Authorization': `Bearer ${token}` }
425
+ });
426
+
427
+ // Add default search params for all subsequent requests
428
+ api.configure({ searchParams: { version: '2', locale: 'en' } });
429
+
430
+ // Chainable
431
+ api
432
+ .configure({ timeout: 5000 })
433
+ .configure({ headers: { 'X-Tenant': 'acme' } })
434
+ .addHooks({ beforeRequest: [logHook] });
435
+ ```
436
+
437
+ ### Request Methods
438
+
439
+ | Method | Description |
440
+ |--------|-------------|
441
+ | `get(path?, options?)` | GET request with auto content-type handling |
442
+ | `post(path?, options?)` | POST request |
443
+ | `put(path?, options?)` | PUT request |
444
+ | `patch(path?, options?)` | PATCH request |
445
+ | `delete(path?, options?)` | DELETE request |
446
+ | `head(path?, options?)` | HEAD request |
447
+ | `options(path?, options?)` | OPTIONS request (returns allowed methods) |
448
+ | `request(path?, options?)` | Raw request returning `TypedResponse<T>` |
449
+
450
+ ### Typed Response Methods
451
+
452
+ | Method | Returns | Accept Header |
453
+ |--------|---------|---------------|
454
+ | `getJson(path?, options?)` | `Json` | `application/json` |
455
+ | `getHtml(path?, options?, selector?)` | `Document \| Element` | `text/html` |
456
+ | `getHtmlFragment(path?, options?, selector?)` | `DocumentFragment \| Element` | `text/html` |
457
+ | `getXml(path?, options?)` | `Document` | `application/xml` |
458
+ | `getScript(path?, options?)` | `void` (injected into DOM) | `application/javascript` |
459
+ | `getStylesheet(path?, options?)` | `void` (injected into DOM) | `text/css` |
460
+ | `getBlob(path?, options?)` | `Blob` | `application/octet-stream` |
461
+ | `getImage(path?, options?)` | `HTMLImageElement` | `image/*` |
462
+ | `getBuffer(path?, options?)` | `ArrayBuffer` | `application/octet-stream` |
463
+ | `getStream(path?, options?)` | `ReadableStream` | `application/octet-stream` |
464
+ | `getEventStream(path?, options?)` | `AsyncIterable<ServerSentEvent>` | `text/event-stream` |
465
+ | `getJsonStream<T>(path?, options?)` | `AsyncIterable<T>` | `application/x-ndjson` |
466
+
467
+ ### Request Options
468
+
469
+ ```typescript
470
+ type RequestOptions = {
471
+ headers?: RequestHeaders;
472
+ searchParams?: URLSearchParams | string | Record<string, string | number | boolean>;
473
+ timeout?: number; // Default: 30000ms
474
+ global?: boolean; // Emit global events (default: true)
475
+ body?: BodyInit | JsonObject; // Auto-serialized for JSON content-type
476
+ retry?: number | RetryOptions;
477
+ dedupe?: boolean; // Deduplicate identical GET/HEAD requests
478
+ xsrf?: boolean | XsrfOptions;
479
+ hooks?: HooksOptions;
480
+ unwrap?: boolean; // false → return Result<T> tuple instead of throwing
481
+ onDownloadProgress?: (progress: DownloadProgress) => void;
482
+ onUploadProgress?: (progress: DownloadProgress) => void;
483
+ // ...all standard RequestInit properties (cache, credentials, mode, etc.)
484
+ };
485
+ ```
486
+
487
+ ### Retry
488
+
489
+ ```typescript
490
+ // Simple: retry up to 3 times with default settings
491
+ await api.get('/data', { retry: 3 });
492
+
493
+ // Advanced configuration
494
+ await api.get('/data', {
495
+ retry: {
496
+ limit: 3,
497
+ statusCodes: [408, 413, 429, 500, 502, 503, 504],
498
+ methods: ['GET', 'PUT', 'HEAD', 'DELETE', 'OPTIONS'],
499
+ delay: 300, // ms before first retry
500
+ backoffFactor: 2 // exponential backoff multiplier
501
+ }
502
+ });
503
+ ```
504
+
505
+ ### Request Deduplication
506
+
507
+ When `dedupe: true`, identical in-flight GET/HEAD requests share a single fetch call. Each consumer receives a cloned response.
508
+
509
+ ```typescript
510
+ // Only one fetch call is made
511
+ const [a, b] = await Promise.all([
512
+ api.get('/data', { dedupe: true }),
513
+ api.get('/data', { dedupe: true })
514
+ ]);
515
+ ```
516
+
517
+ ### Lifecycle Hooks
518
+
519
+ Hooks run in order: global → instance → per-request.
520
+
521
+ ```typescript
522
+ // Global hooks (all instances)
523
+ Transportr.addHooks({
524
+ beforeRequest: [async (options, url) => {
525
+ options.headers.set('X-Request-ID', crypto.randomUUID());
526
+ return options;
527
+ }],
528
+ afterResponse: [async (response, options) => response],
529
+ beforeError: [(error) => error]
530
+ });
531
+
532
+ // Instance hooks
533
+ api.addHooks({
534
+ afterResponse: [async (response) => {
535
+ console.log(`Response: ${response.status}`);
536
+ return response;
537
+ }]
538
+ });
539
+
540
+ // Per-request hooks
541
+ await api.get('/data', {
542
+ hooks: { beforeRequest: [async (opts) => opts] }
543
+ });
544
+ ```
545
+
546
+ ### Events
547
+
548
+ ```typescript
549
+ // Global events (all instances)
550
+ const reg = Transportr.register(Transportr.RequestEvent.SUCCESS, (event, data) => {
551
+ console.log('Request succeeded:', data);
552
+ });
553
+
554
+ // Instance events
555
+ const reg = api.register(Transportr.RequestEvent.ERROR, (event, error) => {
556
+ console.error('Request failed:', error);
557
+ });
558
+
559
+ // Unregister
560
+ api.unregister(reg); // Returns `this` for chaining
561
+ ```
562
+
563
+ **Event lifecycle**: `configured` → `success | error | aborted | timeout` → `complete` → `all-complete`
564
+
565
+ Additional events: `retry` (emitted on each retry attempt)
566
+
567
+ ### Error Handling
568
+
569
+ Non-2xx responses throw an error with `name === 'HttpError'`. Aborted and timed-out requests also produce an `HttpError` with synthetic status codes.
570
+
571
+ ```typescript
572
+ import type { HttpError } from '@d1g1tal/transportr';
573
+
574
+ try {
575
+ const user = await api.getJson('/users/1');
576
+ } catch (error) {
577
+ if (error instanceof Error && error.name === 'HttpError') {
578
+ const httpError = error as unknown as HttpError;
579
+ console.error(httpError.statusCode); // HTTP status code
580
+ console.error(httpError.statusText); // HTTP status text
581
+ console.error(httpError.entity); // parsed response body (if any)
582
+ console.error(httpError.url?.href); // request URL
583
+ console.error(httpError.method); // HTTP method used
584
+ console.error(httpError.timing); // { start, end, duration } in ms
585
+ }
586
+ }
587
+ ```
588
+
589
+ **Synthetic status codes for non-HTTP failures:**
590
+
591
+ | Code | Text | Cause |
592
+ |------|------|-------|
593
+ | `499` | `Aborted` | Cancelled via `controller.abort()` or `Transportr.abortAll()` |
594
+ | `504` | `Request Timeout` | `timeout` option exceeded |
595
+
596
+ ### Abort & Timeout
597
+
598
+ ```typescript
599
+ // Per-request timeout
600
+ await api.get('/slow', { timeout: 5000 });
601
+
602
+ // Manual abort via AbortController
603
+ const controller = new AbortController();
604
+ api.get('/data', { signal: controller.signal });
605
+ controller.abort();
606
+
607
+ // Abort all in-flight requests
608
+ Transportr.abortAll();
609
+ ```
610
+
611
+ ### XSRF/CSRF Protection
612
+
613
+ ```typescript
614
+ // Default: reads 'XSRF-TOKEN' cookie, sets 'X-XSRF-TOKEN' header
615
+ await api.post('/data', { body: payload, xsrf: true });
616
+
617
+ // Custom cookie/header names
618
+ await api.post('/data', {
619
+ body: payload,
620
+ xsrf: { cookieName: 'MY-CSRF', headerName: 'X-MY-CSRF' }
621
+ });
622
+ ```
623
+
624
+ ### Streaming
625
+
626
+ ```typescript
627
+ // Server-Sent Events (SSE)
628
+ for await (const event of await api.getEventStream('/chat/completions', { body: { prompt } })) {
629
+ console.log(event.event, event.data);
630
+ }
631
+
632
+ // NDJSON (Newline Delimited JSON)
633
+ interface LogEntry { ts: number; message: string; }
634
+ for await (const entry of await api.getJsonStream<LogEntry>('/logs/stream')) {
635
+ processEntry(entry);
636
+ }
637
+ ```
638
+
639
+ ### Progress Tracking
640
+
641
+ ```typescript
642
+ // Download progress
643
+ await api.getBlob('/large-file', {
644
+ onDownloadProgress: ({ loaded, total, percentage }) => {
645
+ console.log(`${percentage}% (${loaded}/${total})`);
646
+ }
647
+ });
648
+
649
+ // Upload progress
650
+ await api.post('/upload', {
651
+ body: largeBlob,
652
+ onUploadProgress: ({ loaded, total, percentage }) => {
653
+ console.log(`Uploading: ${percentage}%`);
654
+ }
655
+ });
656
+ ```
657
+
658
+ ### Safe Results (unwrap: false)
659
+
660
+ Pass `unwrap: false` to get a `Result<T>` tuple instead of thrown errors. The tuple is `[true, data]` on success or `[false, HttpError]` on failure.
661
+
662
+ ```typescript
663
+ // Per-request
664
+ const [ok, result] = await api.getJson('/users/1', { unwrap: false });
665
+ if (ok) {
666
+ console.log(result); // typed as Json
667
+ } else {
668
+ console.error(result.statusCode); // typed as HttpError
669
+ }
670
+
671
+ // Constructor-level default
672
+ const safeApi = new Transportr('https://api.example.com', { unwrap: false });
673
+ const [ok, data] = await safeApi.getJson('/data');
674
+ ```
675
+
676
+ ### Concurrent Requests
677
+
678
+ ```typescript
679
+ // Run multiple requests in parallel
680
+ const [users, posts] = await Transportr.all([
681
+ api.getJson('/users'),
682
+ api.getJson('/posts')
683
+ ]);
684
+
685
+ // Race requests — first to settle wins, losers are aborted
686
+ const fastest = await Transportr.race([
687
+ (signal) => api.getJson('/primary-cdn/data', { signal }),
688
+ (signal) => api.getJson('/fallback-cdn/data', { signal })
689
+ ]);
690
+ ```
691
+
692
+ ### HTML Selector Support
693
+
694
+ ```typescript
695
+ // Get a specific element from HTML response
696
+ const nav = await api.getHtml('/page', {}, 'nav.main');
697
+ const item = await api.getHtmlFragment('/partial', {}, '.item:first-child');
698
+ ```
699
+
700
+ ### FormData & Raw Bodies
701
+
702
+ FormData, Blob, ArrayBuffer, ReadableStream, TypedArray, and URLSearchParams are sent as-is. The `Content-Type` header is automatically removed so the runtime can set it (e.g., multipart boundary for FormData).
703
+
704
+ ```typescript
705
+ const form = new FormData();
706
+ form.append('file', fileBlob, 'photo.jpg');
707
+ await api.post('/upload', { body: form });
708
+ ```
709
+
710
+ ### Custom Content-Type Handlers
711
+
712
+ ```typescript
713
+ // Register a custom handler (takes priority over built-in)
714
+ Transportr.registerContentTypeHandler('csv', async (response) => {
715
+ const text = await response.text();
716
+ return text.split('\n').map(row => row.split(','));
717
+ });
718
+
719
+ // Remove a handler
720
+ Transportr.unregisterContentTypeHandler('csv');
721
+ ```
722
+
723
+ ### Cleanup
724
+
725
+ ```typescript
726
+ // Tear down a single instance
727
+ api.destroy();
728
+
729
+ // Tear down all global state
730
+ Transportr.unregisterAll();
731
+
732
+ // Clear only global hooks without aborting in-flight requests
733
+ Transportr.clearHooks();
734
+ ```
735
+
736
+ ### Method Chaining
737
+
738
+ Instance methods `configure()`, `unregister()`, `addHooks()`, and `clearHooks()` return `this`:
739
+
740
+ ```typescript
741
+ api
742
+ .configure({ timeout: 5000, credentials: 'include' })
743
+ .configure({ headers: { 'Authorization': `Bearer ${token}` } })
744
+ .addHooks({ beforeRequest: [myHook] })
745
+ .clearHooks()
746
+ .addHooks({ afterResponse: [logHook] });
747
+ ```
748
+
749
+ ### Instance Properties
750
+
751
+ | Property | Type | Description |
752
+ |----------|------|-------------|
753
+ | `baseUrl` | `URL` | The base URL used for all requests from this instance |
754
+
755
+ ### Static Properties
756
+
757
+ | Property | Description |
758
+ |----------|-------------|
759
+ | `Transportr.CredentialsPolicy` | Credentials policy constants |
760
+ | `Transportr.RequestMode` | Request mode constants |
761
+ | `Transportr.RequestPriority` | Request priority constants |
762
+ | `Transportr.RedirectPolicy` | Redirect policy constants |
763
+ | `Transportr.ReferrerPolicy` | Referrer policy constants |
764
+ | `Transportr.RequestEvent` | Event name constants |
765
+ | `Transportr.all(requests)` | Run requests in parallel (`Promise.all` with tuple typing) |
766
+ | `Transportr.race(requests)` | Race request factories; auto-aborts losers |
767
+ | `Transportr.abortAll()` | Abort all in-flight requests across all instances |
768
+ | `Transportr.addHooks(hooks)` | Add global lifecycle hooks |
769
+ | `Transportr.clearHooks()` | Remove all global hooks |
770
+ | `Transportr.register(event, handler)` | Register a global event handler |
771
+ | `Transportr.unregister(registration)` | Remove a global event handler |
772
+
773
+ ### Submodule Imports
774
+
775
+ HTTP constant objects are available as named submodule imports. Each is a tree-shakeable, side-effect-free object of string constants — useful for avoiding magic strings and getting autocomplete.
776
+
777
+ #### `@d1g1tal/transportr/request-header`
778
+
779
+ Request header name constants.
780
+
781
+ ```typescript
782
+ import { Transportr } from '@d1g1tal/transportr';
783
+ import { RequestHeader } from '@d1g1tal/transportr/request-header';
784
+
785
+ const api = new Transportr('https://api.example.com', {
786
+ headers: {
787
+ [RequestHeader.AUTHORIZATION]: 'Bearer token',
788
+ [RequestHeader.CONTENT_TYPE]: 'application/json',
789
+ [RequestHeader.ACCEPT_LANGUAGE]: 'en-US'
790
+ }
791
+ });
792
+ ```
793
+
794
+ #### `@d1g1tal/transportr/request-method`
795
+
796
+ HTTP method string constants.
797
+
798
+ ```typescript
799
+ import { Transportr } from '@d1g1tal/transportr';
800
+ import { RequestMethod } from '@d1g1tal/transportr/request-method';
801
+
802
+ const api = new Transportr('https://api.example.com');
803
+ const response = await api.request('/data', { method: RequestMethod.PATCH });
804
+ ```
805
+
806
+ #### `@d1g1tal/transportr/content-type`
807
+
808
+ MIME type string constants covering common content types (JSON, HTML, XML, CSS, images, audio, video, and more).
809
+
810
+ ```typescript
811
+ import { Transportr } from '@d1g1tal/transportr';
812
+ import { RequestHeader } from '@d1g1tal/transportr/request-header';
813
+ import { ContentType } from '@d1g1tal/transportr/content-type';
814
+
815
+ const api = new Transportr('https://api.example.com', {
816
+ headers: { [RequestHeader.ACCEPT]: ContentType.JSON }
817
+ });
818
+
819
+ // Use as a content-type value
820
+ const csvData = 'id,name\n1,Alice';
821
+ await api.post('/upload', {
822
+ body: csvData,
823
+ headers: { [RequestHeader.CONTENT_TYPE]: ContentType.CSV }
824
+ });
825
+ ```
826
+
827
+ #### `@d1g1tal/transportr/response-header`
828
+
829
+ Response header name constants — useful when reading headers from a response.
830
+
831
+ ```typescript
832
+ import { Transportr } from '@d1g1tal/transportr';
833
+ import { ResponseHeader } from '@d1g1tal/transportr/response-header';
834
+
835
+ const api = new Transportr('https://api.example.com');
836
+ const reg = api.register(Transportr.RequestEvent.SUCCESS, (event, data) => {
837
+ const response = data as Response;
838
+ const etag = response.headers.get(ResponseHeader.ETAG);
839
+ const retryAfter = response.headers.get(ResponseHeader.RETRY_AFTER);
840
+ const location = response.headers.get(ResponseHeader.LOCATION);
841
+ });
842
+ ```
843
+
844
+ ## License
845
+
846
+ [MIT](LICENSE)