@trycourier/courier-js
Version:
A browser-safe API wrapper
1,588 lines (1,587 loc) • 49.4 kB
JavaScript
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);
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 || {});
const getCourierApiUrls = (urls) => ({
courier: {
rest: (urls == null ? void 0 : urls.courier.rest) || "https://api.courier.com",
graphql: (urls == null ? void 0 : urls.courier.graphql) || "https://api.courier.com/client/q"
},
inbox: {
graphql: (urls == null ? void 0 : urls.inbox.graphql) || "https://inbox.courier.com/q",
webSocket: (urls == null ? void 0 : urls.inbox.webSocket) || "wss://realtime.courier.io"
}
});
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;
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 request = new Request(props.url, {
method: props.method,
headers: {
"Content-Type": "application/json",
...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;
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",
...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;
}
/**
* 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;
return `${this.url}?auth=${accessToken}&cid=${connectionId}&iwpv=${INBOX_WIRE_PROTOCOL_VERSION}&userId=${userId}`;
}
/**
* 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
*/
addMessageEventListener(listener) {
this.messageEventListeners.push(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() {
this.messageEventListeners = [];
}
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 query = `
query GetInboxMessages(
$params: FilterParamsInput = { ${this.options.tenantId ? `accountId: "${this.options.tenantId}"` : ""} }
$limit: Int = ${(props == null ? void 0 : props.paginationLimit) ?? 24}
$after: String ${(props == null ? void 0 : props.startCursor) ? `= "${props.startCursor}"` : ""}
) {
count(params: $params)
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 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
*/
async getArchivedMessages(props) {
const query = `
query GetInboxMessages(
$params: FilterParamsInput = { ${this.options.tenantId ? `accountId: "${this.options.tenantId}"` : ""}, archived: true }
$limit: Int = ${(props == null ? void 0 : props.paginationLimit) ?? 24}
$after: String ${(props == null ? void 0 : props.startCursor) ? `= "${props.startCursor}"` : ""}
) {
count(params: $params)
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 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 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
});
}
}
class PreferenceTransformer {
/**
* Transforms a single API response item to the CourierUserPreferencesTopic type
* @param item - The API response item
* @returns A CourierUserPreferencesTopic object
*/
transformItem(item) {
return {
topicId: item.topic_id,
topicName: item.topic_name,
sectionId: item.section_id,
sectionName: item.section_name,
status: item.status,
defaultStatus: item.default_status,
hasCustomRouting: item.has_custom_routing,
customRouting: item.custom_routing || []
};
}
/**
* Transforms an array of API response items to CourierUserPreferencesTopic objects
* @param items - The API response items
* @returns A generator of CourierUserPreferencesTopic objects
*/
*transform(items) {
for (const item of items) {
yield this.transformItem(item);
}
}
}
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 {
constructor() {
super(...arguments);
__publicField(this, "transformer", new PreferenceTransformer());
}
/**
* Get all preferences for a user
* @param paginationCursor - Optional cursor for pagination
* @returns Promise resolving to user preferences
* @see https://www.courier.com/docs/reference/user-preferences/list-all-user-preferences
*/
async getUserPreferences(props) {
let url = `${this.options.apiUrls.courier.rest}/users/${this.options.userId}/preferences`;
if (props == null ? void 0 : props.paginationCursor) {
url += `?cursor=${props.paginationCursor}`;
}
const json = await http({
options: this.options,
url,
method: "GET",
headers: {
"Authorization": `Bearer ${this.options.accessToken}`
}
});
const data = json;
return {
items: [...this.transformer.transform(data.items)],
paging: data.paging
};
}
/**
* Get preferences for a specific topic
* @param topicId - The ID of the topic to get preferences for
* @returns Promise resolving to topic preferences
* @see https://www.courier.com/docs/reference/user-preferences/get-subscription-topic-preferences
*/
async getUserPreferenceTopic(props) {
const json = await http({
options: this.options,
url: `${this.options.apiUrls.courier.rest}/users/${this.options.userId}/preferences/${props.topicId}`,
method: "GET",
headers: {
"Authorization": `Bearer ${this.options.accessToken}`
}
});
const res = json;
return this.transformer.transformItem(res.topic);
}
/**
* 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
* @see https://www.courier.com/docs/reference/user-preferences/update-subscription-topic-preferences
*/
async putUserPreferenceTopic(props) {
const payload = {
topic: {
status: props.status,
has_custom_routing: props.hasCustomRouting,
custom_routing: props.customRouting
}
};
await http({
options: this.options,
url: `${this.options.apiUrls.courier.rest}/users/${this.options.userId}/preferences/${props.topicId}`,
method: "PUT",
headers: {
"Authorization": `Bearer ${this.options.accessToken}`
},
body: payload
});
}
/**
* 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}`;
}
}
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/reference/token-management/put-token
*/
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/reference/lists/recipient-subscribe
*/
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/reference/lists/delete-subscription
*/
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 {
/**
* Post an inbound courier event
* @param event - The event type: Example: "New Order Placed"
* @param messageId - The message ID
* @param type - The type of event: Available options: "track"
* @param properties - The properties of the event
* @returns Promise resolving to the message ID
* @see https://www.courier.com/docs/reference/inbound/courier-track-event
*/
async postInboundCourier(props) {
return await http({
url: `${this.options.apiUrls.courier.rest}/inbound/courier`,
options: this.options,
method: "POST",
headers: {
Authorization: `Bearer ${this.options.accessToken}`
},
body: {
...props,
userId: this.options.userId
},
validCodes: [200, 202]
});
}
/**
* Post a tracking URL event
* These urls are found in messages sent from Courier
* @param url - The URL to post the event to
* @param event - The event type: Available options: "click", "open", "unsubscribe"
* @returns Promise resolving when the event is posted
*/
async postTrackingUrl(props) {
return await http({
url: props.url,
options: this.options,
method: "POST",
body: {
event: props.event
}
});
}
}
class CourierClient extends Client {
constructor(props) {
var _a, _b;
const showLogs = props.showLogs !== void 0 ? props.showLogs : process.env.NODE_ENV === "development";
const baseOptions = {
...props,
showLogs,
apiUrls: props.apiUrls || getCourierApiUrls(),
accessToken: props.jwt ?? props.publicApiKey
};
super({
...baseOptions,
logger: new Logger(baseOptions.showLogs),
apiUrls: getCourierApiUrls(baseOptions.apiUrls)
});
__publicField(this, "tokens");
__publicField(this, "brands");
__publicField(this, "preferences");
__publicField(this, "inbox");
__publicField(this, "lists");
__publicField(this, "tracking");
this.tokens = new TokenClient(this.options);
this.brands = new BrandClient(this.options);
this.preferences = new PreferenceClient(this.options);
this.inbox = new InboxClient(this.options);
this.lists = new ListClient(this.options);
this.tracking = new TrackingClient(this.options);
if (!this.options.jwt && !this.options.publicApiKey) {
this.options.logger.warn("Courier Client initialized with no authentication method. Please provide a JWT or public API key.");
}
if (this.options.publicApiKey) {
(_a = this.options.logger) == null ? void 0 : _a.warn(
"Courier Warning: Public API Keys are for testing only. Please use JWTs for production.\nYou can generate a JWT with this endpoint: https://www.courier.com/docs/reference/auth/issue-token\nThis endpoint should be called from your backend server, not the SDK."
);
}
if (this.options.jwt && this.options.publicApiKey) {
(_b = this.options.logger) == null ? void 0 : _b.warn(
"Courier Warning: Both a JWT and a Public API Key were provided. The Public API Key will be ignored."
);
}
}
}
class AuthenticationListener {
constructor(callback) {
__publicField(this, "callback");
this.callback = callback;
}
remove() {
Courier.shared.removeAuthenticationListener(this);
}
}
const _Courier = class _Courier {
constructor() {
/**
* The unique identifier for the Courier instance
*/
__publicField(this, "id", UUID.nanoid());
/**
* The Courier client instance
*/
__publicField(this, "instanceClient");
/**
* The pagination limit (min: 1, max: 100)
*/
__publicField(this, "_paginationLimit", 24);
/**
* The authentication listeners
*/
__publicField(this, "authenticationListeners", []);
}
get paginationLimit() {
return this._paginationLimit;
}
set paginationLimit(value) {
this._paginationLimit = Math.min(Math.max(value, 1), 100);
}
/**
* Get the Courier client instance
* @returns The Courier client instance or undefined if not signed in
*/
get client() {
return this.instanceClient;
}
/**
* Get the shared Courier instance
* @returns The shared Courier instance
*/
static get shared() {
if (!_Courier.instance) {
_Courier.instance = new _Courier();
}
return _Courier.instance;
}
/**
* Sign in to Courier
* @param options - The options for the Courier client
*/
signIn(props) {
if (this.instanceClient) {
this.instanceClient.options.logger.warn("Sign in called but there is already a user signed in. Signing out the current user.");
this.signOut();
}
const connectionId = props.connectionId ?? UUID.nanoid();
this.instanceClient = new CourierClient({ ...props, connectionId });
this.notifyAuthenticationListeners({ userId: props.userId });
}
/**
* Sign out of Courier
*/
signOut() {
var _a, _b;
(_b = (_a = this.instanceClient) == null ? void 0 : _a.inbox.socket) == null ? void 0 : _b.close();
this.instanceClient = void 0;
this.notifyAuthenticationListeners({ userId: void 0 });
}
/**
* Register a callback to be notified of authentication state changes
* @param callback - Function to be called when authentication state changes
* @returns AuthenticationListener instance that can be used to remove the listener
*/
addAuthenticationListener(callback) {
var _a;
(_a = this.instanceClient) == null ? void 0 : _a.options.logger.info("Adding authentication listener");
const listener = new AuthenticationListener(callback);
this.authenticationListeners.push(listener);
return listener;
}
/**
* Unregister an authentication state change listener
* @param listener - The AuthenticationListener instance to remove
*/
removeAuthenticationListener(listener) {
var _a;
(_a = this.instanceClient) == null ? void 0 : _a.options.logger.info("Removing authentication listener");
this.authenticationListeners = this.authenticationListeners.filter((l) => l !== listener);
}
/**
* Notify all authentication listeners
* @param props - The props to notify the listeners with
*/
notifyAuthenticationListeners(props) {
this.authenticationListeners.forEach((listener) => listener.callback(props));
}
};
/**
* The shared Courier instance
*/
__publicField(_Courier, "instance");
let Courier = _Courier;
export {
BrandClient,
Courier,
CourierClient,
InboxClient,
InboxMessageEvent,
ListClient,
PreferenceClient,
TokenClient
};
//# sourceMappingURL=index.mjs.map