@sales-bot-llm/sdk 0.2.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.
Files changed (54) hide show
  1. package/biome.json +36 -0
  2. package/docs/superpowers/plans/2026-05-08-sales-bot-sdk-plan.md +258 -0
  3. package/docs/superpowers/plans/2026-05-11-w3-sales-tool-polish-plan.md +476 -0
  4. package/docs/superpowers/specs/2026-05-08-sales-bot-sdk-design.md +587 -0
  5. package/example/.env.example +5 -0
  6. package/example/README.md +90 -0
  7. package/example/index.html +12 -0
  8. package/example/package.json +27 -0
  9. package/example/public/vanilla.global.js +345 -0
  10. package/example/src/App.tsx +50 -0
  11. package/example/src/main.tsx +16 -0
  12. package/example/src/routes/HookDemo.tsx +174 -0
  13. package/example/src/routes/VanillaDemo.tsx +67 -0
  14. package/example/src/routes/WidgetDemo.tsx +55 -0
  15. package/example/src/styles.css +18 -0
  16. package/example/tsconfig.json +19 -0
  17. package/example/tsconfig.tsbuildinfo +1 -0
  18. package/example/vite.config.ts +4 -0
  19. package/package.json +106 -0
  20. package/pnpm-workspace.yaml +3 -0
  21. package/src/core/client.ts +245 -0
  22. package/src/core/conversation.ts +34 -0
  23. package/src/core/index.ts +6 -0
  24. package/src/core/sse-parser.ts +87 -0
  25. package/src/core/storage.ts +72 -0
  26. package/src/core/transport.ts +271 -0
  27. package/src/core/types.ts +314 -0
  28. package/src/core/visitor.ts +21 -0
  29. package/src/react/index.ts +2 -0
  30. package/src/react/use-sales-bot.tsx +182 -0
  31. package/src/vanilla/index.ts +38 -0
  32. package/src/vue/index.ts +2 -0
  33. package/src/vue/use-sales-bot.ts +152 -0
  34. package/src/widget/index.ts +3 -0
  35. package/src/widget/markdown.ts +69 -0
  36. package/src/widget/styles.ts +350 -0
  37. package/src/widget/widget.ts +442 -0
  38. package/tests/contract/wire-format.test.ts +158 -0
  39. package/tests/core/client.test.ts +292 -0
  40. package/tests/core/conversation.test.ts +41 -0
  41. package/tests/core/sse-parser.test.ts +142 -0
  42. package/tests/core/storage.test.ts +78 -0
  43. package/tests/core/transport.test.ts +204 -0
  44. package/tests/core/visitor.test.ts +42 -0
  45. package/tests/react/use-sales-bot.test.tsx +188 -0
  46. package/tests/sales-tool-discriminator.test.ts +45 -0
  47. package/tests/setup.ts +3 -0
  48. package/tests/vanilla/vanilla.test.ts +37 -0
  49. package/tests/vue/use-sales-bot.test.ts +163 -0
  50. package/tests/widget/markdown.test.ts +113 -0
  51. package/tests/widget/widget.test.ts +388 -0
  52. package/tsconfig.json +28 -0
  53. package/tsup.config.ts +38 -0
  54. package/vitest.config.ts +26 -0
