@spotify-confidence/csr-common 0.0.0 → 0.17.2

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/src/events.ts ADDED
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Mirrors rrweb's serialized-node types but decoupled — we own the enum.
3
+ */
4
+ export enum SerializedNodeType {
5
+ Document = 0,
6
+ DocumentType = 1,
7
+ Element = 2,
8
+ Text = 3,
9
+ CDATA = 4,
10
+ Comment = 5,
11
+ }
12
+
13
+ /**
14
+ * Mirrors rrweb event types but decoupled — we own the enum.
15
+ */
16
+ export enum RecordingEventType {
17
+ DomContentLoaded = 0,
18
+ Load = 1,
19
+ FullSnapshot = 2,
20
+ IncrementalSnapshot = 3,
21
+ Meta = 4,
22
+ Custom = 5,
23
+ Plugin = 6,
24
+ }
25
+
26
+ /**
27
+ * Incremental snapshot sub-types.
28
+ */
29
+ export enum IncrementalSource {
30
+ Mutation = 0,
31
+ MouseMove = 1,
32
+ MouseInteraction = 2,
33
+ Scroll = 3,
34
+ ViewportResize = 4,
35
+ Input = 5,
36
+ TouchMove = 6,
37
+ MediaInteraction = 7,
38
+ StyleSheetRule = 8,
39
+ CanvasMutation = 9,
40
+ Font = 10,
41
+ Log = 11,
42
+ Drag = 12,
43
+ StyleDeclaration = 13,
44
+ Selection = 14,
45
+ AdoptedStyleSheet = 15,
46
+ }
47
+
48
+ /**
49
+ * From rrweb MouseInteractions.
50
+ */
51
+ export enum MouseInteractions {
52
+ MouseUp = 0,
53
+ MouseDown = 1,
54
+ Click = 2,
55
+ ContextMenu = 3,
56
+ DblClick = 4,
57
+ Focus = 5,
58
+ Blur = 6,
59
+ TouchStart = 7,
60
+ TouchMove_Departed = 8,
61
+ TouchEnd = 9,
62
+ TouchCancel = 10,
63
+ }
64
+
65
+ export type RageClickCustomData = {
66
+ tag: 'csr:rageClick';
67
+ payload: {
68
+ targetId: number;
69
+ clickCount: number;
70
+ durationMs: number;
71
+ element?: ElementDescriptor;
72
+ pathname?: string;
73
+ };
74
+ };
75
+
76
+ export type FormFieldReEditCustomData = {
77
+ tag: 'csr:formFieldReEdit';
78
+ payload: {
79
+ targetId: number;
80
+ editCount: number;
81
+ element?: ElementDescriptor;
82
+ pathname?: string;
83
+ };
84
+ };
85
+
86
+ export type ScrollBackCustomData = {
87
+ tag: 'csr:scrollBack';
88
+ payload: {
89
+ scrollBackPx: number;
90
+ fromY: number;
91
+ toY: number;
92
+ pathname?: string;
93
+ };
94
+ };
95
+
96
+ export type ElementDescriptor = {
97
+ tagName: string;
98
+ classes?: string[];
99
+ textContent?: string;
100
+ attributes?: Record<string, string>;
101
+ };
102
+
103
+ export type ClickCustomData = {
104
+ tag: 'csr:click';
105
+ payload: {
106
+ targetId: number;
107
+ element?: ElementDescriptor;
108
+ pathname?: string;
109
+ };
110
+ };
111
+
112
+ export type InputCustomData = {
113
+ tag: 'csr:input';
114
+ payload: {
115
+ targetId: number;
116
+ element?: ElementDescriptor;
117
+ pathname?: string;
118
+ };
119
+ };
120
+
121
+ export type DeadClickCustomData = {
122
+ tag: 'csr:deadClick';
123
+ payload: {
124
+ targetId: number;
125
+ element?: ElementDescriptor;
126
+ pathname?: string;
127
+ };
128
+ };
129
+
130
+ export type TabUnfocusCustomData = {
131
+ tag: 'csr:tabUnfocus';
132
+ payload: {
133
+ pathname?: string;
134
+ };
135
+ };
136
+
137
+ export type TabRefocusCustomData = {
138
+ tag: 'csr:tabRefocus';
139
+ payload: {
140
+ awayDurationMs: number;
141
+ pathname?: string;
142
+ };
143
+ };
144
+
145
+ export type RouteChangeTrigger = 'pushState' | 'replaceState' | 'popstate' | 'navigation';
146
+
147
+ export type RouteChangePayload = {
148
+ from: string;
149
+ to: string;
150
+ trigger: RouteChangeTrigger;
151
+ };
152
+
153
+ export type RouteChangeCustomData = {
154
+ tag: 'csr:routeChange';
155
+ payload: RouteChangePayload;
156
+ };
157
+
158
+ /**
159
+ * Plugin event data emitted by the recorder for tab visibility changes.
160
+ */
161
+ export type TabVisibilityPluginData = {
162
+ plugin: 'csr:tabVisibility';
163
+ payload: { hidden: boolean };
164
+ };
165
+
166
+ export type ConsoleLogLevel = 'log' | 'warn' | 'error' | 'debug' | 'info';
167
+
168
+ /**
169
+ * Plugin event data emitted by rrweb's console record plugin.
170
+ * Shape matches `@rrweb/rrweb-plugin-console-record` LogData.
171
+ */
172
+ export type ConsoleLogPluginData = {
173
+ plugin: 'rrweb/console@1';
174
+ payload: {
175
+ level: ConsoleLogLevel;
176
+ payload: string[];
177
+ trace: string[];
178
+ };
179
+ };
180
+
181
+ export type NetworkRequestInitiator = 'fetch' | 'xhr';
182
+
183
+ /**
184
+ * Plugin event data emitted by the recorder for network requests.
185
+ */
186
+ export type NetworkRequestPluginData = {
187
+ plugin: 'csr:networkRequest';
188
+ payload: {
189
+ initiator: NetworkRequestInitiator;
190
+ method: string;
191
+ url: string;
192
+ status: number;
193
+ durationMs: number;
194
+ requestSize?: number;
195
+ responseSize?: number;
196
+ };
197
+ };
198
+
199
+ export type RouteChangePluginData = {
200
+ plugin: 'csr:routeChange';
201
+ payload: RouteChangePayload;
202
+ };
203
+
204
+ export type TagPluginData = {
205
+ plugin: 'csr:tag';
206
+ payload: {
207
+ key: string;
208
+ value?: string;
209
+ };
210
+ };
211
+
212
+ export type MeasurePluginData = {
213
+ plugin: 'csr:measure';
214
+ payload: {
215
+ key: string;
216
+ value?: number;
217
+ };
218
+ };
219
+
220
+ /**
221
+ * Closed union of every Custom event we emit. Add a new variant here when
222
+ * introducing a new tag — emitting an unregistered tag is a TS error.
223
+ */
224
+ export type CustomEventData =
225
+ | ClickCustomData
226
+ | InputCustomData
227
+ | RageClickCustomData
228
+ | FormFieldReEditCustomData
229
+ | ScrollBackCustomData
230
+ | DeadClickCustomData
231
+ | TabUnfocusCustomData
232
+ | TabRefocusCustomData
233
+ | RouteChangeCustomData;
234
+
235
+ /**
236
+ * A single recorded event.
237
+ *
238
+ * For non-Custom events `data` is `unknown` (decoupled from rrweb internals).
239
+ * For Custom events `data.tag` discriminates the payload across the closed
240
+ * `CustomEventData` union.
241
+ */
242
+ export type RecordingEvent =
243
+ | {
244
+ type: RecordingEventType.Custom;
245
+ timestamp: number;
246
+ data: CustomEventData;
247
+ }
248
+ | {
249
+ type: Exclude<RecordingEventType, RecordingEventType.Custom>;
250
+ timestamp: number;
251
+ data: unknown;
252
+ };
package/src/index.ts ADDED
@@ -0,0 +1,44 @@
1
+ export {
2
+ SerializedNodeType,
3
+ RecordingEventType,
4
+ IncrementalSource,
5
+ MouseInteractions,
6
+ type RecordingEvent,
7
+ type CustomEventData,
8
+ type ClickCustomData,
9
+ type InputCustomData,
10
+ type RageClickCustomData,
11
+ type FormFieldReEditCustomData,
12
+ type ScrollBackCustomData,
13
+ type DeadClickCustomData,
14
+ type ElementDescriptor,
15
+ type TabUnfocusCustomData,
16
+ type TabRefocusCustomData,
17
+ type TabVisibilityPluginData,
18
+ type ConsoleLogLevel,
19
+ type ConsoleLogPluginData,
20
+ type NetworkRequestInitiator,
21
+ type NetworkRequestPluginData,
22
+ type RouteChangeTrigger,
23
+ type RouteChangePayload,
24
+ type RouteChangeCustomData,
25
+ type RouteChangePluginData,
26
+ type TagPluginData,
27
+ type MeasurePluginData,
28
+ } from './events';
29
+
30
+ export { stripUrl } from './url';
31
+
32
+ export {
33
+ MAX_KEY_LENGTH,
34
+ MAX_TAG_VALUE_LENGTH,
35
+ MAX_DISTINCT_KEYS,
36
+ MAX_VALUES_PER_KEY,
37
+ validateKey,
38
+ validateTagValue,
39
+ validateMeasureValue,
40
+ } from './custom-event-limits';
41
+
42
+ export { type Frame } from './uploader/types';
43
+
44
+ export { type ClientContext, type UserAgentContext } from './uploader/client-context';
@@ -0,0 +1,3 @@
1
+ export { installMockWsServer } from './mock-ws-server';
2
+ export { installMockFetch, jsonResponse } from './mock-fetch';
3
+ export { createMockPort, type MockPort } from './mock-port';
@@ -0,0 +1,34 @@
1
+ import { onTestFinished, vi } from 'vitest';
2
+
3
+ /**
4
+ * Stub `globalThis.fetch` with a handler. `calls` records every invocation
5
+ * with its URL and `RequestInit`. Auto-restores when the current test ends.
6
+ *
7
+ * Must be called from within a test (or a helper called from one) so vitest
8
+ * has a test context to attach the cleanup to.
9
+ */
10
+ export function installMockFetch(
11
+ handler: (input: RequestInfo | URL, init?: RequestInit) => Response | Promise<Response>,
12
+ ): { calls: Array<{ url: string; init?: RequestInit }> } {
13
+ const calls: Array<{ url: string; init?: RequestInit }> = [];
14
+ const original = globalThis.fetch;
15
+ globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
16
+ calls.push({
17
+ url: typeof input === 'string' ? input : input.toString(),
18
+ init,
19
+ });
20
+ return handler(input, init);
21
+ }) as typeof fetch;
22
+ onTestFinished(() => {
23
+ globalThis.fetch = original;
24
+ });
25
+ return { calls };
26
+ }
27
+
28
+ /** Convenience for the JSON shape returned by the recording backend's init endpoint. */
29
+ export function jsonResponse(body: unknown, status = 200): Response {
30
+ return new Response(JSON.stringify(body), {
31
+ status,
32
+ headers: { 'Content-Type': 'application/json' },
33
+ });
34
+ }
@@ -0,0 +1,51 @@
1
+ import { vi } from 'vitest';
2
+ import type { PortAdapter } from '../uploader/worker/core';
3
+
4
+ /**
5
+ * In-memory `PortAdapter` for testing the worker `core.ts` directly without
6
+ * a real `Worker`.
7
+ *
8
+ * - `tabSends(msg)` simulates a tab posting into the worker (synchronous).
9
+ * - `received` captures everything the worker has posted back.
10
+ * - `next(predicate)` polls until a matching message arrives, then returns it.
11
+ * The cursor advances on each call so successive `next()`s walk forward
12
+ * through the buffer rather than re-matching earlier messages.
13
+ */
14
+ export interface MockPort {
15
+ adapter: PortAdapter;
16
+ tabSends: (message: unknown) => void;
17
+ received: unknown[];
18
+ next: <T = unknown>(predicate: (m: unknown) => boolean) => Promise<T>;
19
+ }
20
+
21
+ export function createMockPort(): MockPort {
22
+ const received: unknown[] = [];
23
+ let handler: ((data: unknown) => void) | null = null;
24
+ let cursor = 0;
25
+ const adapter: PortAdapter = {
26
+ postMessage: message => received.push(message),
27
+ onmessage: cb => {
28
+ handler = cb;
29
+ },
30
+ };
31
+ return {
32
+ adapter,
33
+ received,
34
+ tabSends: message => {
35
+ if (handler === null) {
36
+ throw new Error('no handler registered yet — register the port first');
37
+ }
38
+ handler(message);
39
+ },
40
+ next: <T = unknown>(predicate: (m: unknown) => boolean): Promise<T> =>
41
+ vi.waitFor(() => {
42
+ for (let i = cursor; i < received.length; i++) {
43
+ if (predicate(received[i])) {
44
+ cursor = i + 1;
45
+ return received[i] as T;
46
+ }
47
+ }
48
+ throw new Error('no matching message yet');
49
+ }),
50
+ };
51
+ }
@@ -0,0 +1,78 @@
1
+ import { Server, type Client } from 'mock-socket';
2
+ import { onTestFinished, vi } from 'vitest';
3
+
4
+ /**
5
+ * Spin up a mock WebSocket server at `url`. Replaces `globalThis.WebSocket`
6
+ * so code under test (`new WebSocket(...)`) connects to the mock. Auto-stops
7
+ * when the current test ends.
8
+ *
9
+ * Helpers (always prefer these over reading `connections`/`messages` directly):
10
+ * - `waitForConnection()` resolves with each successive client connection.
11
+ * - `nextMessage()` resolves with the next inbound (client → server) payload.
12
+ * - `nextMessages(n)` resolves once `n` payloads have been received.
13
+ *
14
+ * Must be called from within a test (or a helper called from one) so vitest
15
+ * has a test context to attach the cleanup to.
16
+ */
17
+ export function installMockWsServer(url: string): {
18
+ server: Server;
19
+ connections: Client[];
20
+ messages: string[];
21
+ waitForConnection: () => Promise<Client>;
22
+ nextMessage: () => Promise<string>;
23
+ nextMessages: (n: number) => Promise<string[]>;
24
+ } {
25
+ const server = new Server(url);
26
+ const connections: Client[] = [];
27
+ const messages: string[] = [];
28
+ let nextConnIndex = 0;
29
+ let nextMsgIndex = 0;
30
+
31
+ server.on('connection', (socket: Client) => {
32
+ connections.push(socket);
33
+ // mock-socket dispatches 'server::message' as a MessageEvent — pull the payload off `.data`.
34
+ socket.on('message', ((event: MessageEvent | string) => {
35
+ const data = typeof event === 'string' ? event : event.data;
36
+ messages.push(typeof data === 'string' ? data : String(data));
37
+ }) as (m: string | Blob | ArrayBuffer | ArrayBufferView) => void);
38
+ });
39
+
40
+ function waitForConnection(): Promise<Client> {
41
+ return vi.waitFor(() => {
42
+ if (nextConnIndex >= connections.length) {
43
+ throw new Error('no new connection yet');
44
+ }
45
+ const ws = connections[nextConnIndex];
46
+ nextConnIndex += 1;
47
+ return ws;
48
+ });
49
+ }
50
+
51
+ function nextMessage(): Promise<string> {
52
+ return vi.waitFor(() => {
53
+ if (nextMsgIndex >= messages.length) {
54
+ throw new Error('no new message yet');
55
+ }
56
+ const message = messages[nextMsgIndex];
57
+ nextMsgIndex += 1;
58
+ return message;
59
+ });
60
+ }
61
+
62
+ async function nextMessages(n: number): Promise<string[]> {
63
+ const out: string[] = [];
64
+ for (let i = 0; i < n; i++) out.push(await nextMessage());
65
+ return out;
66
+ }
67
+
68
+ onTestFinished(() => server.stop());
69
+
70
+ return {
71
+ server,
72
+ connections,
73
+ messages,
74
+ waitForConnection,
75
+ nextMessage,
76
+ nextMessages,
77
+ };
78
+ }
@@ -0,0 +1,149 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const parse = vi.hoisted(() => vi.fn());
4
+
5
+ vi.mock('bowser', () => ({ default: { parse } }));
6
+
7
+ import { collectUserAgentContext } from './client-context';
8
+
9
+ function stubBrowser(
10
+ overrides: {
11
+ navigator?: Record<string, unknown>;
12
+ window?: Record<string, unknown>;
13
+ document?: Record<string, unknown>;
14
+ } = {},
15
+ ) {
16
+ vi.stubGlobal('navigator', {
17
+ userAgent: 'Mozilla/5.0',
18
+ language: 'en-US',
19
+ ...overrides.navigator,
20
+ });
21
+ vi.stubGlobal('window', {
22
+ location: { origin: 'https://example.com', pathname: '/path' },
23
+ innerWidth: 1440,
24
+ innerHeight: 900,
25
+ screen: { width: 2560, height: 1440 },
26
+ devicePixelRatio: 2,
27
+ ...overrides.window,
28
+ });
29
+ vi.stubGlobal('document', {
30
+ referrer: 'https://referrer.example/',
31
+ ...overrides.document,
32
+ });
33
+ }
34
+
35
+ describe('collectUserAgentContext', () => {
36
+ beforeEach(() => {
37
+ parse.mockImplementation(() => ({
38
+ os: {},
39
+ browser: {},
40
+ platform: {},
41
+ engine: {},
42
+ }));
43
+ });
44
+
45
+ afterEach(() => {
46
+ vi.unstubAllGlobals();
47
+ vi.restoreAllMocks();
48
+ });
49
+
50
+ it('returns undefined in an SSR/Node context (no window)', () => {
51
+ expect(collectUserAgentContext()).toBeUndefined();
52
+ });
53
+
54
+ it('returns undefined when window exists but navigator does not', () => {
55
+ vi.stubGlobal('window', {});
56
+ vi.stubGlobal('navigator', undefined);
57
+ expect(collectUserAgentContext()).toBeUndefined();
58
+ });
59
+
60
+ it('captures all fields from a populated env', () => {
61
+ parse.mockReturnValueOnce({
62
+ os: { name: 'macOS' },
63
+ browser: { name: 'Chrome', version: '120.0.6099.225' },
64
+ platform: { type: 'desktop' },
65
+ engine: {},
66
+ });
67
+ stubBrowser();
68
+
69
+ expect(collectUserAgentContext()).toMatchObject({
70
+ userAgent: 'Mozilla/5.0',
71
+ os: 'macos',
72
+ browser: 'chrome',
73
+ browserVersion: '120',
74
+ mobile: false,
75
+ languageCode: 'en-US',
76
+ timeZone: expect.any(String),
77
+ viewportWidth: 1440,
78
+ viewportHeight: 900,
79
+ screenWidth: 2560,
80
+ screenHeight: 1440,
81
+ devicePixelRatio: 2,
82
+ uri: 'https://example.com/path',
83
+ referrer: 'https://referrer.example/',
84
+ });
85
+ });
86
+
87
+ it('flags `mobile` platform type as mobile=true', () => {
88
+ parse.mockReturnValueOnce({
89
+ os: {},
90
+ browser: {},
91
+ platform: { type: 'mobile' },
92
+ engine: {},
93
+ });
94
+ stubBrowser();
95
+ expect(collectUserAgentContext()?.mobile).toBe(true);
96
+ });
97
+
98
+ it('flags `tablet` platform type as mobile=true', () => {
99
+ parse.mockReturnValueOnce({
100
+ os: {},
101
+ browser: {},
102
+ platform: { type: 'tablet' },
103
+ engine: {},
104
+ });
105
+ stubBrowser();
106
+ expect(collectUserAgentContext()?.mobile).toBe(true);
107
+ });
108
+
109
+ it('omits os/browser/browserVersion/mobile when bowser returns nothing useful', () => {
110
+ stubBrowser();
111
+ const ctx = collectUserAgentContext();
112
+ expect(ctx?.os).toBeUndefined();
113
+ expect(ctx?.browser).toBeUndefined();
114
+ expect(ctx?.browserVersion).toBeUndefined();
115
+ expect(ctx?.mobile).toBeUndefined();
116
+ });
117
+
118
+ it('extracts major version from a full version string', () => {
119
+ parse.mockReturnValueOnce({
120
+ os: {},
121
+ browser: { name: 'Safari', version: '17.4.1' },
122
+ platform: {},
123
+ engine: {},
124
+ });
125
+ stubBrowser();
126
+ expect(collectUserAgentContext()?.browserVersion).toBe('17');
127
+ });
128
+
129
+ it('lowercases and strips whitespace from os/browser names', () => {
130
+ parse.mockReturnValueOnce({
131
+ os: { name: 'Chrome OS' },
132
+ browser: { name: 'Microsoft Edge', version: '120.0.0' },
133
+ platform: {},
134
+ engine: {},
135
+ });
136
+ stubBrowser();
137
+ const ctx = collectUserAgentContext();
138
+ expect(ctx?.os).toBe('chromeos');
139
+ expect(ctx?.browser).toBe('microsoftedge');
140
+ });
141
+
142
+ it('omits timeZone when Intl.DateTimeFormat throws', () => {
143
+ stubBrowser();
144
+ vi.spyOn(Intl, 'DateTimeFormat').mockImplementation(() => {
145
+ throw new Error('embedded context with no Intl support');
146
+ });
147
+ expect(collectUserAgentContext()?.timeZone).toBeUndefined();
148
+ });
149
+ });
@@ -0,0 +1,70 @@
1
+ import Bowser from 'bowser';
2
+
3
+ /**
4
+ * JSON-shaped values accepted in a Context — matches `google.protobuf.Struct`.
5
+ */
6
+ export type ContextValue = string | number | boolean | null | ContextValue[] | { [key: string]: ContextValue };
7
+
8
+ /**
9
+ * Browser-environment metadata captured at session init. Sent verbatim in the
10
+ * `context` field of the InitSession request.
11
+ */
12
+ export type UserAgentContext = {
13
+ userAgent?: string;
14
+ /** Coarse OS family — `windows`, `macos`, `ios`, `android`, `linux`, or `unknown`. */
15
+ os?: string;
16
+ /** Coarse browser family — `chrome`, `firefox`, `safari`, `edge`, or `unknown`. */
17
+ browser?: string;
18
+ /** Browser major version. */
19
+ browserVersion?: string;
20
+ mobile?: boolean;
21
+ /** BCP-47 language tag (e.g. `en-US`). */
22
+ languageCode?: string;
23
+ /** IANA time zone (e.g. `Europe/Stockholm`). */
24
+ timeZone?: string;
25
+ /** Viewport in CSS pixels. */
26
+ viewportWidth?: number;
27
+ viewportHeight?: number;
28
+ /** Physical screen in CSS pixels. */
29
+ screenWidth?: number;
30
+ screenHeight?: number;
31
+ devicePixelRatio?: number;
32
+ /** Initial document URI — without query/hash to avoid leaking PII. */
33
+ uri?: string;
34
+ referrer?: string;
35
+ };
36
+
37
+ export interface ClientContext {
38
+ userAgent?: UserAgentContext;
39
+ [key: string]: ContextValue | undefined;
40
+ }
41
+
42
+ export function collectUserAgentContext(): UserAgentContext | undefined {
43
+ if (typeof window === 'undefined' || typeof navigator === 'undefined') return undefined;
44
+
45
+ const parsed = Bowser.parse(navigator.userAgent);
46
+
47
+ let timeZone: string | undefined;
48
+ try {
49
+ timeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone;
50
+ } catch (_e) {
51
+ // Some embedded contexts throw.
52
+ }
53
+
54
+ return {
55
+ userAgent: navigator.userAgent,
56
+ os: parsed.os.name?.toLowerCase().replace(/\s+/g, ''),
57
+ browser: parsed.browser.name?.toLowerCase().replace(/\s+/g, ''),
58
+ browserVersion: parsed.browser.version?.split('.')[0],
59
+ mobile: parsed.platform.type ? parsed.platform.type === 'mobile' || parsed.platform.type === 'tablet' : undefined,
60
+ languageCode: navigator.language,
61
+ timeZone,
62
+ viewportWidth: window.innerWidth,
63
+ viewportHeight: window.innerHeight,
64
+ screenWidth: window.screen.width,
65
+ screenHeight: window.screen.height,
66
+ devicePixelRatio: window.devicePixelRatio,
67
+ uri: `${window.location.origin}${window.location.pathname}`,
68
+ referrer: document.referrer,
69
+ };
70
+ }