@jukasdrj/bookstrack-api-client 1.0.0 → 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.
@@ -0,0 +1,508 @@
1
+ # BooksTrack Streaming Guide: SSE vs WebSocket
2
+
3
+ **Last Updated:** December 3, 2025
4
+ **API Version:** V2 (Stable) + V3 (Current)
5
+
6
+ This guide explains when to use Server-Sent Events (SSE) vs WebSockets for real-time progress tracking in BooksTrack.
7
+
8
+ ---
9
+
10
+ ## TL;DR - Which Protocol Should I Use?
11
+
12
+ | **Use Case** | **Protocol** | **Endpoint** | **Why?** |
13
+ |--------------|--------------|--------------|----------|
14
+ | **CSV Import** | SSE | `GET /api/v2/imports/:id/stream` | One-way progress, browser-native reconnect |
15
+ | **Batch Enrichment** | WebSocket | `GET /ws/progress?jobId=xxx` | Bidirectional, cancel support, ready acks |
16
+ | **Bookshelf Scanning** | WebSocket | `GET /ws/progress?jobId=xxx` | Bidirectional, cancel support, real-time feedback |
17
+
18
+ ---
19
+
20
+ ## Architecture Overview
21
+
22
+ BooksTrack uses a **hybrid streaming architecture**:
23
+
24
+ - **SSE** for long-running import jobs (CSV parsing, Gemini processing)
25
+ - **WebSocket** for interactive batch operations (enrichment, scanning)
26
+
27
+ Both protocols coexist to provide optimal UX for different workflows.
28
+
29
+ ---
30
+
31
+ ## Server-Sent Events (SSE)
32
+
33
+ ### When to Use
34
+
35
+ ✅ **CSV Import Progress** - One-way updates from server to client
36
+ ✅ **Long-running jobs** - Import processing can take 30+ seconds
37
+ ✅ **Firewall-friendly environments** - SSE uses HTTP/1.1
38
+ ✅ **Browser-native reconnection** - Automatic retry with `Last-Event-ID`
39
+
40
+ ### Endpoints
41
+
42
+ ```
43
+ POST /api/v2/imports → Returns { jobId, authToken, sseUrl }
44
+ GET /api/v2/imports/:id/stream → SSE stream
45
+ ```
46
+
47
+ ### Client Example (EventSource API)
48
+
49
+ ```typescript
50
+ import { createBooksTrackClient } from '@bookstrack/api-client'
51
+
52
+ const client = createBooksTrackClient({ baseUrl: 'https://api.oooefam.net' })
53
+
54
+ // 1. Upload CSV file
55
+ const formData = new FormData()
56
+ formData.append('file', csvFile)
57
+
58
+ const { data } = await client.POST('/api/v2/imports', {
59
+ body: formData
60
+ })
61
+
62
+ const { jobId, sseUrl } = data.data
63
+
64
+ // 2. Connect to SSE stream
65
+ const eventSource = new EventSource(sseUrl)
66
+
67
+ // Listen for progress events
68
+ eventSource.addEventListener('progress', (e) => {
69
+ const update = JSON.parse(e.data)
70
+ console.log(`Progress: ${update.progress * 100}%`)
71
+ console.log(`Status: ${update.status}`)
72
+ })
73
+
74
+ // Listen for completion
75
+ eventSource.addEventListener('completed', (e) => {
76
+ const result = JSON.parse(e.data)
77
+ console.log(`✅ Import complete: ${result.processedCount} books`)
78
+ console.log('Books:', result.books)
79
+ eventSource.close()
80
+ })
81
+
82
+ // Listen for errors
83
+ eventSource.addEventListener('failed', (e) => {
84
+ const error = JSON.parse(e.data)
85
+ console.error('❌ Import failed:', error.error.message)
86
+ eventSource.close()
87
+ })
88
+
89
+ // Handle connection errors
90
+ eventSource.onerror = (e) => {
91
+ console.error('SSE connection error:', e)
92
+ // EventSource will automatically reconnect with Last-Event-ID
93
+ }
94
+ ```
95
+
96
+ ### SSE Event Types
97
+
98
+ | Event | Description | When Sent |
99
+ |-------|-------------|-----------|
100
+ | `progress` | Import progress update | Every book processed |
101
+ | `completed` | Import finished successfully | Job complete |
102
+ | `failed` | Import failed with error | Job failed |
103
+ | `timeout` | Stream timeout (5 min no updates) | Connection idle |
104
+
105
+ ### SSE Payload Structure
106
+
107
+ **Progress Event:**
108
+ ```json
109
+ {
110
+ "jobId": "uuid",
111
+ "status": "processing",
112
+ "progress": 0.65,
113
+ "processedCount": 32,
114
+ "totalCount": 50,
115
+ "startedAt": "2025-12-03T12:00:00Z"
116
+ }
117
+ ```
118
+
119
+ **Completed Event:**
120
+ ```json
121
+ {
122
+ "jobId": "uuid",
123
+ "status": "completed",
124
+ "progress": 1.0,
125
+ "processedCount": 50,
126
+ "totalCount": 50,
127
+ "completedAt": "2025-12-03T12:05:30Z",
128
+ "books": [
129
+ {
130
+ "isbn": "9780439708180",
131
+ "title": "Harry Potter",
132
+ "authors": ["J.K. Rowling"],
133
+ "language": "en"
134
+ }
135
+ ]
136
+ }
137
+ ```
138
+
139
+ ### SSE Features
140
+
141
+ - ✅ **Automatic reconnection** - Browser retries with `Last-Event-ID` header
142
+ - ✅ **Heartbeat** - Server sends `: heartbeat\n\n` every 30 seconds
143
+ - ✅ **Adaptive polling** - Backend uses 500ms → 3s backoff to reduce DO load
144
+ - ✅ **Books array on completion** - iOS can persist immediately without separate fetch
145
+
146
+ ---
147
+
148
+ ## WebSocket
149
+
150
+ ### When to Use
151
+
152
+ ✅ **Batch Enrichment** - Bidirectional with cancel support
153
+ ✅ **Bookshelf Scanning** - Real-time feedback during AI processing
154
+ ✅ **Ready acknowledgments** - Client signals readiness to receive updates
155
+ ✅ **Job cancellation** - Client can cancel mid-stream
156
+
157
+ ### Endpoints
158
+
159
+ ```
160
+ POST /v1/enrichment/batch → Returns { jobId, authToken, websocketUrl }
161
+ POST /api/batch-enrich → iOS compatibility alias
162
+ GET /ws/progress?jobId=xxx → WebSocket upgrade
163
+ ```
164
+
165
+ ### Client Example (WebSocket API)
166
+
167
+ ```typescript
168
+ import { createBooksTrackClient } from '@bookstrack/api-client'
169
+
170
+ const client = createBooksTrackClient({ baseUrl: 'https://api.oooefam.net' })
171
+
172
+ // 1. Start batch enrichment
173
+ const { data } = await client.POST('/v1/enrichment/batch', {
174
+ body: {
175
+ books: [
176
+ { title: 'Harry Potter', author: 'J.K. Rowling' },
177
+ { isbn: '9780439708180' }
178
+ ],
179
+ jobId: crypto.randomUUID()
180
+ }
181
+ })
182
+
183
+ const { jobId, authToken, websocketUrl } = data.data
184
+
185
+ // 2. Connect to WebSocket
186
+ const ws = new WebSocket(websocketUrl)
187
+
188
+ // Send auth token via Sec-WebSocket-Protocol header (RECOMMENDED)
189
+ // OR include in URL query params (deprecated, leaks in logs)
190
+ const wsSecure = new WebSocket(`wss://api.oooefam.net/ws/progress?jobId=${jobId}`, [
191
+ authToken
192
+ ])
193
+
194
+ ws.onopen = () => {
195
+ console.log('WebSocket connected')
196
+
197
+ // Send ready acknowledgment
198
+ ws.send(JSON.stringify({ type: 'ready', jobId }))
199
+ }
200
+
201
+ ws.onmessage = (event) => {
202
+ const message = JSON.parse(event.data)
203
+
204
+ switch (message.type) {
205
+ case 'ready_ack':
206
+ console.log('Server acknowledged ready signal')
207
+ break
208
+
209
+ case 'progress':
210
+ console.log(`Progress: ${message.progress * 100}%`)
211
+ console.log(`Book: ${message.currentBook.title}`)
212
+ break
213
+
214
+ case 'book_enriched':
215
+ console.log('Book enriched:', message.book)
216
+ break
217
+
218
+ case 'job_complete':
219
+ console.log('✅ All books enriched')
220
+ ws.close()
221
+ break
222
+
223
+ case 'error':
224
+ console.error('❌ Error:', message.error)
225
+ break
226
+ }
227
+ }
228
+
229
+ ws.onerror = (error) => {
230
+ console.error('WebSocket error:', error)
231
+ }
232
+
233
+ ws.onclose = (event) => {
234
+ if (event.wasClean) {
235
+ console.log('WebSocket closed cleanly')
236
+ } else {
237
+ console.error('WebSocket connection lost')
238
+ }
239
+ }
240
+
241
+ // Cancel job mid-stream
242
+ function cancelJob() {
243
+ ws.send(JSON.stringify({ type: 'cancel', jobId }))
244
+ }
245
+ ```
246
+
247
+ ### WebSocket Message Types
248
+
249
+ **Client → Server:**
250
+ | Message Type | Description | When to Send |
251
+ |--------------|-------------|--------------|
252
+ | `ready` | Signal client is ready for updates | On connection |
253
+ | `cancel` | Cancel the job | User cancels |
254
+
255
+ **Server → Client:**
256
+ | Message Type | Description | When Sent |
257
+ |--------------|-------------|-----------|
258
+ | `ready_ack` | Acknowledge ready signal | After client sends `ready` |
259
+ | `progress` | Job progress update | Every book enriched |
260
+ | `book_enriched` | Single book enriched | Per book completion |
261
+ | `job_complete` | Job finished | All books processed |
262
+ | `error` | Error occurred | Job failed |
263
+
264
+ ### WebSocket Authentication
265
+
266
+ **RECOMMENDED: Sec-WebSocket-Protocol Header**
267
+ ```typescript
268
+ const ws = new WebSocket(
269
+ 'wss://api.oooefam.net/ws/progress?jobId=xxx',
270
+ [authToken] // Passed as subprotocol
271
+ )
272
+ ```
273
+
274
+ **DEPRECATED: URL Query Parameter** ⚠️
275
+ ```typescript
276
+ // ⚠️ Leaks token in browser history, logs, and network traffic
277
+ const ws = new WebSocket(
278
+ `wss://api.oooefam.net/ws/progress?jobId=xxx&token=${authToken}`
279
+ )
280
+ ```
281
+
282
+ **Backward Compatible:** Backend supports both methods until March 1, 2026.
283
+
284
+ ---
285
+
286
+ ## Comparison Matrix
287
+
288
+ | Feature | SSE | WebSocket |
289
+ |---------|-----|-----------|
290
+ | **Direction** | Server → Client (one-way) | Bidirectional |
291
+ | **Reconnection** | Browser-native with `Last-Event-ID` | Manual retry logic required |
292
+ | **Protocol** | HTTP/1.1 (firewall-friendly) | WebSocket (upgrade from HTTP) |
293
+ | **Cancel Support** | ❌ Requires separate HTTP DELETE | ✅ Send `cancel` message |
294
+ | **Ready Ack** | ❌ Not needed (one-way stream) | ✅ Client signals readiness |
295
+ | **Use Case** | CSV imports, long jobs | Batch enrichment, scanning |
296
+ | **Complexity** | Low (native `EventSource`) | Medium (manual reconnect) |
297
+ | **Browser Support** | 97%+ (all modern browsers) | 98%+ (all modern browsers) |
298
+
299
+ ---
300
+
301
+ ## Fallback: HTTP Polling
302
+
303
+ If neither SSE nor WebSocket is available (corporate firewalls, legacy browsers), use HTTP polling:
304
+
305
+ ```typescript
306
+ const client = createBooksTrackClient({ baseUrl: 'https://api.oooefam.net' })
307
+
308
+ async function pollJobStatus(jobId: string) {
309
+ const interval = setInterval(async () => {
310
+ const { data } = await client.GET('/api/v2/imports/{jobId}', {
311
+ params: { path: { jobId } }
312
+ })
313
+
314
+ const job = data.data
315
+ console.log(`Progress: ${job.progress * 100}%`)
316
+
317
+ if (job.status === 'completed' || job.status === 'failed') {
318
+ clearInterval(interval)
319
+ console.log('Job finished:', job.status)
320
+ }
321
+ }, 2000) // Poll every 2 seconds (rate-limited to 30 req/min)
322
+ }
323
+ ```
324
+
325
+ **Rate Limits:**
326
+ - CSV Import Status: 30 requests/minute
327
+ - Enrichment Status: 30 requests/minute
328
+
329
+ ---
330
+
331
+ ## Best Practices
332
+
333
+ ### SSE
334
+
335
+ 1. **Always handle reconnection** - `EventSource` auto-reconnects, but handle `onerror`
336
+ 2. **Close on completion** - Call `eventSource.close()` when job completes
337
+ 3. **Include Last-Event-ID** - Browser sends automatically for resume support
338
+ 4. **Handle timeout events** - Stream closes after 5 min of inactivity
339
+
340
+ ### WebSocket
341
+
342
+ 1. **Send ready acknowledgment** - Signal readiness after connection opens
343
+ 2. **Implement reconnection** - WebSocket doesn't auto-reconnect like SSE
344
+ 3. **Use secure protocol** - `wss://` in production, `ws://` for local dev
345
+ 4. **Handle cleanup** - Close connection in `useEffect` cleanup (React)
346
+ 5. **Prefer Sec-WebSocket-Protocol** - Don't leak tokens in URL query params
347
+
348
+ ### Both
349
+
350
+ 1. **Show progress UI** - Update progress bar on every `progress` event
351
+ 2. **Handle network failures** - Display "reconnecting..." message
352
+ 3. **Timeout gracefully** - Fall back to polling if stream fails
353
+
354
+ ---
355
+
356
+ ## React Hooks Example
357
+
358
+ ### SSE Hook
359
+
360
+ ```typescript
361
+ import { useEffect, useState } from 'react'
362
+
363
+ export function useSSEStream(sseUrl: string | null) {
364
+ const [progress, setProgress] = useState(0)
365
+ const [status, setStatus] = useState<string>('idle')
366
+ const [result, setResult] = useState<any>(null)
367
+ const [error, setError] = useState<Error | null>(null)
368
+
369
+ useEffect(() => {
370
+ if (!sseUrl) return
371
+
372
+ const eventSource = new EventSource(sseUrl)
373
+
374
+ eventSource.addEventListener('progress', (e) => {
375
+ const data = JSON.parse(e.data)
376
+ setProgress(data.progress)
377
+ setStatus(data.status)
378
+ })
379
+
380
+ eventSource.addEventListener('completed', (e) => {
381
+ const data = JSON.parse(e.data)
382
+ setResult(data)
383
+ setStatus('completed')
384
+ eventSource.close()
385
+ })
386
+
387
+ eventSource.addEventListener('failed', (e) => {
388
+ const data = JSON.parse(e.data)
389
+ setError(new Error(data.error.message))
390
+ setStatus('failed')
391
+ eventSource.close()
392
+ })
393
+
394
+ return () => eventSource.close()
395
+ }, [sseUrl])
396
+
397
+ return { progress, status, result, error }
398
+ }
399
+ ```
400
+
401
+ ### WebSocket Hook
402
+
403
+ ```typescript
404
+ import { useEffect, useState, useRef } from 'react'
405
+
406
+ export function useWebSocket(url: string | null, authToken: string | null) {
407
+ const [progress, setProgress] = useState(0)
408
+ const [connected, setConnected] = useState(false)
409
+ const [result, setResult] = useState<any>(null)
410
+ const wsRef = useRef<WebSocket | null>(null)
411
+
412
+ useEffect(() => {
413
+ if (!url || !authToken) return
414
+
415
+ const ws = new WebSocket(url, [authToken])
416
+ wsRef.current = ws
417
+
418
+ ws.onopen = () => {
419
+ setConnected(true)
420
+ ws.send(JSON.stringify({ type: 'ready' }))
421
+ }
422
+
423
+ ws.onmessage = (event) => {
424
+ const message = JSON.parse(event.data)
425
+
426
+ if (message.type === 'progress') {
427
+ setProgress(message.progress)
428
+ } else if (message.type === 'job_complete') {
429
+ setResult(message)
430
+ ws.close()
431
+ }
432
+ }
433
+
434
+ ws.onclose = () => setConnected(false)
435
+
436
+ return () => ws.close()
437
+ }, [url, authToken])
438
+
439
+ const cancel = () => {
440
+ wsRef.current?.send(JSON.stringify({ type: 'cancel' }))
441
+ }
442
+
443
+ return { progress, connected, result, cancel }
444
+ }
445
+ ```
446
+
447
+ ---
448
+
449
+ ## Troubleshooting
450
+
451
+ ### SSE Issues
452
+
453
+ **EventSource fails to connect:**
454
+ - Check CORS headers (`Access-Control-Allow-Origin`)
455
+ - Verify `Accept: text/event-stream` header
456
+ - Check firewall rules (port 443 for HTTPS)
457
+
458
+ **No events received:**
459
+ - Check backend heartbeat (should see `: heartbeat\n\n` every 30s)
460
+ - Verify job is actually processing (check polling endpoint)
461
+ - Check browser console for SSE errors
462
+
463
+ **Reconnection loop:**
464
+ - Backend may be rejecting `Last-Event-ID`
465
+ - Check if job already completed (SSE closes on completion)
466
+
467
+ ### WebSocket Issues
468
+
469
+ **Connection refused:**
470
+ - Verify WebSocket URL uses `wss://` (secure) in production
471
+ - Check auth token is valid (generated within last 5 minutes)
472
+ - Ensure `jobId` exists (job was initialized)
473
+
474
+ **No messages received:**
475
+ - Send `ready` message after connection opens
476
+ - Check for `ready_ack` response
477
+ - Verify job is processing (not already complete)
478
+
479
+ **Disconnects immediately:**
480
+ - Auth token invalid or expired
481
+ - Job already completed/failed
482
+ - Backend restarted (connection lost)
483
+
484
+ ---
485
+
486
+ ## Migration Notes
487
+
488
+ **From Legacy `/search/*` API:**
489
+ - Old API had no streaming support
490
+ - Use new V2 endpoints with SSE/WebSocket
491
+
492
+ **From V1 to V2:**
493
+ - V1 jobs used WebSocket only
494
+ - V2 CSV imports use SSE (better UX)
495
+ - V2 batch enrichment still uses WebSocket (bidirectional needed)
496
+
497
+ ---
498
+
499
+ ## Support
500
+
501
+ - **Full API Docs:** [OpenAPI Specification](../../docs/openapi.yaml)
502
+ - **Issues:** [GitHub Issues](https://github.com/yourusername/bendv3/issues)
503
+ - **Backend Code:** [Router](../../src/router.ts), [SSE Handler](../../src/handlers/v2/sse-stream.ts)
504
+
505
+ ---
506
+
507
+ **Last Updated:** December 3, 2025
508
+ **Maintained By:** BooksTrack Team (@jukasdrj)
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { paths } from './schema';
2
2
  export type { paths, components } from './schema';
3
+ export { createSSEStream, createWebSocketStream, useSSEStream_Example, useWebSocketStream_Example, type SSEProgressEvent, type WebSocketProgressMessage, type SSEStreamOptions, type WebSocketStreamOptions, } from './streaming';
3
4
  /**
4
5
  * Create a type-safe BooksTrack API client
5
6
  *
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,UAAU,CAAA;AAErC,YAAY,EAAE,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AAEjD;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,CAAC,EAAE;IAC/C,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,OAAO,CAAC,EAAE,WAAW,CAAA;IACrB,WAAW,CAAC,EAAE,kBAAkB,CAAA;CACjC,gEAWA;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,MAAM,8DAA2B,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,UAAU,CAAA;AAErC,YAAY,EAAE,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AAGjD,OAAO,EACL,eAAe,EACf,qBAAqB,EACrB,oBAAoB,EACpB,0BAA0B,EAC1B,KAAK,gBAAgB,EACrB,KAAK,wBAAwB,EAC7B,KAAK,gBAAgB,EACrB,KAAK,sBAAsB,GAC5B,MAAM,aAAa,CAAA;AAEpB;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,CAAC,EAAE;IAC/C,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,OAAO,CAAC,EAAE,WAAW,CAAA;IACrB,WAAW,CAAC,EAAE,kBAAkB,CAAA;CACjC,gEAWA;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,MAAM,8DAA2B,CAAA"}
package/dist/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import createClient from 'openapi-fetch';
2
+ // Export streaming utilities
3
+ export { createSSEStream, createWebSocketStream, useSSEStream_Example, useWebSocketStream_Example, } from './streaming';
2
4
  /**
3
5
  * Create a type-safe BooksTrack API client
4
6
  *