@trycourier/courier-js
Version:
A browser-safe API wrapper
1 lines • 95.5 kB
Source Map (JSON)
{"version":3,"file":"index.mjs","sources":["../src/types/socket/protocol/messages.ts","../src/types/courier-api-urls.ts","../src/utils/logger.ts","../src/utils/uuid.ts","../src/utils/request.ts","../src/client/client.ts","../src/client/brand-client.ts","../src/types/socket/protocol/errors.ts","../src/socket/version.ts","../src/socket/courier-socket.ts","../src/socket/courier-inbox-transaction-manager.ts","../src/socket/inbox-message-utils.ts","../src/socket/courier-inbox-socket.ts","../src/client/inbox-client.ts","../src/types/preference.ts","../src/utils/coding.ts","../src/client/preference-client.ts","../src/client/token-client.ts","../src/client/list-client.ts","../src/client/tracking-client.ts","../src/client/courier-client.ts","../src/shared/authentication-listener.ts","../src/shared/courier.ts"],"sourcesContent":["import { InboxMessage } from \"../../inbox\";\n\n/** Client actions. */\nexport enum ClientAction {\n /** Subscribe to various events for a particular channel. */\n Subscribe = 'subscribe',\n\n /** Unsubscribe from a channel. */\n Unsubscribe = 'unsubscribe',\n\n /** Pong response to a ping message from the server. */\n Pong = 'pong',\n\n /** Ping the server to keep the connection alive. */\n Ping = 'ping',\n\n /** Get the current configuration. */\n GetConfig = 'get-config',\n}\n\n/** Client request envelope. */\nexport interface ClientMessageEnvelope {\n /**\n * Transaction ID.\n *\n * This is a UUID generated per-socket message.\n *\n * The server response should include the same transaction ID.\n */\n tid: string;\n\n /** Requested action for the server to perform. */\n action: ClientAction;\n\n /** Optional: Statistics describing past requests and/or client state. */\n stats?: Record<string, any>;\n\n /** Optional: Payload for the request, varying by action. */\n data?: Record<string, any>;\n}\n\nexport enum ServerAction {\n /** Ping message from the server. */\n Ping = 'ping',\n}\n\n/**\n * Server action envelope.\n *\n * This is a request for the client to perform an action and respond to the server.\n */\nexport interface ServerActionEnvelope {\n /** Transaction ID. */\n tid: string;\n\n /** Action from the server. */\n action: ServerAction;\n}\n\n/** Server response types. */\nexport enum ServerResponse {\n /** Response to an action request. */\n Ack = 'ack',\n\n /** Response to a ping request. */\n Pong = 'pong',\n}\n\n/**\n * Server response envelope.\n *\n * This is a response from the server to a {@link ClientAction} (ping, subscribe, get-config, etc.).\n */\nexport interface ServerResponseEnvelope {\n /** Transaction ID. */\n tid: string;\n\n /** Response from the server. */\n response: ServerResponse;\n\n /** Optional: Payload for the response, varying by response. */\n data?: Record<string, any>;\n}\n\n/** Message event types broadcast by the server. */\nexport enum InboxMessageEvent {\n NewMessage = 'message',\n Archive = 'archive',\n ArchiveAll = 'archive-all',\n ArchiveRead = 'archive-read',\n Clicked = 'clicked',\n MarkAllRead = 'mark-all-read',\n Opened = 'opened',\n Read = 'read',\n Unarchive = 'unarchive',\n Unopened = 'unopened',\n Unread = 'unread',\n}\n\n/** Envelope for an inbox message event. */\nexport interface InboxMessageEventEnvelope {\n /** Event type indicating a new message, or a mutation to one or more existing messages. */\n event: InboxMessageEvent;\n\n /**\n * Optional:Message ID.\n *\n * messageId is present for events that mutate a single message (e.g. read, unread, archive, etc.).\n */\n messageId?: string;\n\n /** Optional: Message data, varying by event.\n *\n * For {@link InboxMessageEvent.NewMessage}, this is an {@link InboxMessage}.\n * For other events this is undefined.\n */\n data?: InboxMessage;\n}\n\n/** Message sent by the server to indicate that the client should reconnect. */\nexport interface ReconnectMessage {\n /** Event type indicating a reconnection. */\n event: 'reconnect';\n\n /** Message describing the reason for the reconnection. */\n message: string;\n\n /** Seconds after which the client should retry the connection. */\n retryAfter: number;\n\n /** https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code */\n code: number;\n}\n\n/** Configuration for the client. */\nexport interface Config {\n /** The time interval in milliseconds between client ping messages to the server. */\n pingInterval: number;\n\n /**\n * Maximum number of outstanding pings before the client should\n * close the connection and retry connecting.\n */\n maxOutstandingPings: number;\n}\n\n/** Envelope for a config response. */\nexport interface ConfigResponseEnvelope {\n /** Transaction ID. */\n tid: string;\n\n response: 'config';\n\n /** Configuration data for the client. */\n data: Config;\n}\n\nexport type ServerMessage =\n | ConfigResponseEnvelope\n | InboxMessageEventEnvelope\n | ReconnectMessage\n | ServerActionEnvelope\n | ServerResponseEnvelope;\n","export interface CourierApiUrls {\n courier: {\n rest: string;\n graphql: string;\n },\n inbox: {\n graphql: string;\n webSocket: string;\n }\n}\n\nexport const getCourierApiUrls = (urls?: CourierApiUrls): CourierApiUrls => ({\n courier: {\n rest: urls?.courier.rest || 'https://api.courier.com',\n graphql: urls?.courier.graphql || 'https://api.courier.com/client/q',\n },\n inbox: {\n graphql: urls?.inbox.graphql || 'https://inbox.courier.com/q',\n webSocket: urls?.inbox.webSocket || 'wss://realtime.courier.io'\n }\n});","export class Logger {\n\n private readonly PREFIX = '[COURIER]';\n\n constructor(private readonly showLogs: boolean) { }\n\n public warn(message: string, ...args: any[]): void {\n if (this.showLogs) {\n console.warn(`${this.PREFIX} ${message}`, ...args);\n }\n }\n\n public log(message: string, ...args: any[]): void {\n if (this.showLogs) {\n console.log(`${this.PREFIX} ${message}`, ...args);\n }\n }\n\n public error(message: string, ...args: any[]): void {\n if (this.showLogs) {\n console.error(`${this.PREFIX} ${message}`, ...args);\n }\n }\n\n public debug(message: string, ...args: any[]): void {\n if (this.showLogs) {\n console.debug(`${this.PREFIX} ${message}`, ...args);\n }\n }\n\n public info(message: string, ...args: any[]): void {\n if (this.showLogs) {\n console.info(`${this.PREFIX} ${message}`, ...args);\n }\n }\n}\n","export class UUID {\n\n private static readonly ALPHABET = 'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict';\n\n /**\n * nanoid\n * Copyright 2017 Andrey Sitnik <andrey@sitnik.ru>\n *\n * https://github.com/ai/nanoid/blob/main/LICENSE\n *\n * @param size - The size of the UUID to generate.\n * @returns A string representing the UUID.\n */\n static nanoid(size: number = 21): string {\n let id = '';\n let bytes = crypto.getRandomValues(new Uint8Array((size |= 0)));\n\n while (size--) {\n // Using the bitwise AND operator to \"cap\" the value of\n // the random byte from 255 to 63, in that way we can make sure\n // that the value will be a valid index for the \"chars\" string.\n id += UUID.ALPHABET[bytes[size] & 63]\n }\n return id;\n }\n\n}","import { CourierClientOptions } from \"../client/courier-client\";\nimport { Logger } from \"./logger\";\nimport { UUID } from \"./uuid\";\n\nexport class CourierRequestError extends Error {\n constructor(\n public code: number,\n message: string,\n public type?: string\n ) {\n super(message);\n this.name = 'CourierRequestError';\n }\n}\n\nfunction logRequest(logger: Logger, uid: string, type: 'HTTP' | 'GraphQL', data: {\n url: string;\n method?: string;\n headers: Record<string, string>;\n body?: any;\n query?: string;\n variables?: Record<string, any>;\n}) {\n logger.log(`\n📡 New Courier ${type} Request: ${uid}\nURL: ${data.url}\n${data.method ? `Method: ${data.method}` : ''}\n${data.query ? `Query: ${data.query}` : ''}\n${data.variables ? `Variables: ${JSON.stringify(data.variables, null, 2)}` : ''}\nHeaders: ${JSON.stringify(data.headers, null, 2)}\nBody: ${data.body ? JSON.stringify(data.body, null, 2) : 'Empty'}\n `);\n}\n\nfunction logResponse(logger: Logger, uid: string, type: 'HTTP' | 'GraphQL', data: {\n status: number;\n response: any;\n}) {\n logger.log(`\n📡 New Courier ${type} Response: ${uid}\nStatus Code: ${data.status}\nResponse JSON: ${JSON.stringify(data.response, null, 2)}\n `);\n}\n\nexport async function http(props: {\n url: string,\n options: CourierClientOptions,\n method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',\n headers?: Record<string, string>,\n body?: any,\n validCodes?: number[]\n}): Promise<any> {\n const validCodes = props.validCodes ?? [200];\n const uid = props.options.showLogs ? UUID.nanoid() : undefined;\n\n // Create request\n const request = new Request(props.url, {\n method: props.method,\n headers: {\n 'Content-Type': 'application/json',\n ...props.headers\n },\n body: props.body ? JSON.stringify(props.body) : undefined\n });\n\n // Log request if enabled\n if (uid) {\n logRequest(props.options.logger, uid, 'HTTP', {\n url: request.url,\n method: request.method,\n headers: Object.fromEntries(request.headers.entries()),\n body: props.body\n });\n }\n\n // Perform request\n const response = await fetch(request);\n\n // Handle empty responses (like 204 No Content)\n if (response.status === 204) {\n return;\n }\n\n // Try to parse JSON response\n let data;\n try {\n data = await response.json();\n } catch (error) {\n\n // Weird fallback for only tracking url events :facepalm:\n if (response.status === 200) {\n return;\n }\n\n throw new CourierRequestError(\n response.status,\n 'Failed to parse response as JSON',\n 'PARSE_ERROR'\n );\n }\n\n // Log response if enabled\n if (uid) {\n logResponse(props.options.logger, uid, 'HTTP', {\n status: response.status,\n response: data\n });\n }\n\n // Handle invalid status codes\n if (!validCodes.includes(response.status)) {\n throw new CourierRequestError(\n response.status,\n data?.message || 'Unknown Error',\n data?.type\n );\n }\n\n return data;\n}\n\nexport async function graphql(props: {\n url: string,\n options: CourierClientOptions,\n headers: Record<string, string>,\n query: string,\n variables?: Record<string, any>\n}): Promise<any> {\n const uid = props.options.showLogs ? UUID.nanoid() : undefined;\n\n // Log request if enabled\n if (uid) {\n logRequest(props.options.logger, uid, 'GraphQL', {\n url: props.url,\n headers: props.headers,\n query: props.query,\n variables: props.variables\n });\n }\n\n const response = await fetch(props.url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n ...props.headers\n },\n body: JSON.stringify({\n query: props.query,\n variables: props.variables\n })\n });\n\n // Try to parse JSON response\n let data;\n try {\n data = await response.json();\n } catch (error) {\n throw new CourierRequestError(\n response.status,\n 'Failed to parse response as JSON',\n 'PARSE_ERROR'\n );\n }\n\n // Log response if enabled\n if (uid) {\n logResponse(props.options.logger, uid, 'GraphQL', {\n status: response.status,\n response: data\n });\n }\n\n if (!response.ok) {\n throw new CourierRequestError(\n response.status,\n data?.message || 'Unknown Error',\n data?.type\n );\n }\n\n return data;\n}\n","import { CourierClientOptions } from \"./courier-client\";\n\nexport class Client {\n\n constructor(public readonly options: CourierClientOptions) { }\n\n}\n","import { CourierBrand } from '../types/brands';\nimport { graphql } from '../utils/request';\nimport { Client } from './client';\n\nexport class BrandClient extends Client {\n\n /**\n * Get a brand by ID using GraphQL\n * @param brandId - The ID of the brand to retrieve\n * @returns Promise resolving to the requested brand\n */\n public async getBrand(props: { brandId: string }): Promise<CourierBrand> {\n const query = `\n query GetBrand {\n brand(brandId: \"${props.brandId}\") {\n settings {\n colors {\n primary\n secondary\n tertiary\n }\n inapp {\n borderRadius\n disableCourierFooter\n }\n }\n }\n }\n `;\n\n const json = await graphql({\n options: this.options,\n url: this.options.apiUrls.courier.graphql,\n headers: {\n 'x-courier-user-id': this.options.userId,\n 'x-courier-client-key': 'empty', // Empty for now. Will be removed in future.\n 'Authorization': `Bearer ${this.options.accessToken}`\n },\n query,\n variables: { brandId: props.brandId }\n });\n\n return json.data.brand as CourierBrand;\n }\n\n}\n","/**\n * Connection close code for non-error conditions.\n *\n * @see https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code\n */\nexport const CLOSE_CODE_NORMAL_CLOSURE = 1000;\n\n/**\n * Courier-specific close event.\n *\n * @see https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent\n */\nexport interface CourierCloseEvent extends CloseEvent {\n /** The number of seconds to wait before retrying the connection. */\n retryAfterSeconds?: number;\n}\n","export const INBOX_WIRE_PROTOCOL_VERSION = 'v1';\n","import { CourierClientOptions } from \"../client/courier-client\";\nimport { CLOSE_CODE_NORMAL_CLOSURE, CourierCloseEvent } from \"../types/socket/protocol/errors\";\nimport { ServerMessage } from \"../types/socket/protocol/messages\";\nimport { Logger } from \"../utils/logger\";\nimport { INBOX_WIRE_PROTOCOL_VERSION } from \"./version\";\n\n/**\n * Abstract base class for Courier WebSocket implementations.\n *\n * The base class handles the connection and close events, as well as retry logic.\n * Application-specific logic should be implemented in the concrete classes.\n */\nexport abstract class CourierSocket {\n /**\n * The jitter factor for the backoff intervals.\n *\n * Backoff with jitter is calculated as a random value in the range:\n * [BACKOFF_INTERVAL - BACKOFF_JITTER_FACTOR * BACKOFF_INTERVAL,\n * BACKOFF_INTERVAL + BACKOFF_JITTER_FACTOR * BACKOFF_INTERVAL).\n */\n private static readonly BACKOFF_JITTER_FACTOR = 0.5;\n\n /**\n * The maximum number of retry attempts.\n */\n private static readonly MAX_RETRY_ATTEMPTS = 5;\n\n /**\n * Backoff intervals in milliseconds.\n *\n * Each represents an offset from the previous interval, rather than a\n * absolute offset from the initial request time.\n */\n private static readonly BACKOFF_INTERVALS_IN_MILLIS = [\n 30_000, // 30 seconds\n 60_000, // 1 minute\n 120_000, // 2 minutes\n 240_000, // 4 minutes\n 480_000, // 8 minutes\n ];\n\n /**\n * The key of the retry after time in the WebSocket close event reason.\n *\n * The Courier WebSocket server may send the close event reason in the following format:\n *\n * ```json\n * {\n * \"Retry-After\": \"10\" // The retry after time in seconds\n * }\n * ```\n *\n * @see https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/reason\n */\n private static readonly RETRY_AFTER_KEY = 'Retry-After';\n\n /** The WebSocket instance, which may be null if the connection is not established. */\n private webSocket: WebSocket | null = null;\n\n /** The number of connection retry attempts so far, reset after a successful connection. */\n private retryAttempt: number = 0;\n\n /** The timeout ID for the current connectionretry attempt, reset when we attempt to connect. */\n private retryTimeoutId: number | null = null;\n\n /**\n * Flag indicating the application initiated a {@link CourierSocket#close} call.\n *\n * An application-initiated close may look like an abnormal closure (code 1006)\n * if it occurs before the connection is established. We differentiate to\n * prevent retrying the connection when the socket is closed intentionally.\n */\n private closeRequested: boolean = false;\n\n private readonly url: string;\n private readonly options: CourierClientOptions;\n\n constructor(\n options: CourierClientOptions\n ) {\n this.url = options.apiUrls.inbox.webSocket;\n this.options = options;\n }\n\n /**\n * Connects to the Courier WebSocket server.\n *\n * If the connection is already established, this is a no-op.\n *\n * @returns A promise that resolves when the connection is established or rejects if the connection could not be established.\n */\n public async connect(): Promise<void> {\n if (this.isConnecting || this.isOpen) {\n this.options.logger?.info(`Attempted to open a WebSocket connection, but one already exists in state '${this.webSocket?.readyState}'.`);\n\n // This isn't necessarily an error (the result is a no-op), so we resolve the promise.\n return Promise.resolve();\n }\n\n // If we're in the process of retrying, clear the timeout to prevent further retries.\n this.clearRetryTimeout();\n\n // Reset the close requested flag when we attempt to connect.\n this.closeRequested = false;\n\n return new Promise((resolve, reject) => {\n this.webSocket = new WebSocket(this.getWebSocketUrl());\n\n this.webSocket.addEventListener('open', (event: Event) => {\n // Reset the retry attempt counter when the connection is established.\n this.retryAttempt = 0;\n\n this.onOpen(event);\n\n // Resolve the promise when the WebSocket is opened (i.e. the connection is established)\n resolve();\n });\n\n this.webSocket.addEventListener('message', async (event: MessageEvent) => {\n try {\n const json = JSON.parse(event.data) as ServerMessage;\n if ('event' in json && json.event === 'reconnect') {\n this.close(CLOSE_CODE_NORMAL_CLOSURE);\n await this.retryConnection(json.retryAfter * 1000);\n return;\n }\n\n this.onMessageReceived(json)\n } catch (error) {\n this.options.logger?.error('Error parsing socket message', error);\n }\n });\n\n this.webSocket.addEventListener('close', (event: CloseEvent) => {\n // Close events are fired when the connection is closed either normally or abnormally.\n //\n // The 'close' event triggers a retry if the 'close' is:\n // 1) not a normal closure and,\n // 2) the application did not request the close (see CourierSocket#closeRequested)\n if (event.code !== CLOSE_CODE_NORMAL_CLOSURE && !this.closeRequested) {\n const courierCloseEvent = CourierSocket.parseCloseEvent(event);\n\n if (courierCloseEvent.retryAfterSeconds) {\n this.retryConnection(courierCloseEvent.retryAfterSeconds * 1000);\n } else {\n this.retryConnection();\n }\n }\n\n this.onClose(event);\n });\n\n this.webSocket.addEventListener('error', (event: Event) => {\n // If the closure was requested by the application, don't retry the connection.\n // The error event may be fired for a normal closure if it occurs before the connection is established.\n if (!this.closeRequested) {\n this.retryConnection();\n }\n\n this.onError(event);\n\n // If the HTTP Upgrade request fails, the WebSocket API fires an error event,\n // so we reject the promise to indicate that the connection could not be established.\n reject(event);\n });\n });\n }\n\n /**\n * Closes the WebSocket connection.\n *\n * See {@link https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close} for more details.\n *\n * @param code The WebSocket close code. Defaults to {@link CLOSE_CODE_NORMAL_CLOSURE}.\n * @param reason The WebSocket close reason.\n */\n public close(code = CLOSE_CODE_NORMAL_CLOSURE, reason?: string): void {\n if (this.webSocket === null) {\n return;\n }\n\n this.closeRequested = true;\n\n // Cancel any pending retries and reset the retry attempt counter.\n this.clearRetryTimeout();\n this.retryAttempt = 0;\n\n this.webSocket.close(code, reason);\n\n }\n\n /**\n * Sends a message to the Courier WebSocket server.\n *\n * @param message The message to send. The message will be serialized to a JSON string.\n */\n public send(message: Record<string, any>): void {\n if (this.webSocket === null || this.isConnecting) {\n this.options.logger?.info('Attempted to send a message, but the WebSocket is not yet open.');\n return;\n }\n\n const json = JSON.stringify(message);\n this.webSocket.send(json);\n }\n\n protected get userId(): string {\n return this.options.userId;\n }\n\n /** The sub-tenant ID, if specified by the user. */\n protected get subTenantId(): string | undefined {\n return this.options.tenantId;\n }\n\n protected get logger(): Logger | undefined {\n return this.options.logger;\n }\n\n /**\n * Called when the WebSocket connection is established with the Courier WebSocket server.\n *\n * @param event The WebSocket open event.\n */\n public abstract onOpen(event: Event): Promise<void>;\n\n /**\n * Called when a message is received from the Courier WebSocket server.\n *\n * @param data The message received.\n */\n public abstract onMessageReceived(data: ServerMessage): Promise<void>;\n\n /**\n * Called when the WebSocket connection is closed.\n *\n * @param event The WebSocket close event.\n */\n public abstract onClose(event: CloseEvent): Promise<void>;\n\n /**\n * Called when an error occurs on the WebSocket connection.\n *\n * @param event The WebSocket error event.\n */\n public abstract onError(event: Event): Promise<void>;\n\n /**\n * Whether the WebSocket connection is currently being established.\n */\n public get isConnecting(): boolean {\n return this.webSocket !== null && this.webSocket.readyState === WebSocket.CONNECTING;\n }\n\n /**\n * Whether the WebSocket connection is currently open.\n */\n public get isOpen(): boolean {\n return this.webSocket !== null && this.webSocket.readyState === WebSocket.OPEN;\n }\n\n /**\n * Constructs the WebSocket URL for the Courier WebSocket server using context\n * from the {@link CourierClientOptions} passed to the constructor.\n *\n * @returns The WebSocket URL\n */\n private getWebSocketUrl(): string {\n const accessToken = this.options.accessToken;\n const connectionId = this.options.connectionId;\n const userId = this.userId;\n\n return `${this.url}?auth=${accessToken}&cid=${connectionId}&iwpv=${INBOX_WIRE_PROTOCOL_VERSION}&userId=${userId}`;\n }\n\n /**\n * Parses the Retry-After time from the WebSocket close event reason,\n * and returns a new {@link CourierCloseEvent} with the retry after time in seconds\n * if present.\n *\n * The Courier WebSocket server may send the close event reason in the following format:\n *\n * ```json\n * {\n * \"Retry-After\": \"10\" // The retry after time in seconds\n * }\n * ```\n *\n * @see https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/reason\n *\n * @param closeEvent The WebSocket close event.\n * @returns The WebSocket close event with the retry after time in seconds.\n */\n private static parseCloseEvent(closeEvent: CloseEvent): CourierCloseEvent {\n if (closeEvent.reason === null || closeEvent.reason === '') {\n return closeEvent;\n }\n\n try {\n const jsonReason = JSON.parse(closeEvent.reason);\n if (!jsonReason[CourierSocket.RETRY_AFTER_KEY]) {\n return closeEvent;\n }\n\n const retryAfterSeconds = parseInt(jsonReason[CourierSocket.RETRY_AFTER_KEY]);\n if (Number.isNaN(retryAfterSeconds) || retryAfterSeconds < 0) {\n return closeEvent;\n }\n\n return {\n ...closeEvent,\n retryAfterSeconds,\n };\n } catch (error) {\n return closeEvent;\n }\n }\n\n /**\n * Calculates the retry backoff time in milliseconds based on the current retry attempt.\n */\n private getBackoffTimeInMillis(): number {\n const backoffIntervalInMillis = CourierSocket.BACKOFF_INTERVALS_IN_MILLIS[this.retryAttempt];\n const lowerBound = backoffIntervalInMillis - (backoffIntervalInMillis * CourierSocket.BACKOFF_JITTER_FACTOR);\n const upperBound = backoffIntervalInMillis + (backoffIntervalInMillis * CourierSocket.BACKOFF_JITTER_FACTOR);\n\n return Math.floor(Math.random() * (upperBound - lowerBound) + lowerBound);\n }\n\n /**\n * Retries the connection to the Courier WebSocket server after\n * either {@param suggestedBackoffTimeInMillis} or a random backoff time\n * calculated using {@link getBackoffTimeInMillis}.\n *\n * @param suggestedBackoffTimeInMillis The suggested backoff time in milliseconds.\n * @returns A promise that resolves when the connection is established or rejects if the connection could not be established.\n */\n protected async retryConnection(suggestedBackoffTimeInMillis?: number): Promise<void> {\n if (this.retryTimeoutId !== null) {\n this.logger?.debug('Skipping retry attempt because a previous retry is already scheduled.');\n return;\n }\n\n if (this.retryAttempt >= CourierSocket.MAX_RETRY_ATTEMPTS) {\n this.logger?.error(`Max retry attempts (${CourierSocket.MAX_RETRY_ATTEMPTS}) reached.`);\n return;\n }\n\n const backoffTimeInMillis = suggestedBackoffTimeInMillis ?? this.getBackoffTimeInMillis();\n this.retryTimeoutId = window.setTimeout(async () => {\n try {\n await this.connect();\n } catch (error) {\n // connect() will retry if applicable\n }\n }, backoffTimeInMillis);\n this.logger?.debug(`Retrying connection in ${Math.floor(backoffTimeInMillis / 1000)}s. Retry attempt ${this.retryAttempt + 1} of ${CourierSocket.MAX_RETRY_ATTEMPTS}.`);\n\n this.retryAttempt++;\n }\n\n /**\n * Clears the retry timeout if it exists.\n */\n private clearRetryTimeout(): void {\n if (this.retryTimeoutId !== null) {\n window.clearTimeout(this.retryTimeoutId);\n this.retryTimeoutId = null;\n }\n }\n}\n","import { ClientMessageEnvelope, ServerMessage } from \"../types/socket/protocol/messages\";\n\nexport class TransactionManager {\n /**\n * The map of <transactionId, Transaction> representing outstanding requests.\n */\n private readonly outstandingRequestsMap: Map<string, Transaction> = new Map();\n\n /**\n * The queue of completed requests. This is a FIFO queue of the last N completed requests,\n * where N is {@link completedTransactionsToKeep}.\n */\n private readonly completedTransactionsQueue: Transaction[] = [];\n\n /**\n * Number of completed requests to keep in memory.\n */\n private readonly completedTransactionsToKeep: number;\n\n constructor(completedTransactionsToKeep: number = 10) {\n this.completedTransactionsToKeep = completedTransactionsToKeep;\n }\n\n public addOutstandingRequest(transactionId: string, request: ClientMessageEnvelope): void {\n const isOutstanding = this.outstandingRequestsMap.has(transactionId);\n if (isOutstanding) {\n throw new Error(`Transaction [${transactionId}] already has an outstanding request`);\n }\n\n const transaction: Transaction = {\n transactionId,\n request,\n response: null,\n start: new Date(),\n end: null,\n };\n\n this.outstandingRequestsMap.set(transactionId, transaction);\n }\n\n public addResponse(transactionId: string, response: ServerMessage): void {\n const transaction = this.outstandingRequestsMap.get(transactionId);\n if (transaction === undefined) {\n throw new Error(`Transaction [${transactionId}] does not have an outstanding request`);\n }\n\n transaction.response = response;\n transaction.end = new Date();\n\n // Move the transaction from the outstanding requests to the completed requests.\n this.outstandingRequestsMap.delete(transactionId);\n this.addCompletedTransaction(transaction);\n }\n\n public get outstandingRequests(): Transaction[] {\n return Array.from(this.outstandingRequestsMap.values());\n }\n\n public get completedTransactions(): Transaction[] {\n return this.completedTransactionsQueue;\n }\n\n public clearOutstandingRequests(): void {\n this.outstandingRequestsMap.clear();\n }\n\n /**\n * Adds a completed request to the queue.\n *\n * If the number of completed requests exceeds the maximum number of completed requests to keep,\n * remove the oldest completed request.\n */\n private addCompletedTransaction(transaction: Transaction): void {\n this.completedTransactionsQueue.push(transaction);\n\n if (this.completedTransactionsQueue.length > this.completedTransactionsToKeep) {\n this.completedTransactionsQueue.shift();\n }\n }\n}\n\ninterface Transaction {\n /**\n * The transaction ID.\n */\n transactionId: string;\n\n /**\n * The request to the server.\n */\n request: ClientMessageEnvelope;\n\n /**\n * The response to the request.\n *\n * The response is null until the request is completed.\n */\n response: ServerMessage | null;\n\n /**\n * The start time of the transaction.\n */\n start: Date;\n\n /**\n * The end time of the transaction.\n */\n end: Date | null;\n}\n","import { InboxMessage } from \"../types/inbox\";\nimport { InboxMessageEvent, InboxMessageEventEnvelope } from \"../types/socket/protocol/messages\";\n\n/**\n * Ensure the `created` timestamp is set for a new message.\n *\n * New messages received from the WebSocket may not have a created time,\n * until they are retrieved from the GraphQL API.\n *\n * @param envelope - The envelope containing the message event.\n * @returns The envelope with the created time set.\n */\nfunction ensureCreatedTime(envelope: InboxMessageEventEnvelope): InboxMessageEventEnvelope {\n if (envelope.event === InboxMessageEvent.NewMessage) {\n const message = envelope.data as InboxMessage;\n\n if (!message.created) {\n message.created = new Date().toISOString();\n }\n\n return {\n ...envelope,\n data: message,\n };\n }\n\n return envelope;\n}\n\n/**\n * Apply fixes to a message event envelope.\n *\n * @param envelope - The envelope containing the message event.\n * @returns The envelope with the fixes applied.\n */\nexport function fixMessageEventEnvelope(envelope: InboxMessageEventEnvelope): InboxMessageEventEnvelope {\n // Apply any fixes to the message event envelope.\n return ensureCreatedTime(envelope);\n}\n","import { CourierClientOptions } from '../client/courier-client';\nimport { ClientAction, ClientMessageEnvelope, Config, ConfigResponseEnvelope, InboxMessageEvent, InboxMessageEventEnvelope, ServerAction, ServerActionEnvelope, ServerMessage, ServerResponseEnvelope } from '../types/socket/protocol/messages';\nimport { CLOSE_CODE_NORMAL_CLOSURE } from '../types/socket/protocol/errors';\nimport { UUID } from '../utils/uuid';\nimport { CourierSocket } from './courier-socket';\nimport { TransactionManager } from './courier-inbox-transaction-manager';\nimport { fixMessageEventEnvelope } from './inbox-message-utils';\n\n/** Application-layer implementation of the Courier WebSocket API for Inbox messages. */\nexport class CourierInboxSocket extends CourierSocket {\n /**\n * The default interval in milliseconds at which to send a ping message to the server\n * if no other message has been received from the server.\n *\n * Fallback when the server does not provide a config.\n */\n private static readonly DEFAULT_PING_INTERVAL_MILLIS = 60_000; // 1 minute\n\n /**\n * The default maximum number of outstanding pings before the client should\n * close the connection and retry connecting.\n *\n * Fallback when the server does not provide a config.\n */\n private static readonly DEFAULT_MAX_OUTSTANDING_PINGS = 3;\n\n /**\n * The interval ID for the ping interval.\n *\n * @see https://developer.mozilla.org/en-US/docs/Web/API/Window/setInterval\n */\n private pingIntervalId: number | null = null;\n\n /**\n * The list of message event listeners, called when a message event is received\n * from the Courier WebSocket server.\n */\n private messageEventListeners: ((message: InboxMessageEventEnvelope) => void)[] = [];\n\n /** Server-provided configuration for the client. */\n private config: Config | null = null;\n\n /**\n * The transaction manager, used to track outstanding requests and responses.\n */\n private readonly pingTransactionManager: TransactionManager = new TransactionManager();\n\n constructor(options: CourierClientOptions) {\n super(options);\n }\n\n public onOpen(_: Event): Promise<void> {\n // Clear any outstanding pings from the previous connection before starting to ping.\n this.pingTransactionManager.clearOutstandingRequests();\n this.restartPingInterval();\n\n // Send a request for the client's configuration.\n this.sendGetConfig();\n\n // Subscribe to all events for the user.\n this.sendSubscribe();\n\n return Promise.resolve();\n }\n\n public onMessageReceived(data: ServerMessage): Promise<void> {\n // ServerActionEnvelope\n // Respond to pings.\n if ('action' in data && data.action === ServerAction.Ping) {\n const envelope: ServerActionEnvelope = data as ServerActionEnvelope;\n this.sendPong(envelope);\n }\n\n // ServerResponseEnvelope\n // Track pongs.\n if ('response' in data && data.response === 'pong') {\n const envelope: ServerResponseEnvelope = data as ServerResponseEnvelope;\n\n // Keep track of the pong response and clear out any outstanding pings.\n // We only need to keep track of the most recent missed pings.\n this.pingTransactionManager.addResponse(envelope.tid, envelope);\n this.pingTransactionManager.clearOutstandingRequests();\n }\n\n // ConfigResponseEnvelope\n // Update the client's config.\n if ('response' in data && data.response === 'config') {\n const envelope: ConfigResponseEnvelope = data as ConfigResponseEnvelope;\n this.setConfig(envelope.data);\n }\n\n // InboxMessageEventEnvelope\n // Handle message events, calling all registered listeners.\n if ('event' in data && CourierInboxSocket.isInboxMessageEvent(data.event)) {\n const envelope: InboxMessageEventEnvelope = data as InboxMessageEventEnvelope;\n const fixedEnvelope = fixMessageEventEnvelope(envelope);\n for (const listener of this.messageEventListeners) {\n listener(fixedEnvelope);\n }\n }\n\n // Restart the ping interval if a message is received from the server.\n this.restartPingInterval();\n\n return Promise.resolve();\n }\n\n public onClose(_: CloseEvent): Promise<void> {\n // Cancel scheduled pings.\n this.clearPingInterval();\n\n // Remove any message event listeners.\n this.clearMessageEventListeners();\n\n // Clear any outstanding pings.\n this.pingTransactionManager.clearOutstandingRequests();\n\n return Promise.resolve();\n }\n\n public onError(_: Event): Promise<void> {\n return Promise.resolve();\n }\n\n /**\n * Sends a subscribe message to the server.\n *\n * Subscribes to all events for the user.\n */\n public sendSubscribe(): void {\n const data: Record<string, any> = {\n channel: this.userId,\n event: '*',\n };\n\n // Set accountId to the sub-tenant ID if it is specified.\n if (this.subTenantId) {\n data.accountId = this.subTenantId;\n }\n\n const envelope: ClientMessageEnvelope = {\n tid: UUID.nanoid(),\n action: ClientAction.Subscribe,\n data,\n };\n\n this.send(envelope);\n }\n\n /**\n * Sends an unsubscribe message to the server.\n *\n * Unsubscribes from all events for the user.\n */\n public sendUnsubscribe(): void {\n const envelope: ClientMessageEnvelope = {\n tid: UUID.nanoid(),\n action: ClientAction.Unsubscribe,\n data: {\n channel: this.userId,\n },\n };\n\n this.send(envelope);\n }\n\n /**\n * Adds a message event listener, called when a message event is received\n * from the Courier WebSocket server.\n *\n * @param listener The listener function\n */\n public addMessageEventListener(listener: (message: InboxMessageEventEnvelope) => void): void {\n this.messageEventListeners.push(listener);\n }\n\n /**\n * Send a ping message to the server.\n *\n * ping/pong is implemented at the application layer since the browser's\n * WebSocket implementation does not support control-level ping/pong.\n */\n private sendPing(): void {\n if (this.pingTransactionManager.outstandingRequests.length >= this.maxOutstandingPings) {\n this.logger?.debug('Max outstanding pings reached, retrying connection.');\n this.close(CLOSE_CODE_NORMAL_CLOSURE, 'Max outstanding pings reached, retrying connection.');\n this.retryConnection();\n\n return;\n }\n\n const envelope: ClientMessageEnvelope = {\n tid: UUID.nanoid(),\n action: ClientAction.Ping,\n };\n\n this.send(envelope);\n this.pingTransactionManager.addOutstandingRequest(envelope.tid, envelope);\n }\n\n /**\n * Send a pong response to the server.\n *\n * ping/pong is implemented at the application layer since the browser's\n * WebSocket implementation does not support control-level ping/pong.\n */\n private sendPong(incomingMessage: ServerActionEnvelope): void {\n const response: ClientMessageEnvelope = {\n tid: incomingMessage.tid,\n action: ClientAction.Pong,\n };\n\n this.send(response);\n }\n\n /**\n * Send a request for the client's configuration.\n */\n private sendGetConfig(): void {\n const envelope: ClientMessageEnvelope = {\n tid: UUID.nanoid(),\n action: ClientAction.GetConfig,\n };\n\n this.send(envelope);\n }\n\n /**\n * Restart the ping interval, clearing the previous interval if it exists.\n */\n private restartPingInterval(): void {\n this.clearPingInterval();\n\n this.pingIntervalId = window.setInterval(() => {\n this.sendPing();\n }, this.pingInterval);\n }\n\n private clearPingInterval(): void {\n if (this.pingIntervalId) {\n window.clearInterval(this.pingIntervalId);\n }\n }\n\n private get pingInterval(): number {\n if (this.config) {\n // Server-provided ping interval is in seconds.\n return this.config.pingInterval * 1000;\n }\n\n return CourierInboxSocket.DEFAULT_PING_INTERVAL_MILLIS;\n }\n\n private get maxOutstandingPings(): number {\n if (this.config) {\n return this.config.maxOutstandingPings;\n }\n\n return CourierInboxSocket.DEFAULT_MAX_OUTSTANDING_PINGS;\n }\n\n private setConfig(config: Config): void {\n this.config = config;\n }\n\n /**\n * Removes all message event listeners.\n */\n private clearMessageEventListeners(): void {\n this.messageEventListeners = [];\n }\n\n private static isInboxMessageEvent(event: string): event is InboxMessageEvent {\n return Object.values(InboxMessageEvent).includes(event as InboxMessageEvent);\n }\n}\n","import { CourierInboxSocket } from '../socket/courier-inbox-socket';\nimport { CourierGetInboxMessagesResponse } from '../types/inbox';\nimport { graphql } from '../utils/request';\nimport { Client } from './client';\nimport { CourierClientOptions } from './courier-client';\n\nexport class InboxClient extends Client {\n\n readonly socket: CourierInboxSocket;\n\n constructor(options: CourierClientOptions) {\n super(options);\n this.socket = new CourierInboxSocket(options);\n }\n\n /**\n * Get paginated messages\n * @param paginationLimit - Number of messages to return per page (default: 24)\n * @param startCursor - Cursor for pagination\n * @returns Promise resolving to paginated messages response\n */\n public async getMessages(props?: { paginationLimit?: number; startCursor?: string; }): Promise<CourierGetInboxMessagesResponse> {\n const query = `\n query GetInboxMessages(\n $params: FilterParamsInput = { ${this.options.tenantId ? `accountId: \"${this.options.tenantId}\"` : ''} }\n $limit: Int = ${props?.paginationLimit ?? 24}\n $after: String ${props?.startCursor ? `= \"${props.startCursor}\"` : ''}\n ) {\n count(params: $params)\n messages(params: $params, limit: $limit, after: $after) {\n totalCount\n pageInfo {\n startCursor\n hasNextPage\n }\n nodes {\n messageId\n read\n archived\n created\n opened\n title\n preview\n data\n tags\n trackingIds {\n clickTrackingId\n }\n actions {\n content\n data\n href\n }\n }\n }\n }\n `;\n\n return await graphql({\n options: this.options,\n query,\n headers: {\n 'x-courier-user-id': this.options.userId,\n 'Authorization': `Bearer ${this.options.accessToken}`\n },\n url: this.options.apiUrls.inbox.graphql,\n });\n }\n\n /**\n * Get paginated archived messages\n * @param paginationLimit - Number of messages to return per page (default: 24)\n * @param startCursor - Cursor for pagination\n * @returns Promise resolving to paginated archived messages response\n */\n public async getArchivedMessages(props?: { paginationLimit?: number; startCursor?: string; }): Promise<CourierGetInboxMessagesResponse> {\n const query = `\n query GetInboxMessages(\n $params: FilterParamsInput = { ${this.options.tenantId ? `accountId: \"${this.options.tenantId}\"` : ''}, archived: true }\n $limit: Int = ${props?.paginationLimit ?? 24}\n $after: String ${props?.startCursor ? `= \"${props.startCursor}\"` : ''}\n ) {\n count(params: $params)\n messages(params: $params, limit: $limit, after: $after) {\n totalCount\n pageInfo {\n startCursor\n hasNextPage\n }\n nodes {\n messageId\n read\n archived\n created\n opened\n title\n preview\n data\n tags\n trackingIds {\n clickTrackingId\n }\n actions {\n content\n data\n href\n }\n }\n }\n }\n `;\n\n return graphql({\n options: this.options,\n query,\n headers: {\n 'x-courier-user-id': this.options.userId,\n 'Authorization': `Bearer ${this.options.accessToken}`\n },\n url: this.options.apiUrls.inbox.graphql,\n });\n }\n\n /**\n * Get unread message count\n * @returns Promise resolving to number of unread messages\n */\n public async getUnreadMessageCount(): Promise<number> {\n const query = `\n query GetMessages {\n count(params: { status: \"unread\" ${this.options.tenantId ? `, accountId: \"${this.options.tenantId}\"` : ''} })\n }\n `;\n\n const response = await graphql({\n options: this.options,\n query,\n headers: {\n 'x-courier-user-id': this.options.userId,\n 'Authorization': `Bearer ${this.options.accessToken}`\n },\n url: this.options.apiUrls.inbox.graphql,\n });\n\n return response.data?.count ?? 0;\n }\n\n /**\n * Track a click event\n * @param messageId - ID of the message\n * @param trackingId - ID for tracking the click\n * @returns Promise resolving when click is tracked\n */\n public async click(props: { messageId: string, trackingId: string }): Promise<void> {\n const query = `\n mutation TrackEvent {\n clicked(messageId: \"${props.messageId}\", trackingId: \"${props.trackingId}\")\n }\n `;\n\n const headers: Record<string, string> = {\n 'x-courier-user-id': this.options.userId,\n 'Authorization': `Bearer ${this.options.accessToken}`\n };\n\n if (this.options.connectionId) {\n headers['x-courier-client-source-id'] = this.options.connectionId;\n }\n\n await graphql({\n options: this.options,\n query,\n headers,\n url: this.options.apiUrls.inbox.graphql,\n });\n }\n\n /**\n * Mark a message as read\n * @param messageId - ID of the message to mark as read\n * @returns Promise resolving when message is marked as read\n */\n public async read(props: { messageId: string }): Promise<void> {\n const query = `\n mutation TrackEvent {\n read(messageId: \"${props.messageId}\")\n }\n `;\n\n const headers: Record<string, string> = {\n 'x-courier-user-id': this.options.userId,\n 'Authorization': `Bearer ${this.options.accessToken}`\n };\n\n if (this.options.connectionId) {\n headers['x-courier-client-source-id'] = this.options.connectionId;\n }\n\n await graphql({\n options: this.options,\n query,\n headers,\n url: this.options.apiUrls.inbox.graphql,\n });\n }\n\n /**\n * Mark a message as unread\n * @param messageId - ID of the message to mark as unread\n * @returns Promise resolving when message is marked as unread\n */\n public async unread(props: { messageId: string }): Promise<void> {\n const query = `\n mutation TrackEvent {\n unread(messageId: \"${props.messageId}\")\n }\n `;\n\n const headers: Record<string, string> = {\n 'x-courier-user-id': this.options.userId,\n 'Authorization': `Bearer ${this.options.accessToken}`\n };\n\n if (this.options.connectionId) {\n headers['x-courier-client-source-id'] = this.options.connectionId;\n }\n\n await graphql({\n options: this.options,\n query,\n headers,\n url: this.options.apiUrls.inbox.graphql,\n });\n }\n\n /**\n * Mark a message as opened\n * @param messageId - ID of the message to mark as opened\n * @returns Promise resolving when message is marked as opened\n */\n public async open(props: { messageId: string }): Promise<void> {\n const query = `\n mutation TrackEvent {\n opened(messageId: \"${props.messageId}\")\n }\n `;\n\n const headers: Record<string, string> = {\n 'x-courier-user-id': this.options.userId,\n 'Authorization': `Bearer ${this.options.accessToken}`\n };\n\n if (this.options.connectionId) {\n headers['x-courier-client-source-id'] = this.options.connectionId;\n }\n\n await graphql({\n options: this.options,\n query,\n headers,\n url: this.options.apiUrls.inbox.graphql,\n });\n }\n\n /**\n * Archive a message\n * @param messageId - ID of the message to archive\n * @returns Promise resolving when message is archived\n */\n public async archive(props: { messageId: string }): Promise<void> {\n const query = `\n mutation TrackEvent {\n archive(messageId: \"${props.messageId}\")\n }\n `;\n\n const headers: Record<string, string> = {\n 'x-courier-user-id': this.options.userId,\n 'Authorization': `Bearer ${this.options.accessToken}`\n };\n\n if (this.options.connectionId) {\n headers['x-courier-client-source-id'] = this.options.connectionId;\n }\n\n await graphql({\n options: this.options,\n query,\n headers,\n url: this.options.apiUrls.inbox.graphql,\n });\n }\n\n /**\n * Unarchive a message\n * @param messageId - ID of the message to unarchive\n * @returns Promise resolving when message is unarchived\n */\n public async unarchive(props: { messageId: string }): Promise<void> {\n const query = `\n mutation TrackEvent {\n unarchive(messageId: \"${props.messageId}\")\n }\n `;\n\n const headers: Record<string, string> = {\n 'x-courier-user-id': this.options.userId,\n 'Authorization': `Bearer ${this.options.accessToken}`\n };\n\n if (this.options.connectionId) {\n headers['x-courier-client-source-id'] = this.options.connectionId;\n }\n\n await graphql({\n options: this.options,\n query,\n headers,\n url: this.options.apiUrls.inbox.graphql,\n });\n }\n\n /**\n * Mark all messages as read\n * @returns Promise resolving when all messages are marked as read\n */\n public async readAll(): Promise<void> {\n const query = `\n mutation TrackEvent {\n markAllRead\n }\n `;\n\n const headers: Record<string, string> = {\n 'x-courier-user-id': this.options.userId,\n 'Authorization': `Bearer ${this.options.accessToken}`\n };\n\n if (this.options.connectionId) {\n headers['x-courier-client-source-id'] = this.options.connectionId;\n }\n\n await graphql({\n options: this.options,\n query,\n headers,\n url: this.options.apiUrls.inbox.graphql,\n });\n }\n\n /**\n * Archive all read messages.\n */\n public async archiveRead(): Promise<void> {\n const query = `\n mutation TrackEvent {\n archiveRead\n }\n `;\n\n const headers: Record<string, string> = {\n 'x-courier-user-id': this.options.userId,\n 'Authorization': `Bearer ${this.options.accessToken}`\n };\n\n if (this.options.connectionId) {\n headers['x-courier-client-source-id'] = this