@zoomrx/zena-chatbot-client 1.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.
- package/README.md +211 -0
- package/dist/favicon.svg +26 -0
- package/dist/mockServiceWorker.js +349 -0
- package/dist/zena-chatbot-client.iife.js +17950 -0
- package/dist/zena-chatbot-client.iife.js.map +1 -0
- package/dist/zena-chatbot-client.js +18327 -0
- package/dist/zena-chatbot-client.js.map +1 -0
- package/package.json +78 -0
package/README.md
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# Zena Chatbot
|
|
2
|
+
|
|
3
|
+
`@zoomrx/zena-chatbot-client` is a standalone web component (`<zena-chatbot>`) built with Svelte 5 and bundled via Vite into a single IIFE file. It is embedded inside host applications as a script tag — no framework dependency required on the host side.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install
|
|
9
|
+
npm run dev # dev server at http://localhost:5173
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
> Requires Node.js 24 or newer. Use `nvm use --lts` if you manage multiple runtimes.
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install @zoomrx/zena-chatbot-client
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Tech stack
|
|
23
|
+
|
|
24
|
+
- **Framework**: Svelte 5 (runes: `$state`, `$derived`, `$effect`, `$props`)
|
|
25
|
+
- **Build tool**: Vite 8 (outputs ES module + IIFE)
|
|
26
|
+
- **Styles**: Tailwind CSS 4
|
|
27
|
+
- **HTTP**: ky with a memoized instance factory
|
|
28
|
+
- **Testing**: Vitest + @testing-library/svelte + MSW
|
|
29
|
+
- **Error tracking**: Sentry (`@sentry/browser`)
|
|
30
|
+
- **Analytics**: PostHog
|
|
31
|
+
- **E2E**: Playwright
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Scripts
|
|
36
|
+
|
|
37
|
+
| Command | Purpose |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `npm run dev` | Dev server at http://localhost:5173 with MSW + PostHog stub |
|
|
40
|
+
| `npm run build` | Production IIFE + ES module in `dist/` |
|
|
41
|
+
| `npm run preview` | Serve the production build locally |
|
|
42
|
+
| `npm test -- --run` | Run all unit tests once |
|
|
43
|
+
| `npm run test:coverage` | Coverage report |
|
|
44
|
+
| `node e2e-test.cjs` | Playwright E2E test against localhost:5173 |
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Web Component API
|
|
49
|
+
|
|
50
|
+
### Usage
|
|
51
|
+
|
|
52
|
+
The recommended approach is to pass **all configuration via `get-config`** — one event, one place.
|
|
53
|
+
|
|
54
|
+
```html
|
|
55
|
+
<script src="path/to/zena-chatbot-client.iife.js"></script>
|
|
56
|
+
|
|
57
|
+
<zena-chatbot></zena-chatbot>
|
|
58
|
+
|
|
59
|
+
<script>
|
|
60
|
+
const chatbot = document.querySelector('zena-chatbot');
|
|
61
|
+
|
|
62
|
+
// All config — URLs, user ID, tokens, analytics keys — resolved in one place.
|
|
63
|
+
chatbot.on('get-config', ({ resolve }) => {
|
|
64
|
+
resolve({
|
|
65
|
+
authToken: 'Bearer eyJ...', // required
|
|
66
|
+
zenaServerUrl: 'https://your-zena-api.com',
|
|
67
|
+
panelistServerUrl: 'https://your-panelist-server.com',
|
|
68
|
+
userId: '12345',
|
|
69
|
+
availableSurveyIds: '[1, 2, 3]',
|
|
70
|
+
nativeApp: false,
|
|
71
|
+
fabStyle: { bottom: '20px', right: '20px' },
|
|
72
|
+
fabShape: 'circle',
|
|
73
|
+
autoSuggestions: ["I'm stuck in the survey"],
|
|
74
|
+
posthogKey: 'phc_xxx',
|
|
75
|
+
posthogHost: 'https://app.posthog.com',
|
|
76
|
+
sentryDsn: 'https://xxx@sentry.io/123',
|
|
77
|
+
sentryRelease: 'zena-chatbot-client@1.0.0', // optional
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
</script>
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
HTML attributes are also supported as a fallback (e.g. for static HTML pages), but `get-config` values take precedence when both are provided.
|
|
84
|
+
|
|
85
|
+
### HTML Attributes (fallback)
|
|
86
|
+
|
|
87
|
+
All of these can alternatively be passed via `get-config` (see Events below). When both are provided, `get-config` wins.
|
|
88
|
+
|
|
89
|
+
| Attribute | Type | Required | Default | Description |
|
|
90
|
+
|---|---|---|---|---|
|
|
91
|
+
| `zena-server-url` | `string` | Yes* | — | Base URL for the Zena API |
|
|
92
|
+
| `user-id` | `string` | No | `''` | User identifier passed to analytics |
|
|
93
|
+
| `available-survey-ids` | `string` (JSON array) | No | `'[]'` | Array of IDs (or objects with `survey_id`) currently on the user's dashboard |
|
|
94
|
+
| `panelist-server-url` | `string` | No | `''` | Base URL for the panelist server (survey fetching) |
|
|
95
|
+
| `native-app` | `boolean` (presence) | No | `false` | Set to enable native app mode |
|
|
96
|
+
| `fab-style` | `string` (JSON object) | No | `{}` | CSS properties for FAB position/size (e.g. `{"bottom":"20px","right":"20px"}`) |
|
|
97
|
+
| `fab-shape` | `string` | No | `'circle'` | `'circle'` or `'rectangle'` |
|
|
98
|
+
| `auto-suggestions` | `string` (JSON array) | No | `[]` | Priority suggestions prepended to defaults |
|
|
99
|
+
|
|
100
|
+
*Not required if `zenaServerUrl` is passed via `get-config`.
|
|
101
|
+
|
|
102
|
+
### Methods
|
|
103
|
+
|
|
104
|
+
| Method | Returns | Description |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| `close()` | `void` | Programmatically close the chat panel |
|
|
107
|
+
| `generateConversationSummary()` | `string` | Returns last user + bot message as plain text (useful for support ticket context) |
|
|
108
|
+
| `setFabStyle(style)` | `void` | Update FAB position/style without remounting — preserves chat state |
|
|
109
|
+
| `on(event, handler)` | `void` | Subscribe to a component event |
|
|
110
|
+
| `off(event, handler)` | `void` | Unsubscribe from a component event |
|
|
111
|
+
|
|
112
|
+
### Events
|
|
113
|
+
|
|
114
|
+
| Event | Payload | Description |
|
|
115
|
+
|---|---|---|
|
|
116
|
+
| `get-config` | `{ resolve }` | Fired once on init. Call `resolve(config)` to provide sensitive credentials. See below. |
|
|
117
|
+
| `before-send` | `{ message, resolve }` | Fired before each message is sent. Call `resolve(data)` with any extra data to merge into the request. |
|
|
118
|
+
| `stuck-survey-selected` | `{ survey }` | Fire-and-forget. Fired when the user selects a survey from a stuck-survey dropdown, before the support ticket is created. |
|
|
119
|
+
|
|
120
|
+
#### `get-config`
|
|
121
|
+
|
|
122
|
+
Fired once when the component connects. The host must call `resolve` with a
|
|
123
|
+
`ZenaChatbotConfig` object. If the host does not handle the event within 2 seconds
|
|
124
|
+
the component mounts with an empty auth token (API calls will fail).
|
|
125
|
+
|
|
126
|
+
```js
|
|
127
|
+
chatbot.on('get-config', ({ resolve }) => {
|
|
128
|
+
resolve({
|
|
129
|
+
// Credentials (required / recommended)
|
|
130
|
+
authToken: 'Bearer eyJ...', // required
|
|
131
|
+
sentryDsn: 'https://...', // optional — omit to disable Sentry
|
|
132
|
+
sentryRelease: '1.0.0', // optional — defaults to package version
|
|
133
|
+
posthogKey: 'phc_xxx', // optional — omit to disable analytics
|
|
134
|
+
posthogHost: 'https://app.posthog.com', // optional
|
|
135
|
+
|
|
136
|
+
// Component config (can also be set via HTML attributes as fallback)
|
|
137
|
+
zenaServerUrl: 'https://your-zena-api.com', // required
|
|
138
|
+
panelistServerUrl: 'https://your-panelist.com', // optional
|
|
139
|
+
userId: '12345', // optional
|
|
140
|
+
availableSurveyIds: '[1, 2, 3]', // optional
|
|
141
|
+
nativeApp: false, // optional
|
|
142
|
+
fabStyle: { bottom: '20px', right: '20px' }, // optional
|
|
143
|
+
fabShape: 'circle', // optional — 'circle' | 'rectangle'
|
|
144
|
+
autoSuggestions: ["I'm stuck in the survey"], // optional
|
|
145
|
+
defaultOpen: false, // optional — open panel on mount
|
|
146
|
+
initialMessage: { // optional — hidden trigger sent to server;
|
|
147
|
+
type: 'assistant', // only the assistant response is shown.
|
|
148
|
+
text: 'Hello! How can I help?', // Skipped on page reload automatically.
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
#### `before-send`
|
|
155
|
+
|
|
156
|
+
```js
|
|
157
|
+
chatbot.on('before-send', ({ message, resolve }) => {
|
|
158
|
+
resolve({ userId: 42, sessionToken: 'abc' });
|
|
159
|
+
});
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
#### `stuck-survey-selected`
|
|
163
|
+
|
|
164
|
+
Fired when the user picks a survey from a `stuck_survey_dropdown` response. Fires **before** the support-ticket API call — the host receives it even if ticket creation subsequently fails.
|
|
165
|
+
|
|
166
|
+
```js
|
|
167
|
+
chatbot.on('stuck-survey-selected', ({ survey }) => {
|
|
168
|
+
// survey — the selected survey object { name, survey_id, wave_id, users_wave_id, ... }
|
|
169
|
+
console.log('User is stuck in survey:', survey.name);
|
|
170
|
+
});
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Sentry Error Tracking
|
|
176
|
+
|
|
177
|
+
The package ships with Sentry support but **does not auto-initialize it**. Provide
|
|
178
|
+
`sentryDsn` via the `get-config` event — the component initialises Sentry after
|
|
179
|
+
credentials are resolved:
|
|
180
|
+
|
|
181
|
+
```js
|
|
182
|
+
chatbot.on('get-config', ({ resolve }) => {
|
|
183
|
+
resolve({
|
|
184
|
+
authToken: '...',
|
|
185
|
+
sentryDsn: 'https://YOUR_DSN@oXXXXXX.ingest.sentry.io/XXXXXX',
|
|
186
|
+
sentryRelease: 'zena-chatbot-client@1.0.0', // optional
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
- `sentryRelease` is optional. When omitted it defaults to `zena-chatbot-client@<package version>` and must match the release name used when uploading sourcemaps in CI (`sentry-cli sourcemaps upload`).
|
|
192
|
+
- Omit `sentryDsn` from the resolved config in local / UAT environments and Sentry will never initialise.
|
|
193
|
+
- `tracesSampleRate` is always `0` — errors only, no performance tracing overhead.
|
|
194
|
+
|
|
195
|
+
### Capturing errors manually
|
|
196
|
+
|
|
197
|
+
`captureError` is the only Sentry helper exported from the package:
|
|
198
|
+
|
|
199
|
+
```js
|
|
200
|
+
import { captureError } from '@zoomrx/zena-chatbot-client';
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
// ...
|
|
204
|
+
} catch (err) {
|
|
205
|
+
captureError(err);
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
For contribution guidelines, staging workflow, and publishing instructions see [CONTRIBUTING.md](CONTRIBUTING.md).
|
package/dist/favicon.svg
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<rect width="32" height="32" fill="black"/>
|
|
3
|
+
<path d="M15.3825 15.9305L7.84295 11.3761C7.59382 11.2254 7.36435 11.0374 7.17859 10.8079C5.90014 9.22135 6.94694 6.62292 9.13889 6.62292H21.6262C23.8181 6.62292 24.8671 9.22135 23.5887 10.8079C23.4051 11.0374 23.1735 11.2254 22.9243 11.3761L15.3847 15.9327L15.3825 15.9305Z" fill="url(#paint0_linear_281_133)"/>
|
|
4
|
+
<path d="M16.4403 15.8912L23.9799 20.4477C24.229 20.5985 24.4585 20.7864 24.6442 21.0159C25.9227 22.6047 24.8759 25.2009 22.6839 25.2009H10.1966C8.00468 25.2009 6.95788 22.6025 8.23633 21.0159C8.41991 20.7864 8.65156 20.5985 8.90069 20.4477L16.4403 15.8912Z" fill="url(#paint1_linear_281_133)"/>
|
|
5
|
+
<path d="M19.7601 7.55691L7.55689 19.7602C6.31256 21.0045 6.31256 23.022 7.55689 24.2663C8.80121 25.5106 10.8187 25.5106 12.063 24.2663L24.2663 12.063C25.5106 10.8187 25.5106 8.80124 24.2663 7.55692C23.0219 6.31259 21.0045 6.31259 19.7601 7.55691Z" fill="white"/>
|
|
6
|
+
<defs>
|
|
7
|
+
<linearGradient id="paint0_linear_281_133" x1="7.96315" y1="4.67793" x2="22.225" y2="13.3277" gradientUnits="userSpaceOnUse">
|
|
8
|
+
<stop stop-color="white"/>
|
|
9
|
+
<stop offset="0.17" stop-color="white" stop-opacity="0.78"/>
|
|
10
|
+
<stop offset="0.4" stop-color="white" stop-opacity="0.5"/>
|
|
11
|
+
<stop offset="0.6" stop-color="white" stop-opacity="0.29"/>
|
|
12
|
+
<stop offset="0.78" stop-color="white" stop-opacity="0.13"/>
|
|
13
|
+
<stop offset="0.92" stop-color="white" stop-opacity="0.04"/>
|
|
14
|
+
<stop offset="1" stop-color="white" stop-opacity="0"/>
|
|
15
|
+
</linearGradient>
|
|
16
|
+
<linearGradient id="paint1_linear_281_133" x1="23.8597" y1="27.1459" x2="9.59783" y2="18.494" gradientUnits="userSpaceOnUse">
|
|
17
|
+
<stop stop-color="white"/>
|
|
18
|
+
<stop offset="0.17" stop-color="white" stop-opacity="0.78"/>
|
|
19
|
+
<stop offset="0.4" stop-color="white" stop-opacity="0.5"/>
|
|
20
|
+
<stop offset="0.6" stop-color="white" stop-opacity="0.29"/>
|
|
21
|
+
<stop offset="0.78" stop-color="white" stop-opacity="0.13"/>
|
|
22
|
+
<stop offset="0.92" stop-color="white" stop-opacity="0.04"/>
|
|
23
|
+
<stop offset="1" stop-color="white" stop-opacity="0"/>
|
|
24
|
+
</linearGradient>
|
|
25
|
+
</defs>
|
|
26
|
+
</svg>
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
/* tslint:disable */
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Mock Service Worker.
|
|
6
|
+
* @see https://github.com/mswjs/msw
|
|
7
|
+
* - Please do NOT modify this file.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const PACKAGE_VERSION = '2.14.3'
|
|
11
|
+
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
|
12
|
+
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
|
13
|
+
const activeClientIds = new Set()
|
|
14
|
+
|
|
15
|
+
addEventListener('install', function () {
|
|
16
|
+
self.skipWaiting()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
addEventListener('activate', function (event) {
|
|
20
|
+
event.waitUntil(self.clients.claim())
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
addEventListener('message', async function (event) {
|
|
24
|
+
const clientId = Reflect.get(event.source || {}, 'id')
|
|
25
|
+
|
|
26
|
+
if (!clientId || !self.clients) {
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const client = await self.clients.get(clientId)
|
|
31
|
+
|
|
32
|
+
if (!client) {
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const allClients = await self.clients.matchAll({
|
|
37
|
+
type: 'window',
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
switch (event.data) {
|
|
41
|
+
case 'KEEPALIVE_REQUEST': {
|
|
42
|
+
sendToClient(client, {
|
|
43
|
+
type: 'KEEPALIVE_RESPONSE',
|
|
44
|
+
})
|
|
45
|
+
break
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
case 'INTEGRITY_CHECK_REQUEST': {
|
|
49
|
+
sendToClient(client, {
|
|
50
|
+
type: 'INTEGRITY_CHECK_RESPONSE',
|
|
51
|
+
payload: {
|
|
52
|
+
packageVersion: PACKAGE_VERSION,
|
|
53
|
+
checksum: INTEGRITY_CHECKSUM,
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
break
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
case 'MOCK_ACTIVATE': {
|
|
60
|
+
activeClientIds.add(clientId)
|
|
61
|
+
|
|
62
|
+
sendToClient(client, {
|
|
63
|
+
type: 'MOCKING_ENABLED',
|
|
64
|
+
payload: {
|
|
65
|
+
client: {
|
|
66
|
+
id: client.id,
|
|
67
|
+
frameType: client.frameType,
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
break
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
case 'CLIENT_CLOSED': {
|
|
75
|
+
activeClientIds.delete(clientId)
|
|
76
|
+
|
|
77
|
+
const remainingClients = allClients.filter((client) => {
|
|
78
|
+
return client.id !== clientId
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
// Unregister itself when there are no more clients
|
|
82
|
+
if (remainingClients.length === 0) {
|
|
83
|
+
self.registration.unregister()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
break
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
addEventListener('fetch', function (event) {
|
|
92
|
+
const requestInterceptedAt = Date.now()
|
|
93
|
+
|
|
94
|
+
// Bypass navigation requests.
|
|
95
|
+
if (event.request.mode === 'navigate') {
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Opening the DevTools triggers the "only-if-cached" request
|
|
100
|
+
// that cannot be handled by the worker. Bypass such requests.
|
|
101
|
+
if (
|
|
102
|
+
event.request.cache === 'only-if-cached' &&
|
|
103
|
+
event.request.mode !== 'same-origin'
|
|
104
|
+
) {
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Bypass all requests when there are no active clients.
|
|
109
|
+
// Prevents the self-unregistered worked from handling requests
|
|
110
|
+
// after it's been terminated (still remains active until the next reload).
|
|
111
|
+
if (activeClientIds.size === 0) {
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const requestId = crypto.randomUUID()
|
|
116
|
+
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @param {FetchEvent} event
|
|
121
|
+
* @param {string} requestId
|
|
122
|
+
* @param {number} requestInterceptedAt
|
|
123
|
+
*/
|
|
124
|
+
async function handleRequest(event, requestId, requestInterceptedAt) {
|
|
125
|
+
const client = await resolveMainClient(event)
|
|
126
|
+
const requestCloneForEvents = event.request.clone()
|
|
127
|
+
const response = await getResponse(
|
|
128
|
+
event,
|
|
129
|
+
client,
|
|
130
|
+
requestId,
|
|
131
|
+
requestInterceptedAt,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
// Send back the response clone for the "response:*" life-cycle events.
|
|
135
|
+
// Ensure MSW is active and ready to handle the message, otherwise
|
|
136
|
+
// this message will pend indefinitely.
|
|
137
|
+
if (client && activeClientIds.has(client.id)) {
|
|
138
|
+
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
|
139
|
+
|
|
140
|
+
// Clone the response so both the client and the library could consume it.
|
|
141
|
+
const responseClone = response.clone()
|
|
142
|
+
|
|
143
|
+
sendToClient(
|
|
144
|
+
client,
|
|
145
|
+
{
|
|
146
|
+
type: 'RESPONSE',
|
|
147
|
+
payload: {
|
|
148
|
+
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
|
149
|
+
request: {
|
|
150
|
+
id: requestId,
|
|
151
|
+
...serializedRequest,
|
|
152
|
+
},
|
|
153
|
+
response: {
|
|
154
|
+
type: responseClone.type,
|
|
155
|
+
status: responseClone.status,
|
|
156
|
+
statusText: responseClone.statusText,
|
|
157
|
+
headers: Object.fromEntries(responseClone.headers.entries()),
|
|
158
|
+
body: responseClone.body,
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return response
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Resolve the main client for the given event.
|
|
171
|
+
* Client that issues a request doesn't necessarily equal the client
|
|
172
|
+
* that registered the worker. It's with the latter the worker should
|
|
173
|
+
* communicate with during the response resolving phase.
|
|
174
|
+
* @param {FetchEvent} event
|
|
175
|
+
* @returns {Promise<Client | undefined>}
|
|
176
|
+
*/
|
|
177
|
+
async function resolveMainClient(event) {
|
|
178
|
+
const client = await self.clients.get(event.clientId)
|
|
179
|
+
|
|
180
|
+
if (activeClientIds.has(event.clientId)) {
|
|
181
|
+
return client
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (client?.frameType === 'top-level') {
|
|
185
|
+
return client
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const allClients = await self.clients.matchAll({
|
|
189
|
+
type: 'window',
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
return allClients
|
|
193
|
+
.filter((client) => {
|
|
194
|
+
// Get only those clients that are currently visible.
|
|
195
|
+
return client.visibilityState === 'visible'
|
|
196
|
+
})
|
|
197
|
+
.find((client) => {
|
|
198
|
+
// Find the client ID that's recorded in the
|
|
199
|
+
// set of clients that have registered the worker.
|
|
200
|
+
return activeClientIds.has(client.id)
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* @param {FetchEvent} event
|
|
206
|
+
* @param {Client | undefined} client
|
|
207
|
+
* @param {string} requestId
|
|
208
|
+
* @param {number} requestInterceptedAt
|
|
209
|
+
* @returns {Promise<Response>}
|
|
210
|
+
*/
|
|
211
|
+
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
|
212
|
+
// Clone the request because it might've been already used
|
|
213
|
+
// (i.e. its body has been read and sent to the client).
|
|
214
|
+
const requestClone = event.request.clone()
|
|
215
|
+
|
|
216
|
+
function passthrough() {
|
|
217
|
+
// Cast the request headers to a new Headers instance
|
|
218
|
+
// so the headers can be manipulated with.
|
|
219
|
+
const headers = new Headers(requestClone.headers)
|
|
220
|
+
|
|
221
|
+
// Remove the "accept" header value that marked this request as passthrough.
|
|
222
|
+
// This prevents request alteration and also keeps it compliant with the
|
|
223
|
+
// user-defined CORS policies.
|
|
224
|
+
const acceptHeader = headers.get('accept')
|
|
225
|
+
if (acceptHeader) {
|
|
226
|
+
const values = acceptHeader.split(',').map((value) => value.trim())
|
|
227
|
+
const filteredValues = values.filter(
|
|
228
|
+
(value) => value !== 'msw/passthrough',
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if (filteredValues.length > 0) {
|
|
232
|
+
headers.set('accept', filteredValues.join(', '))
|
|
233
|
+
} else {
|
|
234
|
+
headers.delete('accept')
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return fetch(requestClone, { headers })
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Bypass mocking when the client is not active.
|
|
242
|
+
if (!client) {
|
|
243
|
+
return passthrough()
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Bypass initial page load requests (i.e. static assets).
|
|
247
|
+
// The absence of the immediate/parent client in the map of the active clients
|
|
248
|
+
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
|
249
|
+
// and is not ready to handle requests.
|
|
250
|
+
if (!activeClientIds.has(client.id)) {
|
|
251
|
+
return passthrough()
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Notify the client that a request has been intercepted.
|
|
255
|
+
const serializedRequest = await serializeRequest(event.request)
|
|
256
|
+
const clientMessage = await sendToClient(
|
|
257
|
+
client,
|
|
258
|
+
{
|
|
259
|
+
type: 'REQUEST',
|
|
260
|
+
payload: {
|
|
261
|
+
id: requestId,
|
|
262
|
+
interceptedAt: requestInterceptedAt,
|
|
263
|
+
...serializedRequest,
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
[serializedRequest.body],
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
switch (clientMessage.type) {
|
|
270
|
+
case 'MOCK_RESPONSE': {
|
|
271
|
+
return respondWithMock(clientMessage.data)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
case 'PASSTHROUGH': {
|
|
275
|
+
return passthrough()
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return passthrough()
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* @param {Client} client
|
|
284
|
+
* @param {any} message
|
|
285
|
+
* @param {Array<Transferable>} transferrables
|
|
286
|
+
* @returns {Promise<any>}
|
|
287
|
+
*/
|
|
288
|
+
function sendToClient(client, message, transferrables = []) {
|
|
289
|
+
return new Promise((resolve, reject) => {
|
|
290
|
+
const channel = new MessageChannel()
|
|
291
|
+
|
|
292
|
+
channel.port1.onmessage = (event) => {
|
|
293
|
+
if (event.data && event.data.error) {
|
|
294
|
+
return reject(event.data.error)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
resolve(event.data)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
client.postMessage(message, [
|
|
301
|
+
channel.port2,
|
|
302
|
+
...transferrables.filter(Boolean),
|
|
303
|
+
])
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* @param {Response} response
|
|
309
|
+
* @returns {Response}
|
|
310
|
+
*/
|
|
311
|
+
function respondWithMock(response) {
|
|
312
|
+
// Setting response status code to 0 is a no-op.
|
|
313
|
+
// However, when responding with a "Response.error()", the produced Response
|
|
314
|
+
// instance will have status code set to 0. Since it's not possible to create
|
|
315
|
+
// a Response instance with status code 0, handle that use-case separately.
|
|
316
|
+
if (response.status === 0) {
|
|
317
|
+
return Response.error()
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const mockedResponse = new Response(response.body, response)
|
|
321
|
+
|
|
322
|
+
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
|
323
|
+
value: true,
|
|
324
|
+
enumerable: true,
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
return mockedResponse
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* @param {Request} request
|
|
332
|
+
*/
|
|
333
|
+
async function serializeRequest(request) {
|
|
334
|
+
return {
|
|
335
|
+
url: request.url,
|
|
336
|
+
mode: request.mode,
|
|
337
|
+
method: request.method,
|
|
338
|
+
headers: Object.fromEntries(request.headers.entries()),
|
|
339
|
+
cache: request.cache,
|
|
340
|
+
credentials: request.credentials,
|
|
341
|
+
destination: request.destination,
|
|
342
|
+
integrity: request.integrity,
|
|
343
|
+
redirect: request.redirect,
|
|
344
|
+
referrer: request.referrer,
|
|
345
|
+
referrerPolicy: request.referrerPolicy,
|
|
346
|
+
body: await request.arrayBuffer(),
|
|
347
|
+
keepalive: request.keepalive,
|
|
348
|
+
}
|
|
349
|
+
}
|