UNPKG

@trycourier/courier-js

Version:

A browser-safe API wrapper

1,576 lines • 58.3 kB
var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); const freezeCourierApiUrls = (urls) => Object.freeze({ courier: Object.freeze({ ...urls.courier }), inbox: Object.freeze({ ...urls.inbox }) }); const cloneCourierApiUrls = (urls) => ({ courier: { ...urls.courier }, inbox: { ...urls.inbox } }); const COURIER_API_URLS_BY_REGION = { us: freezeCourierApiUrls({ courier: { rest: "https://api.courier.com", graphql: "https://api.courier.com/client/q" }, inbox: { graphql: "https://inbox.courier.com/q", webSocket: "wss://realtime.courier.io" } }), eu: freezeCourierApiUrls({ courier: { rest: "https://api.eu.courier.com", graphql: "https://api.eu.courier.com/client/q" }, inbox: { graphql: "https://inbox.eu.courier.io/q", webSocket: "wss://realtime.eu.courier.io" } }) }; const DEFAULT_COURIER_API_URLS = COURIER_API_URLS_BY_REGION.us; const EU_COURIER_API_URLS = COURIER_API_URLS_BY_REGION.eu; const getCourierApiUrlsForRegion = (region = "us") => cloneCourierApiUrls(COURIER_API_URLS_BY_REGION[region]); const getCourierApiUrls = (urls) => { const defaultUrls = DEFAULT_COURIER_API_URLS; return { courier: { rest: (urls == null ? void 0 : urls.courier.rest) || defaultUrls.courier.rest, graphql: (urls == null ? void 0 : urls.courier.graphql) || defaultUrls.courier.graphql }, inbox: { graphql: (urls == null ? void 0 : urls.inbox.graphql) || defaultUrls.inbox.graphql, webSocket: (urls == null ? void 0 : urls.inbox.webSocket) || defaultUrls.inbox.webSocket } }; }; var ClientAction = /* @__PURE__ */ ((ClientAction2) => { ClientAction2["Subscribe"] = "subscribe"; ClientAction2["Unsubscribe"] = "unsubscribe"; ClientAction2["Pong"] = "pong"; ClientAction2["Ping"] = "ping"; ClientAction2["GetConfig"] = "get-config"; return ClientAction2; })(ClientAction || {}); var ServerAction = /* @__PURE__ */ ((ServerAction2) => { ServerAction2["Ping"] = "ping"; return ServerAction2; })(ServerAction || {}); var InboxMessageEvent = /* @__PURE__ */ ((InboxMessageEvent2) => { InboxMessageEvent2["NewMessage"] = "message"; InboxMessageEvent2["Archive"] = "archive"; InboxMessageEvent2["ArchiveAll"] = "archive-all"; InboxMessageEvent2["ArchiveRead"] = "archive-read"; InboxMessageEvent2["Clicked"] = "clicked"; InboxMessageEvent2["MarkAllRead"] = "mark-all-read"; InboxMessageEvent2["Opened"] = "opened"; InboxMessageEvent2["Read"] = "read"; InboxMessageEvent2["Unarchive"] = "unarchive"; InboxMessageEvent2["Unopened"] = "unopened"; InboxMessageEvent2["Unread"] = "unread"; return InboxMessageEvent2; })(InboxMessageEvent || {}); class Logger { constructor(showLogs) { __publicField(this, "PREFIX", "[COURIER]"); this.showLogs = showLogs; } warn(message, ...args) { if (this.showLogs) { console.warn(`${this.PREFIX} ${message}`, ...args); } } log(message, ...args) { if (this.showLogs) { console.log(`${this.PREFIX} ${message}`, ...args); } } error(message, ...args) { if (this.showLogs) { console.error(`${this.PREFIX} ${message}`, ...args); } } debug(message, ...args) { if (this.showLogs) { console.debug(`${this.PREFIX} ${message}`, ...args); } } info(message, ...args) { if (this.showLogs) { console.info(`${this.PREFIX} ${message}`, ...args); } } } const _UUID = class _UUID { /** * nanoid * Copyright 2017 Andrey Sitnik <andrey@sitnik.ru> * * https://github.com/ai/nanoid/blob/main/LICENSE * * @param size - The size of the UUID to generate. * @returns A string representing the UUID. */ static nanoid(size = 21) { let id = ""; let bytes = crypto.getRandomValues(new Uint8Array(size |= 0)); while (size--) { id += _UUID.ALPHABET[bytes[size] & 63]; } return id; } }; __publicField(_UUID, "ALPHABET", "useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict"); let UUID = _UUID; const HTTP_HEADER_KEY = "x-courier-ua"; const SDK_KEY = "sdk"; const SDK_VERSION_KEY = "sdkv"; const CLIENT_ID_KEY = "cid"; class CourierRequestError extends Error { constructor(code, message, type) { super(message); this.code = code; this.type = type; this.name = "CourierRequestError"; } } function logRequest(logger, uid, type, data) { logger.log(` šŸ“” New Courier ${type} Request: ${uid} URL: ${data.url} ${data.method ? `Method: ${data.method}` : ""} ${data.query ? `Query: ${data.query}` : ""} ${data.variables ? `Variables: ${JSON.stringify(data.variables, null, 2)}` : ""} Headers: ${JSON.stringify(data.headers, null, 2)} Body: ${data.body ? JSON.stringify(data.body, null, 2) : "Empty"} `); } function logResponse(logger, uid, type, data) { logger.log(` šŸ“” New Courier ${type} Response: ${uid} Status Code: ${data.status} Response JSON: ${JSON.stringify(data.response, null, 2)} `); } async function http(props) { const validCodes = props.validCodes ?? [200]; const uid = props.options.showLogs ? UUID.nanoid() : void 0; const courierUserAgentHeader = props.options.courierUserAgent.toHttpHeaderValue(); const request = new Request(props.url, { method: props.method, headers: { "Content-Type": "application/json", [HTTP_HEADER_KEY]: courierUserAgentHeader, ...props.headers }, body: props.body ? JSON.stringify(props.body) : void 0 }); if (uid) { logRequest(props.options.logger, uid, "HTTP", { url: request.url, method: request.method, headers: Object.fromEntries(request.headers.entries()), body: props.body }); } const response = await fetch(request); if (response.status === 204) { return; } let data; try { data = await response.json(); } catch (error) { if (response.status === 200) { return; } throw new CourierRequestError( response.status, "Failed to parse response as JSON", "PARSE_ERROR" ); } if (uid) { logResponse(props.options.logger, uid, "HTTP", { status: response.status, response: data }); } if (!validCodes.includes(response.status)) { throw new CourierRequestError( response.status, (data == null ? void 0 : data.message) || "Unknown Error", data == null ? void 0 : data.type ); } return data; } async function graphql(props) { const uid = props.options.showLogs ? UUID.nanoid() : void 0; const courierUserAgentHeader = props.options.courierUserAgent.toHttpHeaderValue(); if (uid) { logRequest(props.options.logger, uid, "GraphQL", { url: props.url, headers: props.headers, query: props.query, variables: props.variables }); } const response = await fetch(props.url, { method: "POST", headers: { "Content-Type": "application/json", [HTTP_HEADER_KEY]: courierUserAgentHeader, ...props.headers }, body: JSON.stringify({ query: props.query, variables: props.variables }) }); let data; try { data = await response.json(); } catch (error) { throw new CourierRequestError( response.status, "Failed to parse response as JSON", "PARSE_ERROR" ); } if (uid) { logResponse(props.options.logger, uid, "GraphQL", { status: response.status, response: data }); } if (!response.ok) { throw new CourierRequestError( response.status, (data == null ? void 0 : data.message) || "Unknown Error", data == null ? void 0 : data.type ); } return data; } class Client { constructor(options) { this.options = options; } } class BrandClient extends Client { /** * Get a brand by ID using GraphQL * @param brandId - The ID of the brand to retrieve * @returns Promise resolving to the requested brand */ async getBrand(props) { const query = ` query GetBrand { brand(brandId: "${props.brandId}") { settings { colors { primary secondary tertiary } inapp { borderRadius disableCourierFooter } } } } `; const json = await graphql({ options: this.options, url: this.options.apiUrls.courier.graphql, headers: { "x-courier-user-id": this.options.userId, "x-courier-client-key": "empty", // Empty for now. Will be removed in future. "Authorization": `Bearer ${this.options.accessToken}` }, query, variables: { brandId: props.brandId } }); return json.data.brand; } } const CLOSE_CODE_NORMAL_CLOSURE = 1e3; const INBOX_WIRE_PROTOCOL_VERSION = "v1"; const _CourierSocket = class _CourierSocket { constructor(options) { /** The WebSocket instance, which may be null if the connection is not established. */ __publicField(this, "webSocket", null); /** The number of connection retry attempts so far, reset after a successful connection. */ __publicField(this, "retryAttempt", 0); /** The timeout ID for the current connectionretry attempt, reset when we attempt to connect. */ __publicField(this, "retryTimeoutId", null); /** * Flag indicating the application initiated a {@link CourierSocket#close} call. * * An application-initiated close may look like an abnormal closure (code 1006) * if it occurs before the connection is established. We differentiate to * prevent retrying the connection when the socket is closed intentionally. */ __publicField(this, "closeRequested", false); __publicField(this, "url"); __publicField(this, "options"); this.url = options.apiUrls.inbox.webSocket; this.options = options; } /** * Connects to the Courier WebSocket server. * * If the connection is already established, this is a no-op. * * @returns A promise that resolves when the connection is established or rejects if the connection could not be established. */ async connect() { var _a, _b; if (this.isConnecting || this.isOpen) { (_b = this.options.logger) == null ? void 0 : _b.info(`Attempted to open a WebSocket connection, but one already exists in state '${(_a = this.webSocket) == null ? void 0 : _a.readyState}'.`); return Promise.resolve(); } this.clearRetryTimeout(); this.closeRequested = false; return new Promise((resolve, reject) => { this.webSocket = new WebSocket(this.getWebSocketUrl()); this.webSocket.addEventListener("open", (event) => { this.retryAttempt = 0; this.onOpen(event); resolve(); }); this.webSocket.addEventListener("message", async (event) => { var _a2; try { const json = JSON.parse(event.data); if ("event" in json && json.event === "reconnect") { this.close(CLOSE_CODE_NORMAL_CLOSURE); await this.retryConnection(json.retryAfter * 1e3); return; } this.onMessageReceived(json); } catch (error) { (_a2 = this.options.logger) == null ? void 0 : _a2.error("Error parsing socket message", error); } }); this.webSocket.addEventListener("close", (event) => { if (event.code !== CLOSE_CODE_NORMAL_CLOSURE && !this.closeRequested) { const courierCloseEvent = _CourierSocket.parseCloseEvent(event); if (courierCloseEvent.retryAfterSeconds) { this.retryConnection(courierCloseEvent.retryAfterSeconds * 1e3); } else { this.retryConnection(); } } this.onClose(event); }); this.webSocket.addEventListener("error", (event) => { if (!this.closeRequested) { this.retryConnection(); } this.onError(event); reject(event); }); }); } /** * Closes the WebSocket connection. * * See {@link https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close} for more details. * * @param code The WebSocket close code. Defaults to {@link CLOSE_CODE_NORMAL_CLOSURE}. * @param reason The WebSocket close reason. */ close(code = CLOSE_CODE_NORMAL_CLOSURE, reason) { if (this.webSocket === null) { return; } this.closeRequested = true; this.clearRetryTimeout(); this.retryAttempt = 0; this.webSocket.close(code, reason); } /** * Sends a message to the Courier WebSocket server. * * @param message The message to send. The message will be serialized to a JSON string. */ send(message) { var _a; if (this.webSocket === null || this.isConnecting) { (_a = this.options.logger) == null ? void 0 : _a.info("Attempted to send a message, but the WebSocket is not yet open."); return; } const json = JSON.stringify(message); this.webSocket.send(json); } get userId() { return this.options.userId; } /** The sub-tenant ID, if specified by the user. */ get subTenantId() { return this.options.tenantId; } get logger() { return this.options.logger; } get courierUserAgent() { return this.options.courierUserAgent; } /** * Whether the WebSocket connection is currently being established. */ get isConnecting() { return this.webSocket !== null && this.webSocket.readyState === WebSocket.CONNECTING; } /** * Whether the WebSocket connection is currently open. */ get isOpen() { return this.webSocket !== null && this.webSocket.readyState === WebSocket.OPEN; } /** * Constructs the WebSocket URL for the Courier WebSocket server using context * from the {@link CourierClientOptions} passed to the constructor. * * @returns The WebSocket URL */ getWebSocketUrl() { const accessToken = this.options.accessToken; const connectionId = this.options.connectionId; const userId = this.userId; const sdkName = this.courierUserAgent.getUserAgentInfo()[SDK_KEY]; const sdkVersion = this.courierUserAgent.getUserAgentInfo()[SDK_VERSION_KEY]; return `${this.url}?auth=${accessToken}&cid=${connectionId}&iwpv=${INBOX_WIRE_PROTOCOL_VERSION}&userId=${userId}&${SDK_KEY}=${sdkName}&${SDK_VERSION_KEY}=${sdkVersion}`; } /** * Parses the Retry-After time from the WebSocket close event reason, * and returns a new {@link CourierCloseEvent} with the retry after time in seconds * if present. * * The Courier WebSocket server may send the close event reason in the following format: * * ```json * { * "Retry-After": "10" // The retry after time in seconds * } * ``` * * @see https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/reason * * @param closeEvent The WebSocket close event. * @returns The WebSocket close event with the retry after time in seconds. */ static parseCloseEvent(closeEvent) { if (closeEvent.reason === null || closeEvent.reason === "") { return closeEvent; } try { const jsonReason = JSON.parse(closeEvent.reason); if (!jsonReason[_CourierSocket.RETRY_AFTER_KEY]) { return closeEvent; } const retryAfterSeconds = parseInt(jsonReason[_CourierSocket.RETRY_AFTER_KEY]); if (Number.isNaN(retryAfterSeconds) || retryAfterSeconds < 0) { return closeEvent; } return { ...closeEvent, retryAfterSeconds }; } catch (error) { return closeEvent; } } /** * Calculates the retry backoff time in milliseconds based on the current retry attempt. */ getBackoffTimeInMillis() { const backoffIntervalInMillis = _CourierSocket.BACKOFF_INTERVALS_IN_MILLIS[this.retryAttempt]; const lowerBound = backoffIntervalInMillis - backoffIntervalInMillis * _CourierSocket.BACKOFF_JITTER_FACTOR; const upperBound = backoffIntervalInMillis + backoffIntervalInMillis * _CourierSocket.BACKOFF_JITTER_FACTOR; return Math.floor(Math.random() * (upperBound - lowerBound) + lowerBound); } /** * Retries the connection to the Courier WebSocket server after * either {@param suggestedBackoffTimeInMillis} or a random backoff time * calculated using {@link getBackoffTimeInMillis}. * * @param suggestedBackoffTimeInMillis The suggested backoff time in milliseconds. * @returns A promise that resolves when the connection is established or rejects if the connection could not be established. */ async retryConnection(suggestedBackoffTimeInMillis) { var _a, _b, _c; if (this.retryTimeoutId !== null) { (_a = this.logger) == null ? void 0 : _a.debug("Skipping retry attempt because a previous retry is already scheduled."); return; } if (this.retryAttempt >= _CourierSocket.MAX_RETRY_ATTEMPTS) { (_b = this.logger) == null ? void 0 : _b.error(`Max retry attempts (${_CourierSocket.MAX_RETRY_ATTEMPTS}) reached.`); return; } const backoffTimeInMillis = suggestedBackoffTimeInMillis ?? this.getBackoffTimeInMillis(); this.retryTimeoutId = window.setTimeout(async () => { try { await this.connect(); } catch (error) { } }, backoffTimeInMillis); (_c = this.logger) == null ? void 0 : _c.debug(`Retrying connection in ${Math.floor(backoffTimeInMillis / 1e3)}s. Retry attempt ${this.retryAttempt + 1} of ${_CourierSocket.MAX_RETRY_ATTEMPTS}.`); this.retryAttempt++; } /** * Clears the retry timeout if it exists. */ clearRetryTimeout() { if (this.retryTimeoutId !== null) { window.clearTimeout(this.retryTimeoutId); this.retryTimeoutId = null; } } }; /** * The jitter factor for the backoff intervals. * * Backoff with jitter is calculated as a random value in the range: * [BACKOFF_INTERVAL - BACKOFF_JITTER_FACTOR * BACKOFF_INTERVAL, * BACKOFF_INTERVAL + BACKOFF_JITTER_FACTOR * BACKOFF_INTERVAL). */ __publicField(_CourierSocket, "BACKOFF_JITTER_FACTOR", 0.5); /** * The maximum number of retry attempts. */ __publicField(_CourierSocket, "MAX_RETRY_ATTEMPTS", 5); /** * Backoff intervals in milliseconds. * * Each represents an offset from the previous interval, rather than a * absolute offset from the initial request time. */ __publicField(_CourierSocket, "BACKOFF_INTERVALS_IN_MILLIS", [ 3e4, // 30 seconds 6e4, // 1 minute 12e4, // 2 minutes 24e4, // 4 minutes 48e4 // 8 minutes ]); /** * The key of the retry after time in the WebSocket close event reason. * * The Courier WebSocket server may send the close event reason in the following format: * * ```json * { * "Retry-After": "10" // The retry after time in seconds * } * ``` * * @see https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/reason */ __publicField(_CourierSocket, "RETRY_AFTER_KEY", "Retry-After"); let CourierSocket = _CourierSocket; class TransactionManager { constructor(completedTransactionsToKeep = 10) { /** * The map of <transactionId, Transaction> representing outstanding requests. */ __publicField(this, "outstandingRequestsMap", /* @__PURE__ */ new Map()); /** * The queue of completed requests. This is a FIFO queue of the last N completed requests, * where N is {@link completedTransactionsToKeep}. */ __publicField(this, "completedTransactionsQueue", []); /** * Number of completed requests to keep in memory. */ __publicField(this, "completedTransactionsToKeep"); this.completedTransactionsToKeep = completedTransactionsToKeep; } addOutstandingRequest(transactionId, request) { const isOutstanding = this.outstandingRequestsMap.has(transactionId); if (isOutstanding) { throw new Error(`Transaction [${transactionId}] already has an outstanding request`); } const transaction = { transactionId, request, response: null, start: /* @__PURE__ */ new Date(), end: null }; this.outstandingRequestsMap.set(transactionId, transaction); } addResponse(transactionId, response) { const transaction = this.outstandingRequestsMap.get(transactionId); if (transaction === void 0) { throw new Error(`Transaction [${transactionId}] does not have an outstanding request`); } transaction.response = response; transaction.end = /* @__PURE__ */ new Date(); this.outstandingRequestsMap.delete(transactionId); this.addCompletedTransaction(transaction); } get outstandingRequests() { return Array.from(this.outstandingRequestsMap.values()); } get completedTransactions() { return this.completedTransactionsQueue; } clearOutstandingRequests() { this.outstandingRequestsMap.clear(); } /** * Adds a completed request to the queue. * * If the number of completed requests exceeds the maximum number of completed requests to keep, * remove the oldest completed request. */ addCompletedTransaction(transaction) { this.completedTransactionsQueue.push(transaction); if (this.completedTransactionsQueue.length > this.completedTransactionsToKeep) { this.completedTransactionsQueue.shift(); } } } function ensureCreatedTime(envelope) { if (envelope.event === InboxMessageEvent.NewMessage) { const message = envelope.data; if (!message.created) { message.created = (/* @__PURE__ */ new Date()).toISOString(); } return { ...envelope, data: message }; } return envelope; } function fixMessageEventEnvelope(envelope) { return ensureCreatedTime(envelope); } const _CourierInboxSocket = class _CourierInboxSocket extends CourierSocket { constructor(options) { super(options); /** * The interval ID for the ping interval. * * @see https://developer.mozilla.org/en-US/docs/Web/API/Window/setInterval */ __publicField(this, "pingIntervalId", null); /** * The list of message event listeners, called when a message event is received * from the Courier WebSocket server. */ __publicField(this, "messageEventListeners", []); /** Server-provided configuration for the client. */ __publicField(this, "config", null); /** * The transaction manager, used to track outstanding requests and responses. */ __publicField(this, "pingTransactionManager", new TransactionManager()); } onOpen(_) { this.pingTransactionManager.clearOutstandingRequests(); this.restartPingInterval(); this.sendGetConfig(); this.sendSubscribe(); return Promise.resolve(); } onMessageReceived(data) { if ("action" in data && data.action === ServerAction.Ping) { const envelope = data; this.sendPong(envelope); } if ("response" in data && data.response === "pong") { const envelope = data; this.pingTransactionManager.addResponse(envelope.tid, envelope); this.pingTransactionManager.clearOutstandingRequests(); } if ("response" in data && data.response === "config") { const envelope = data; this.setConfig(envelope.data); } if ("event" in data && _CourierInboxSocket.isInboxMessageEvent(data.event)) { const envelope = data; const fixedEnvelope = fixMessageEventEnvelope(envelope); for (const listener of this.messageEventListeners) { listener(fixedEnvelope); } } this.restartPingInterval(); return Promise.resolve(); } onClose(_) { this.clearPingInterval(); this.clearMessageEventListeners(); this.pingTransactionManager.clearOutstandingRequests(); return Promise.resolve(); } onError(_) { return Promise.resolve(); } /** * Sends a subscribe message to the server. * * Subscribes to all events for the user. */ sendSubscribe() { const data = { channel: this.userId, event: "*" }; if (this.subTenantId) { data.accountId = this.subTenantId; } const envelope = { tid: UUID.nanoid(), action: ClientAction.Subscribe, data }; this.send(envelope); } /** * Sends an unsubscribe message to the server. * * Unsubscribes from all events for the user. */ sendUnsubscribe() { const envelope = { tid: UUID.nanoid(), action: ClientAction.Unsubscribe, data: { channel: this.userId } }; this.send(envelope); } /** * Adds a message event listener, called when a message event is received * from the Courier WebSocket server. * * @param listener The listener function * @returns A function that can be called to remove this specific listener */ addMessageEventListener(listener) { this.messageEventListeners.push(listener); return () => { this.removeMessageEventListener(listener); }; } /** * Send a ping message to the server. * * ping/pong is implemented at the application layer since the browser's * WebSocket implementation does not support control-level ping/pong. */ sendPing() { var _a; if (this.pingTransactionManager.outstandingRequests.length >= this.maxOutstandingPings) { (_a = this.logger) == null ? void 0 : _a.debug("Max outstanding pings reached, retrying connection."); this.close(CLOSE_CODE_NORMAL_CLOSURE, "Max outstanding pings reached, retrying connection."); this.retryConnection(); return; } const envelope = { tid: UUID.nanoid(), action: ClientAction.Ping }; this.send(envelope); this.pingTransactionManager.addOutstandingRequest(envelope.tid, envelope); } /** * Send a pong response to the server. * * ping/pong is implemented at the application layer since the browser's * WebSocket implementation does not support control-level ping/pong. */ sendPong(incomingMessage) { const response = { tid: incomingMessage.tid, action: ClientAction.Pong }; this.send(response); } /** * Send a request for the client's configuration. */ sendGetConfig() { const envelope = { tid: UUID.nanoid(), action: ClientAction.GetConfig }; this.send(envelope); } /** * Restart the ping interval, clearing the previous interval if it exists. */ restartPingInterval() { this.clearPingInterval(); this.pingIntervalId = window.setInterval(() => { this.sendPing(); }, this.pingInterval); } clearPingInterval() { if (this.pingIntervalId) { window.clearInterval(this.pingIntervalId); } } get pingInterval() { if (this.config) { return this.config.pingInterval * 1e3; } return _CourierInboxSocket.DEFAULT_PING_INTERVAL_MILLIS; } get maxOutstandingPings() { if (this.config) { return this.config.maxOutstandingPings; } return _CourierInboxSocket.DEFAULT_MAX_OUTSTANDING_PINGS; } setConfig(config) { this.config = config; } /** * Removes all message event listeners. */ clearMessageEventListeners() { while (this.messageEventListeners.length > 0) { this.messageEventListeners.pop(); } } /** * Remove the message event listener specified. * * This is the same listener function passed to {@link addMessageEventListener}. */ removeMessageEventListener(listener) { const index = this.messageEventListeners.indexOf(listener); if (index > -1) { this.messageEventListeners.splice(index, 1); } } static isInboxMessageEvent(event) { return Object.values(InboxMessageEvent).includes(event); } }; /** * The default interval in milliseconds at which to send a ping message to the server * if no other message has been received from the server. * * Fallback when the server does not provide a config. */ __publicField(_CourierInboxSocket, "DEFAULT_PING_INTERVAL_MILLIS", 6e4); // 1 minute /** * The default maximum number of outstanding pings before the client should * close the connection and retry connecting. * * Fallback when the server does not provide a config. */ __publicField(_CourierInboxSocket, "DEFAULT_MAX_OUTSTANDING_PINGS", 3); let CourierInboxSocket = _CourierInboxSocket; class InboxClient extends Client { constructor(options) { super(options); __publicField(this, "socket"); this.socket = new CourierInboxSocket(options); } /** * Get paginated messages * @param paginationLimit - Number of messages to return per page (default: 24) * @param startCursor - Cursor for pagination * @returns Promise resolving to paginated messages response */ async getMessages(props) { const filter = (props == null ? void 0 : props.filter) || {}; const filterParams = this.createFilterParams(filter); const unreadCountFilterParams = this.createUnreadCountFilterParams(filter); const query = ` query GetInboxMessages( $params: FilterParamsInput = ${filterParams} $unreadCountParams: FilterParamsInput = ${unreadCountFilterParams} $limit: Int = ${(props == null ? void 0 : props.paginationLimit) ?? 24} $after: String ${(props == null ? void 0 : props.startCursor) ? `= "${props.startCursor}"` : ""} ) { count: count(params: $params) unreadCount: count(params: $unreadCountParams) messages(params: $params, limit: $limit, after: $after) { totalCount pageInfo { startCursor hasNextPage } nodes { messageId read archived created opened title preview data tags trackingIds { clickTrackingId } actions { content data href } } } } `; return await graphql({ options: this.options, query, headers: { "x-courier-user-id": this.options.userId, "Authorization": `Bearer ${this.options.accessToken}` }, url: this.options.apiUrls.inbox.graphql }); } /** * Get unread counts for multiple filters in a single query. * * @param filtersMap - Map of dataset ID to filter * @returns Promise resolving to map of dataset ID to unread count */ async getUnreadCounts(filtersMap) { const result = {}; const filtersToQuery = {}; for (const [datasetId, filter] of Object.entries(filtersMap)) { if (filter.status === "read") { result[datasetId] = 0; } else { filtersToQuery[datasetId] = filter; } } if (Object.keys(filtersToQuery).length === 0) { return result; } const variables = []; const fields = []; const sanitizedIdMapping = {}; for (const [datasetId, filter] of Object.entries(filtersToQuery)) { const sanitizedId = InboxClient.sanitizeGraphQLIdentifier(datasetId); sanitizedIdMapping[sanitizedId] = datasetId; const unreadCountFilterParams = this.createUnreadCountFilterParams(filter); variables.push(`$${sanitizedId}: FilterParamsInput = ${unreadCountFilterParams}`); fields.push(`${sanitizedId}: count(params: $${sanitizedId})`); } const query = ` query GetUnreadCounts( ${variables.join("\n")} ) { ${fields.join("\n")} } `; const response = await graphql({ options: this.options, query, headers: { "x-courier-user-id": this.options.userId, "Authorization": `Bearer ${this.options.accessToken}` }, url: this.options.apiUrls.inbox.graphql }); if (response.data) { for (const [sanitizedId, originalId] of Object.entries(sanitizedIdMapping)) { result[originalId] = response.data[sanitizedId] ?? 0; } } return result; } /** * Get paginated archived messages * @param paginationLimit - Number of messages to return per page (default: 24) * @param startCursor - Cursor for pagination * @returns Promise resolving to paginated archived messages response * * @deprecated - replace usages with {@link InboxClient.getMessages}, passing the filter `{ archived: true }` */ async getArchivedMessages(props) { return this.getMessages({ paginationLimit: props == null ? void 0 : props.paginationLimit, startCursor: props == null ? void 0 : props.startCursor, filter: { archived: true } }); } /** * Get unread message count * @returns Promise resolving to number of unread messages */ async getUnreadMessageCount() { var _a; const query = ` query GetMessages { count(params: { status: "unread" ${this.options.tenantId ? `, accountId: "${this.options.tenantId}"` : ""} }) } `; const response = await graphql({ options: this.options, query, headers: { "x-courier-user-id": this.options.userId, "Authorization": `Bearer ${this.options.accessToken}` }, url: this.options.apiUrls.inbox.graphql }); return ((_a = response.data) == null ? void 0 : _a.count) ?? 0; } /** * Track a click event * @param messageId - ID of the message * @param trackingId - ID for tracking the click * @returns Promise resolving when click is tracked */ async click(props) { const query = ` mutation TrackEvent { clicked(messageId: "${props.messageId}", trackingId: "${props.trackingId}") } `; const headers = { "x-courier-user-id": this.options.userId, "Authorization": `Bearer ${this.options.accessToken}` }; if (this.options.connectionId) { headers["x-courier-client-source-id"] = this.options.connectionId; } await graphql({ options: this.options, query, headers, url: this.options.apiUrls.inbox.graphql }); } /** * Mark a message as read * @param messageId - ID of the message to mark as read * @returns Promise resolving when message is marked as read */ async read(props) { const query = ` mutation TrackEvent { read(messageId: "${props.messageId}") } `; const headers = { "x-courier-user-id": this.options.userId, "Authorization": `Bearer ${this.options.accessToken}` }; if (this.options.connectionId) { headers["x-courier-client-source-id"] = this.options.connectionId; } await graphql({ options: this.options, query, headers, url: this.options.apiUrls.inbox.graphql }); } /** * Mark a message as unread * @param messageId - ID of the message to mark as unread * @returns Promise resolving when message is marked as unread */ async unread(props) { const query = ` mutation TrackEvent { unread(messageId: "${props.messageId}") } `; const headers = { "x-courier-user-id": this.options.userId, "Authorization": `Bearer ${this.options.accessToken}` }; if (this.options.connectionId) { headers["x-courier-client-source-id"] = this.options.connectionId; } await graphql({ options: this.options, query, headers, url: this.options.apiUrls.inbox.graphql }); } /** * Mark a message as opened * @param messageId - ID of the message to mark as opened * @returns Promise resolving when message is marked as opened */ async open(props) { const query = ` mutation TrackEvent { opened(messageId: "${props.messageId}") } `; const headers = { "x-courier-user-id": this.options.userId, "Authorization": `Bearer ${this.options.accessToken}` }; if (this.options.connectionId) { headers["x-courier-client-source-id"] = this.options.connectionId; } await graphql({ options: this.options, query, headers, url: this.options.apiUrls.inbox.graphql }); } /** * Archive a message * @param messageId - ID of the message to archive * @returns Promise resolving when message is archived */ async archive(props) { const query = ` mutation TrackEvent { archive(messageId: "${props.messageId}") } `; const headers = { "x-courier-user-id": this.options.userId, "Authorization": `Bearer ${this.options.accessToken}` }; if (this.options.connectionId) { headers["x-courier-client-source-id"] = this.options.connectionId; } await graphql({ options: this.options, query, headers, url: this.options.apiUrls.inbox.graphql }); } /** * Unarchive a message * @param messageId - ID of the message to unarchive * @returns Promise resolving when message is unarchived */ async unarchive(props) { const query = ` mutation TrackEvent { unarchive(messageId: "${props.messageId}") } `; const headers = { "x-courier-user-id": this.options.userId, "Authorization": `Bearer ${this.options.accessToken}` }; if (this.options.connectionId) { headers["x-courier-client-source-id"] = this.options.connectionId; } await graphql({ options: this.options, query, headers, url: this.options.apiUrls.inbox.graphql }); } /** * Mark all messages as read * @returns Promise resolving when all messages are marked as read */ async readAll() { const query = ` mutation TrackEvent { markAllRead } `; const headers = { "x-courier-user-id": this.options.userId, "Authorization": `Bearer ${this.options.accessToken}` }; if (this.options.connectionId) { headers["x-courier-client-source-id"] = this.options.connectionId; } await graphql({ options: this.options, query, headers, url: this.options.apiUrls.inbox.graphql }); } /** * Archive all read messages. */ async archiveRead() { const query = ` mutation TrackEvent { archiveRead } `; const headers = { "x-courier-user-id": this.options.userId, "Authorization": `Bearer ${this.options.accessToken}` }; if (this.options.connectionId) { headers["x-courier-client-source-id"] = this.options.connectionId; } await graphql({ options: this.options, query, headers, url: this.options.apiUrls.inbox.graphql }); } /** * Archive all read messages. */ async archiveAll() { const query = ` mutation TrackEvent { archiveAll } `; const headers = { "x-courier-user-id": this.options.userId, "Authorization": `Bearer ${this.options.accessToken}` }; if (this.options.connectionId) { headers["x-courier-client-source-id"] = this.options.connectionId; } await graphql({ options: this.options, query, headers, url: this.options.apiUrls.inbox.graphql }); } /** * Create FilterParamsInput for the given filters. * * @param filter - the filtering options to include in the output * @returns the FilterParamsInput to pass to a GraphQL query for messages */ createFilterParams(filter) { const parts = []; if (this.options.tenantId) { parts.push(`accountId: "${this.options.tenantId}"`); } if (filter.tags) { parts.push(`tags: [${filter.tags.map((tag) => `"${tag}"`).join(",")}]`); } if (filter.status) { parts.push(`status: "${filter.status}"`); } if (filter.archived) { parts.push(`archived: ${filter.archived}`); } if (filter.from) { parts.push(`from: "${filter.from}"`); } return `{ ${parts.join(",")} }`; } /** * Create FilterParamsInput for the unread message count. * * The status: "unread" filter is only added if status is unset. This is because: * - If status is "unread", the params already include the filter that would be added. * - If status is "read", the unread count for the dataset would be a different set * of messages rather than a count of the unread subset. */ createUnreadCountFilterParams(filter) { if (!filter.status) { return this.createFilterParams({ ...filter, status: "unread" }); } return this.createFilterParams(filter); } /** * Sanitize dataset IDs for use as GraphQL identifiers. * * GraphQL identifiers must contain only alphanumerics/underscores and begin with a letter or underscore. * https://spec.graphql.org/draft/#sec-Names */ static sanitizeGraphQLIdentifier(id) { return `id_${id.replace(/_/g, "__").replace(/-/g, "_")}`; } } function decode(clientKey) { const binaryString = atob(clientKey); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } return String.fromCharCode(...bytes); } function encode(key) { const bytes = new Uint8Array(key.length); for (let i = 0; i < key.length; i++) { bytes[i] = key.charCodeAt(i); } return btoa(String.fromCharCode(...bytes)); } class PreferenceClient extends Client { /** * Get all preferences for a user * @param paginationCursor - Optional cursor for pagination (not used in GraphQL implementation) * @returns Promise resolving to user preferences */ async getUserPreferences(props) { var _a, _b; const query = ` query GetRecipientPreferences { recipientPreferences${this.options.tenantId ? `(accountId: "${this.options.tenantId}")` : ""} { nodes { templateId templateName sectionId sectionName defaultStatus status hasCustomRouting routingPreferences digestSchedule } } } `; const response = await graphql({ options: this.options, url: this.options.apiUrls.courier.graphql, query, headers: { "x-courier-user-id": this.options.userId, "x-courier-client-key": "empty", // Empty for now. Will be removed in future. "Authorization": `Bearer ${this.options.accessToken}` } }); const nodes = ((_b = (_a = response.data) == null ? void 0 : _a.recipientPreferences) == null ? void 0 : _b.nodes) || []; return { items: nodes.map((node) => this.transformToTopic(node)), paging: { cursor: props == null ? void 0 : props.paginationCursor, more: false // GraphQL returns all preferences at once } }; } /** * Get preferences for a specific topic * @param topicId - The ID of the topic to get preferences for * @returns Promise resolving to topic preferences */ async getUserPreferenceTopic(props) { var _a; const query = ` query GetRecipientPreferenceTopic { recipientPreference(templateId: "${props.topicId}"${this.options.tenantId ? `, accountId: "${this.options.tenantId}"` : ""}) { templateId templateName status hasCustomRouting routingPreferences digestSchedule sectionId sectionName defaultStatus } } `; const response = await graphql({ options: this.options, url: this.options.apiUrls.courier.graphql, query, headers: { "x-courier-user-id": this.options.userId, "x-courier-client-key": "empty", // Empty for now. Will be removed in future. "Authorization": `Bearer ${this.options.accessToken}` } }); const node = (_a = response.data) == null ? void 0 : _a.recipientPreference; if (!node) { throw new Error(`Preference topic not found: ${props.topicId}`); } return this.transformToTopic(node); } /** * Update preferences for a specific topic * @param topicId - The ID of the topic to update preferences for * @param status - The new status for the topic * @param hasCustomRouting - Whether the topic has custom routing * @param customRouting - The custom routing channels for the topic * @returns Promise resolving when update is complete */ async putUserPreferenceTopic(props) { const routingPreferences = props.customRouting.length > 0 ? `[${props.customRouting.join(", ")}]` : "[]"; const query = ` mutation UpdateRecipientPreferences { updatePreferences( templateId: "${props.topicId}", preferences: { status: ${props.status}, hasCustomRouting: ${props.hasCustomRouting}, routingPreferences: ${routingPreferences} }${this.options.tenantId ? `, accountId: "${this.options.tenantId}"` : ""} ) } `; await graphql({ options: this.options, url: this.options.apiUrls.courier.graphql, query, headers: { "x-courier-user-id": this.options.userId, "x-courier-client-key": "empty", // Empty for now. Will be removed in future. "Authorization": `Bearer ${this.options.accessToken}` } }); } /** * Get the notification center URL * @param clientKey - The client key to use for the URL * @returns The notification center URL */ getNotificationCenterUrl(props) { const rootTenantId = decode(props.clientKey); const url = encode(`${rootTenantId}#${this.options.userId}${this.options.tenantId ? `#${this.options.tenantId}` : ""}#${false}`); return `https://view.notificationcenter.app/p/${url}`; } /** * Transform a GraphQL RecipientPreference node to CourierUserPreferencesTopic */ transformToTopic(node) { return { topicId: node.templateId, topicName: node.templateName || "", sectionId: node.sectionId || "", sectionName: node.sectionName || "", status: node.status || "UNKNOWN", defaultStatus: node.defaultStatus || "UNKNOWN", hasCustomRouting: node.hasCustomRouting || false, customRouting: node.routingPreferences || [] }; } } class TokenClient extends Client { /** * Store a push notification token for a user * @param token - The push notification token * @param provider - The provider of the token * @param device - The device information * @see https://www.courier.com/docs/api-reference/device-tokens/add-single-token-to-user */ async putUserToken(props) { const payload = { provider_key: props.provider, ...props.device && { device: { app_id: props.device.appId, ad_id: props.device.adId, device_id: props.device.deviceId, platform: props.device.platform, manufacturer: props.device.manufacturer, model: props.device.model } } }; await http({ options: this.options, url: `${this.options.apiUrls.courier.rest}/users/${this.options.userId}/tokens/${props.token}`, method: "PUT", headers: { "Authorization": `Bearer ${this.options.accessToken}` }, body: payload, validCodes: [200, 204] }); } /** * Delete a push notification token for a user * @param token - The push notification token * @returns Promise resolving when token is deleted */ async deleteUserToken(props) { await http({ options: this.options, url: `${this.options.apiUrls.courier.rest}/users/${this.options.userId}/tokens/${props.token}`, method: "DELETE", headers: { "Authorization": `Bearer ${this.options.accessToken}` }, validCodes: [200, 204] }); } } class ListClient extends Client { /** * Subscribe a user to a list * @param listId - The ID of the list to subscribe to * @returns Promise resolving when subscription is complete * @see https://www.courier.com/docs/api-reference/lists/subscribe-user-profile-to-list */ async putSubscription(props) { return await http({ url: `${this.options.apiUrls.courier.rest}/lists/${props.listId}/subscriptions/${this.options.userId}`, options: this.options, method: "PUT", headers: { Authorization: `Bearer ${this.options.accessToken}` } }); } /** * Unsubscribe a user from a list * @param listId - The ID of the list to unsubscribe from * @returns Promise resolving when unsubscription is complete * @see https://www.courier.com/docs/api-reference/lists/unsubscribe-user-profile-from-list */ async deleteSubscription(props) { return await http({ url: `${this.options.apiUrls.courier.rest}/lists/${props.listId}/subscriptions/${this.options.userId}`, options: this.options, method: "DELETE", headers: { Authorization: `Bearer ${this.options.accessToken}` } }); } } class TrackingClient extends Client { /** * @deprecated This method is deprecated and will be removed or changed significantly in a future release. * * Post an inbound courier event. This is typically used for tracking custom events * related to a specific message in Courier. * * @param props - The event properties object containing: * - `clientKey`: The client key associated with your Courier project. * You can get your client key here: https://app.courier.com/settings/api-keys * - `event`: The name of the event (e.g., "New Order Placed"). * - `messageId`: The unique ID of the message this event relates to. * - `type`: The type of event. Only supported value: "track". * - `properties`: (Optional) Additional custom properties for the event. * @returns Promise resolving to an object containing the messageId. * @see https://www.courier.com/docs/api-reference/inbound/courier-track-event */ async postInboundCourier(props) { const { clientKey, ...bodyProps } = props; return await h