fetchguard 1.6.3 → 2.1.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
@@ -183,6 +273,36 @@ await api.logout()
183
273
  unsubscribe()
184
274
  ```
185
275
 
276
+ ### Token Exchange (Tenant Switch, Scope Change)
277
+
278
+ Exchange your current token for a new one with different context:
279
+
280
+ ```ts
281
+ // Switch tenant
282
+ const result = await api.exchangeToken('https://auth.example.com/auth/select-tenant', {
283
+ payload: { tenantId: 'tenant_123' }
284
+ })
285
+
286
+ // Change scope with PUT method
287
+ const result = await api.exchangeToken('https://auth.example.com/auth/switch-context', {
288
+ method: 'PUT',
289
+ payload: { scope: 'admin' }
290
+ })
291
+
292
+ if (result.ok) {
293
+ const { authenticated, user, expiresAt } = result.data
294
+ console.log('New context:', user)
295
+ } else {
296
+ // TOKEN_EXCHANGE_FAILED or NOT_AUTHENTICATED
297
+ console.error('Exchange failed:', result.errors[0].message)
298
+ }
299
+ ```
300
+
301
+ Use cases:
302
+ - Multi-tenant apps: switch between tenants without re-login
303
+ - Role/scope changes: elevate or reduce permissions
304
+ - User impersonation: admin acting as another user
305
+
186
306
  ### Multiple Login URLs
187
307
 
188
308
  FetchGuard supports dynamic login URL selection for different auth methods (OAuth, phone, etc.):
@@ -217,8 +337,8 @@ await api.get('/public/config', { requiresAuth: false })
217
337
 
218
338
  // Include response headers in the result
219
339
  const r = await api.get('/profile', { includeHeaders: true })
220
- if (r.isOk()) {
221
- console.log(r.status, r.headers, r.data)
340
+ if (r.ok) {
341
+ console.log(r.data.status, r.data.headers, r.data.body)
222
342
  }
223
343
  ```
224
344
 
