UNPKG

@statezero/core

Version:

The type-safe frontend client for StateZero - connect directly to your backend models with zero boilerplate

267 lines (264 loc) 10.3 kB
import Pusher from "pusher-js"; /** * Structure of events received from the server. * @typedef {Object} ModelEvent * @property {string} [type] - Support both frontend (type) and backend (event) naming conventions. * @property {string} [event] * @property {string} model * @property {any} [data] * @property {string} [operationId] * @property {string} [namespace] * @property {(string|number)[]} [instances] - For bulk events. * @property {string} [pk_field_name] * @property {string} [configKey] - The backend configuration key this event is associated with. * @property {any} [key] - Additional open-ended keys. */ /** * Event types that can be received from the server. * @readonly * @enum {string} */ export const EventType = { CREATE: "create", UPDATE: "update", DELETE: "delete", BULK_CREATE: "bulk_create", BULK_UPDATE: "bulk_update", BULK_DELETE: "bulk_delete", }; /** * Callback for handling model events. * @callback EventHandler * @param {ModelEvent} event - The event object. */ /** * A namespace resolver function. * @callback NamespaceResolver * @param {string} modelName - The model name. * @returns {string} The namespace. */ /** * Options for instantiating a Pusher client. * @typedef {Object} PusherClientOptions * @property {string} appKey * @property {string} cluster * @property {boolean} [forceTLS] * @property {string} authEndpoint * @property {function(): Object<string, string>} [getAuthHeaders] */ /** * Configuration options for Pusher event receivers. * @typedef {Object} PusherReceiverOptions * @property {PusherClientOptions} clientOptions * @property {function(string): string} [formatChannelName] - Optional channel name formatter. Default: (namespace) => `private-${namespace}` * @property {NamespaceResolver} [namespaceResolver] - Optional namespace resolver. Default: (modelName) => modelName. */ /** * Implementation of EventReceiver that uses Pusher. */ export class PusherEventReceiver { /** * @param {PusherReceiverOptions} options * @param {string} configKey - The backend configuration key */ constructor(options, configKey) { const { clientOptions, formatChannelName, namespaceResolver } = options; const CONNECTION_TIMEOUT = 10000; // 10 seconds this.configKey = configKey; this.connectionTimeoutId = null; if (clientOptions.appKey && /^\d+$/.test(clientOptions.appKey) && clientOptions.appKey.length < 15) { console.warn(`%c[Pusher Warning] The provided appKey ("${clientOptions.appKey}") looks like a numeric app_id. Pusher requires the alphanumeric key, not the ID. Please verify your configuration for backend: "${this.configKey}".`, "color: orange; font-weight: bold; font-size: 14px;"); } this.pusherClient = new Pusher(clientOptions.appKey, { cluster: clientOptions.cluster, forceTLS: clientOptions.forceTLS ?? true, authEndpoint: clientOptions.authEndpoint, auth: { headers: clientOptions.getAuthHeaders?.() || {} }, }); this.pusherClient.connection.bind("connected", () => { console.log(`Pusher client connected successfully for backend: ${this.configKey}.`); if (this.connectionTimeoutId) { clearTimeout(this.connectionTimeoutId); this.connectionTimeoutId = null; } }); this.pusherClient.connection.bind("failed", () => { this._logConnectionError("Pusher connection explicitly failed."); if (this.connectionTimeoutId) { clearTimeout(this.connectionTimeoutId); this.connectionTimeoutId = null; } }); this.connectionTimeoutId = setTimeout(() => { if (this.pusherClient.connection.state !== "connected") { this._logConnectionError(`Pusher connection timed out after ${CONNECTION_TIMEOUT / 1000} seconds.`); } }, CONNECTION_TIMEOUT); this.formatChannelName = formatChannelName ?? ((ns) => `private-${ns}`); this.namespaceResolver = namespaceResolver ?? ((modelName) => modelName); this.channels = new Map(); this.eventHandlers = new Set(); } /** * @private * @param {string} reason */ _logConnectionError(reason) { console.error(`%c ████████████████████████████████████████████████████████████████ █ █ █ PUSHER CONNECTION FAILED for backend: "${this.configKey}" █ █ █ ████████████████████████████████████████████████████████████████ %c Reason: ${reason} CRITICAL: Real-time updates from the server will NOT be received. This application will not reflect remote changes propagated via Pusher. Common causes: 1. Incorrect 'appKey' or 'cluster' in the configuration. 2. The 'authEndpoint' is unreachable or returning an error (check network tab). 3. Network connectivity issues (firewall, offline). 4. Using an 'app_id' instead of the 'appKey'.`, "background-color: red; color: white; font-weight: bold; font-size: 16px; padding: 10px;", "color: red; font-size: 12px;"); } /** * Set the namespace resolver function. * @param {NamespaceResolver} resolver */ setNamespaceResolver(resolver) { this.namespaceResolver = resolver; } /** * Connect to Pusher (no-op since Pusher handles connection automatically). */ connect() { } /** * Subscribe to events for a specific namespace. * @param {string} namespace */ subscribe(namespace) { if (this.channels.has(namespace)) return; const channelName = namespace.startsWith("private-") ? namespace : this.formatChannelName(namespace); console.log(`Subscribing to channel: ${channelName} for backend: ${this.configKey}`); const channel = this.pusherClient.subscribe(channelName); channel.bind("pusher:subscription_succeeded", () => { console.log(`Subscription succeeded for channel: ${channelName}`); }); channel.bind("pusher:subscription_error", (status) => { console.error(`Subscription error for channel: ${channelName}. Status:`, status); if (status.status === 401 || status.status === 403) { console.error(`%cAuthentication failed for channel ${channelName}. Check your authEndpoint and server-side permissions.`, "color: orange; font-weight: bold;"); } }); Object.values(EventType).forEach((eventType) => { channel.bind(eventType, (data) => { const event = { ...data, type: data.event || eventType, namespace, configKey: this.configKey, }; this.eventHandlers.forEach((handler) => handler(event)); }); }); this.channels.set(namespace, channel); } unsubscribe(namespace) { const channel = this.channels.get(namespace); if (!channel) return; Object.values(EventType).forEach((eventType) => { channel.unbind(eventType); }); const channelName = namespace.startsWith("private-") ? namespace : this.formatChannelName(namespace); this.pusherClient.unsubscribe(channelName); this.channels.delete(namespace); } /** * Disconnect from Pusher. */ disconnect() { if (this.connectionTimeoutId) { clearTimeout(this.connectionTimeoutId); this.connectionTimeoutId = null; } [...this.channels.keys()].forEach((ns) => this.unsubscribe(ns)); this.pusherClient.disconnect(); } /** * Add handler for model events * @param {EventHandler} handler */ addModelEventHandler(handler) { this.eventHandlers.add(handler); } /** * Legacy method - adds event handler for backwards compatibility * @param {EventHandler} handler */ addEventHandler(handler) { this.eventHandlers.add(handler); } /** * Remove an event handler callback. * @param {EventHandler} handler */ removeEventHandler(handler) { this.eventHandlers.delete(handler); } /** * Get namespace from model name using the resolver. * @param {string} modelName * @returns {string} */ getNamespace(modelName) { return this.namespaceResolver(modelName); } } // Map of event receivers by backend key const eventReceivers = new Map(); /** * Set an event receiver for a specific backend. * @param {string} configKey - The backend configuration key * @param {EventReceiver} receiver - The event receiver instance */ export function setEventReceiver(configKey, receiver) { const currentReceiver = eventReceivers.get(configKey); if (currentReceiver) { currentReceiver.disconnect(); } eventReceivers.set(configKey, receiver); receiver.connect(); } /** * Get the event receiver for a specific backend. * @param {string} configKey - The backend configuration key * @returns {EventReceiver|null} */ export function getEventReceiver(configKey = "default") { return eventReceivers.get(configKey); } /** * Get all registered event receivers. * @returns {Map<string, EventReceiver>} */ export function getAllEventReceivers() { return eventReceivers; } /** * Set a custom namespace resolver function for a specific backend. * @param {string} configKey - The backend configuration key * @param {NamespaceResolver} resolver */ export function setNamespaceResolver(configKey, resolver) { const receiver = getEventReceiver(configKey); if (receiver) { receiver.setNamespaceResolver(resolver); } }