fetchguard 1.6.3 → 2.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,13 +1,15 @@
1
1
  # FetchGuard
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/fetchguard.svg)](https://www.npmjs.com/package/fetchguard)
4
- ![npm bundle size](https://img.shields.io/bundlephobia/min/fetchguard)
5
4
  [![npm downloads](https://img.shields.io/npm/dm/fetchguard.svg)](https://www.npmjs.com/package/fetchguard)
6
5
  [![license](https://img.shields.io/npm/l/fetchguard.svg)](https://github.com/minhtaimc/fetchguard/blob/main/LICENSE)
7
6
 
8
7
 
9
8
  FetchGuard is a secure, type-safe API client that runs your network requests inside a Web Worker. Access tokens never touch the main thread, reducing XSS risk while providing automatic token refresh, domain allow-listing, and a clean Result-based API.
10
9
 
10
+ > **FetchGuard is a transport & security gateway, not an API client nor a business SDK.**
11
+
12
+
11
13
  ## Why FetchGuard
12
14
 
13
15
  - XSS hardening via closure: tokens live inside a Worker IIFE closure, not in `window`, not in JS-readable cookies, and not in local/session storage.
@@ -18,6 +20,33 @@ FetchGuard is a secure, type-safe API client that runs your network requests ins
18
20
  - Domain allow-list: block requests to unexpected hosts (wildcards and ports supported).
19
21
  - FormData support: automatic serialization for file uploads through the Worker.
20
22
 
23
+
24
+ ## Comparison with Alternatives
25
+
26
+ | Feature | FetchGuard | axios | ky | ofetch |
27
+ |---------|-----------|-------|-----|--------|
28
+ | XSS Token Protection | Worker + IIFE closure | None | None | None |
29
+ | Result Pattern | `ts-micro-result` | throw | throw | throw |
30
+ | Auto Token Refresh | Proactive (before expiry) | Manual | Manual | Manual |
31
+ | Domain Whitelist | Built-in | None | None | None |
32
+ | Bundle Size | ~10KB | ~30KB | ~8KB | ~5KB |
33
+
34
+ **Key differentiator:** FetchGuard is the only library that isolates tokens in a Web Worker IIFE closure, making them inaccessible to XSS attacks on the main thread.
35
+
36
+ ## When to Use FetchGuard
37
+
38
+ **Good fit:**
39
+ - SPAs that need to protect tokens from XSS attacks
40
+ - Apps requiring automatic token refresh without UI flicker
41
+ - Projects that prefer Result-based error handling over try/catch
42
+ - Teams wanting domain-level request restrictions
43
+
44
+ **Not ideal for:**
45
+ - Server-side rendering (Web Workers don't run on the server)
46
+ - High-throughput scenarios requiring many concurrent requests
47
+ - Apps needing streaming responses (SSE/WebSocket)
48
+ - Legacy browser support (requires Web Worker)
49
+
21
50
  ## Architecture (Simplified)
22
51
 
23
52
  ```
@@ -86,6 +115,61 @@ Hardening tips:
86
115
  - Use the domain allow-list aggressively (include explicit ports in development).
87
116
  - Avoid logging or exposing tokens in any responses; keep login/refresh parsing inside the worker.
88
117
 
118
+ ## Multi-Tab Refresh Behavior
119
+
120
+ When using cookie-based auth with multiple browser tabs, each tab has its own FetchGuard Worker instance but they share the same refresh token cookie. This means concurrent refresh requests are legitimate and expected.
121
+
122
+ ```
123
+ Tab 1: Token expires → calls /refresh
124
+ Tab 2: Token expires → calls /refresh (same cookie)
125
+ Tab 3: Token expires → calls /refresh (same cookie)
126
+ ```
127
+
128
+ **Server Requirements:**
129
+
130
+ Your auth server MUST handle concurrent refresh requests gracefully. Recommended patterns:
131
+
132
+ 1. **Grace Window (Recommended)**
133
+ Allow the old refresh token for N seconds after rotation:
134
+ ```typescript
135
+ // Server-side example
136
+ const GRACE_WINDOW_MS = 30_000 // 30 seconds
137
+
138
+ async function refreshToken(oldToken: string) {
139
+ const record = await db.findRefreshToken(oldToken)
140
+
141
+ if (record.rotatedAt) {
142
+ // Token was already rotated
143
+ const elapsed = Date.now() - record.rotatedAt
144
+ if (elapsed < GRACE_WINDOW_MS) {
145
+ // Within grace window: return same new tokens
146
+ return record.replacementTokens
147
+ }
148
+ throw new Error('Refresh token expired')
149
+ }
150
+
151
+ // First use: rotate and mark
152
+ const newTokens = generateNewTokens()
153
+ await db.markRotated(oldToken, newTokens, Date.now())
154
+ return newTokens
155
+ }
156
+ ```
157
+
158
+ 2. **Token Family Tracking**
159
+ Track token lineage and allow previous tokens within grace period.
160
+
161
+ 3. **Idempotency Keys**
162
+ Accept a unique key per refresh attempt and dedupe server-side.
163
+
164
+ **FetchGuard Grace Window:**
165
+
166
+ FetchGuard uses proactive token refresh (default: 60 seconds before expiry via `refreshEarlyMs`). This means:
167
+ - Tokens are refreshed BEFORE they expire, not after
168
+ - Multiple tabs may trigger refresh around the same time
169
+ - Your server grace window should be at least `refreshEarlyMs + network_latency`
170
+
171
+ Recommendation: Set server grace window to 30-60 seconds to safely handle multi-tab scenarios.
172
+
89
173
  ## Installation
90
174
 
91
175
  ```bash
@@ -127,12 +211,18 @@ const api = createClient({
127
211
  })
128
212
 
129
213
  type User = { id: string; name: string }
130
- const res = await api.get<User[]>('https://api.example.com/users')
131
-
132
- if (res.isOk()) {
133
- console.log('Users:', res.data)
214
+ const result = await api.get('https://api.example.com/users')
215
+
216
+ if (result.ok) {
217
+ const envelope = result.data
218
+ if (envelope.status >= 200 && envelope.status < 400) {
219
+ const users: User[] = JSON.parse(envelope.body)
220
+ console.log('Users:', users)
221
+ } else {
222
+ console.error('HTTP error:', envelope.status, envelope.body)
223
+ }
134
224
  } else {
135
- console.error('Error:', res.errors?.[0])
225
+ console.error('Network error:', result.errors[0].message)
136
226
  }
137
227
 
138
228
  // Cleanup
@@ -217,8 +307,8 @@ await api.get('/public/config', { requiresAuth: false })
217
307
 
218
308
  // Include response headers in the result
219
309
  const r = await api.get('/profile', { includeHeaders: true })
220
- if (r.isOk()) {
221
- console.log(r.status, r.headers, r.data)
310
+ if (r.ok) {
311
+ console.log(r.data.status, r.data.headers, r.data.body)
222
312
  }
223
313
  ```
224
314
 
@@ -249,8 +339,8 @@ const result = await api.fetch('https://api.example.com/upload', {
249
339
  await api.put('https://api.example.com/upload/123', formData)
250
340
  await api.patch('https://api.example.com/upload/123', formData)
251
341
 
252
- if (result.isOk()) {
253
- console.log('Upload successful:', result.data)
342
+ if (result.ok && result.data.status < 400) {
343
+ console.log('Upload successful:', result.data.body)
254
344
  }
255
345
  ```
256
346
 
@@ -531,8 +621,8 @@ The library targets modern browsers with Web Worker and (optionally) IndexedDB s
531
621
  - `onReady(callback)`: `() => void` - Subscribe to ready event (returns unsubscribe function)
532
622
 
533
623
  **HTTP Methods:**
534
- - `fetch(url, options?)`: `Promise<Result<ApiResponse>>`
535
- - `get/post/put/patch/delete(...)`: `Promise<Result<ApiResponse>>`
624
+ - `fetch(url, options?)`: `Promise<Result<FetchEnvelope>>`
625
+ - `get/post/put/patch/delete(...)`: `Promise<Result<FetchEnvelope>>`
536
626
  - `fetchWithId(url, options?)`: `{ id, result, cancel }`
537
627
  - `cancel(id)`: Cancel pending request
538
628
 
@@ -551,15 +641,72 @@ The library targets modern browsers with Web Worker and (optionally) IndexedDB s
551
641
 
552
642
  Types:
553
643
 
554
- - ApiResponse = { body: string; status: number; contentType: string; headers: Record<string, string> }
644
+ - FetchEnvelope = { status: number; body: string; contentType: string; headers: Record<string, string> }
645
+ - `status`: HTTP status code (2xx, 3xx, 4xx, 5xx) - Worker returns ALL HTTP responses
555
646
  - `body`: Raw string (text/JSON) or base64 (for binary content like images, PDFs)
556
647
  - `contentType`: Content type header (always present, e.g., 'application/json', 'image/png')
557
648
  - Use `isBinaryContentType(contentType)` to detect binary responses
558
649
  - Use `base64ToArrayBuffer(body)` to decode binary data
650
+ - **Note:** Worker no longer judges HTTP status. Consumer code should check `envelope.status` to determine success/error.
559
651
  - AuthResult = { authenticated: boolean; user?: unknown; expiresAt?: number | null }
560
652
  - FetchGuardRequestInit extends RequestInit with:
561
653
  - requiresAuth?: boolean // default true
562
654
  - includeHeaders?: boolean // default false
655
+ - signal?: AbortSignal // for cancellation
656
+
657
+ ## Helper Functions
658
+
659
+ FetchGuard exports helper functions for common Result patterns:
660
+
661
+ ```ts
662
+ import {
663
+ isSuccess,
664
+ isClientError,
665
+ isServerError,
666
+ isNetworkError,
667
+ parseJson,
668
+ getErrorMessage,
669
+ matchResult,
670
+ ERROR_CODES
671
+ } from 'fetchguard'
672
+
673
+ const result = await api.get('/users')
674
+
675
+ // Simple checks
676
+ if (isSuccess(result)) {
677
+ const users = parseJson<User[]>(result)
678
+ }
679
+
680
+ // Pattern matching
681
+ const message = matchResult(result, {
682
+ success: (envelope) => `Got ${parseJson(result)?.length} users`,
683
+ clientError: (envelope) => `Client error: ${envelope.status}`,
684
+ serverError: (envelope) => `Server error: ${envelope.status}`,
685
+ networkError: (errors) => `Network failed: ${errors[0]?.message}`
686
+ })
687
+
688
+ // Type-safe error code matching
689
+ if (!result.ok && result.errors[0]?.code === ERROR_CODES.NETWORK_ERROR) {
690
+ console.log('Connection failed')
691
+ }
692
+ ```
693
+
694
+ **Available helpers:**
695
+ - `isSuccess(result)` - Check if 2xx response
696
+ - `isClientError(result)` - Check if 4xx response
697
+ - `isServerError(result)` - Check if 5xx response
698
+ - `isNetworkError(result)` - Check if network error (no response)
699
+ - `parseJson<T>(result)` - Safe JSON parsing with type inference
700
+ - `getErrorMessage(result)` - Extract error message
701
+ - `getErrorBody<T>(result)` - Get typed error body from HTTP errors
702
+ - `getStatus(result)` / `hasStatus(result, code)` - Status code helpers
703
+ - `matchResult(result, handlers)` - Pattern matching
704
+
705
+ **Error codes** (`ERROR_CODES`):
706
+ - `NETWORK_ERROR`, `REQUEST_CANCELLED`, `REQUEST_TIMEOUT`
707
+ - `HTTP_ERROR`, `RESPONSE_PARSE_FAILED`, `QUEUE_FULL`
708
+ - `LOGIN_FAILED`, `LOGOUT_FAILED`, `TOKEN_REFRESH_FAILED`, `NOT_AUTHENTICATED`
709
+ - `DOMAIN_NOT_ALLOWED`, `INIT_ERROR`, `UNEXPECTED`
563
710
 
564
711
  ## Message Protocol (pairs, summary)
565
712
 
@@ -571,109 +718,119 @@ Types:
571
718
 
572
719
  ## Error Handling
573
720
 
574
- All methods return a `Result<T>` from `ts-micro-result`.
721
+ All methods return a `Result<T>` from `ts-micro-result` v3.
575
722
 
576
723
  ```ts
577
- const res = await api.get('/users')
578
- if (res.isOk()) {
579
- console.log(res.data)
724
+ const result = await api.get('/users')
725
+ if (result.ok) {
726
+ const envelope = result.data
727
+ // Check HTTP status - worker doesn't judge
728
+ if (envelope.status >= 200 && envelope.status < 400) {
729
+ console.log('Success:', JSON.parse(envelope.body))
730
+ } else {
731
+ console.log('HTTP error:', envelope.status, envelope.body)
732
+ }
580
733
  } else {
581
- const err = res.errors?.[0]
582
- console.warn(err?.code, err?.message)
734
+ // Network error only (connection failed, timeout, cancelled)
735
+ const err = result.errors[0]
736
+ console.warn(err.code, err.message)
583
737
  }
584
738
  ```
585
739
 
586
740
  Grouped error helpers are exported: `GeneralErrors`, `InitErrors`, `AuthErrors`, `DomainErrors`, `RequestErrors`.
587
741
 
588
- ### Request Errors (HTTP & Network)
742
+ ### Request Handling (v2.0 - New Pattern)
589
743
 
590
- FetchGuard uses a unified `RequestErrors` category that includes both HTTP and network errors:
744
+ In v2.0, the Worker returns ALL HTTP responses as `ok(FetchEnvelope)`. Only network failures return `err()`:
591
745
 
592
746
  ```ts
593
- const res = await api.post('/data', payload)
747
+ const result = await api.post('/data', payload)
594
748
 
595
- if (res.isOk()) {
596
- // HTTP 2xx/3xx - success
597
- console.log('Success:', res.data.body)
598
- } else {
599
- const err = res.errors?.[0]
749
+ if (result.ok) {
750
+ const envelope = result.data
600
751
 
601
- // HTTP errors (4xx/5xx) - server responded with error status
602
- if (err?.code === 'HTTP_ERROR') {
603
- const status = res.meta?.status
604
- console.log(`HTTP ${status} error`)
605
- console.log('Response body:', res.meta?.body) // Debug server error response
752
+ // Success (2xx/3xx)
753
+ if (envelope.status >= 200 && envelope.status < 400) {
754
+ console.log('Success:', JSON.parse(envelope.body))
755
+ }
756
+ // HTTP errors (4xx/5xx) - still got a response!
757
+ else {
758
+ console.log(`HTTP ${envelope.status} error`)
759
+ console.log('Response body:', envelope.body)
606
760
 
607
- // Check specific status codes if needed
608
- if (status === 404) {
761
+ // Check specific status codes
762
+ if (envelope.status === 404) {
609
763
  console.log('Resource not found')
610
- } else if (status === 401) {
764
+ } else if (envelope.status === 401) {
611
765
  console.log('Unauthorized - need to login')
766
+ } else if (envelope.status === 422) {
767
+ // Parse validation errors from server
768
+ const errors = JSON.parse(envelope.body)
769
+ console.log('Validation errors:', errors)
612
770
  }
613
771
  }
772
+ } else {
773
+ // Network errors ONLY - no HTTP response received
774
+ const err = result.errors[0]
614
775
 
615
- // Network errors - connection failed, no HTTP response
616
- else if (err?.code === 'NETWORK_ERROR') {
776
+ if (err.code === 'NETWORK_ERROR') {
617
777
  console.log('Connection failed - check internet')
618
- }
619
-
620
- // Request cancelled by user
621
- else if (err?.code === 'REQUEST_CANCELLED') {
778
+ } else if (err.code === 'REQUEST_CANCELLED') {
622
779
  console.log('Request was cancelled')
623
780
  }
624
781
  }
625
782
  ```
626
783
 
627
- **Available Request Error Codes:**
628
- - `HTTP_ERROR` - Server returned 4xx/5xx status (includes response body in `result.meta`)
784
+ **Available Error Codes (network errors only):**
629
785
  - `NETWORK_ERROR` - Connection failed, timeout, or DNS error (no response)
630
786
  - `REQUEST_CANCELLED` - Request was cancelled via `cancel()` method
631
787
  - `RESPONSE_PARSE_FAILED` - Failed to read/parse response body
632
788
 
633
- **Key Points:**
634
- - ✅ Single `RequestErrors` category - easier to remember
635
- - ✅ HTTP errors include status code via `defineErrorAdvanced` (message: "HTTP 404 error")
636
- - ✅ HTTP error response body available in `result.meta` for debugging
637
- - ✅ Network errors have no response (connection failed before server responded)
638
- - ✅ Check `result.meta?.status` for specific HTTP status codes when needed
789
+ **Key Points (v2.0):**
790
+ - ✅ Worker returns `ok(envelope)` for ALL HTTP responses (2xx-5xx)
791
+ - ✅ Worker returns `err()` ONLY for network failures
792
+ - ✅ Consumer code decides what is "success" based on `envelope.status`
793
+ - ✅ Full response body available for HTTP errors (validation errors, etc.)
794
+ - ✅ Cleaner separation: transport errors vs business errors
639
795
 
640
796
  ### Auth Errors (Login/Refresh/Logout)
641
797
 
642
- Auth errors also include HTTP status and response body for detailed debugging:
798
+ Auth errors include HTTP status and response body in `meta.params`:
643
799
 
644
800
  ```ts
645
801
  const result = await api.login({ email: 'wrong@example.com', password: 'wrong' })
646
802
 
647
- if (result.isError()) {
803
+ if (!result.ok) {
648
804
  const error = result.errors[0]
805
+ const params = result.meta?.params as { body?: string; status?: number }
649
806
 
650
- console.log('Status:', error.status) // 401
807
+ console.log('Code:', error.code) // 'LOGIN_FAILED'
651
808
  console.log('Message:', error.message) // "Login failed"
652
- console.log('Body:', error.meta?.body) // '{"error": "Invalid credentials"}'
809
+ console.log('Status:', params?.status) // 401
810
+ console.log('Body:', params?.body) // '{"error": "Invalid credentials"}'
653
811
 
654
812
  // Parse JSON error details from server
655
813
  try {
656
- const details = JSON.parse(error.meta?.body || '{}')
814
+ const details = JSON.parse(params?.body || '{}')
657
815
  console.log('Server error:', details.error) // "Invalid credentials"
658
- console.log('Details:', details.message) // Additional error message
659
816
  } catch {
660
817
  // Not JSON - use raw body
661
- console.log('Raw error:', error.meta?.body)
818
+ console.log('Raw error:', params?.body)
662
819
  }
663
820
  }
664
821
  ```
665
822
 
666
823
  **Available Auth Error Codes:**
667
- - `LOGIN_FAILED` - Login failed with HTTP status and response body in `error.meta.body`
824
+ - `LOGIN_FAILED` - Login failed with HTTP status and response body in `meta.params`
668
825
  - `TOKEN_REFRESH_FAILED` - Token refresh failed with HTTP status and response body
669
826
  - `LOGOUT_FAILED` - Logout failed with HTTP status and response body
670
827
  - `NOT_AUTHENTICATED` - User is not authenticated (attempted auth-required request without token)
671
828
 
672
- **Key Points:**
673
- - ✅ Auth errors include HTTP status code in `error.status`
674
- - ✅ Response body available in `error.meta.body` (raw text/JSON string)
829
+ **Key Points (v2.0):**
830
+ - ✅ Auth errors use `meta.params` for custom data (ts-micro-result v3 pattern)
831
+ - ✅ Response body available in `result.meta?.params?.body`
832
+ - ✅ HTTP status available in `result.meta?.params?.status`
675
833
  - ✅ Parse JSON yourself if server returns structured error messages
676
- - ✅ Consistent pattern with fetch error handling
677
834
 
678
835
  ## Auth Methods and Events
679
836
 
@@ -700,7 +857,7 @@ await api.login(credentials) // Same as above
700
857
 
701
858
  // Silent (no event - useful for checks)
702
859
  const result = await api.refreshToken(false)
703
- if (result.isOk()) {
860
+ if (result.ok) {
704
861
  const { authenticated, expiresAt } = result.data
705
862
  // Check state without triggering listeners
706
863
  }
@@ -735,9 +892,22 @@ FormData serialization is tested in [tests/browser/formdata-serialization.test.t
735
892
 
736
893
  - SSE streaming support
737
894
  - Upload progress tracking
738
- - Request/response interceptors
739
- - Advanced retries (exponential backoff)
740
- - Offline queueing
895
+ - Offline queueing (with careful auth handling)
896
+
897
+ ## Claude Code Skill
898
+
899
+ FetchGuard includes a [Claude Code](https://claude.ai/code) skill for AI-assisted development. To use it:
900
+
901
+ 1. Copy the skill folder to your global Claude settings:
902
+ ```bash
903
+ # macOS/Linux
904
+ cp -r node_modules/fetchguard/skills/fetchguard ~/.claude/skills/
905
+
906
+ # Windows
907
+ xcopy /E /I node_modules\fetchguard\skills\fetchguard %USERPROFILE%\.claude\skills\fetchguard
908
+ ```
909
+
910
+ 2. Use `/fetchguard` in Claude Code when working with FetchGuard in any project.
741
911
 
742
912
  ## License
743
913