Implementing a Custom Hook
Create a hook by extending BaseHook, implementing execute(), and registering it with the server pipeline. Hooks are stateful objects — constructor injection is the pattern for configuration and dependencies.
The BaseHook Class
BaseHook is the recommended base class. It provides default implementations for the logging helpers defined in IHookActivityLogger and ensures your hook satisfies the full IHook contract:
import { BaseHook, IPipelineContext, HookResult } from 'edge-stream-js';
export class MyCustomHook extends BaseHook {
readonly name = 'MyCustomHook';
readonly priority = 150; // choose based on when it should run
// Dependencies injected via constructor
constructor(private config: { allowedTopics: string[] }) {
super();
}
async execute(context: IPipelineContext): Promise<HookResult> {
const topic = context.envelope.meta.topic || '';
// Example: filter by allowed topics
if (!this.config.allowedTopics.some(t => topic.startsWith(t))) {
return { continue: false }; // drop silently
}
// Enrich context metadata for downstream hooks
context.metadata.processedByMyHook = true;
context.addActivity(`MyCustomHook processed topic: ${topic}`);
return { continue: true };
}
}
Typed Context Hook
For hooks that need domain-specific context data, extend IPipelineContext:
import { IHook, IPipelineContext, HookResult } from 'edge-stream-js';
interface IAuthContext extends IPipelineContext {
readonly userId: string;
readonly tenantId: string;
readonly roles: string[];
}
export class RoleAuthorizationHook implements IHook<IAuthContext> {
readonly name = 'RoleAuthorizationHook';
readonly priority = 40;
private readonly requiredRole: string;
constructor(requiredRole: string) {
this.requiredRole = requiredRole;
}
async execute(context: IAuthContext): Promise<HookResult> {
if (!context.roles.includes(this.requiredRole)) {
context.abort(`Access denied: requires role '${this.requiredRole}'`);
return { continue: false };
}
return { continue: true };
}
}
Complete Hook Checklist
Choose a priority
Pick a number that places your hook in the right stage. Use 5 for observability, 90–110 for security/normalization, 120–199 for enrichment, 200–299 for processing, 300+ for post-processing.
Define a unique name
The name property is used by HooksMonitor and in all log output. Use PascalCase with "Hook" suffix: 'TenantValidationHook'.
Implement execute()
Always return a HookResult. Never throw — catch exceptions internally and decide whether to abort or continue. Return { continue: false } to drop the message.
Register before start()
Call server.incomingPipeline.addHook(new MyCustomHook(...)) before edgeStream.start().
Test in isolation
Hooks are plain classes — unit test them by creating a mock IPipelineContext and calling execute() directly.
Testing a Hook
import { describe, it, expect } from 'vitest';
import { MyCustomHook } from './MyCustomHook';
describe('MyCustomHook', () => {
it('should drop messages not in allowed topics', async () => {
const hook = new MyCustomHook({ allowedTopics: ['workflow.', 'agent.'] });
const mockContext = {
envelope: { meta: { topic: 'other.event', id: 'test-123', serverId: 'bas' } },
body: {},
metadata: {},
isAborted: false,
isPaused: false,
activityLog: [],
abort: vi.fn(),
pause: vi.fn(),
addActivity: vi.fn(),
clearActivityLog: vi.fn(),
};
const result = await hook.execute(mockContext as any);
expect(result.continue).toBe(false);
});
it('should pass through allowed topics', async () => {
const hook = new MyCustomHook({ allowedTopics: ['workflow.'] });
const mockContext = {
envelope: { meta: { topic: 'workflow.started', id: 'test-456', serverId: 'bas' } },
metadata: {},
addActivity: vi.fn(),
...otherMocks
};
const result = await hook.execute(mockContext as any);
expect(result.continue).toBe(true);
expect(mockContext.metadata.processedByMyHook).toBe(true);
});
});