UNPKG

@statezero/core

Version:

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

273 lines (272 loc) 10.4 kB
import { z } from 'zod'; import { ConfigError } from './flavours/django/errors.js'; import { PusherEventReceiver, setEventReceiver } from './core/eventReceivers.js'; // The live configuration object. By default it is empty. export let liveConfig = { backendConfigs: {} }; // --- Zod Schemas --- const pusherSchema = z.object({ clientOptions: z.object({ appKey: z.string({ required_error: 'Pusher appKey is required' }), cluster: z.string({ required_error: 'Pusher cluster is required' }), forceTLS: z.boolean().optional(), authEndpoint: z.string() .url('Pusher authentication endpoint URL is required'), getAuthHeaders: z.function().optional() .refine((fn) => fn === undefined || typeof fn === 'function', 'getAuthHeaders must be a function if provided') }) }); const eventConfigSchema = z.object({ type: z.enum(['websocket', 'pusher', 'none']), websocketUrl: z.string().url().optional(), pusher: pusherSchema.optional(), hotpaths: z.array(z.string()).default(['default']) }).superRefine((data, ctx) => { // Conditional validation based on type if (data.type === 'websocket') { if (!data.websocketUrl) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['websocketUrl'], message: 'WebSocket URL is required for WebSocket event receiver' }); } } if (data.type === 'pusher') { if (!data.pusher) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['pusher'], message: 'Pusher configuration is required for Pusher event receiver' }); } } }); const backendSchema = z.object({ API_URL: z.string().url("API_URL must be a valid URL"), GENERATED_TYPES_DIR: z.string({ required_error: "GENERATED_TYPES_DIR is required", }), GENERATED_ACTIONS_DIR: z.string().optional(), BACKEND_TZ: z.string().optional(), fileRootURL: z.string().url("fileRootURL must be a valid URL").optional(), fileUploadMode: z.enum(["server", "s3"]).default("server"), getAuthHeaders: z .function() .optional() .refine((fn) => fn === undefined || typeof fn === "function", "getAuthHeaders must be a function if provided"), eventInterceptor: z .function() .optional() .refine((fn) => fn === undefined || typeof fn === "function", "eventInterceptor must be a function if provided"), events: z.lazy(() => eventConfigSchema.optional()), }); const configSchema = z.object({ backendConfigs: z.record(z.string(), backendSchema) .refine((configs) => { // Validate each backend config for (const [key, backend] of Object.entries(configs)) { const result = backendSchema.safeParse(backend); if (!result.success) { return false; } } return true; }, (configs) => { // Generate detailed error message const errors = []; for (const [key, backend] of Object.entries(configs)) { const result = backendSchema.safeParse(backend); if (!result.success) { const errorMessages = result.error.errors.map(err => err.message); errors.push(`Backend "${key}" is invalid: ${errorMessages.join(', ')}`); } } return { message: errors.join('; ') }; }), periodicSyncIntervalSeconds: z.number().min(5).nullable().optional().default(null), }); // Internal variable to hold the validated configuration. let config = null; /** * Sets the entire configuration, validating it before storing. * If the configuration is invalid, it throws a ConfigError. */ export function setConfig(newConfig) { liveConfig = newConfig; const result = configSchema.safeParse(liveConfig); if (!result.success) { const errorMessages = result.error.errors.map(err => err.message); throw new ConfigError(`Error setting configuration: ${errorMessages.join(', ')}`); } config = result.data; } /** * Retrieves the validated configuration. * If no configuration has been set, it throws a ConfigError. */ export function getConfig() { if (!config) { throw new ConfigError('Configuration not set. Please call setConfig() with a valid configuration.'); } return config; } /** * Merges a partial override into an existing backend config. */ export function setBackendConfig(backendKey, newConfig) { try { const cfg = getConfig(); if (!cfg.backendConfigs[backendKey]) { throw new ConfigError(`Backend "${backendKey}" not found in configuration.`); } const merged = { ...cfg.backendConfigs[backendKey], ...newConfig }; const result = backendSchema.safeParse(merged); if (!result.success) { const errorMessages = result.error.errors.map(err => err.message); throw new ConfigError(`Invalid backend configuration: ${errorMessages.join(', ')}`); } cfg.backendConfigs[backendKey] = result.data; } catch (error) { if (error instanceof ConfigError) { throw error; } throw new ConfigError(error.message || 'Invalid backend configuration'); } } /** * Initializes the event receiver based on the configuration. */ export function initializeEventReceiver(backendKey = 'default') { try { const cfg = getConfig(); if (!cfg.backendConfigs[backendKey]) { throw new ConfigError(`Backend "${backendKey}" not found in configuration.`); } const backendConfig = cfg.backendConfigs[backendKey]; if (!backendConfig.events) { return null; } let receiver = null; switch (backendConfig.events.type) { case 'pusher': if (!backendConfig.events.pusher || !backendConfig.events.pusher.clientOptions) { throw new ConfigError('Pusher client options are required for Pusher event receiver.'); } if (!backendConfig.events.pusher.clientOptions.authEndpoint) { throw new ConfigError('Pusher auth endpoint is required for Pusher event receiver.'); } const clientOptions = { ...backendConfig.events.pusher.clientOptions, getAuthHeaders: backendConfig.events.pusher.clientOptions.getAuthHeaders || backendConfig.getAuthHeaders }; // Pass the backendKey to the constructor receiver = new PusherEventReceiver({ clientOptions }, backendKey); break; case 'none': return null; default: throw new ConfigError(`Unknown event receiver type: ${backendConfig.events.type}`); } if (receiver) { // Pass the backendKey to associate the receiver with this backend setEventReceiver(backendKey, receiver); } return receiver; } catch (error) { if (error instanceof ConfigError) { throw error; } throw new ConfigError(`Failed to initialize event receiver: ${error.message}`); } } /** * Initializes event receivers for all configured backends. * * @returns {Object} Map of backend keys to their initialized event receivers * @throws {ConfigError} If configuration is not set or if initialization fails */ export function initializeAllEventReceivers() { try { const cfg = getConfig(); const receivers = {}; // Initialize event receivers for each backend configuration Object.keys(cfg.backendConfigs).forEach(backendKey => { try { receivers[backendKey] = initializeEventReceiver(backendKey); } catch (error) { console.warn(`Failed to initialize event receiver for backend "${backendKey}": ${error.message}`); } }); return receivers; } catch (error) { throw new ConfigError(`Failed to initialize event receivers: ${error.message}`); } } // --- Declare the variable to hold the registered model getter --- let modelGetter = null; /** * Registers the function used to retrieve model classes. * This should be called once during application setup after setConfig. * @param {Function} getterFn - The getModelClass function imported from model-registry.js */ export function registerModelGetter(getterFn) { if (typeof getterFn !== 'function') { throw new ConfigError('Provided model getter must be a function.'); } modelGetter = getterFn; } /** * Helper function to build file URLs */ export function buildFileUrl(fileUrl, backendKey = 'default') { // If URL is already absolute (cloud storage), use it as-is if (fileUrl && fileUrl.startsWith('http')) { return fileUrl; } const cfg = getConfig(); const backend = cfg.backendConfigs[backendKey]; if (!backend) { throw new ConfigError(`Backend "${backendKey}" not found in configuration.`); } // If no fileRootURL provided, return relative URL as-is if (!backend.fileRootURL) { return fileUrl; } // Construct full URL return backend.fileRootURL.replace(/\/$/, '') + fileUrl; } /** * Get a model class by name using the registered getter. * * @param {string} modelName - The model name (e.g., 'app.MyModel') * @param {string} configKey - The config key (backend name) * @returns {Function|null} - The model class or null if not found */ export function getModelClass(modelName, configKey) { if (!modelGetter) { // Optionally check if config is set first // getConfig(); // This will throw if config isn't set throw new ConfigError('Model registry not registered. Please call registerModelGetter() with the function from model-registry.js during app initialization.'); } // Delegate to the function provided by the user from model-registry.js return modelGetter(modelName, configKey); } /** * Exposes a singleton object for configuration functionality. */ export const configInstance = { setConfig, getConfig, setBackendConfig, initializeEventReceiver, registerModelGetter, getModelClass, buildFileUrl }; export default configInstance;