@paramms/chat-widget 0.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 +23 -0
- package/build-preview.js +136 -0
- package/index.html +37 -0
- package/package.json +44 -0
- package/src/__tests__/chatlist.test.ts +133 -0
- package/src/__tests__/connection.test.ts +163 -0
- package/src/__tests__/crypto.test.ts +28 -0
- package/src/__tests__/history.test.ts +91 -0
- package/src/__tests__/ime.test.ts +93 -0
- package/src/__tests__/render.test.ts +58 -0
- package/src/__tests__/render_new.test.ts +441 -0
- package/src/__tests__/store.test.ts +86 -0
- package/src/__tests__/x3dh.test.ts +204 -0
- package/src/connection.ts +133 -0
- package/src/crypto.ts +252 -0
- package/src/e2e.ts +161 -0
- package/src/history.ts +43 -0
- package/src/index.ts +380 -0
- package/src/outbox.ts +58 -0
- package/src/protocol/actions.ts +114 -0
- package/src/protocol/codec.ts +35 -0
- package/src/protocol/entities.ts +104 -0
- package/src/protocol/frames.ts +86 -0
- package/src/protocol/ids.ts +27 -0
- package/src/protocol/index.ts +5 -0
- package/src/react.tsx +37 -0
- package/src/renderer.ts +906 -0
- package/src/store.ts +207 -0
- package/tsconfig.json +33 -0
- package/vercel.json +22 -0
- package/vite.config.ts +26 -0
- package/vitest.config.ts +2 -0
package/README.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# @paramms/chat-widget
|
|
2
|
+
|
|
3
|
+
Embeddable guest chat client. Pure `ChatStore` (state) + `ConnectionManager`
|
|
4
|
+
(ws/reconnect/outbox/cursor) + thin `Renderer`, wired by `mount()`.
|
|
5
|
+
|
|
6
|
+
`src/protocol/` is a vendored copy of the wire contract (only what the widget uses).
|
|
7
|
+
|
|
8
|
+
## Run
|
|
9
|
+
```bash
|
|
10
|
+
npm install
|
|
11
|
+
npm run dev # Vite demo at http://localhost:5173/ (expects the server on ws://localhost:3000)
|
|
12
|
+
npm run build # tsc → dist/ (library: importable by the dashboard)
|
|
13
|
+
npm run build:bundle # vite → standalone browser bundle
|
|
14
|
+
npm test # vitest (store, connection, jsdom render)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Embed
|
|
18
|
+
```html
|
|
19
|
+
<script type="module">
|
|
20
|
+
import { mount } from '@paramms/chat-widget'
|
|
21
|
+
mount({ el: document.getElementById('chat'), url: 'wss://your-host', profileId: 'p_hotel', subjectId: 'room-101' })
|
|
22
|
+
</script>
|
|
23
|
+
```
|
package/build-preview.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// build-preview.js — generates dist/index.html after vite library build.
|
|
2
|
+
//
|
|
3
|
+
// Two modes, chosen by URL at load time (not build time):
|
|
4
|
+
// - relay.paramms.com → marketing/install-guide landing page
|
|
5
|
+
// - relay.paramms.com/?profileId=x → just the widget, in a contained card
|
|
6
|
+
// (this is the URL the dashboard's "Preview" link and shared test links
|
|
7
|
+
// use — should look exactly like the widget would look embedded on a
|
|
8
|
+
// real page, not a full-bleed app)
|
|
9
|
+
import { writeFileSync, mkdirSync } from 'node:fs'
|
|
10
|
+
|
|
11
|
+
mkdirSync('dist', { recursive: true })
|
|
12
|
+
|
|
13
|
+
const html = `<!doctype html>
|
|
14
|
+
<html lang="en">
|
|
15
|
+
<head>
|
|
16
|
+
<meta charset="utf-8" />
|
|
17
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
18
|
+
<title>Relay Chat Widget</title>
|
|
19
|
+
<style>
|
|
20
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
21
|
+
html, body { height: 100%; }
|
|
22
|
+
body {
|
|
23
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
24
|
+
min-height: 100vh;
|
|
25
|
+
display: flex;
|
|
26
|
+
align-items: center;
|
|
27
|
+
justify-content: center;
|
|
28
|
+
padding: 24px;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* Marketing mode (no profileId in URL): purple gradient + info column */
|
|
32
|
+
body.marketing {
|
|
33
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
34
|
+
}
|
|
35
|
+
.container {
|
|
36
|
+
max-width: 900px;
|
|
37
|
+
width: 100%;
|
|
38
|
+
display: flex;
|
|
39
|
+
gap: 48px;
|
|
40
|
+
align-items: flex-start;
|
|
41
|
+
}
|
|
42
|
+
.info { flex: 1; color: #fff; }
|
|
43
|
+
.info h1 { font-size: 36px; font-weight: 800; margin-bottom: 12px; }
|
|
44
|
+
.info p { font-size: 16px; opacity: .85; line-height: 1.6; margin-bottom: 24px; }
|
|
45
|
+
.snippet {
|
|
46
|
+
background: rgba(0,0,0,.3);
|
|
47
|
+
border-radius: 12px;
|
|
48
|
+
padding: 20px;
|
|
49
|
+
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
50
|
+
font-size: 13px;
|
|
51
|
+
color: #e2e8f0;
|
|
52
|
+
line-height: 1.6;
|
|
53
|
+
white-space: pre;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* Widget-only mode (?profileId=... present): bare contained card on a
|
|
57
|
+
neutral backdrop — what the widget actually looks like embedded on a
|
|
58
|
+
real page, just centered for easy viewing/sharing as a test link. */
|
|
59
|
+
body.widget-only { background: #e9eaee; }
|
|
60
|
+
|
|
61
|
+
/* Both modes use the same card shape for the widget itself */
|
|
62
|
+
.preview {
|
|
63
|
+
width: 390px;
|
|
64
|
+
height: 680px;
|
|
65
|
+
max-width: calc(100vw - 48px);
|
|
66
|
+
max-height: calc(100vh - 48px);
|
|
67
|
+
border-radius: 22px;
|
|
68
|
+
overflow: hidden;
|
|
69
|
+
box-shadow: 0 24px 60px rgba(0,0,0,.25);
|
|
70
|
+
background: #fff;
|
|
71
|
+
flex-shrink: 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#app { width: 100%; height: 100%; }
|
|
75
|
+
</style>
|
|
76
|
+
</head>
|
|
77
|
+
<body>
|
|
78
|
+
<div id="marketing-info" class="info" style="display:none">
|
|
79
|
+
<h1>Relay Chat Widget</h1>
|
|
80
|
+
<p>Add a real-time chat widget to any website in two lines of code.</p>
|
|
81
|
+
<div class="snippet"><div id="chat"></div>
|
|
82
|
+
<script type="module">
|
|
83
|
+
import { mount } from 'https://relay.paramms.com/index.js'
|
|
84
|
+
mount({
|
|
85
|
+
el: document.getElementById('chat'),
|
|
86
|
+
url: 'wss://api.paramms.com/ws',
|
|
87
|
+
profileId: 'YOUR_PROFILE_ID',
|
|
88
|
+
})
|
|
89
|
+
</script></div>
|
|
90
|
+
</div>
|
|
91
|
+
<div id="layout"></div>
|
|
92
|
+
<script type="module">
|
|
93
|
+
import { mount } from './index.js'
|
|
94
|
+
|
|
95
|
+
const q = new URLSearchParams(location.search)
|
|
96
|
+
const profileId = q.get('profileId')
|
|
97
|
+
const isWidgetOnly = !!profileId
|
|
98
|
+
|
|
99
|
+
document.body.className = isWidgetOnly ? 'widget-only' : 'marketing'
|
|
100
|
+
|
|
101
|
+
const layout = document.getElementById('layout')
|
|
102
|
+
const preview = document.createElement('div')
|
|
103
|
+
preview.className = 'preview'
|
|
104
|
+
const app = document.createElement('div')
|
|
105
|
+
app.id = 'app'
|
|
106
|
+
preview.append(app)
|
|
107
|
+
|
|
108
|
+
if (isWidgetOnly) {
|
|
109
|
+
// Just the contained card, centered on a neutral backdrop.
|
|
110
|
+
layout.append(preview)
|
|
111
|
+
} else {
|
|
112
|
+
// Marketing landing page: info column + card preview, side by side.
|
|
113
|
+
const container = document.createElement('div')
|
|
114
|
+
container.className = 'container'
|
|
115
|
+
const info = document.getElementById('marketing-info')
|
|
116
|
+
info.style.display = ''
|
|
117
|
+
container.append(info, preview)
|
|
118
|
+
layout.append(container)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const wsUrl = q.get('url') || 'wss://api.paramms.com/ws'
|
|
122
|
+
const subjectId = q.get('subjectId') || undefined
|
|
123
|
+
mount({
|
|
124
|
+
el: app,
|
|
125
|
+
url: wsUrl,
|
|
126
|
+
profileId: profileId || 'p_hotel',
|
|
127
|
+
...(subjectId ? { subjectId } : {}),
|
|
128
|
+
accent: q.get('accent') || '#4F63F5',
|
|
129
|
+
showChatList: q.get('chatList') !== 'off',
|
|
130
|
+
})
|
|
131
|
+
</script>
|
|
132
|
+
</body>
|
|
133
|
+
</html>`
|
|
134
|
+
|
|
135
|
+
writeFileSync('dist/index.html', html)
|
|
136
|
+
console.log('✓ dist/index.html written')
|
package/index.html
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
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" />
|
|
6
|
+
<title>Relay widget</title>
|
|
7
|
+
<style>
|
|
8
|
+
body { margin:0; background:#e9ebef; font-family:system-ui,sans-serif; }
|
|
9
|
+
#app { width:390px; height:720px; margin:24px auto; border-radius:22px; overflow:hidden;
|
|
10
|
+
box-shadow:0 18px 50px rgba(0,0,0,.18); background:#fff; }
|
|
11
|
+
</style>
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<div id="app"></div>
|
|
15
|
+
<script type="module">
|
|
16
|
+
import { mount } from './src/index.ts'
|
|
17
|
+
const q = new URLSearchParams(location.search)
|
|
18
|
+
const subjectId = q.get('subjectId') || undefined
|
|
19
|
+
const title = q.get('title')
|
|
20
|
+
mount({
|
|
21
|
+
el: document.getElementById('app'),
|
|
22
|
+
url: q.get('url') || import.meta.env.VITE_SERVER_URL || 'ws://localhost:3000/ws',
|
|
23
|
+
profileId: q.get('profileId') || import.meta.env.VITE_PROFILE_ID || 'p_hotel',
|
|
24
|
+
...(subjectId ? { subjectId } : {}),
|
|
25
|
+
accent: q.get('accent') || '#4F63F5',
|
|
26
|
+
...(title ? { subject: {
|
|
27
|
+
ownerLabel: q.get('owner') || undefined,
|
|
28
|
+
title,
|
|
29
|
+
subtitle: q.get('subtitle') || '',
|
|
30
|
+
status: q.get('status') || '',
|
|
31
|
+
tags: (q.get('tags') || '').split(',').filter(Boolean),
|
|
32
|
+
} } : {}),
|
|
33
|
+
quickReplies: (q.get('quick') || '').split(',').filter(Boolean),
|
|
34
|
+
})
|
|
35
|
+
</script>
|
|
36
|
+
</body>
|
|
37
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@paramms/chat-widget",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"build": "vite build && tsc -p tsconfig.json --emitDeclarationOnly && node build-preview.js",
|
|
7
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
8
|
+
"test": "vitest run",
|
|
9
|
+
"dev": "vite",
|
|
10
|
+
"build:bundle": "vite build",
|
|
11
|
+
"preview": "vite preview"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@msgpack/msgpack": "^3.0.0"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"typescript": "^5.5.0",
|
|
18
|
+
"vite": "^5.4.0",
|
|
19
|
+
"vitest": "^2.1.0",
|
|
20
|
+
"jsdom": "^25.0.0",
|
|
21
|
+
"react": "^18.3.0",
|
|
22
|
+
"@types/react": "^18.3.0"
|
|
23
|
+
},
|
|
24
|
+
"main": "./dist/index.js",
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"default": "./dist/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./react": {
|
|
32
|
+
"types": "./dist/react.d.ts",
|
|
33
|
+
"default": "./dist/react.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"react": ">=18"
|
|
38
|
+
},
|
|
39
|
+
"peerDependenciesMeta": {
|
|
40
|
+
"react": {
|
|
41
|
+
"optional": true
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
3
|
+
import { asUserId, asConversationId } from '../protocol/index.js'
|
|
4
|
+
import { ChatStore } from '../store.js'
|
|
5
|
+
import { Renderer, type ChatListEntry } from '../renderer.js'
|
|
6
|
+
|
|
7
|
+
const me = 'alice'
|
|
8
|
+
const cid = asConversationId('c1')
|
|
9
|
+
|
|
10
|
+
function openedConv() {
|
|
11
|
+
return {
|
|
12
|
+
type: 'opened' as const,
|
|
13
|
+
conversation: {
|
|
14
|
+
id: cid, tenantId: 't' as never, profileId: 'p' as never,
|
|
15
|
+
guestId: asUserId(me), participants: [asUserId(me)],
|
|
16
|
+
state: 'open', lastSeq: 0, createdAt: 0, updatedAt: 0,
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('Renderer — multi-subject chat list', () => {
|
|
22
|
+
it('does not show the chat-list button when onListChats is not provided', () => {
|
|
23
|
+
const root = document.createElement('div')
|
|
24
|
+
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn() }
|
|
25
|
+
const store = new ChatStore(asUserId(me))
|
|
26
|
+
const r = new Renderer(root, me, h)
|
|
27
|
+
store.apply(openedConv())
|
|
28
|
+
r.render(store)
|
|
29
|
+
expect(root.querySelector('.ocw-chatlist-btn')).toBeNull()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('shows the chat-list button when onListChats is provided', () => {
|
|
33
|
+
const root = document.createElement('div')
|
|
34
|
+
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onListChats: vi.fn() }
|
|
35
|
+
const store = new ChatStore(asUserId(me))
|
|
36
|
+
const r = new Renderer(root, me, h)
|
|
37
|
+
store.apply(openedConv())
|
|
38
|
+
r.render(store)
|
|
39
|
+
expect(root.querySelector('.ocw-chatlist-btn')).not.toBeNull()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('opens the panel and renders one item per conversation, newest first as returned by the handler', async () => {
|
|
43
|
+
const entries: ChatListEntry[] = [
|
|
44
|
+
{ id: 'c_car_a', subjectId: 'car_a', subjectTitle: '2019 Toyota Camry', state: 'open', updatedAt: Date.now() },
|
|
45
|
+
{ id: 'c_car_b', subjectId: 'car_b', subjectTitle: '2021 Honda Civic', state: 'resolved', updatedAt: Date.now() - 1000 },
|
|
46
|
+
]
|
|
47
|
+
const onListChats = vi.fn().mockResolvedValue(entries)
|
|
48
|
+
const root = document.createElement('div')
|
|
49
|
+
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onListChats }
|
|
50
|
+
const store = new ChatStore(asUserId(me))
|
|
51
|
+
const r = new Renderer(root, me, h)
|
|
52
|
+
store.apply(openedConv())
|
|
53
|
+
r.render(store)
|
|
54
|
+
|
|
55
|
+
const btn = root.querySelector('.ocw-chatlist-btn') as HTMLButtonElement
|
|
56
|
+
btn.click()
|
|
57
|
+
expect(onListChats).toHaveBeenCalledTimes(1)
|
|
58
|
+
await Promise.resolve(); await Promise.resolve() // flush the .then()
|
|
59
|
+
|
|
60
|
+
expect(root.querySelector('.ocw-chatlist-panel')?.classList.contains('open')).toBe(true)
|
|
61
|
+
const items = root.querySelectorAll('.ocw-chatlist-item')
|
|
62
|
+
expect(items.length).toBe(2)
|
|
63
|
+
expect(items[0]!.textContent).toContain('2019 Toyota Camry')
|
|
64
|
+
expect(items[1]!.textContent).toContain('2021 Honda Civic')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('shows a general-enquiry label for an entry with no subjectTitle', async () => {
|
|
68
|
+
const entries: ChatListEntry[] = [
|
|
69
|
+
{ id: 'c_general', state: 'open', updatedAt: Date.now() },
|
|
70
|
+
]
|
|
71
|
+
const onListChats = vi.fn().mockResolvedValue(entries)
|
|
72
|
+
const root = document.createElement('div')
|
|
73
|
+
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onListChats }
|
|
74
|
+
const store = new ChatStore(asUserId(me))
|
|
75
|
+
const r = new Renderer(root, me, h)
|
|
76
|
+
store.apply(openedConv())
|
|
77
|
+
r.render(store)
|
|
78
|
+
|
|
79
|
+
;(root.querySelector('.ocw-chatlist-btn') as HTMLButtonElement).click()
|
|
80
|
+
await Promise.resolve(); await Promise.resolve()
|
|
81
|
+
|
|
82
|
+
expect(root.querySelector('.ocw-chatlist-item')?.textContent).toContain('General enquiry')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('shows an empty state when the guest has no conversations yet', async () => {
|
|
86
|
+
const onListChats = vi.fn().mockResolvedValue([])
|
|
87
|
+
const root = document.createElement('div')
|
|
88
|
+
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onListChats }
|
|
89
|
+
const store = new ChatStore(asUserId(me))
|
|
90
|
+
const r = new Renderer(root, me, h)
|
|
91
|
+
store.apply(openedConv())
|
|
92
|
+
r.render(store)
|
|
93
|
+
|
|
94
|
+
;(root.querySelector('.ocw-chatlist-btn') as HTMLButtonElement).click()
|
|
95
|
+
await Promise.resolve(); await Promise.resolve()
|
|
96
|
+
|
|
97
|
+
expect(root.querySelector('.ocw-chatlist-empty')?.textContent).toMatch(/no conversations/i)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('calls onSwitchChat with the clicked entry and closes the panel', async () => {
|
|
101
|
+
const entry: ChatListEntry = { id: 'c_car_a', subjectId: 'car_a', subjectTitle: '2019 Toyota Camry', state: 'open', updatedAt: Date.now() }
|
|
102
|
+
const onListChats = vi.fn().mockResolvedValue([entry])
|
|
103
|
+
const onSwitchChat = vi.fn()
|
|
104
|
+
const root = document.createElement('div')
|
|
105
|
+
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onListChats, onSwitchChat }
|
|
106
|
+
const store = new ChatStore(asUserId(me))
|
|
107
|
+
const r = new Renderer(root, me, h)
|
|
108
|
+
store.apply(openedConv())
|
|
109
|
+
r.render(store)
|
|
110
|
+
|
|
111
|
+
;(root.querySelector('.ocw-chatlist-btn') as HTMLButtonElement).click()
|
|
112
|
+
await Promise.resolve(); await Promise.resolve()
|
|
113
|
+
|
|
114
|
+
;(root.querySelector('.ocw-chatlist-item') as HTMLButtonElement).click()
|
|
115
|
+
expect(onSwitchChat).toHaveBeenCalledWith(entry)
|
|
116
|
+
expect(root.querySelector('.ocw-chatlist-panel')?.classList.contains('open')).toBe(false)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('shows a friendly error state if the handler rejects', async () => {
|
|
120
|
+
const onListChats = vi.fn().mockRejectedValue(new Error('network down'))
|
|
121
|
+
const root = document.createElement('div')
|
|
122
|
+
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onListChats }
|
|
123
|
+
const store = new ChatStore(asUserId(me))
|
|
124
|
+
const r = new Renderer(root, me, h)
|
|
125
|
+
store.apply(openedConv())
|
|
126
|
+
r.render(store)
|
|
127
|
+
|
|
128
|
+
;(root.querySelector('.ocw-chatlist-btn') as HTMLButtonElement).click()
|
|
129
|
+
await Promise.resolve(); await Promise.resolve()
|
|
130
|
+
|
|
131
|
+
expect(root.querySelector('.ocw-chatlist-empty')?.textContent).toMatch(/could not load/i)
|
|
132
|
+
})
|
|
133
|
+
})
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { encodeFrame, decodeFrame, type ClientFrame, type ServerFrame, asConversationId } from '../protocol/index.js'
|
|
3
|
+
import { ConnectionManager, type SocketLike } from '../connection.js'
|
|
4
|
+
|
|
5
|
+
class FakeSocket implements SocketLike {
|
|
6
|
+
binaryType = 'blob'
|
|
7
|
+
sent: ClientFrame[] = []
|
|
8
|
+
onopen: (() => void) | null = null
|
|
9
|
+
onclose: (() => void) | null = null
|
|
10
|
+
onerror: (() => void) | null = null
|
|
11
|
+
onmessage: ((ev: { data: ArrayBuffer }) => void) | null = null
|
|
12
|
+
closed = false
|
|
13
|
+
send(data: Uint8Array): void { this.sent.push(decodeFrame(data) as ClientFrame) }
|
|
14
|
+
close(): void { this.closed = true; this.onclose?.() }
|
|
15
|
+
// test helpers
|
|
16
|
+
open(): void { this.onopen?.() }
|
|
17
|
+
deliver(frame: ServerFrame): void {
|
|
18
|
+
const u8 = encodeFrame(frame)
|
|
19
|
+
this.onmessage?.({ data: u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength) })
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const open = { type: 'open', profileId: 'p1' as never, subjectId: 's1' as never } as Extract<ClientFrame, { type: 'open' }>
|
|
24
|
+
|
|
25
|
+
function setup(cursor = 0) {
|
|
26
|
+
const sockets: FakeSocket[] = []
|
|
27
|
+
const received: ServerFrame[] = []
|
|
28
|
+
const cm = new ConnectionManager({
|
|
29
|
+
url: 'ws://x', token: 'guest-alice', open,
|
|
30
|
+
onFrame: (f) => received.push(f),
|
|
31
|
+
getCursor: () => cursor,
|
|
32
|
+
socketFactory: () => { const s = new FakeSocket(); sockets.push(s); return s },
|
|
33
|
+
backoffBaseMs: 10, backoffMaxMs: 20,
|
|
34
|
+
})
|
|
35
|
+
return { cm, sockets, received }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('ConnectionManager', () => {
|
|
39
|
+
beforeEach(() => vi.useFakeTimers())
|
|
40
|
+
afterEach(() => vi.useRealTimers())
|
|
41
|
+
|
|
42
|
+
it('authenticates first, then opens and flushes the outbox after authed', () => {
|
|
43
|
+
const { cm, sockets } = setup()
|
|
44
|
+
cm.connect()
|
|
45
|
+
const s = sockets[0]!
|
|
46
|
+
// queue a send before auth completes — it must be buffered, not sent
|
|
47
|
+
cm.send({ type: 'send', conversationId: asConversationId('c1'), clientMsgId: 'm1', content: { kind: 'text', text: 'hi' } })
|
|
48
|
+
s.open()
|
|
49
|
+
expect(s.sent[0]).toMatchObject({ type: 'auth' })
|
|
50
|
+
expect(s.sent.some(f => f.type === 'send')).toBe(false) // not before authed
|
|
51
|
+
|
|
52
|
+
s.deliver({ type: 'authed', userId: 'alice' as never, connectionId: 'c' as never })
|
|
53
|
+
const types = s.sent.map(f => f.type)
|
|
54
|
+
expect(types).toContain('open') // open sent on authed
|
|
55
|
+
expect(types).toContain('send') // outbox flushed after
|
|
56
|
+
expect(types.indexOf('open')).toBeLessThan(types.indexOf('send'))
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('syncs from the cursor when a conversation is opened', () => {
|
|
60
|
+
const { cm, sockets } = setup(5)
|
|
61
|
+
cm.connect()
|
|
62
|
+
const s = sockets[0]!
|
|
63
|
+
s.open()
|
|
64
|
+
s.deliver({ type: 'authed', userId: 'alice' as never, connectionId: 'c' as never })
|
|
65
|
+
s.deliver({ type: 'opened', conversation: { id: asConversationId('c9'), tenantId: 't' as never, profileId: 'p1' as never, guestId: 'alice' as never, participants: [], state: 'open', lastSeq: 5, createdAt: 0, updatedAt: 0 } })
|
|
66
|
+
const sync = s.sent.find(f => f.type === 'sync')
|
|
67
|
+
expect(sync).toMatchObject({ type: 'sync', conversationId: 'c9', sinceSeq: 5 })
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('reconnects with backoff after an unexpected close', () => {
|
|
71
|
+
const { cm, sockets } = setup()
|
|
72
|
+
cm.connect()
|
|
73
|
+
sockets[0]!.open()
|
|
74
|
+
sockets[0]!.deliver({ type: 'authed', userId: 'a' as never, connectionId: 'c' as never })
|
|
75
|
+
sockets[0]!.onclose?.() // drop
|
|
76
|
+
expect(sockets).toHaveLength(1)
|
|
77
|
+
vi.advanceTimersByTime(50) // past backoff
|
|
78
|
+
expect(sockets.length).toBe(2) // reconnected with a new socket
|
|
79
|
+
sockets[1]!.open()
|
|
80
|
+
expect(sockets[1]!.sent[0]).toMatchObject({ type: 'auth' })
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('bounds the outbox so a long outage cannot grow memory unbounded', () => {
|
|
84
|
+
const sockets: FakeSocket[] = []
|
|
85
|
+
const cm = new ConnectionManager({
|
|
86
|
+
url: 'ws://x', token: 't', open,
|
|
87
|
+
onFrame: () => {}, getCursor: () => 0,
|
|
88
|
+
socketFactory: () => { const s = new FakeSocket(); sockets.push(s); return s },
|
|
89
|
+
maxOutbox: 2,
|
|
90
|
+
})
|
|
91
|
+
cm.connect()
|
|
92
|
+
const s = sockets[0]!
|
|
93
|
+
for (let i = 0; i < 5; i++) cm.send({ type: 'send', conversationId: asConversationId('c1'), clientMsgId: `m${i}`, content: { kind: 'text', text: 'x' } })
|
|
94
|
+
s.open()
|
|
95
|
+
s.deliver({ type: 'authed', userId: 'a' as never, connectionId: 'c' as never })
|
|
96
|
+
const sends = s.sent.filter(f => f.type === 'send')
|
|
97
|
+
expect(sends).toHaveLength(2) // capped
|
|
98
|
+
expect((sends[1] as { clientMsgId: string }).clientMsgId).toBe('m4') // newest kept
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('does not reconnect after an explicit close', () => {
|
|
102
|
+
const { cm, sockets } = setup()
|
|
103
|
+
cm.connect()
|
|
104
|
+
sockets[0]!.open()
|
|
105
|
+
cm.close()
|
|
106
|
+
vi.advanceTimersByTime(100)
|
|
107
|
+
expect(sockets).toHaveLength(1)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('ConnectionManager — status callbacks', () => {
|
|
112
|
+
beforeEach(() => { vi.useFakeTimers() })
|
|
113
|
+
afterEach(() => { vi.useRealTimers() })
|
|
114
|
+
|
|
115
|
+
it('emits "connecting" on first connect', () => {
|
|
116
|
+
const statuses: string[] = []
|
|
117
|
+
const sockets: FakeSocket[] = []
|
|
118
|
+
const cm = new ConnectionManager({
|
|
119
|
+
url: 'ws://x', token: 't', open,
|
|
120
|
+
getCursor: () => 0,
|
|
121
|
+
onFrame: () => {},
|
|
122
|
+
onStatusChange: (s) => statuses.push(s),
|
|
123
|
+
socketFactory: (url) => { const s = new FakeSocket(); sockets.push(s); return s },
|
|
124
|
+
})
|
|
125
|
+
cm.connect()
|
|
126
|
+
expect(statuses).toContain('connecting')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('emits "open" after successful auth', () => {
|
|
130
|
+
const statuses: string[] = []
|
|
131
|
+
const sockets: FakeSocket[] = []
|
|
132
|
+
const cm = new ConnectionManager({
|
|
133
|
+
url: 'ws://x', token: 't', open,
|
|
134
|
+
getCursor: () => 0,
|
|
135
|
+
onFrame: () => {},
|
|
136
|
+
onStatusChange: (s) => statuses.push(s),
|
|
137
|
+
socketFactory: () => { const s = new FakeSocket(); sockets.push(s); return s },
|
|
138
|
+
})
|
|
139
|
+
cm.connect()
|
|
140
|
+
sockets[0]!.open()
|
|
141
|
+
sockets[0]!.deliver({ type: 'authed', userId: 'u1' as never })
|
|
142
|
+
expect(statuses).toContain('open')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('emits "reconnecting" on second+ attempt', () => {
|
|
146
|
+
const statuses: string[] = []
|
|
147
|
+
const sockets: FakeSocket[] = []
|
|
148
|
+
const cm = new ConnectionManager({
|
|
149
|
+
url: 'ws://x', token: 't', open,
|
|
150
|
+
getCursor: () => 0,
|
|
151
|
+
onFrame: () => {},
|
|
152
|
+
onStatusChange: (s) => statuses.push(s),
|
|
153
|
+
backoffBaseMs: 10,
|
|
154
|
+
socketFactory: () => { const s = new FakeSocket(); sockets.push(s); return s },
|
|
155
|
+
})
|
|
156
|
+
cm.connect()
|
|
157
|
+
sockets[0]!.open()
|
|
158
|
+
sockets[0]!.deliver({ type: 'authed', userId: 'u1' as never })
|
|
159
|
+
sockets[0]!.close() // triggers reconnect
|
|
160
|
+
vi.advanceTimersByTime(100)
|
|
161
|
+
expect(statuses).toContain('reconnecting')
|
|
162
|
+
})
|
|
163
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { generateKeyPair, exportPublicKey, deriveSharedKey, encrypt, decrypt } from '../crypto.js'
|
|
3
|
+
|
|
4
|
+
describe('E2E crypto (ECDH + AES-GCM)', () => {
|
|
5
|
+
it('two parties derive the same key and round-trip a message', async () => {
|
|
6
|
+
const guest = await generateKeyPair()
|
|
7
|
+
const agent = await generateKeyPair()
|
|
8
|
+
const guestPub = await exportPublicKey(guest.publicKey)
|
|
9
|
+
const agentPub = await exportPublicKey(agent.publicKey)
|
|
10
|
+
|
|
11
|
+
// Guest encrypts to the agent; agent decrypts. Shared secret is symmetric.
|
|
12
|
+
const kGuest = await deriveSharedKey(guest.privateKey, agentPub)
|
|
13
|
+
const kAgent = await deriveSharedKey(agent.privateKey, guestPub)
|
|
14
|
+
|
|
15
|
+
const { ct, iv } = await encrypt(kGuest, 'is the price negotiable?')
|
|
16
|
+
expect(ct).not.toContain('negotiable') // actually encrypted
|
|
17
|
+
const plain = await decrypt(kAgent, ct, iv)
|
|
18
|
+
expect(plain).toBe('is the price negotiable?')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('a wrong key cannot decrypt', async () => {
|
|
22
|
+
const a = await generateKeyPair(); const b = await generateKeyPair(); const c = await generateKeyPair()
|
|
23
|
+
const kAB = await deriveSharedKey(a.privateKey, await exportPublicKey(b.publicKey))
|
|
24
|
+
const kAC = await deriveSharedKey(a.privateKey, await exportPublicKey(c.publicKey))
|
|
25
|
+
const { ct, iv } = await encrypt(kAB, 'secret')
|
|
26
|
+
await expect(decrypt(kAC, ct, iv)).rejects.toBeTruthy()
|
|
27
|
+
})
|
|
28
|
+
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { asUserId, asConversationId, asMessageId } from '../protocol/index.js'
|
|
3
|
+
import { ChatStore } from '../store.js'
|
|
4
|
+
import { httpBaseFromWsUrl, restoreHistory } from '../history.js'
|
|
5
|
+
|
|
6
|
+
describe('httpBaseFromWsUrl', () => {
|
|
7
|
+
it('strips /ws and converts wss to https', () => {
|
|
8
|
+
expect(httpBaseFromWsUrl('wss://api.paramms.com/ws')).toBe('https://api.paramms.com')
|
|
9
|
+
})
|
|
10
|
+
it('strips /ws and converts ws to http', () => {
|
|
11
|
+
expect(httpBaseFromWsUrl('ws://localhost:3000/ws')).toBe('http://localhost:3000')
|
|
12
|
+
})
|
|
13
|
+
it('handles a trailing slash after /ws', () => {
|
|
14
|
+
expect(httpBaseFromWsUrl('wss://api.paramms.com/ws/')).toBe('https://api.paramms.com')
|
|
15
|
+
})
|
|
16
|
+
it('is a no-op when there is no /ws suffix', () => {
|
|
17
|
+
expect(httpBaseFromWsUrl('wss://api.paramms.com')).toBe('https://api.paramms.com')
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('restoreHistory', () => {
|
|
22
|
+
const me = asUserId('g_test')
|
|
23
|
+
const cid = asConversationId('c1')
|
|
24
|
+
let fetchMock: ReturnType<typeof vi.fn>
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
fetchMock = vi.fn()
|
|
28
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
29
|
+
})
|
|
30
|
+
afterEach(() => { vi.unstubAllGlobals() })
|
|
31
|
+
|
|
32
|
+
function fakeRenderer() {
|
|
33
|
+
return { render: vi.fn() } as unknown as import('../renderer.js').Renderer
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
it('fetches the right URL with the auth header and applies messages to the store', async () => {
|
|
37
|
+
fetchMock.mockResolvedValue({
|
|
38
|
+
ok: true,
|
|
39
|
+
json: async () => ({
|
|
40
|
+
messages: [
|
|
41
|
+
{ id: asMessageId('m1'), conversationId: cid, seq: 1, senderId: me, senderRole: 'guest', content: { kind: 'text', text: 'hi' }, ts: 1 },
|
|
42
|
+
],
|
|
43
|
+
}),
|
|
44
|
+
})
|
|
45
|
+
const store = new ChatStore(me)
|
|
46
|
+
const renderer = fakeRenderer()
|
|
47
|
+
|
|
48
|
+
await restoreHistory('wss://api.paramms.com/ws', 'g_test', cid, store, renderer)
|
|
49
|
+
|
|
50
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
51
|
+
'https://api.paramms.com/conversations/c1/history',
|
|
52
|
+
{ headers: { authorization: 'Bearer g_test' } },
|
|
53
|
+
)
|
|
54
|
+
expect(store.messages()).toHaveLength(1)
|
|
55
|
+
expect(store.messages()[0]!.content).toEqual({ kind: 'text', text: 'hi' })
|
|
56
|
+
expect(renderer.render).toHaveBeenCalledWith(store)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('does nothing (no render) when the response has no messages', async () => {
|
|
60
|
+
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ messages: [] }) })
|
|
61
|
+
const store = new ChatStore(me)
|
|
62
|
+
const renderer = fakeRenderer()
|
|
63
|
+
await restoreHistory('wss://api.paramms.com/ws', 'g_test', cid, store, renderer)
|
|
64
|
+
expect(store.messages()).toHaveLength(0)
|
|
65
|
+
expect(renderer.render).not.toHaveBeenCalled()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('does nothing when the response is not ok (e.g. 403)', async () => {
|
|
69
|
+
fetchMock.mockResolvedValue({ ok: false, status: 403, json: async () => ({ error: 'not a participant' }) })
|
|
70
|
+
const store = new ChatStore(me)
|
|
71
|
+
const renderer = fakeRenderer()
|
|
72
|
+
await restoreHistory('wss://api.paramms.com/ws', 'g_test', cid, store, renderer)
|
|
73
|
+
expect(store.messages()).toHaveLength(0)
|
|
74
|
+
expect(renderer.render).not.toHaveBeenCalled()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('swallows network errors without throwing', async () => {
|
|
78
|
+
fetchMock.mockRejectedValue(new Error('network down'))
|
|
79
|
+
const store = new ChatStore(me)
|
|
80
|
+
const renderer = fakeRenderer()
|
|
81
|
+
await expect(restoreHistory('wss://api.paramms.com/ws', 'g_test', cid, store, renderer)).resolves.toBeUndefined()
|
|
82
|
+
expect(renderer.render).not.toHaveBeenCalled()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('swallows a malformed JSON response without throwing', async () => {
|
|
86
|
+
fetchMock.mockResolvedValue({ ok: true, json: async () => { throw new Error('bad json') } })
|
|
87
|
+
const store = new ChatStore(me)
|
|
88
|
+
const renderer = fakeRenderer()
|
|
89
|
+
await expect(restoreHistory('wss://api.paramms.com/ws', 'g_test', cid, store, renderer)).resolves.toBeUndefined()
|
|
90
|
+
})
|
|
91
|
+
})
|