@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
JavaScript
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);
}
}