Resolid Framework includes a lightweight, type-safe event system powered by the @resolid/event package. The event system enables loose coupling between extensions and the application through the Emitter class.
The Emitter Class
Every Resolid application has an Emitter instance accessible via app.emitter or context.emitter:
export class Emitter {
on < Args extends unknown []>( event : string , callback : Callback < Args >) : () => void ;
off < Args extends unknown []>( event : string , callback : Callback < Args >) : void ;
offAll ( event ?: string ) : void ;
once < Args extends unknown []>( event : string , callback : Callback < Args >) : void ;
emit < Args extends unknown []>( event : string , ... args : Args ) : void ;
emitAsync < Args extends unknown []>( event : string , ... args : Args ) : void ;
}
type Callback < Args extends unknown [] = unknown []> = ( ... args : Args ) => void ;
Listening to Events
Basic Event Listener
Use on() to register an event listener:
import { createApp } from '@resolid/core' ;
const app = await createApp ({ name: 'MyApp' });
app . emitter . on ( 'user:created' , ( userId : string ) => {
console . log ( `User created: ${ userId } ` );
});
app . emitter . emit ( 'user:created' , 'user-123' );
// Logs: "User created: user-123"
One-Time Listeners
Use once() for listeners that should only fire once:
app . emitter . once ( 'app:ready' , () => {
console . log ( 'App is ready!' );
});
await app . run ();
// Logs: "App is ready!"
app . emitter . emit ( 'app:ready' );
// No output - listener already removed
Unsubscribing
The on() method returns an unsubscribe function:
const unsubscribe = app . emitter . on ( 'data:changed' , () => {
console . log ( 'Data changed!' );
});
// Later, when you want to stop listening:
unsubscribe ();
You can also use off() to remove a specific listener:
const handler = ( data : string ) => console . log ( data );
app . emitter . on ( 'event' , handler );
// Remove specific handler
app . emitter . off ( 'event' , handler );
Removing All Listeners
Use offAll() to remove listeners:
// Remove all listeners for a specific event
app . emitter . offAll ( 'user:created' );
// Remove ALL listeners for ALL events
app . emitter . offAll ();
app.dispose() automatically calls emitter.offAll() to clean up all event listeners.
Emitting Events
Synchronous Emission
Use emit() to trigger event handlers synchronously:
app . emitter . on ( 'order:placed' , ( orderId : string , amount : number ) => {
console . log ( `Order ${ orderId } placed for $ ${ amount } ` );
});
app . emitter . emit ( 'order:placed' , 'order-456' , 99.99 );
// Handlers execute immediately
Asynchronous Emission
Use emitAsync() to defer handler execution to the next microtask:
app . emitter . on ( 'log:info' , ( message : string ) => {
console . log ( message );
});
console . log ( 'Before emit' );
app . emitter . emitAsync ( 'log:info' , 'Async event' );
console . log ( 'After emit' );
// Output:
// Before emit
// After emit
// Async event
emitAsync() uses queueMicrotask() to schedule handlers, which means they execute before the next event loop tick but after the current synchronous code completes.
Built-in App Events
Resolid applications emit the following built-in events:
app:ready
Emitted when app.run() completes and all extension bootstrap functions have executed:
const app = await createApp ({
name: 'MyApp' ,
extensions: [
{
name: 'logger' ,
bootstrap : ( context ) => {
context . emitter . on ( 'app:ready' , () => {
console . log ( 'Application is fully initialized' );
});
},
},
],
});
await app . run ();
// Logs: "Application is fully initialized"
Using Events in Extensions
Extensions can listen to and emit events through the AppContext:
import { type Extension } from '@resolid/core' ;
const auditExtension : Extension = {
name: 'audit' ,
bootstrap : async ( context ) => {
// Listen to custom events
context . emitter . on ( 'user:login' , ( userId : string ) => {
console . log ( `[AUDIT] User ${ userId } logged in at ${ new Date (). toISOString () } ` );
});
context . emitter . on ( 'user:logout' , ( userId : string ) => {
console . log ( `[AUDIT] User ${ userId } logged out at ${ new Date (). toISOString () } ` );
});
// Listen to app lifecycle events
context . emitter . on ( 'app:ready' , () => {
console . log ( '[AUDIT] Audit logging initialized' );
});
},
};
Inter-Extension Communication
Extensions can communicate with each other through events without direct coupling:
// Authentication extension emits events
const authExtension : Extension = {
name: 'auth' ,
providers: [
{
token: AUTH_SERVICE ,
factory : () => {
const emitter = inject ( EMITTER );
return {
login : async ( username : string , password : string ) => {
// Login logic
const userId = await validateCredentials ( username , password );
// Emit event for other extensions
emitter . emit ( 'user:login' , userId , username );
return userId ;
},
logout : async ( userId : string ) => {
// Logout logic
emitter . emit ( 'user:logout' , userId );
},
};
},
},
],
};
// Analytics extension listens to auth events
const analyticsExtension : Extension = {
name: 'analytics' ,
bootstrap : ( context ) => {
context . emitter . on ( 'user:login' , ( userId : string , username : string ) => {
// Track login event
trackEvent ( 'login' , { userId , username , timestamp: Date . now () });
});
context . emitter . on ( 'user:logout' , ( userId : string ) => {
// Track logout event
trackEvent ( 'logout' , { userId , timestamp: Date . now () });
});
},
};
// Notification extension also listens
const notificationExtension : Extension = {
name: 'notifications' ,
bootstrap : ( context ) => {
context . emitter . on ( 'user:login' , async ( userId : string ) => {
// Send welcome notification
await sendNotification ( userId , 'Welcome back!' );
});
},
};
Type-Safe Events
Create type-safe event helpers for better developer experience:
type AppEvents = {
'user:created' : [ userId : string , email : string ];
'user:deleted' : [ userId : string ];
'order:placed' : [ orderId : string , amount : number ];
'app:ready' : [];
};
class TypedEmitter {
constructor ( private emitter : Emitter ) {}
on < K extends keyof AppEvents >(
event : K ,
callback : ( ... args : AppEvents [ K ]) => void
) : () => void {
return this . emitter . on ( event , callback );
}
emit < K extends keyof AppEvents >(
event : K ,
... args : AppEvents [ K ]
) : void {
this . emitter . emit ( event , ... args );
}
}
// Usage
const app = await createApp ({ name: 'MyApp' });
const events = new TypedEmitter ( app . emitter );
// Type-safe!
events . on ( 'user:created' , ( userId , email ) => {
console . log ( `User ${ userId } created with email ${ email } ` );
});
events . emit ( 'user:created' , 'user-123' , '[email protected] ' );
// Type error - wrong arguments!
events . emit ( 'user:created' , 'user-123' ); // ✗ Missing email argument
Event Naming Conventions
Follow these conventions for consistent event naming:
Use colons for namespacing user:created, order:placed, app:ready
Use past tense for actions user:created (not user:create), data:loaded (not data:load)
Use present tense for states connection:ready, service:available
Be specific user:password:changed instead of just changed
Real-World Example
Here’s a complete example showing the event system in action:
import { createApp , inject , type Extension , type Token } from '@resolid/core' ;
import { type Emitter } from '@resolid/event' ;
const EMITTER = Symbol ( 'EMITTER' ) as Token < Emitter >;
const USER_SERVICE = Symbol ( 'USER_SERVICE' );
const EMAIL_SERVICE = Symbol ( 'EMAIL_SERVICE' );
// User service that emits events
const userExtension : Extension = {
name: 'user' ,
providers: [
{
token: USER_SERVICE ,
factory : () => {
const emitter = inject ( EMITTER );
return {
createUser : async ( email : string , name : string ) => {
const userId = crypto . randomUUID ();
// Save user to database...
// Emit event
emitter . emit ( 'user:created' , userId , email , name );
return userId ;
},
};
},
},
],
};
// Email service that listens to user events
const emailExtension : Extension = {
name: 'email' ,
providers: [
{
token: EMAIL_SERVICE ,
factory : () => ({
sendWelcome : async ( email : string , name : string ) => {
console . log ( `Sending welcome email to ${ email } ` );
// Email sending logic...
},
}),
},
],
bootstrap : ( context ) => {
const emailService = context . container . get ( EMAIL_SERVICE );
context . emitter . on ( 'user:created' , async ( userId , email , name ) => {
await emailService . sendWelcome ( email , name );
});
},
};
// Analytics extension that tracks events
const analyticsExtension : Extension = {
name: 'analytics' ,
bootstrap : ( context ) => {
context . emitter . on ( 'user:created' , ( userId , email ) => {
console . log ( `[Analytics] New user signup: ${ userId } ` );
// Send to analytics service...
});
},
};
const app = await createApp ({
name: 'UserApp' ,
extensions: [ userExtension , emailExtension , analyticsExtension ],
providers: [
{ token: EMITTER , factory : () => app . emitter },
],
expose: {
userService: USER_SERVICE ,
},
});
await app . run ();
// Create a user - triggers events automatically
const userId = await app . $ . userService . createUser ( '[email protected] ' , 'John Doe' );
// Output:
// Sending welcome email to [email protected]
// [Analytics] New user signup: <userId>
Best Practices
Events are great for decoupling, but for direct service-to-service communication within the same domain, consider using dependency injection instead.
Maintain a list of all events your application/extension emits, including their arguments and when they fire.
Always remove event listeners when they’re no longer needed, especially in transient-scoped services.
Use descriptive event names
Event names should clearly indicate what happened. Use namespacing (colons) to organize related events.