@flasher/flasher
Version:
585 lines (528 loc) • 19.1 kB
text/typescript
/**
* @file Flasher Core
* @description Main orchestration class for the PHPFlasher notification system
* @author Younes ENNAJI
*/
import type { Asset, Context, Envelope, Options, PluginInterface, Response, Theme } from './types'
import { AbstractPlugin } from './plugin'
import FlasherPlugin from './flasher-plugin'
/**
* Main Flasher class that manages plugins, themes, and notifications.
*
* Flasher is the central orchestration class for PHPFlasher. It handles:
* 1. Plugin registration and management
* 2. Theme registration and resolution
* 3. Asset loading (JS and CSS)
* 4. Routing notifications to the appropriate plugin
* 5. Response processing and normalization
*
* This class follows the façade pattern, providing a simple interface to the
* underlying plugin ecosystem.
*
* @example
* ```typescript
* // Create a flasher instance
* const flasher = new Flasher();
*
* // Register a plugin
* flasher.addPlugin('toastr', new ToastrPlugin());
*
* // Show a notification
* flasher.use('toastr').success('Operation completed successfully');
*
* // Process server response
* flasher.render(response);
* ```
*/
export default class Flasher extends AbstractPlugin {
/**
* Default plugin to use when none is specified.
* This plugin will be used when displaying notifications without
* explicitly specifying a plugin.
*
* @private
*/
private defaultPlugin = 'flasher'
/**
* Map of registered plugins.
* Stores plugin instances by name for easy lookup.
*
* @private
*/
private plugins: Map<string, PluginInterface> = new Map<string, PluginInterface>()
/**
* Map of registered themes.
* Stores theme configurations by name.
*
* @private
*/
private themes: Map<string, Theme> = new Map<string, Theme>()
/**
* Set of assets that have been loaded.
* Used to prevent duplicate loading of the same asset.
*
* @private
*/
private loadedAssets: Set<string> = new Set<string>()
/**
* Renders notifications from a response.
*
* This method processes a server response containing notifications and configuration.
* It handles asset loading, option application, and notification rendering in a
* coordinated sequence.
*
* @param response - The response containing notifications and configuration
* @returns A promise that resolves when all operations are complete
*
* @example
* ```typescript
* // From an AJAX response
* const response = await fetch('/api/notifications').then(r => r.json());
* await flasher.render(response);
*
* // With a partial response
* flasher.render({
* envelopes: [
* { message: 'Hello world', type: 'info', title: 'Greeting', options: {}, metadata: {} }
* ]
* });
* ```
*/
public async render(response: Partial<Response>): Promise<void> {
const resolved = this.resolveResponse(response)
try {
// Load required assets
await this.addAssets([
{
urls: resolved.styles,
nonce: resolved.context.csp_style_nonce as string,
type: 'style',
},
{
urls: resolved.scripts,
nonce: resolved.context.csp_script_nonce as string,
type: 'script',
},
])
// Apply options and render notifications
this.renderOptions(resolved.options)
this.renderEnvelopes(resolved.envelopes)
} catch (error) {
console.error('PHPFlasher: Error rendering notifications', error)
}
}
/**
* Renders multiple notification envelopes.
*
* This method groups envelopes by plugin and delegates rendering to each plugin.
* This ensures that each notification is processed by the appropriate plugin.
*
* @param envelopes - Array of notification envelopes to render
*
* @example
* ```typescript
* flasher.renderEnvelopes([
* {
* message: 'Operation completed',
* type: 'success',
* title: 'Success',
* options: {},
* metadata: { plugin: 'toastr' }
* },
* {
* message: 'An error occurred',
* type: 'error',
* title: 'Error',
* options: {},
* metadata: { plugin: 'sweetalert' }
* }
* ]);
* ```
*/
public renderEnvelopes(envelopes: Envelope[]): void {
if (!envelopes?.length) {
return
}
const groupedByPlugin: Record<string, Envelope[]> = {}
// Group envelopes by plugin for batch processing
envelopes.forEach((envelope) => {
const plugin = this.resolvePluginAlias(envelope.metadata.plugin)
groupedByPlugin[plugin] = groupedByPlugin[plugin] || []
groupedByPlugin[plugin].push(envelope)
})
// Render each group with the appropriate plugin
Object.entries(groupedByPlugin).forEach(([pluginName, pluginEnvelopes]) => {
try {
this.use(pluginName).renderEnvelopes(pluginEnvelopes)
} catch (error) {
console.error(`PHPFlasher: Error rendering envelopes for plugin "${pluginName}"`, error)
}
})
}
/**
* Applies options to each plugin.
*
* This method distributes options to the appropriate plugins based on the keys
* in the options object. Each plugin receives only its specific options.
*
* @param options - Object mapping plugin names to their specific options
*
* @example
* ```typescript
* flasher.renderOptions({
* toastr: { timeOut: 3000, closeButton: true },
* sweetalert: { confirmButtonColor: '#3085d6' }
* });
* ```
*/
public renderOptions(options: Options): void {
if (!options) {
return
}
Object.entries(options).forEach(([plugin, option]) => {
try {
// @ts-expect-error - We know this is an Options object
this.use(plugin).renderOptions(option)
} catch (error) {
console.error(`PHPFlasher: Error applying options for plugin "${plugin}"`, error)
}
})
}
/**
* Registers a new plugin.
*
* Plugins are the notification renderers that actually display notifications.
* Each plugin typically integrates with a specific notification library like
* Toastr, SweetAlert, etc.
*
* @param name - Unique identifier for the plugin
* @param plugin - Plugin instance that implements the PluginInterface
* @throws {Error} If name or plugin is invalid
*
* @example
* ```typescript
* // Register a custom plugin
* flasher.addPlugin('myplugin', new MyCustomPlugin());
*
* // Use the registered plugin
* flasher.use('myplugin').info('Hello world');
* ```
*/
public addPlugin(name: string, plugin: PluginInterface): void {
if (!name || !plugin) {
throw new Error('Both plugin name and instance are required')
}
this.plugins.set(name, plugin)
}
/**
* Registers a new theme.
*
* Themes define the visual appearance of notifications when using
* the default FlasherPlugin. They provide HTML templates and CSS styles.
*
* @param name - Unique identifier for the theme
* @param theme - Theme configuration object
* @throws {Error} If name or theme is invalid
*
* @example
* ```typescript
* // Register a bootstrap theme
* flasher.addTheme('bootstrap', {
* styles: ['bootstrap.min.css'],
* render: (envelope) => `
* <div class="alert alert-${envelope.type}">
* <h4>${envelope.title}</h4>
* <p>${envelope.message}</p>
* </div>
* `
* });
*
* // Use the theme
* flasher.use('theme.bootstrap').success('Hello world');
* ```
*/
public addTheme(name: string, theme: Theme): void {
if (!name || !theme) {
throw new Error('Both theme name and definition are required')
}
this.themes.set(name, theme)
}
/**
* Gets a plugin by name.
*
* This method resolves plugin aliases and creates theme-based plugins
* on demand. If a theme-based plugin is requested but doesn't exist yet,
* it will be created automatically.
*
* @param name - Name of the plugin to retrieve
* @returns The requested plugin instance
* @throws {Error} If the plugin cannot be resolved
*
* @example
* ```typescript
* // Get and use a plugin
* const toastr = flasher.use('toastr');
* toastr.success('Operation completed');
*
* // Use a theme as a plugin (automatically creates a FlasherPlugin)
* flasher.use('theme.bootstrap').error('Something went wrong');
* ```
*/
public use(name: string): PluginInterface {
const resolvedName = this.resolvePluginAlias(name)
this.resolvePlugin(resolvedName)
const plugin = this.plugins.get(resolvedName)
if (!plugin) {
throw new Error(`Unable to resolve "${resolvedName}" plugin, did you forget to register it?`)
}
return plugin
}
/**
* Alias for use().
*
* @param name - Name of the plugin to retrieve
* @returns The requested plugin instance
*/
public create(name: string): PluginInterface {
return this.use(name)
}
/**
* Resolves and normalizes a response object.
*
* This method:
* 1. Fills in default values for missing properties
* 2. Resolves plugin aliases for envelopes
* 3. Converts string functions to actual functions
* 4. Adds theme styles to the response
*
* @param response - Partial response object
* @returns Fully resolved response object
* @private
*/
private resolveResponse(response: Partial<Response>): Response {
const resolved = {
envelopes: [],
options: {},
scripts: [],
styles: [],
context: {},
...response,
} as Response
// Process options
Object.entries(resolved.options).forEach(([plugin, options]) => {
resolved.options[plugin] = this.resolveOptions(options)
})
// Set default CSP nonces if not provided
resolved.context.csp_style_nonce = resolved.context.csp_style_nonce || ''
resolved.context.csp_script_nonce = resolved.context.csp_script_nonce || ''
// Process envelopes
resolved.envelopes.forEach((envelope) => {
envelope.metadata = envelope.metadata || {}
envelope.metadata.plugin = this.resolvePluginAlias(envelope.metadata.plugin)
this.addThemeStyles(resolved, envelope.metadata.plugin)
envelope.options = this.resolveOptions(envelope.options)
envelope.context = response.context as Context
})
return resolved
}
/**
* Resolves string functions to actual function objects.
*
* This allows options to include functions serialized as strings,
* which is useful for passing functions from the server to the client.
*
* @param options - Options object that may contain string functions
* @returns Options object with string functions converted to actual functions
* @private
*/
private resolveOptions(options: Options): Options {
if (!options) {
return {}
}
const resolved = { ...options }
Object.entries(resolved).forEach(([key, value]) => {
resolved[key] = this.resolveFunction(value)
})
return resolved
}
/**
* Converts a string function representation to an actual function.
*
* Supports both traditional and arrow function syntax:
* - `function(a, b) { return a + b; }`
* - `(a, b) => a + b`
* - `a => a * 2`
*
* @param func - Value to check and potentially convert
* @returns Function if conversion was successful, otherwise the original value
* @private
*/
private resolveFunction(func: unknown): unknown {
if (typeof func !== 'string') {
return func
}
const functionRegex = /^function\s*(\w*)\s*\(([^)]*)\)\s*\{([\s\S]*)\}$/
const arrowFunctionRegex = /^\s*(\(([^)]*)\)|[^=]+)\s*=>\s*([\s\S]+)$/
const match = func.match(functionRegex) || func.match(arrowFunctionRegex)
if (!match) {
return func
}
const args = match[2]?.split(',').map((arg) => arg.trim()) ?? []
let body = match[3].trim()
// Arrow functions with a single expression can omit the curly braces and the return keyword
if (!body.startsWith('{')) {
body = `{ return ${body}; }`
}
try {
// eslint-disable-next-line no-new-func
return new Function(...args, body)
} catch (e) {
console.error('PHPFlasher: Error converting string to function:', e)
return func
}
}
/**
* Creates theme-based plugins on demand.
*
* This method automatically creates a FlasherPlugin instance for a theme
* when a theme-based plugin is requested but doesn't exist yet.
*
* @param alias - Plugin alias to resolve
* @private
*/
private resolvePlugin(alias: string): void {
const factory = this.plugins.get(alias)
if (factory || !alias.includes('theme.')) {
return
}
const themeName = alias.replace('theme.', '')
const theme = this.themes.get(themeName)
if (!theme) {
return
}
// Create and register a FlasherPlugin for this theme
this.addPlugin(alias, new FlasherPlugin(theme))
}
/**
* Resolves a plugin name to its actual implementation name.
*
* This method handles the default plugin and theme aliases.
*
* @param alias - Plugin alias to resolve
* @returns Resolved plugin name
* @private
*/
private resolvePluginAlias(alias?: string): string {
alias = alias || this.defaultPlugin
// Special case: 'flasher' is aliased to 'theme.flasher'
return alias === 'flasher' ? 'theme.flasher' : alias
}
/**
* Adds CSS and JavaScript assets to the page.
*
* This method efficiently loads assets, respecting the order for scripts
* which is crucial for libraries with dependencies like jQuery plugins.
*
* @param assets - Array of assets to load
* @returns Promise that resolves when all assets are loaded
* @private
*/
private async addAssets(assets: Asset[]): Promise<void> {
try {
// Process CSS files in parallel (order doesn't matter for CSS)
const styleAssets = assets.filter((asset) => asset.type === 'style')
const stylePromises: Promise<void>[] = []
for (const { urls, nonce, type } of styleAssets) {
if (!urls?.length) {
continue
}
for (const url of urls) {
if (!url || this.loadedAssets.has(url)) {
continue
}
stylePromises.push(this.loadAsset(url, nonce, type))
this.loadedAssets.add(url)
}
}
// Load all styles in parallel
await Promise.all(stylePromises)
// Process script files sequentially to respect dependency order
const scriptAssets = assets.filter((asset) => asset.type === 'script')
for (const { urls, nonce, type } of scriptAssets) {
if (!urls?.length) {
continue
}
// Load each script URL in the order provided
for (const url of urls) {
if (!url || this.loadedAssets.has(url)) {
continue
}
// Wait for each script to load before proceeding to the next
await this.loadAsset(url, nonce, type)
this.loadedAssets.add(url)
}
}
} catch (error) {
console.error('PHPFlasher: Error loading assets', error)
}
}
/**
* Loads a single asset (CSS or JavaScript) into the document.
*
* @param url - URL of the asset to load
* @param nonce - CSP nonce for the asset
* @param type - Type of asset ('style' or 'script')
* @returns Promise that resolves when the asset is loaded
* @private
*/
private loadAsset(url: string, nonce: string, type: 'style' | 'script'): Promise<void> {
// Check if asset is already loaded
if (document.querySelector(`${type === 'style' ? 'link' : 'script'}[src="${url}"]`)) {
return Promise.resolve()
}
return new Promise((resolve, reject) => {
const element = document.createElement(type === 'style' ? 'link' : 'script') as HTMLLinkElement & HTMLScriptElement
if (type === 'style') {
element.rel = 'stylesheet'
element.href = url
} else {
element.type = 'text/javascript'
element.src = url
}
// Apply CSP nonce if provided
if (nonce) {
element.setAttribute('nonce', nonce)
}
// Set up load handlers
element.onload = () => resolve()
element.onerror = () => reject(new Error(`Failed to load ${url}`))
// Add to document
document.head.appendChild(element)
})
}
/**
* Adds theme styles to the list of assets to load.
*
* This method extracts style URLs from theme definitions and adds them
* to the response styles array.
*
* @param response - Response object to modify
* @param plugin - Plugin name that may reference a theme
* @private
*/
private addThemeStyles(response: Response, plugin: string): void {
// Only process theme plugins
if (plugin !== 'flasher' && !plugin.includes('theme.')) {
return
}
const themeName = plugin.replace('theme.', '')
const theme = this.themes.get(themeName)
if (!theme?.styles) {
return
}
// Convert single style to array if needed
const themeStyles = Array.isArray(theme.styles) ? theme.styles : [theme.styles]
// Add styles without duplicates
response.styles = Array.from(new Set([...response.styles, ...themeStyles]))
}
}