@spotify-confidence/session-recording 0.0.0 → 0.17.3

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/package.json CHANGED
@@ -1 +1,49 @@
1
- {"name":"@spotify-confidence/session-recording","version":"0.0.0","description":"Placeholder"}
1
+ {
2
+ "name": "@spotify-confidence/session-recording",
3
+ "license": "Apache-2.0",
4
+ "version": "0.17.3",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/spotify/confidence-sdk-js.git",
8
+ "directory": "csr/session-recording"
9
+ },
10
+ "type": "module",
11
+ "main": "./dist/index.cjs",
12
+ "types": "./dist/index.d.ts",
13
+ "scripts": {
14
+ "prebuild": "node sync-version.mjs",
15
+ "build": "yarn run -T tsdown",
16
+ "typecheck": "tsc --noEmit"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "src"
21
+ ],
22
+ "publishConfig": {
23
+ "registry": "https://registry.npmjs.org/",
24
+ "access": "public",
25
+ "type": "module",
26
+ "main": "./dist/index.cjs",
27
+ "module": "./dist/index.js",
28
+ "types": "./dist/index.d.ts",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/index.js",
33
+ "require": "./dist/index.cjs"
34
+ }
35
+ }
36
+ },
37
+ "dependencies": {
38
+ "@spotify-confidence/csr-common": "^0.17.3",
39
+ "@spotify-confidence/csr-recorder": "^0.17.3"
40
+ },
41
+ "module": "./dist/index.js",
42
+ "exports": {
43
+ ".": {
44
+ "types": "./dist/index.d.ts",
45
+ "import": "./dist/index.js",
46
+ "require": "./dist/index.cjs"
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,96 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it, vi, beforeEach } from 'vitest';
3
+ import { observeFlags, type FlagWrite } from './flag-observer';
4
+
5
+ describe('observeFlags', () => {
6
+ beforeEach(() => {
7
+ delete (window as any).__confidence;
8
+ });
9
+
10
+ it('calls back when a flag is written after observation starts', () => {
11
+ const writes: FlagWrite[] = [];
12
+ observeFlags(w => writes.push(w));
13
+
14
+ (window as any).__confidence.flags['my-flag'] = { variant: 'treatment-a' };
15
+
16
+ expect(writes).toEqual([{ flagKey: 'my-flag', variant: 'treatment-a' }]);
17
+ });
18
+
19
+ it('emits snapshot entries for pre-existing flags', () => {
20
+ (window as any).__confidence = {
21
+ flags: {
22
+ 'flag-a': { variant: 'v1' },
23
+ 'flag-b': { variant: 'v2' },
24
+ },
25
+ };
26
+
27
+ const writes: FlagWrite[] = [];
28
+ observeFlags(w => writes.push(w));
29
+
30
+ expect(writes).toContainEqual({ flagKey: 'flag-a', variant: 'v1' });
31
+ expect(writes).toContainEqual({ flagKey: 'flag-b', variant: 'v2' });
32
+ });
33
+
34
+ it('observes new writes after reading the snapshot', () => {
35
+ (window as any).__confidence = {
36
+ flags: { existing: { variant: 'old' } },
37
+ };
38
+
39
+ const writes: FlagWrite[] = [];
40
+ observeFlags(w => writes.push(w));
41
+
42
+ (window as any).__confidence.flags['new-flag'] = { variant: 'new' };
43
+
44
+ expect(writes).toHaveLength(2);
45
+ expect(writes[0]).toEqual({ flagKey: 'existing', variant: 'old' });
46
+ expect(writes[1]).toEqual({ flagKey: 'new-flag', variant: 'new' });
47
+ });
48
+
49
+ it('cleanup replaces proxy with plain copy', () => {
50
+ const writes: FlagWrite[] = [];
51
+ const cleanup = observeFlags(w => writes.push(w));
52
+
53
+ (window as any).__confidence.flags['flag-a'] = { variant: 'v1' };
54
+ expect(writes).toHaveLength(1);
55
+
56
+ cleanup();
57
+
58
+ (window as any).__confidence.flags['flag-b'] = { variant: 'v2' };
59
+ expect(writes).toHaveLength(1);
60
+ });
61
+
62
+ it('preserves data after cleanup', () => {
63
+ observeFlags(() => {});
64
+ (window as any).__confidence.flags['my-flag'] = { variant: 'treatment' };
65
+
66
+ const cleanup = observeFlags(() => {});
67
+ cleanup();
68
+
69
+ expect((window as any).__confidence.flags['my-flag']).toEqual({ variant: 'treatment' });
70
+ });
71
+
72
+ it('ignores writes with missing variant', () => {
73
+ const writes: FlagWrite[] = [];
74
+ observeFlags(w => writes.push(w));
75
+
76
+ (window as any).__confidence.flags.bad = { noVariant: true };
77
+
78
+ expect(writes).toHaveLength(0);
79
+ });
80
+
81
+ it('ignores writes with non-string variant', () => {
82
+ const writes: FlagWrite[] = [];
83
+ observeFlags(w => writes.push(w));
84
+
85
+ (window as any).__confidence.flags.bad = { variant: 42 };
86
+
87
+ expect(writes).toHaveLength(0);
88
+ });
89
+
90
+ it('initializes window.__confidence if not present', () => {
91
+ observeFlags(() => {});
92
+
93
+ expect((window as any).__confidence).toBeDefined();
94
+ expect((window as any).__confidence.flags).toBeDefined();
95
+ });
96
+ });
@@ -0,0 +1,32 @@
1
+ export type FlagWrite = { flagKey: string; variant: string };
2
+ export type FlagWriteCallback = (write: FlagWrite) => void;
3
+
4
+ export function observeFlags(onFlagWrite: FlagWriteCallback): () => void {
5
+ if (typeof window === 'undefined') return () => {};
6
+
7
+ const confidence = ((window as any).__confidence ??= {});
8
+ const existing: Record<string, { variant: string }> = confidence.flags ?? {};
9
+ const target: Record<string, { variant: string }> = { ...existing };
10
+
11
+ const proxy = new Proxy(target, {
12
+ set(_target, prop, value) {
13
+ if (typeof prop === 'string' && value && typeof value.variant === 'string') {
14
+ _target[prop] = value;
15
+ onFlagWrite({ flagKey: prop, variant: value.variant });
16
+ }
17
+ return true;
18
+ },
19
+ });
20
+
21
+ confidence.flags = proxy;
22
+
23
+ for (const [name, data] of Object.entries(existing)) {
24
+ if (data && typeof (data as any).variant === 'string') {
25
+ onFlagWrite({ flagKey: name, variant: (data as any).variant });
26
+ }
27
+ }
28
+
29
+ return () => {
30
+ confidence.flags = { ...target };
31
+ };
32
+ }
@@ -0,0 +1,195 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const createUploader = vi.hoisted(() => vi.fn());
4
+ const record = vi.hoisted(() => vi.fn());
5
+
6
+ vi.mock('@spotify-confidence/csr-common/uploader', () => ({
7
+ createUploader,
8
+ }));
9
+ vi.mock('@spotify-confidence/csr-recorder', () => ({
10
+ record,
11
+ }));
12
+
13
+ import { initSessionRecorder } from './index';
14
+
15
+ function flushPromises() {
16
+ return new Promise(r => setTimeout(r, 0));
17
+ }
18
+
19
+ function mockUploader() {
20
+ const fn = Object.assign(vi.fn(), { close: vi.fn() });
21
+ return fn;
22
+ }
23
+
24
+ describe('initSessionRecorder', () => {
25
+ afterEach(() => vi.resetAllMocks());
26
+
27
+ it('always returns a SessionRecorder', () => {
28
+ createUploader.mockResolvedValueOnce(mockUploader());
29
+ record.mockReturnValueOnce(() => {});
30
+
31
+ const recorder = initSessionRecorder({ clientSecret: 'secret' });
32
+
33
+ expect(recorder.start).toBeTypeOf('function');
34
+ expect(recorder.stop).toBeTypeOf('function');
35
+ });
36
+
37
+ it('automatic mode inits and records immediately', async () => {
38
+ createUploader.mockResolvedValueOnce(mockUploader());
39
+ record.mockReturnValueOnce(() => {});
40
+
41
+ initSessionRecorder({ clientSecret: 'secret' });
42
+ await flushPromises();
43
+
44
+ expect(createUploader).toHaveBeenCalledOnce();
45
+ expect(createUploader.mock.calls[0][0]).toMatchObject({
46
+ forceRecord: false,
47
+ });
48
+ expect(record).toHaveBeenCalledOnce();
49
+ });
50
+
51
+ it('forwards options to createUploader and record', async () => {
52
+ createUploader.mockResolvedValueOnce(mockUploader());
53
+ record.mockReturnValueOnce(() => {});
54
+
55
+ const ctx = { buildVersion: '2.3.1' };
56
+ initSessionRecorder({
57
+ clientSecret: 'secret',
58
+ targetingKey: 'user-42',
59
+ context: ctx,
60
+ maskSelectors: ['.private'],
61
+ blockSelectors: ['video', '.third-party'],
62
+ maskInputs: false,
63
+ });
64
+ await flushPromises();
65
+
66
+ expect(createUploader.mock.calls[0][0]).toMatchObject({
67
+ apiUrl: 'https://recording.confidence.dev',
68
+ websocketUrl: 'wss://recording-ws.confidence.dev/sessions/stream',
69
+ clientSecret: 'secret',
70
+ targetingKey: 'user-42',
71
+ context: ctx,
72
+ });
73
+ expect(record.mock.calls[0][1]).toEqual({
74
+ maskSelectors: ['.private'],
75
+ blockSelectors: ['video', '.third-party'],
76
+ maskInputs: false,
77
+ });
78
+ });
79
+
80
+ it('automatic mode does not call record when backend skips', async () => {
81
+ createUploader.mockResolvedValueOnce(null);
82
+
83
+ initSessionRecorder({ clientSecret: 'secret' });
84
+ await flushPromises();
85
+
86
+ expect(record).not.toHaveBeenCalled();
87
+ });
88
+
89
+ it('does not throw when createUploader rejects', async () => {
90
+ createUploader.mockRejectedValueOnce(new Error('boom'));
91
+
92
+ const recorder = initSessionRecorder({ clientSecret: 'secret' });
93
+ await flushPromises();
94
+
95
+ expect(recorder).toBeDefined();
96
+ });
97
+
98
+ it('manual mode does not init until start is called', async () => {
99
+ createUploader.mockResolvedValueOnce(mockUploader());
100
+ record.mockReturnValueOnce(() => {});
101
+
102
+ const recorder = initSessionRecorder({
103
+ clientSecret: 'secret',
104
+ mode: 'manual',
105
+ });
106
+ await flushPromises();
107
+
108
+ expect(createUploader).not.toHaveBeenCalled();
109
+ expect(record).not.toHaveBeenCalled();
110
+
111
+ recorder.start();
112
+ await flushPromises();
113
+
114
+ expect(createUploader).toHaveBeenCalledOnce();
115
+ expect(createUploader.mock.calls[0][0]).toMatchObject({
116
+ forceRecord: true,
117
+ });
118
+ expect(record).toHaveBeenCalledOnce();
119
+ });
120
+
121
+ it('start is a no-op in automatic mode', async () => {
122
+ createUploader.mockResolvedValueOnce(mockUploader());
123
+ record.mockReturnValueOnce(() => {});
124
+
125
+ const recorder = initSessionRecorder({ clientSecret: 'secret' });
126
+ await flushPromises();
127
+
128
+ recorder.start();
129
+ await flushPromises();
130
+
131
+ expect(createUploader).toHaveBeenCalledOnce();
132
+ });
133
+
134
+ it('stop tears down the recorder and closes the transport', async () => {
135
+ const stopFn = vi.fn();
136
+ const uploader = mockUploader();
137
+ createUploader.mockResolvedValueOnce(uploader);
138
+ record.mockReturnValueOnce(stopFn);
139
+
140
+ const recorder = initSessionRecorder({ clientSecret: 'secret' });
141
+ await flushPromises();
142
+
143
+ recorder.stop();
144
+ expect(stopFn).toHaveBeenCalledOnce();
145
+ expect(uploader.close).toHaveBeenCalledOnce();
146
+
147
+ recorder.stop();
148
+ expect(stopFn).toHaveBeenCalledOnce();
149
+ expect(uploader.close).toHaveBeenCalledOnce();
150
+ });
151
+
152
+ it('isRecording reflects recording state', async () => {
153
+ const stopFn = vi.fn();
154
+ createUploader.mockResolvedValueOnce(mockUploader());
155
+ record.mockReturnValueOnce(stopFn);
156
+
157
+ const recorder = initSessionRecorder({ clientSecret: 'secret' });
158
+ expect(recorder.isRecording).toBe(false);
159
+
160
+ await flushPromises();
161
+ expect(recorder.isRecording).toBe(true);
162
+
163
+ recorder.stop();
164
+ expect(recorder.isRecording).toBe(false);
165
+ });
166
+
167
+ it('isRecording is false in manual mode before start', async () => {
168
+ createUploader.mockResolvedValueOnce(mockUploader());
169
+ record.mockReturnValueOnce(() => {});
170
+
171
+ const recorder = initSessionRecorder({
172
+ clientSecret: 'secret',
173
+ mode: 'manual',
174
+ });
175
+ await flushPromises();
176
+
177
+ expect(recorder.isRecording).toBe(false);
178
+
179
+ recorder.start();
180
+ await flushPromises();
181
+
182
+ expect(recorder.isRecording).toBe(true);
183
+ });
184
+
185
+ it('stop before init completes prevents recording', async () => {
186
+ createUploader.mockResolvedValueOnce(mockUploader());
187
+ record.mockReturnValueOnce(() => {});
188
+
189
+ const recorder = initSessionRecorder({ clientSecret: 'secret' });
190
+ recorder.stop();
191
+ await flushPromises();
192
+
193
+ expect(record).not.toHaveBeenCalled();
194
+ });
195
+ });
package/src/index.ts ADDED
@@ -0,0 +1,248 @@
1
+ import { record } from '@spotify-confidence/csr-recorder';
2
+ import type { ConsoleLogLevel } from '@spotify-confidence/csr-common';
3
+ import {
4
+ RecordingEventType,
5
+ type TagPluginData,
6
+ type MeasurePluginData,
7
+ type FlagEvaluationPluginData,
8
+ validateKey,
9
+ validateTagValue,
10
+ validateMeasureValue,
11
+ } from '@spotify-confidence/csr-common';
12
+ import { observeFlags } from './flag-observer';
13
+ import { createUploader, type ClientContext } from '@spotify-confidence/csr-common/uploader';
14
+ import { SDK_VERSION } from './version';
15
+
16
+ const DEFAULT_API_URL = 'https://recording.confidence.dev';
17
+ const DEFAULT_WEBSOCKET_URL = 'wss://recording-ws.confidence.dev/sessions/stream';
18
+ export interface InitSessionRecorderOptions {
19
+ /** Per-tenant secret. */
20
+ clientSecret: string;
21
+ /** End-user identifier (visitor / device id). */
22
+ targetingKey?: string;
23
+ /** CSS selectors whose text content should be masked. */
24
+ maskSelectors?: string[];
25
+ /** CSS selectors whose subtrees should be blocked (replaced with a placeholder, never serialized). */
26
+ blockSelectors?: string[];
27
+ /** Mask values of every `<input>` / `<textarea>` / `contenteditable`. Defaults to `true`. */
28
+ maskInputs?: boolean;
29
+ /** Capture browser console output. Defaults to `false`. Pass `true` for all levels or `{ levels: [...] }` for specific ones. */
30
+ captureConsoleLogs?: boolean | { levels: ConsoleLogLevel[] };
31
+ /** Capture fetch/XHR metadata (method, URL, status, duration). Defaults to `false`. */
32
+ captureNetworkRequests?: boolean;
33
+ /** Capture client-side route changes (pathname only). Defaults to `true`. */
34
+ captureRouteChanges?: boolean;
35
+ /**
36
+ * Transform a raw pathname into a route pattern before it is emitted in
37
+ * route-change and Meta events. For example, `/users/123/profile` becomes
38
+ * `/users/:id/profile`. This ensures per-page metrics are grouped by route
39
+ * rather than by individual page visit.
40
+ *
41
+ * Import `defaultParameterizeRoute` from `@spotify-confidence/csr-recorder`
42
+ * to compose with the built-in rules.
43
+ */
44
+ parameterizeRoute?: (route: string) => string;
45
+ /** Backend base URL. Defaults to the Confidence production endpoint. */
46
+ apiUrl?: string;
47
+ /** WebSocket ingest URL. Defaults to the Confidence production endpoint. */
48
+ websocketUrl?: string;
49
+ /** Application version or commit hash, e.g. "1.2.3" or "abc1234". Stored on the recording for filtering. */
50
+ appVersion?: string;
51
+ /** Custom dimensions merged into the session context alongside auto-collected browser metadata. */
52
+ context?: ClientContext;
53
+ /**
54
+ * `'automatic'` (default) — starts recording as soon as the session is established.
55
+ * `'manual'` — does nothing until `start()` is called, bypassing sampling and targeting rules.
56
+ */
57
+ mode?: 'automatic' | 'manual';
58
+ /** Verbose tracer for debugging — called with one-line lifecycle/transport messages. */
59
+ debugLogger?: (msg: string) => void;
60
+ }
61
+
62
+ export interface SessionRecorder {
63
+ /** Start recording. In `automatic` mode this is a no-op. In `manual` mode it establishes a session and begins recording. */
64
+ start(): void;
65
+ /** Stop recording permanently. Idempotent. */
66
+ stop(): void;
67
+ /** Attach a custom tag to this recording. Tags with the same key accumulate values. Omit value for a valueless marker. */
68
+ tag(key: string, value?: string): void;
69
+ /** Record a numeric measurement. Measurements with the same key are summed. Omit value to count occurrences (each call adds 1). */
70
+ measure(key: string, value?: number): void;
71
+ /** Whether the recorder is actively capturing events. */
72
+ readonly isRecording: boolean;
73
+ }
74
+
75
+ function csrDebugLogger(): ((msg: string) => void) | undefined {
76
+ try {
77
+ if (sessionStorage.getItem('CSR_DEBUG')) {
78
+ // Debug logger intentionally uses console — only active when CSR_DEBUG is set.
79
+ // eslint-disable-next-line no-console
80
+ return (msg: string) => console.log(msg);
81
+ }
82
+ } catch (_e) {
83
+ // sessionStorage may be unavailable (sandboxed iframe, etc.)
84
+ }
85
+ return undefined;
86
+ }
87
+
88
+ /**
89
+ * Create a session recorder. In `automatic` mode (default) recording begins
90
+ * as soon as a session is established. In `manual` mode nothing happens
91
+ * until {@link SessionRecorder.start} is called.
92
+ *
93
+ * Always returns a {@link SessionRecorder} — safe to call, never throws.
94
+ */
95
+ export function initSessionRecorder(options: InitSessionRecorderOptions): SessionRecorder {
96
+ const userLogger = options.debugLogger ?? csrDebugLogger();
97
+ const debugLogger = userLogger ? (msg: string) => userLogger(`[CSR] ${msg}`) : undefined;
98
+
99
+ let stopRecorder: (() => void) | null = null;
100
+ let closeUploader: (() => void) | null = null;
101
+ let stopObservingFlags: (() => void) | null = null;
102
+ let sendEvent: ((event: unknown) => void) | null = null;
103
+ let started = false;
104
+ let stopped = false;
105
+
106
+ const recordingConfig = {
107
+ maskSelectors: options.maskSelectors,
108
+ blockSelectors: options.blockSelectors,
109
+ maskInputs: options.maskInputs,
110
+ captureConsoleLogs: options.captureConsoleLogs,
111
+ captureNetworkRequests: options.captureNetworkRequests,
112
+ captureRouteChanges: options.captureRouteChanges,
113
+ parameterizeRoute: options.parameterizeRoute,
114
+ };
115
+
116
+ async function initAndRecord(forceRecord: boolean) {
117
+ try {
118
+ const uploader = await createUploader({
119
+ apiUrl: options.apiUrl ?? DEFAULT_API_URL,
120
+ websocketUrl: options.websocketUrl ?? DEFAULT_WEBSOCKET_URL,
121
+ clientSecret: options.clientSecret,
122
+ targetingKey: options.targetingKey,
123
+ context: {
124
+ ...options.context,
125
+ _csr_sdk_version: SDK_VERSION,
126
+ ...(options.appVersion ? { _app_version: options.appVersion } : {}),
127
+ },
128
+ forceRecord,
129
+ debugLogger,
130
+ onTerminate: ({ reason }) => {
131
+ debugLogger?.(`Recording terminated: ${reason}`);
132
+ stopObservingFlags?.();
133
+ stopObservingFlags = null;
134
+ stopRecorder?.();
135
+ stopRecorder = null;
136
+ stopped = true;
137
+ },
138
+ });
139
+
140
+ if (stopped) {
141
+ uploader?.close();
142
+ return;
143
+ }
144
+
145
+ if (uploader === null) {
146
+ debugLogger?.('Recording skipped by backend');
147
+ return;
148
+ }
149
+
150
+ closeUploader = () => uploader.close();
151
+
152
+ sendEvent = event => {
153
+ try {
154
+ uploader(event);
155
+ } catch (err) {
156
+ debugLogger?.(`Event dropped: ${err instanceof Error ? err.message : String(err)}`);
157
+ }
158
+ };
159
+
160
+ stopRecorder = record(sendEvent, recordingConfig);
161
+
162
+ stopObservingFlags = observeFlags(({ flagKey, variant }) => {
163
+ const data: FlagEvaluationPluginData = {
164
+ plugin: 'csr:flagEvaluation',
165
+ payload: { flagKey, variant },
166
+ };
167
+ sendEvent?.({
168
+ type: RecordingEventType.Plugin,
169
+ timestamp: Date.now(),
170
+ data,
171
+ });
172
+ });
173
+ } catch (err) {
174
+ debugLogger?.(`Recording disabled: ${err instanceof Error ? err.message : String(err)}`);
175
+ }
176
+ }
177
+
178
+ const mode = options.mode ?? 'automatic';
179
+
180
+ if (mode === 'automatic') {
181
+ started = true;
182
+ void initAndRecord(false);
183
+ }
184
+
185
+ return {
186
+ start() {
187
+ if (started || stopped) return;
188
+ started = true;
189
+ void initAndRecord(true);
190
+ },
191
+ stop() {
192
+ if (stopped) return;
193
+ stopped = true;
194
+ stopObservingFlags?.();
195
+ stopObservingFlags = null;
196
+ stopRecorder?.();
197
+ stopRecorder = null;
198
+ sendEvent = null;
199
+ closeUploader?.();
200
+ closeUploader = null;
201
+ },
202
+ tag(key: string, value?: string) {
203
+ const keyErr = validateKey(key);
204
+ if (keyErr) {
205
+ debugLogger?.(`tag() dropped: ${keyErr}`);
206
+ return;
207
+ }
208
+ const valErr = validateTagValue(value);
209
+ if (valErr) {
210
+ debugLogger?.(`tag() dropped: ${valErr}`);
211
+ return;
212
+ }
213
+ const data: TagPluginData = {
214
+ plugin: 'csr:tag',
215
+ payload: value !== undefined ? { key, value } : { key },
216
+ };
217
+ sendEvent?.({
218
+ type: RecordingEventType.Plugin,
219
+ timestamp: Date.now(),
220
+ data,
221
+ });
222
+ },
223
+ measure(key: string, value?: number) {
224
+ const keyErr = validateKey(key);
225
+ if (keyErr) {
226
+ debugLogger?.(`measure() dropped: ${keyErr}`);
227
+ return;
228
+ }
229
+ const valErr = validateMeasureValue(value);
230
+ if (valErr) {
231
+ debugLogger?.(`measure() dropped: ${valErr}`);
232
+ return;
233
+ }
234
+ const data: MeasurePluginData = {
235
+ plugin: 'csr:measure',
236
+ payload: value !== undefined ? { key, value } : { key },
237
+ };
238
+ sendEvent?.({
239
+ type: RecordingEventType.Plugin,
240
+ timestamp: Date.now(),
241
+ data,
242
+ });
243
+ },
244
+ get isRecording() {
245
+ return stopRecorder !== null;
246
+ },
247
+ };
248
+ }
package/src/version.ts ADDED
@@ -0,0 +1 @@
1
+ export const SDK_VERSION = '0.17.3';