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