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 +268 -66
- package/dist/index.d.ts +594 -31
- package/dist/index.js +544 -86
- package/dist/index.js.map +1 -1
- package/dist/worker.js +177 -74
- package/dist/worker.js.map +1 -1
- package/package.json +8 -3
package/README.md
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
# FetchGuard
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/fetchguard)
|
|
4
|
-

|
|
5
4
|
[](https://www.npmjs.com/package/fetchguard)
|
|
6
5
|
[](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
|
|
131
|
-
|
|
132
|
-
if (
|
|
133
|
-
|
|
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('
|
|
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.
|
|
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.
|
|
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<
|
|
535
|
-
- `get/post/put/patch/delete(...)`: `Promise<Result<
|
|
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
|
-
-
|
|
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
|
|
578
|
-
if (
|
|
579
|
-
|
|
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
|
-
|
|
582
|
-
|
|
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
|
|
773
|
+
### Request Handling (v2.0 - New Pattern)
|
|
589
774
|
|
|
590
|
-
|
|
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
|
|
778
|
+
const result = await api.post('/data', payload)
|
|
594
779
|
|
|
595
|
-
if (
|
|
596
|
-
|
|
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
|
-
//
|
|
602
|
-
if (
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
- ✅
|
|
635
|
-
- ✅
|
|
636
|
-
- ✅
|
|
637
|
-
- ✅
|
|
638
|
-
- ✅
|
|
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
|
|
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.
|
|
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('
|
|
838
|
+
console.log('Code:', error.code) // 'LOGIN_FAILED'
|
|
651
839
|
console.log('Message:', error.message) // "Login failed"
|
|
652
|
-
console.log('
|
|
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(
|
|
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:',
|
|
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 `
|
|
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
|
|
674
|
-
- ✅ Response body available in `
|
|
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.
|
|
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
|
-
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
|