@@ -249,8 +369,8 @@ const result = await api.fetch('https://api.example.com/upload', {
249
369
  await api.put('https://api.example.com/upload/123', formData)
250
370
  await api.patch('https://api.example.com/upload/123', formData)
251
371
 
252
- if (result.isOk()) {
253
- console.log('Upload successful:', result.data)
372
+ if (result.ok && result.data.status < 400) {
373
+ console.log('Upload successful:', result.data.body)
254
374
  }
255
375
  ```
256
376
 
@@ -531,15 +651,16 @@ The library targets modern browsers with Web Worker and (optionally) IndexedDB s
531
651
  - `onReady(callback)`: `() => void` - Subscribe to ready event (returns unsubscribe function)
532
652
 
533
653
  **HTTP Methods:**
534
- - `fetch(url, options?)`: `Promise<Result<ApiResponse>>`
535
- - `get/post/put/patch/delete(...)`: `Promise<Result<ApiResponse>>`
654
+ - `fetch(url, options?)`: `Promise<Result<FetchEnvelope>>`
655
+ - `get/post/put/patch/delete(...)`: `Promise<Result<FetchEnvelope>>`
536
656
  - `fetchWithId(url, options?)`: `{ id, result, cancel }`
537
657
  - `cancel(id)`: Cancel pending request
538
658
 
539
659
  **Authentication:**
540
- - `login(payload?, emitEvent?)`: `Promise<Result<AuthResult>>` - Login with optional event emission (default: true)
660
+ - `login(payload?, url?, emitEvent?)`: `Promise<Result<AuthResult>>` - Login with optional URL override and event emission (default: true)
541
661
  - `logout(payload?, emitEvent?)`: `Promise<Result<AuthResult>>` - Logout with optional event emission (default: true)
542
662
  - `refreshToken(emitEvent?)`: `Promise<Result<AuthResult>>` - Refresh access token with optional event emission (default: true)
663
+ - `exchangeToken(url, options?, emitEvent?)`: `Promise<Result<AuthResult>>` - Exchange current token for new one (tenant switch, scope change)
543
664
  - `call(method, emitEvent?, ...args)`: `Promise<Result<AuthResult>>` - Call custom provider methods with event emission
544
665
 
545
666
  **Events:**
@@ -551,15 +672,72 @@ The library targets modern browsers with Web Worker and (optionally) IndexedDB s
551
672
 
552
673
  Types:
553
674
 
554
- - ApiResponse = { body: string; status: number; contentType: string; headers: Record<string, string> }
675
+ - FetchEnvelope = { status: number; body: string; contentType: string; headers: Record<string, string> }
676
+ - `status`: HTTP status code (2xx, 3xx, 4xx, 5xx) - Worker returns ALL HTTP responses
555
677
  - `body`: Raw string (text/JSON) or base64 (for binary content like images, PDFs)
556
678
  - `contentType`: Content type header (always present, e.g., 'application/json', 'image/png')
557
679
  - Use `isBinaryContentType(contentType)` to detect binary responses
558
680
  - Use `base64ToArrayBuffer(body)` to decode binary data
681
+ - **Note:** Worker no longer judges HTTP status. Consumer code should check `envelope.status` to determine success/error.
559
682
  - AuthResult = { authenticated: boolean; user?: unknown; expiresAt?: number | null }
560
683
  - FetchGuardRequestInit extends RequestInit with:
561
684
  - requiresAuth?: boolean // default true
562
685
  - includeHeaders?: boolean // default false
686
+ - signal?: AbortSignal // for cancellation
687
+
688
+ ## Helper Functions
689
+
690
+ FetchGuard exports helper functions for common Result patterns:
691
+
692
+ ```ts
693
+ import {
694
+ isSuccess,
695
+ isClientError,
696
+ isServerError,
697
+ isNetworkError,
698
+ parseJson,
699
+ getErrorMessage,
700
+ matchResult,
701
+ ERROR_CODES
702
+ } from 'fetchguard'
703
+
704
+ const result = await api.get('/users')
705
+
706
+ // Simple checks
707
+ if (isSuccess(result)) {
708
+ const users = parseJson<User[]>(result)
709
+ }
710
+
711
+ // Pattern matching
712
+ const message = matchResult(result, {
713
+ success: (envelope) => `Got ${parseJson(result)?.length} users`,
714
+ clientError: (envelope) => `Client error: ${envelope.status}`,
715
+ serverError: (envelope) => `Server error: ${envelope.status}`,
716
+ networkError: (errors) => `Network failed: ${errors[0]?.message}`
717
+ })
718
+
719
+ // Type-safe error code matching
720
+ if (!result.ok && result.errors[0]?.code === ERROR_CODES.NETWORK_ERROR) {
721
+ console.log('Connection failed')
722
+ }
723
+ ```
724
+
725
+ **Available helpers:**
726
+ - `isSuccess(result)` - Check if 2xx response
727
+ - `isClientError(result)` - Check if 4xx response
728
+ - `isServerError(result)` - Check if 5xx response
729
+ - `isNetworkError(result)` - Check if network error (no response)
730
+ - `parseJson<T>(result)` - Safe JSON parsing with type inference
731
+ - `getErrorMessage(result)` - Extract error message
732
+ - `getErrorBody<T>(result)` - Get typed error body from HTTP errors
733
+ - `getStatus(result)` / `hasStatus(result, code)` - Status code helpers
734
+ - `matchResult(result, handlers)` - Pattern matching
735
+
736
+ **Error codes** (`ERROR_CODES`):
737
+ - `NETWORK_ERROR`, `REQUEST_CANCELLED`, `REQUEST_TIMEOUT`
738
+ - `HTTP_ERROR`, `RESPONSE_PARSE_FAILED`, `QUEUE_FULL`
739
+ - `LOGIN_FAILED`, `LOGOUT_FAILED`, `TOKEN_REFRESH_FAILED`, `NOT_AUTHENTICATED`
740
+ - `DOMAIN_NOT_ALLOWED`, `INIT_ERROR`, `UNEXPECTED`
563
741
 
564
742
  ## Message Protocol (pairs, summary)
565
743
 
@@ -571,109 +749,120 @@ Types:
571
749
 
572
750
  ## Error Handling
573
751
 
574
- All methods return a `Result<T>` from `ts-micro-result`.
752
+ All methods return a `Result<T>` from `ts-micro-result` v3.
575
753
 
576
754
  ```ts
577
- const res = await api.get('/users')
578
- if (res.isOk()) {
579
- console.log(res.data)
755
+ const result = await api.get('/users')
756
+ if (result.ok) {
757
+ const envelope = result.data
758
+ // Check HTTP status - worker doesn't judge
759
+ if (envelope.status >= 200 && envelope.status < 400) {
760
+ console.log('Success:', JSON.parse(envelope.body))
761
+ } else {
762
+ console.log('HTTP error:', envelope.status, envelope.body)
763
+ }
580
764
  } else {
581
- const err = res.errors?.[0]
582
- console.warn(err?.code, err?.message)
765
+ // Network error only (connection failed, timeout, cancelled)
766
+ const err = result.errors[0]
767
+ console.warn(err.code, err.message)
583
768
  }
584
769
  ```
585
770
 
586
771
  Grouped error helpers are exported: `GeneralErrors`, `InitErrors`, `AuthErrors`, `DomainErrors`, `RequestErrors`.
587
772
 
588
- ### Request Errors (HTTP & Network)
773
+ ### Request Handling (v2.0 - New Pattern)
589
774
 
590
- FetchGuard uses a unified `RequestErrors` category that includes both HTTP and network errors:
775
+ In v2.0, the Worker returns ALL HTTP responses as `ok(FetchEnvelope)`. Only network failures return `err()`:
591
776
 
592
777
  ```ts
593
- const res = await api.post('/data', payload)
778
+ const result = await api.post('/data', payload)
594
779
 
595
- if (res.isOk()) {
596
- // HTTP 2xx/3xx - success
597
- console.log('Success:', res.data.body)
598
- } else {
599
- const err = res.errors?.[0]
780
+ if (result.ok) {
781
+ const envelope = result.data
600
782
 
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
783
+ // Success (2xx/3xx)
784
+ if (envelope.status >= 200 && envelope.status < 400) {
785
+ console.log('Success:', JSON.parse(envelope.body))
786
+ }
787
+ // HTTP errors (4xx/5xx) - still got a response!
788
+ else {
789
+ console.log(`HTTP ${envelope.status} error`)
790
+ console.log('Response body:', envelope.body)
606
791
 
607
- // Check specific status codes if needed
608
- if (status === 404) {
792
+ // Check specific status codes
793
+ if (envelope.status === 404) {
609
794
  console.log('Resource not found')
610
- } else if (status === 401) {
795
+ } else if (envelope.status === 401) {
611
796
  console.log('Unauthorized - need to login')
797
+ } else if (envelope.status === 422) {
798
+ // Parse validation errors from server
799
+ const errors = JSON.parse(envelope.body)
800
+ console.log('Validation errors:', errors)
612
801
  }
613
802
  }
803
+ } else {
804
+ // Network errors ONLY - no HTTP response received
805
+ const err = result.errors[0]
614
806
 
615
- // Network errors - connection failed, no HTTP response
616
- else if (err?.code === 'NETWORK_ERROR') {
807
+ if (err.code === 'NETWORK_ERROR') {
617
808
  console.log('Connection failed - check internet')
618
- }
619
-
620
- // Request cancelled by user
621
- else if (err?.code === 'REQUEST_CANCELLED') {
809
+ } else if (err.code === 'REQUEST_CANCELLED') {
622
810
  console.log('Request was cancelled')
623
811
  }
624
812
  }
625
813
  ```
626
814
 
627
- **Available Request Error Codes:**
628
- - `HTTP_ERROR` - Server returned 4xx/5xx status (includes response body in `result.meta`)
815
+ **Available Error Codes (network errors only):**
629
816
  - `NETWORK_ERROR` - Connection failed, timeout, or DNS error (no response)
630
817
  - `REQUEST_CANCELLED` - Request was cancelled via `cancel()` method
631
818
  - `RESPONSE_PARSE_FAILED` - Failed to read/parse response body
632
819
 
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
820
+ **Key Points (v2.0):**
821
+ - ✅ Worker returns `ok(envelope)` for ALL HTTP responses (2xx-5xx)
822
+ - ✅ Worker returns `err()` ONLY for network failures
823
+ - ✅ Consumer code decides what is "success" based on `envelope.status`
824
+ - ✅ Full response body available for HTTP errors (validation errors, etc.)
825
+ - ✅ Cleaner separation: transport errors vs business errors
639
826
 
640
827
  ### Auth Errors (Login/Refresh/Logout)
641
828
 
642
- Auth errors also include HTTP status and response body for detailed debugging:
829
+ Auth errors include HTTP status and response body in `meta.params`:
643
830
 
644
831
  ```ts
645
832
  const result = await api.login({ email: 'wrong@example.com', password: 'wrong' })
646
833
 
647
- if (result.isError()) {
834
+ if (!result.ok) {
648
835
  const error = result.errors[0]
836
+ const params = result.meta?.params as { body?: string; status?: number }
649
837
 
650
- console.log('Status:', error.status) // 401
838
+ console.log('Code:', error.code) // 'LOGIN_FAILED'
651
839
  console.log('Message:', error.message) // "Login failed"
652
- console.log('Body:', error.meta?.body) // '{"error": "Invalid credentials"}'
840
+ console.log('Status:', params?.status) // 401
841
+ console.log('Body:', params?.body) // '{"error": "Invalid credentials"}'
653
842
 
654
843
  // Parse JSON error details from server
655
844
  try {
656
- const details = JSON.parse(error.meta?.body || '{}')
845
+ const details = JSON.parse(params?.body || '{}')
657
846
  console.log('Server error:', details.error) // "Invalid credentials"
658
- console.log('Details:', details.message) // Additional error message
659
847
  } catch {
660
848
  // Not JSON - use raw body
661
- console.log('Raw error:', error.meta?.body)
849
+ console.log('Raw error:', params?.body)
662
850
  }
663
851
  }
664
852
  ```
665
853
 
666
854
  **Available Auth Error Codes:**
667
- - `LOGIN_FAILED` - Login failed with HTTP status and response body in `error.meta.body`
855
+ - `LOGIN_FAILED` - Login failed with HTTP status and response body in `meta.params`
668
856
  - `TOKEN_REFRESH_FAILED` - Token refresh failed with HTTP status and response body
857
+ - `TOKEN_EXCHANGE_FAILED` - Token exchange failed with HTTP status and response body
669
858
  - `LOGOUT_FAILED` - Logout failed with HTTP status and response body
670
859
  - `NOT_AUTHENTICATED` - User is not authenticated (attempted auth-required request without token)
671
860
 
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)
861
+ **Key Points (v2.0):**
862
+ - ✅ Auth errors use `meta.params` for custom data (ts-micro-result v3 pattern)
863
+ - ✅ Response body available in `result.meta?.params?.body`
864
+ - ✅ HTTP status available in `result.meta?.params?.status`
675
865
  - ✅ Parse JSON yourself if server returns structured error messages
676
- - ✅ Consistent pattern with fetch error handling
677
866
 
678
867
  ## Auth Methods and Events
679
868
 
@@ -700,7 +889,7 @@ await api.login(credentials) // Same as above
700
889
 
701
890
  // Silent (no event - useful for checks)
702
891
  const result = await api.refreshToken(false)
703
- if (result.isOk()) {
892
+ if (result.ok) {
704
893
  const { authenticated, expiresAt } = result.data
705
894
  // Check state without triggering listeners
706
895
  }
@@ -735,9 +924,22 @@ FormData serialization is tested in [tests/browser/formdata-serialization.test.t
735
924
 
736
925
  - SSE streaming support
737
926
  - Upload progress tracking
738
- - Request/response interceptors
739
- - Advanced retries (exponential backoff)
740
- - Offline queueing
927
+ - Offline queueing (with careful auth handling)
928
+
929
+ ## Claude Code Skill
930
+
931
+ FetchGuard includes a [Claude Code](https://claude.ai/code) skill for AI-assisted development. To use it:
932
+
933
+ 1. Copy the skill folder to your global Claude settings:
934
+ ```bash
935
+ # macOS/Linux
936
+ cp -r node_modules/fetchguard/skills/fetchguard ~/.claude/skills/
937
+
938
+ # Windows
939
+ xcopy /E /I node_modules\fetchguard\skills\fetchguard %USERPROFILE%\.claude\skills\fetchguard
940
+ ```
941
+
942
+ 2. Use `/fetchguard` in Claude Code when working with FetchGuard in any project.
741
943
 
742
944
  ## License
743
945