@@ -0,0 +1,587 @@
1
+ # Sales Bot — Sub-project #2: Frontend SDK Design
2
+
3
+ **Date:** 2026-05-08
4
+ **Status:** Approved (drives Phase 2-N implementation)
5
+ **Author:** Claude (autonomous build, delegated by Artem Zaitsev)
6
+ **Scope:** Sub-project #2 of 5. Consumes the locked wire format from sub-project #1.
7
+
8
+ ---
9
+
10
+ ## 1. Goals & Non-Goals
11
+
12
+ ### 1.1 Goals
13
+
14
+ - **Framework-agnostic TypeScript core** — a single `SalesBotClient` class that any framework adapter can wrap.
15
+ - **React adapter** — `useSalesBot` hook, compatible with React 18 + Next.js App Router.
16
+ - **Vue adapter** — `useSalesBot` Composition API composable, Vue 3.4+.
17
+ - **Vanilla / IIFE adapter** — `window.SalesBot.init(...)` for plain `<script>` embed tags.
18
+ - **Floating widget** — a self-contained shadow-DOM chat widget, zero host-page CSS bleed.
19
+ - **Type-safe wire format** — TypeScript types verbatim from the backend's `sse-events.ts`; contract tests assert no drift.
20
+ - **localStorage visitor-token persistence** — keyed per embed-key, with in-memory fallback for SSR/Node.
21
+ - **SSE streaming consumer** — parses `event: <name>\ndata: <json>\n\n` frames from `fetch` responses.
22
+ - **v1 ships ESM + CJS + IIFE bundles** — via `tsup`, all in one package.
23
+
24
+ ### 1.2 Non-Goals (v1)
25
+
26
+ - Voice channel / WebRTC (sub-project #4)
27
+ - File uploads
28
+ - Multi-bot management UI
29
+ - Server-side rendering of chat history
30
+ - Deep theme customization beyond CSS custom-properties
31
+ - Admin dashboard
32
+ - Publishing to npm (local-only builds)
33
+
34
+ ---
35
+
36
+ ## 2. Repo Layout
37
+
38
+ Single package (`@sales-bot/sdk`) with multiple entry points. This avoids version skew between adapters and halves dependency management overhead compared to a monorepo.
39
+
40
+ ```
41
+ sales_bot_sdk/
42
+ ├── src/
43
+ │ ├── core/
44
+ │ │ ├── types.ts ← SalesBotEvent union, SalesBotError, wire types
45
+ │ │ ├── sse-parser.ts ← ReadableStream → AsyncIterable<SalesBotEvent>
46
+ │ │ ├── transport.ts ← fetch-based HTTP layer
47
+ │ │ ├── storage.ts ← Storage interface + adapters
48
+ │ │ ├── visitor.ts ← visitor token management
49
+ │ │ ├── client.ts ← SalesBotClient class
50
+ │ │ └── index.ts ← re-exports for ./core entry
51
+ │ ├── react/
52
+ │ │ ├── use-sales-bot.tsx ← useSalesBot hook
53
+ │ │ └── index.ts
54
+ │ ├── vue/
55
+ │ │ ├── use-sales-bot.ts ← useSalesBot composable
56
+ │ │ └── index.ts
57
+ │ ├── vanilla/
58
+ │ │ ├── index.ts ← window.SalesBot.init
59
+ │ │ └── iife-wrapper.ts
60
+ │ └── widget/
61
+ │ ├── widget.ts ← shadow DOM floating widget
62
+ │ ├── styles.ts ← CSS-in-JS injected into shadow root
63
+ │ └── index.ts
64
+ ├── tests/
65
+ │ ├── core/
66
+ │ │ ├── sse-parser.test.ts
67
+ │ │ ├── transport.test.ts
68
+ │ │ ├── storage.test.ts
69
+ │ │ ├── visitor.test.ts
70
+ │ │ └── client.test.ts
71
+ │ ├── react/
72
+ │ │ └── use-sales-bot.test.tsx
73
+ │ ├── vue/
74
+ │ │ └── use-sales-bot.test.ts
75
+ │ ├── widget/
76
+ │ │ └── widget.test.ts
77
+ │ └── contract/
78
+ │ └── wire-format.test.ts ← asserts SDK types match backend
79
+ ├── docs/
80
+ ├── tsup.config.ts
81
+ ├── tsconfig.json
82
+ ├── vitest.config.ts
83
+ ├── package.json
84
+ └── biome.json
85
+ ```
86
+
87
+ ---
88
+
89
+ ## 3. Public API
90
+
91
+ ### 3.1 Core class
92
+
93
+ ```typescript
94
+ class SalesBotClient {
95
+ constructor(opts: SalesBotClientOptions)
96
+
97
+ // Identify the current visitor. Merged into the next ask() call.
98
+ identify(traits: IdentifyInput): void
99
+
100
+ // Start a new agent turn. Returns an AsyncIterable and also emits events
101
+ // on the client's event bus.
102
+ ask(message: string, opts?: AskOptions): AsyncIterable<SalesBotEvent>
103
+
104
+ // Re-attach to an in-flight or completed turn by turnId.
105
+ resume(turnId: string): AsyncIterable<SalesBotEvent>
106
+
107
+ // Visitor token — a UUID stable across page reloads.
108
+ getVisitorToken(): string
109
+
110
+ // Active conversation ID (set after turn_started, cleared on reset).
111
+ getConversationId(): string | null
112
+ setConversationId(id: string | null): void
113
+
114
+ // Event-bus interface (alternative to async iterable).
115
+ on(event: 'delta', handler: (e: DeltaEvent) => void): Unsubscribe
116
+ on(event: 'message_complete', handler: (e: MessageCompleteEvent) => void): Unsubscribe
117
+ on(event: 'turn_started', handler: (e: TurnStartedEvent) => void): Unsubscribe
118
+ on(event: 'done', handler: (e: DoneEvent) => void): Unsubscribe
119
+ on(event: 'error', handler: (e: ErrorEvent) => void): Unsubscribe
120
+ on(event: 'tool_call_started', handler: (e: ToolCallStartedEvent) => void): Unsubscribe
121
+ on(event: 'tool_call_finished', handler: (e: ToolCallFinishedEvent) => void): Unsubscribe
122
+ on(event: 'usage', handler: (e: UsageEventData) => void): Unsubscribe
123
+ on(event: string, handler: (e: unknown) => void): Unsubscribe
124
+ }
125
+ ```
126
+
127
+ ### 3.2 Options
128
+
129
+ ```typescript
130
+ interface SalesBotClientOptions {
131
+ embedKey: string // pk_live_<32 chars>
132
+ baseUrl?: string // default: 'http://localhost:3000'
133
+ storage?: StorageAdapter // default: LocalStorageAdapter (with MemoryStorageAdapter fallback)
134
+ customHeaders?: Record<string, string> // for Node: { Origin: 'https://...' }
135
+ }
136
+
137
+ interface AskOptions {
138
+ conversationId?: string
139
+ metadata?: Record<string, unknown>
140
+ }
141
+
142
+ interface IdentifyInput {
143
+ externalId?: string
144
+ email?: string
145
+ name?: string
146
+ traits?: Record<string, unknown>
147
+ }
148
+ ```
149
+
150
+ ### 3.3 Error class
151
+
152
+ ```typescript
153
+ class SalesBotError extends Error {
154
+ code: SalesBotErrorCode
155
+ retryable: boolean
156
+ details?: Record<string, unknown>
157
+ }
158
+
159
+ type SalesBotErrorCode =
160
+ | 'invalid_embed_key'
161
+ | 'origin_not_allowed'
162
+ | 'rate_limited'
163
+ | 'out_of_credits'
164
+ | 'unauthorized'
165
+ | 'forbidden'
166
+ | 'not_found'
167
+ | 'bad_request'
168
+ | 'unprocessable_entity'
169
+ | 'conflict'
170
+ | 'internal'
171
+ | 'llm_unavailable'
172
+ | 'mcp_unavailable'
173
+ | 'network_error' // SDK-only: fetch failed
174
+ | 'parse_error' // SDK-only: SSE frame malformed
175
+ ```
176
+
177
+ ### 3.4 React adapter
178
+
179
+ ```typescript
180
+ // Returns stable references; re-renders only on state changes.
181
+ function useSalesBot(opts: UseSalesBotOptions): UseSalesBotResult
182
+
183
+ interface UseSalesBotOptions extends SalesBotClientOptions {
184
+ // Optional initial conversationId (e.g. restored from URL hash).
185
+ conversationId?: string
186
+ }
187
+
188
+ interface UseSalesBotResult {
189
+ ask: (message: string, opts?: AskOptions) => Promise<void>
190
+ messages: Message[] // [{role, content, id?}]
191
+ isStreaming: boolean
192
+ error: SalesBotError | null
193
+ conversationId: string | null
194
+ reset: () => void // clears messages + conversationId
195
+ }
196
+ ```
197
+
198
+ ### 3.5 Vue adapter
199
+
200
+ ```typescript
201
+ function useSalesBot(opts: UseSalesBotOptions): {
202
+ ask: (message: string, opts?: AskOptions) => Promise<void>
203
+ messages: Ref<Message[]>
204
+ isStreaming: Ref<boolean>
205
+ error: Ref<SalesBotError | null>
206
+ conversationId: Ref<string | null>
207
+ reset: () => void
208
+ }
209
+ ```
210
+
211
+ ### 3.6 Vanilla / IIFE
212
+
213
+ ```typescript
214
+ // Exposed as window.SalesBot
215
+ interface SalesBotGlobal {
216
+ init(opts: SalesBotClientOptions): SalesBotClient
217
+ // Convenience: open the floating widget
218
+ widget(opts: WidgetOptions): WidgetInstance
219
+ }
220
+ ```
221
+
222
+ ### 3.7 Widget API
223
+
224
+ ```typescript
225
+ interface WidgetOptions extends SalesBotClientOptions {
226
+ container?: HTMLElement // default: document.body
227
+ position?: 'bottom-right' | 'bottom-left' // default: 'bottom-right'
228
+ title?: string // default: 'Chat with us'
229
+ placeholder?: string // default: 'Type a message...'
230
+ primaryColor?: string // overrides --sb-primary CSS var
231
+ }
232
+
233
+ interface WidgetInstance {
234
+ open(): void
235
+ close(): void
236
+ toggle(): void
237
+ destroy(): void
238
+ }
239
+ ```
240
+
241
+ ---
242
+
243
+ ## 4. SSE Parser
244
+
245
+ The parser is a pure function consuming `ReadableStream<Uint8Array>` and yielding typed `SalesBotEvent` objects.
246
+
247
+ Wire format (per backend contract):
248
+ ```
249
+ event: turn_started\ndata: {"turnId":"...","conversationId":"...","endUserId":"..."}\n\n
250
+ event: delta\ndata: {"content":"Hello"}\n\n
251
+ event: done\ndata: {"turnId":"..."}\n\n
252
+ ```
253
+
254
+ Algorithm:
255
+ 1. TextDecoder chunk-by-chunk from the readable stream.
256
+ 2. Buffer accumulates text; split on `\n\n` for complete frames.
257
+ 3. Each frame: extract `event:` line for the name, `data:` line for JSON.
258
+ 4. `JSON.parse` the data; cast to the matching interface by event name.
259
+ 5. Yield `{ event: SseEventName, data: <typed payload> }`.
260
+ 6. Malformed frames throw `SalesBotError({ code: 'parse_error' })`.
261
+
262
+ Hand-rolled parser, under 80 lines, zero dependencies.
263
+
264
+ ---
265
+
266
+ ## 5. Storage
267
+
268
+ ```typescript
269
+ interface StorageAdapter {
270
+ getItem(key: string): string | null
271
+ setItem(key: string, value: string): void
272
+ removeItem(key: string): void
273
+ }
274
+ ```
275
+
276
+ - **`LocalStorageAdapter`** — wraps `window.localStorage`. Construction throws if `window` is undefined; callers should catch and fall back.
277
+ - **`MemoryStorageAdapter`** — `Map<string, string>`. Used when localStorage is unavailable (SSR, Node, private browsing with quota error).
278
+ - **Auto-detect** at `SalesBotClient` construction: try `LocalStorageAdapter`; catch → fall back to `MemoryStorageAdapter` with a `console.warn`.
279
+
280
+ Visitor token key: `salesbot:visitor:<embedKey>` (embedKey suffix prevents collisions when multiple bots are embedded).
281
+
282
+ ---
283
+
284
+ ## 6. Visitor Token Management
285
+
286
+ On first call to `getVisitorToken()`:
287
+ 1. Read key from storage.
288
+ 2. If present, return it.
289
+ 3. If absent, generate via `crypto.randomUUID()` (browser native, no polyfill needed for targets we support).
290
+ 4. Persist to storage, return.
291
+
292
+ Tokens are UUIDs; they are stable across page reloads and cleared only when the storage is cleared. The SDK never exposes a `clearVisitorToken()` — consumers who need that can call `storage.removeItem(key)` directly.
293
+
294
+ ---
295
+
296
+ ## 7. Transport
297
+
298
+ ```typescript
299
+ async function postTurn(
300
+ input: PostTurnInput,
301
+ opts: TransportOptions
302
+ ): Promise<ReadableStream<Uint8Array>>
303
+
304
+ async function getResumeStream(
305
+ turnId: string,
306
+ opts: TransportOptions
307
+ ): Promise<ReadableStream<Uint8Array>>
308
+ ```
309
+
310
+ - Sets `Authorization: Bearer <embedKey>`, `Accept: text/event-stream`, `Content-Type: application/json`.
311
+ - Sets `Idempotency-Key` header with a fresh `crypto.randomUUID()` per `ask()` invocation.
312
+ - `Origin` is set automatically by the browser; Node callers pass via `customHeaders`.
313
+ - On non-2xx response: reads the JSON error body, maps to `SalesBotError`.
314
+ - HTTP 429 → `rate_limited`, 402 → `out_of_credits`, 403 → `origin_not_allowed` or `forbidden`, etc.
315
+
316
+ ---
317
+
318
+ ## 8. Error Handling
319
+
320
+ All errors thrown by the SDK are `SalesBotError` instances. The `.code` field is a stable TypeScript union that consumers can `switch` on.
321
+
322
+ HTTP status → code mapping:
323
+ - 400 → `bad_request`
324
+ - 401 → `unauthorized` or `invalid_embed_key` (body.code takes precedence)
325
+ - 402 → `out_of_credits`
326
+ - 403 → body.code takes precedence; default `forbidden`
327
+ - 404 → `not_found`
328
+ - 409 → `conflict`
329
+ - 422 → `unprocessable_entity`
330
+ - 429 → `rate_limited`
331
+ - 5xx → `internal` (or body.code if present)
332
+ - fetch throws (network down) → `network_error`
333
+
334
+ SSE `error` event → throw `SalesBotError` from the async iterable, emit on `on('error')` bus.
335
+
336
+ ---
337
+
338
+ ## 9. Reconnect / Resume
339
+
340
+ The SDK tracks the latest `turnId` from `turn_started` on the active client instance. `resume(turnId)` calls `GET /api/chat/turns/:turnId/stream` and returns an `AsyncIterable` of remaining events.
341
+
342
+ **Policy: opt-in, default off.** The `ask()` method does NOT auto-resume on stream drop. Consumers that want reconnect logic should:
343
+
344
+ ```typescript
345
+ let lastTurnId: string | null = null
346
+ client.on('turn_started', e => { lastTurnId = e.turnId })
347
+
348
+ async function askWithResume(msg: string) {
349
+ try {
350
+ for await (const event of client.ask(msg)) { /* handle */ }
351
+ } catch (err) {
352
+ if (lastTurnId && err instanceof SalesBotError && err.retryable) {
353
+ for await (const event of client.resume(lastTurnId)) { /* handle */ }
354
+ }
355
+ }
356
+ }
357
+ ```
358
+
359
+ This keeps `ask()` behavior predictable and avoids ghost messages from double-processing.
360
+
361
+ ---
362
+
363
+ ## 10. Framework Adapters
364
+
365
+ ### 10.1 React (`./react`)
366
+
367
+ - `useSalesBot(opts)` creates a `SalesBotClient` via `useRef` (stable across renders).
368
+ - State: `messages: Message[]`, `isStreaming: boolean`, `error: SalesBotError | null`.
369
+ - `ask()` appends the user message, sets `isStreaming = true`, drives the async iterable on each `delta`, appends a streaming assistant message, finalizes on `message_complete`, clears on `done` / `error`.
370
+ - `'use client'` directive at top so it's compatible with Next.js App Router (the hook itself uses `useState`/`useEffect`).
371
+ - React is a `peerDependency` (^18).
372
+
373
+ ### 10.2 Vue (`./vue`)
374
+
375
+ - Same semantics as React adapter, built on `ref`, `shallowRef`, and the `SalesBotClient.on()` event bus.
376
+ - Uses `onUnmounted` to unsubscribe event handlers.
377
+ - Vue is a `peerDependency` (^3.4).
378
+ - No `<script setup>` macro — pure function composable for testing clarity.
379
+
380
+ ### 10.3 Next.js
381
+
382
+ The React adapter works out of the box with Next.js App Router. Document:
383
+ - Add `'use client'` to any component that calls `useSalesBot`.
384
+ - `baseUrl` should point to the production backend (not localhost).
385
+ - SSR: the hook returns empty `messages` and noop `ask` until hydration. The `SalesBotClient` is constructed client-side only.
386
+
387
+ ### 10.4 Vanilla / IIFE (`./vanilla`)
388
+
389
+ - `tsup` builds an IIFE bundle that sets `window.SalesBot = { init, widget }`.
390
+ - `init(opts)` returns a `SalesBotClient`.
391
+ - `widget(opts)` constructs and mounts the shadow-DOM widget.
392
+
393
+ ---
394
+
395
+ ## 11. Widget UI
396
+
397
+ ### 11.1 Shadow DOM approach
398
+
399
+ The widget is mounted inside a shadow root attached to a `<sales-bot-widget>` custom element. This completely isolates its CSS from the host page. No CSS reset needed.
400
+
401
+ ```html
402
+ <!-- Injected into document.body -->
403
+ <sales-bot-widget>
404
+ #shadow-root
405
+ <style> ... </style>
406
+ <button class="sb-launcher">💬</button>
407
+ <div class="sb-panel">
408
+ <div class="sb-header">...</div>
409
+ <div class="sb-messages">...</div>
410
+ <div class="sb-input-row">
411
+ <input class="sb-input" />
412
+ <button class="sb-send">Send</button>
413
+ </div>
414
+ </div>
415
+ </sales-bot-widget>
416
+ ```
417
+
418
+ ### 11.2 Theming
419
+
420
+ Consumed as CSS custom properties declared on `:host`:
421
+
422
+ ```css
423
+ :host {
424
+ --sb-primary: #2563eb;
425
+ --sb-text: #111827;
426
+ --sb-bg: #ffffff;
427
+ --sb-radius: 12px;
428
+ --sb-z: 9999;
429
+ }
430
+ ```
431
+
432
+ Override from the host page:
433
+ ```css
434
+ sales-bot-widget {
435
+ --sb-primary: hotpink;
436
+ }
437
+ ```
438
+
439
+ Or via `WidgetOptions.primaryColor` which sets an inline style on the custom element.
440
+
441
+ ### 11.3 Markdown rendering
442
+
443
+ Lightweight hand-rolled renderer:
444
+ - `**bold**` → `<strong>`
445
+ - `*italic*` → `<em>`
446
+ - `` `code` `` → `<code>`
447
+ - `[text](url)` → `<a target="_blank" rel="noopener">` (only https:// links)
448
+ - Newlines → `<br>`
449
+
450
+ No full Markdown parser dependency (keeps widget bundle small).
451
+
452
+ ### 11.4 Message flow in widget
453
+
454
+ 1. User types and presses Enter or clicks Send.
455
+ 2. Widget calls `client.ask(message)`.
456
+ 3. Widget appends user bubble immediately.
457
+ 4. On `turn_started`, shows a "thinking" indicator.
458
+ 5. On first `delta`, replaces thinking indicator with a streaming assistant bubble.
459
+ 6. Each `delta` appends to bubble content (rendered as markdown).
460
+ 7. On `message_complete`, marks the bubble as complete.
461
+ 8. On `done`, enables the input.
462
+ 9. On `error`, shows an error message in the chat.
463
+
464
+ ---
465
+
466
+ ## 12. Build & Bundling
467
+
468
+ ### 12.1 tsup config (conceptual)
469
+
470
+ ```typescript
471
+ // tsup.config.ts
472
+ export default defineConfig([
473
+ // Core, React, Vue — ESM + CJS
474
+ {
475
+ entry: {
476
+ core: 'src/core/index.ts',
477
+ react: 'src/react/index.ts',
478
+ vue: 'src/vue/index.ts',
479
+ widget: 'src/widget/index.ts',
480
+ },
481
+ format: ['esm', 'cjs'],
482
+ dts: true,
483
+ external: ['react', 'react-dom', 'vue'],
484
+ },
485
+ // Vanilla IIFE
486
+ {
487
+ entry: { vanilla: 'src/vanilla/index.ts' },
488
+ format: ['iife'],
489
+ globalName: 'SalesBot',
490
+ minify: true,
491
+ dts: false,
492
+ },
493
+ ])
494
+ ```
495
+
496
+ ### 12.2 package.json exports map
497
+
498
+ ```json
499
+ {
500
+ "exports": {
501
+ "./core": {
502
+ "import": "./dist/core.mjs",
503
+ "require": "./dist/core.cjs"
504
+ },
505
+ "./react": {
506
+ "import": "./dist/react.mjs",
507
+ "require": "./dist/react.cjs"
508
+ },
509
+ "./vue": {
510
+ "import": "./dist/vue.mjs",
511
+ "require": "./dist/vue.cjs"
512
+ },
513
+ "./widget": {
514
+ "import": "./dist/widget.mjs",
515
+ "require": "./dist/widget.cjs"
516
+ },
517
+ "./vanilla": "./dist/vanilla.global.js"
518
+ }
519
+ }
520
+ ```
521
+
522
+ ---
523
+
524
+ ## 13. Testing
525
+
526
+ ### 13.1 Framework
527
+
528
+ **Vitest** with `happy-dom` environment for DOM tests. `@testing-library/react` for React adapter. `@vue/test-utils` for Vue adapter.
529
+
530
+ ### 13.2 Test categories
531
+
532
+ | File | Approach |
533
+ |---|---|
534
+ | `sse-parser.test.ts` | Feeds mock `ReadableStream` with raw SSE bytes, asserts yielded events |
535
+ | `transport.test.ts` | Mocks `globalThis.fetch` via `vi.stubGlobal`, asserts headers/body/error mapping |
536
+ | `storage.test.ts` | Unit tests for both adapters |
537
+ | `visitor.test.ts` | Asserts token generation + persistence + embedKey namespacing |
538
+ | `client.test.ts` | Integration: mock fetch → full event sequence → state transitions |
539
+ | `use-sales-bot.test.tsx` | React Testing Library, `renderHook`, asserts messages/isStreaming/error |
540
+ | `use-sales-bot.test.ts` | Vue Test Utils, asserts refs update correctly |
541
+ | `widget.test.ts` | happy-dom, asserts shadow DOM structure + interaction |
542
+ | `wire-format.test.ts` | Contract test: TypeScript assignability + runtime shape assertions |
543
+
544
+ ### 13.3 Contract sync
545
+
546
+ `tests/contract/wire-format.test.ts` imports the event type interfaces from `src/core/types.ts` and asserts they match the exact shapes from the backend's `sse-events.ts` (copied verbatim, not imported cross-project). Any shape mismatch is a compile error before the test even runs.
547
+
548
+ ---
549
+
550
+ ## 14. Bundle-size Budget
551
+
552
+ Measured gzipped, tracked via `size-limit` in `package.json`.
553
+
554
+ | Entry | Budget |
555
+ |---|---|
556
+ | `./core` | 8 KB |
557
+ | `./react` | 12 KB (includes core) |
558
+ | `./vue` | 12 KB (includes core) |
559
+ | `./widget` | 25 KB (includes core + styles) |
560
+ | `./vanilla` IIFE | 20 KB |
561
+
562
+ ---
563
+
564
+ ## 15. Decisions Log
565
+
566
+ | # | Decision | Rationale |
567
+ |---|---|---|
568
+ | 1 | Single package, multiple entry points (not monorepo) | Eliminates version skew between adapters; one `npm install`, one `package.json` to maintain. |
569
+ | 2 | Both `AsyncIterable` AND event-bus (`on()`) interfaces | `AsyncIterable` is cleaner for custom consumers (for-await); event-bus is better for React/Vue adapter internals (no need to manage async loop lifetime). Both are thin wrappers over the same stream. |
570
+ | 3 | Hand-rolled SSE parser, no `eventsource-parser` dep | The backend's SSE format is fixed and simple; a hand-rolled parser stays under 80 lines, is zero-dependency, and is straightforwardly testable. Adds an `eventsource-parser` dep if complexity grows. |
571
+ | 4 | Shadow DOM for widget | Host-page CSS bleed is the #1 real-world pain point for chat widgets. Shadow DOM eliminates it completely and is supported in all target browsers. |
572
+ | 5 | `crypto.randomUUID()` instead of `nanoid` | One fewer dependency; native `crypto.randomUUID()` is available in all modern browsers and Node 15+. |
573
+ | 6 | `MemoryStorageAdapter` fallback (silent warning) | Private browsing mode throws on `localStorage.setItem`. A silent fallback with a `console.warn` keeps the widget working while informing developers. |
574
+ | 7 | `'use client'` on React adapter | Next.js App Router requires this for hooks that use `useState`/`useEffect`; making it explicit in the entry file avoids consumer confusion. |
575
+ | 8 | No auto-reconnect | Opt-in reconnect prevents double-processing of messages; the turn lifecycle is well-defined by the backend, so consumers can implement exactly the reconnect policy they need. |
576
+ | 9 | Biome for linting/formatting | Single binary, minimal config, significantly faster than ESLint + Prettier. Acceptable for a library project. |
577
+ | 10 | Hand-rolled markdown renderer | `marked` is 22KB+ and overkill for the subset needed (`**bold**`, links, code). Hand-rolled renderer stays under 50 lines and keeps the widget bundle small. |
578
+
579
+ ---
580
+
581
+ ## 16. Known Limitations / Follow-ups
582
+
583
+ - **No retry built into `ask()`** — callers must implement their own retry or use `resume()`.
584
+ - **No conversation history pre-load** — `GET /api/chat/conversations/:id` exists on the backend but the SDK doesn't expose a `loadHistory()` method in v1. Add in a follow-up.
585
+ - **No accessibility audit on widget** — shadow DOM requires explicit ARIA attributes. The v1 widget has basic `aria-label` on key elements but hasn't been audited against WCAG 2.1 AA.
586
+ - **Vue SSR** — `useSalesBot` returns empty state safely in SSR context, but hydration mismatch avoidance hasn't been tested with Nuxt. Document as a known gap.
587
+ - **iOS Safari `ReadableStream` tee** — some older iOS Safari versions have quirks with `ReadableStream`. If user targeting requires iOS < 16, consider adding a `getReader()` polyfill guard.
@@ -0,0 +1,5 @@
1
+ # Backend (NestJS) URL
2
+ VITE_BACKEND_URL=http://localhost:3000
3
+
4
+ # A bot embed key from /admin/bots/:id
5
+ VITE_EMBED_KEY=pk_live_REPLACE_WITH_ACTUAL_KEY
@@ -0,0 +1,90 @@
1
+ # Sales Bot SDK — Example app
2
+
3
+ Vite + React app that exercises the SDK locally against a running backend.
4
+
5
+ ## Prereqs
6
+
7
+ 1. Backend running at `http://localhost:3000` (sub-project #1 / `sales_bot/`).
8
+ 2. A verified Resource on the backend whose `domain` allows `localhost` as an origin.
9
+ **This is the most common "I tried it and got 403" gotcha** — the SDK sends an `Origin` header
10
+ with every request. Browsers automatically send `Origin: http://localhost:5173`. The bot's
11
+ resource must have `localhost` as the verified domain (or use a wildcard match) for the backend
12
+ to permit the request. Verify a Resource with domain `localhost` for local dev.
13
+ 3. A Bot created against that Resource. Copy its `embedKey` (`pk_live_…`) from the admin UI.
14
+
15
+ ## Run
16
+
17
+ ```sh
18
+ # From the SDK repo root (sales_bot_sdk/):
19
+ pnpm build # build the SDK → dist/
20
+ pnpm install # installs root + example via workspace
21
+ pnpm --filter @sales-bot/sdk-example dev
22
+ # Open http://localhost:5173
23
+ ```
24
+
25
+ Configure via `example/.env.local` (copy from `.env.example`):
26
+
27
+ ```
28
+ VITE_BACKEND_URL=http://localhost:3000
29
+ VITE_EMBED_KEY=pk_live_…
30
+ ```
31
+
32
+ ## Pages
33
+
34
+ | Route | What it exercises |
35
+ |------------|-------------------|
36
+ | `/` | `useSalesBot` hook from `@sales-bot/sdk/react` — text input, streaming messages, conversation continuity, identify form |
37
+ | `/widget` | `createWidget()` from `@sales-bot/sdk/widget` — floating button + panel in shadow DOM, mounted to `<body>` |
38
+ | `/vanilla` | IIFE bundle (`dist/vanilla.global.js`) loaded via `<script>`, uses `window.SalesBot.default.widget()` |
39
+
40
+ ## Known gotchas
41
+
42
+ ### Origin header required
43
+
44
+ The backend validates `Origin` on every chat request. Vite serves on `http://localhost:5173` by
45
+ default — that is the origin browsers send automatically. Ensure the Bot's Resource has `localhost`
46
+ as a verified domain. If you see `403 origin_not_allowed`, this is why.
47
+
48
+ Fix: verify a Resource with domain `localhost` in the backend admin UI, then link a Bot to it and
49
+ copy its `embedKey` to `.env.local`.
50
+
51
+ ### Vanilla IIFE global wrapping
52
+
53
+ The SDK's vanilla bundle is built with `tsup` using `globalName: 'SalesBot'` and `format: 'iife'`.
54
+ The output wraps the module as:
55
+
56
+ ```js
57
+ var SalesBot = (function(exports) {
58
+ // ...
59
+ exports.SalesBot = { init, widget }
60
+ exports.default = { init, widget }
61
+ return exports
62
+ })({})
63
+ ```
64
+
65
+ In a browser, top-level `var` in a `<script>` tag becomes `window.SalesBot`. However `window.SalesBot`
66
+ is the *exports wrapper object*, not the SDK object directly. The actual API is at:
67
+
68
+ - `window.SalesBot.default.widget(...)` — recommended
69
+ - `window.SalesBot.SalesBot.widget(...)` — also works
70
+
71
+ `window.SalesBot.widget(...)` will be `undefined`. This is a known SDK-side quirk (follow-up item).
72
+ The `VanillaDemo` page accounts for this automatically.
73
+
74
+ ### `useSalesBot` does not expose `identify()`
75
+
76
+ The hook does not currently expose the underlying client's `identify()` method in its return value.
77
+ To call `identify()` you need a direct reference to a `SalesBotClient` instance. This is a
78
+ SDK-side follow-up. The Hook demo notes this inline with an alert.
79
+
80
+ ## Import paths used
81
+
82
+ ```ts
83
+ import { useSalesBot } from '@sales-bot/sdk/react'
84
+ import { createWidget } from '@sales-bot/sdk/widget'
85
+ import type { WidgetInstance } from '@sales-bot/sdk/widget'
86
+ // vanilla: loaded via <script src="/vanilla.global.js">
87
+ // → window.SalesBot.default.widget(...)
88
+ ```
89
+
90
+ These resolve through the SDK's `exports` map in `package.json`.
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Sales Bot SDK — Example</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>