Lifecycle Hooks
View SourceAshTypescript provides comprehensive lifecycle hooks for both HTTP and Phoenix Channel-based RPC actions. These hooks enable cross-cutting concerns like logging, telemetry, performance tracking, and error monitoring. HTTP hooks additionally support authentication header injection.
Table of Contents
HTTP Lifecycle Hooks
AshTypescript provides lifecycle hooks that let you inject custom logic before and after HTTP requests. These hooks enable cross-cutting concerns like authentication, logging, telemetry, performance tracking, and error monitoring.
Why Use HTTP Lifecycle Hooks?
Lifecycle hooks provide a centralized way to:
- Add authentication tokens - Automatically inject auth headers from localStorage
- Log requests and responses - Track API calls for debugging
- Measure performance - Time API calls and track latency
- Send telemetry - Report metrics to monitoring services
- Handle errors globally - Track errors in Sentry, Datadog, etc.
- Add correlation IDs - Track requests across distributed systems
- Add default headers - Set client version, request IDs, etc.
- Transform requests - Modify config before sending
HTTP Configuration
Configure lifecycle hooks in your application config:
# config/config.exs
config :ash_typescript,
# Hook functions for RPC actions
rpc_action_before_request_hook: "RpcHooks.beforeRequest",
rpc_action_after_request_hook: "RpcHooks.afterRequest",
# Hook functions for validation actions
rpc_validation_before_request_hook: "RpcHooks.beforeValidationRequest",
rpc_validation_after_request_hook: "RpcHooks.afterValidationRequest",
# TypeScript types for hook context (optional)
rpc_action_hook_context_type: "RpcHooks.ActionHookContext",
rpc_validation_hook_context_type: "RpcHooks.ValidationHookContext",
# Import the module containing your hook functions
import_into_generated: [
%{
import_name: "RpcHooks",
file: "./rpcHooks"
}
]Configuration Options:
| Config | Purpose | Default |
|---|---|---|
rpc_action_before_request_hook | Function called before RPC action requests | nil (disabled) |
rpc_action_after_request_hook | Function called after RPC action requests | nil (disabled) |
rpc_validation_before_request_hook | Function called before validation requests | nil (disabled) |
rpc_validation_after_request_hook | Function called after validation requests | nil (disabled) |
rpc_action_hook_context_type | TypeScript type for action hook context | "Record<string, any>" |
rpc_validation_hook_context_type | TypeScript type for validation hook context | "Record<string, any>" |
Hook Types: Actions vs Validations
AshTypescript provides separate hooks for actions and validations because they serve different purposes:
- Action Hooks - Execute when calling RPC actions (create, read, update, delete, custom actions)
- Validation Hooks - Execute when calling validation functions (client-side form validation)
This separation allows you to:
- Use different logging levels (validations are typically more frequent)
- Track different metrics (validation performance vs action performance)
Action hooks are for actual API calls, validation hooks are for form validation.
Hook Function Signatures
Both beforeRequest and afterRequest hooks receive the full config object and can access the optional hookCtx from it.
Important: AshTypescript exports ActionConfig and ValidationConfig types from the generated file. These types automatically include your custom hookCtx types based on your configuration settings.
Configuring Custom Hook Context Types
When you configure context type settings in your Elixir config, the generated TypeScript interfaces will automatically include these types:
# config/config.exs
config :ash_typescript,
# TypeScript types for hook context
rpc_action_hook_context_type: "RpcHooks.ActionHookContext",
rpc_validation_hook_context_type: "RpcHooks.ValidationHookContext"With this configuration, the generated ActionConfig and ValidationConfig types will have properly typed hookCtx fields:
// Generated types (in your generated file)
export interface ActionConfig {
// ... other fields ...
hookCtx?: RpcHooks.ActionHookContext; // ← Your custom type
}
export interface ValidationConfig {
// ... other fields ...
hookCtx?: RpcHooks.ValidationHookContext; // ← Your custom type
}Implementing Hook Functions
Simply import and use the generated config types directly - no generics needed!
// rpcHooks.ts - Define your custom hook context interfaces
export interface ActionHookContext {
enableLogging?: boolean;
enableTiming?: boolean;
customHeaders?: Record<string, string>;
startTime?: number;
}
export interface ValidationHookContext {
enableLogging?: boolean;
validationLevel?: "strict" | "normal";
}
// Import the generated config types
import type { ActionConfig, ValidationConfig } from './generated';
// Implement your hook functions - the hookCtx is already properly typed!
export async function beforeActionRequest(
actionName: string,
config: ActionConfig
): Promise<ActionConfig> {
const ctx = config.hookCtx;
// ctx is automatically typed as ActionHookContext | undefined
if (ctx?.enableLogging) {
console.log(`[Action] ${actionName} started`);
}
// Modify hookCtx if needed
const modifiedCtx = ctx ? { ...ctx, startTime: Date.now() } : undefined;
return {
...config,
...(modifiedCtx && { hookCtx: modifiedCtx })
};
}
export async function afterActionRequest(
actionName: string,
response: Response,
result: any | null,
config: ActionConfig
): Promise<void> {
const ctx = config.hookCtx;
// ctx.startTime is properly typed (no type assertion needed!)
if (ctx?.enableTiming && ctx.startTime) {
const duration = Date.now() - ctx.startTime;
console.log(`Request took ${duration}ms`);
}
}
// Similarly for validation hooks
export async function beforeValidationRequest(
actionName: string,
config: ValidationConfig
): Promise<ValidationConfig> {
const ctx = config.hookCtx;
if (ctx?.validationLevel === "strict") {
console.log(`[Validation] Running in strict mode`);
}
return config;
}Key Benefits:
- Type safety - Your custom context fields are properly typed automatically
- IntelliSense - IDE autocomplete works for your custom fields
- No generics needed - The generated types already include your context types
- Simpler code - Direct usage without complex generic constraints
The exported ActionConfig interface includes all available configuration fields:
// This type is exported from your generated file
export interface ActionConfig {
// Request data
input?: Record<string, any>;
primaryKey?: any;
fields?: Array<string | Record<string, any>>; // Field selection
filter?: Record<string, any>; // Filter options (for reads)
sort?: string; // Sort options
page?:
| {
// Offset-based pagination
limit?: number;
offset?: number;
count?: boolean;
}
| {
// Keyset pagination
limit?: number;
after?: string;
before?: string;
};
// Metadata
metadataFields?: Record<string, any>; // Metadata field selection
// HTTP customization
headers?: Record<string, string>; // Custom headers
fetchOptions?: RequestInit; // Fetch options (signal, cache, etc.)
customFetch?: (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
// Multitenancy
tenant?: string; // Tenant parameter
// Hook context
hookCtx?: Record<string, any>;
}
// This type is also exported from your generated file
export interface ValidationConfig {
// Request data
input?: Record<string, any>;
// HTTP customization
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
// Hook context
hookCtx?: Record<string, any>;
}Key Points:
- Hooks receive the entire
configobject as a parameter - Hook context is accessed via
config.hookCtx(optional) beforeRequestreturns a modified config objectafterRequestreturns nothing (void) - it's for side effects only- Hooks run unconditionally when configured (not gated by
hookCtxpresence)
beforeRequest Hook
The beforeRequest hook runs before the HTTP request and can modify the request configuration. Common use cases:
Adding Authentication Tokens
// rpcHooks.ts
import type { ActionConfig } from './generated';
export function beforeRequest(actionName: string, config: ActionConfig): ActionConfig {
// Fetch auth token from localStorage (if it exists)
const authToken = localStorage.getItem('authToken');
// Add authentication header if token is present
if (authToken) {
return {
...config,
headers: {
...config.headers,
'Authorization': `Bearer ${authToken}`
}
};
}
return config;
}This pattern automatically adds authentication to all RPC requests without needing to pass tokens through every call. The hook centralizes auth header logic in one place.
// Usage: Auth headers are added automatically
const todos = await listTodos({
fields: ["id", "title"]
// No need to pass auth tokens - hook handles it!
});Adding Correlation IDs for Request Tracking
// rpcHooks.ts
import type { ActionConfig } from './generated';
export interface ActionHookContext {
correlationId?: string;
}
export function beforeRequest(actionName: string, config: ActionConfig): ActionConfig {
const ctx = config.hookCtx;
// Use provided correlation ID or generate one
const correlationId = ctx?.correlationId || generateRequestId();
return {
...config,
headers: {
'X-Client-Version': '1.0.0',
'X-Correlation-ID': correlationId,
'X-Request-ID': correlationId,
...config.headers // Original headers take precedence
}
};
}
function generateRequestId(): string {
return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}// Usage: Pass correlation ID for distributed tracing
const todos = await listTodos({
fields: ["id", "title"],
hookCtx: {
correlationId: 'user-dashboard-load-456'
}
});Request Timing Setup
export interface ActionHookContext {
startTime?: number;
}
export function beforeRequest(actionName: string, config: ActionConfig): ActionConfig {
const ctx = config.hookCtx;
// Store request start time in context for afterRequest hook
if (ctx) {
ctx.startTime = Date.now();
}
return config;
}Logging Outgoing Requests
export function beforeRequest(actionName: string, config: ActionConfig): ActionConfig {
const ctx = config.hookCtx;
console.log('Outgoing RPC request:', {
action: actionName,
domain: config.domain,
hasInput: !!config.input,
timestamp: new Date().toISOString(),
correlationId: ctx?.correlationId
});
return config;
}afterRequest Hook
The afterRequest hook runs after the HTTP request completes (both success and error) and is used for side effects. It receives three parameters:
response: Response- The raw HTTP response objectresult: any | null- Parsed JSON result (null whenresponse.okis false)config: ActionConfig- The config used for the request
Important: Null Result Handling
The afterRequest hook receives null as the result parameter when the response is not OK:
export function afterRequest(
actionName: string,
response: Response,
result: any | null,
config: ActionConfig
): void {
if (result === null) {
// Response failed (response.ok === false)
console.error('Request failed:', {
status: response.status,
statusText: response.statusText,
url: response.url
});
} else {
// Response succeeded (response.ok === true)
console.log('Request succeeded:', {
hasData: !!result.data,
success: result.success
});
}
}Logging All Responses
export function afterRequest(
actionName: string,
response: Response,
result: any | null,
config: ActionConfig
): void {
const ctx = config.hookCtx;
console.log('RPC response received:', {
action: actionName,
domain: config.domain,
status: response.status,
ok: response.ok,
hasResult: result !== null,
correlationId: ctx?.correlationId,
timestamp: new Date().toISOString()
});
}Performance Timing
export interface ActionHookContext {
startTime?: number;
trackPerformance?: boolean;
}
export function afterRequest(
actionName: string,
response: Response,
result: any | null,
config: ActionConfig
): void {
const ctx = config.hookCtx;
if (ctx?.trackPerformance && ctx.startTime) {
const duration = Date.now() - ctx.startTime;
console.log('Performance metrics:', {
action: actionName,
duration: `${duration}ms`,
status: response.status,
success: result !== null && result.success
});
// Send to analytics service
trackMetric('rpc.duration', duration, {
action: actionName,
status: response.status
});
}
}Telemetry Tracking
export function afterRequest(
actionName: string,
response: Response,
result: any | null,
config: ActionConfig
): void {
// Send telemetry to monitoring service
sendTelemetry({
event: 'rpc.request.completed',
action: actionName,
domain: config.domain,
status: response.status,
success: response.ok && result?.success,
timestamp: Date.now()
});
}Error Monitoring
export function afterRequest(
actionName: string,
response: Response,
result: any | null,
config: ActionConfig
): void {
// Track errors in error monitoring service
if (result === null || !result.success) {
Sentry.captureMessage('RPC request failed', {
level: 'error',
extra: {
action: actionName,
status: response.status,
errors: result?.errors,
url: response.url
}
});
}
}Config Precedence Rules
When using beforeRequest hooks, the original config passed to the action always takes precedence over the modified config:
export function beforeRequest(actionName: string, config: ActionConfig): ActionConfig {
return {
...config,
headers: {
'X-Default-Header': 'value',
...config.headers // ← Original headers override defaults
},
customFetch: config.customFetch || myDefaultFetch // ← Original takes precedence
};
}Precedence order:
- Original
configvalues used in action (highest priority) - Modified config from
beforeRequesthook - Default fetch implementation (lowest priority)
This ensures that per-request customizations always override hook defaults.
Exception Handling
Hooks do not catch exceptions - any errors thrown by hooks will propagate to the caller:
export function beforeRequest(actionName: string, config: ActionConfig): ActionConfig {
if (!isValidConfig(config)) {
// This exception propagates to the caller
throw new Error('Invalid RPC configuration');
}
return config;
}Use Cases for Exception Propagation:
- Error Boundaries - Let framework error boundaries catch and display errors
- Global Error Handlers - Centralized error handling in your app
- Fail-Fast Validation - Stop execution on critical errors
// React component with error boundary
function MyComponent() {
const handleSubmit = async () => {
try {
const result = await createTodo({
fields: ["id", "title"],
input: {
title: "New Todo",
userId: "123e4567-e89b-12d3-a456-426614174000"
},
hookCtx: {
correlationId: 'user-submit-action',
trackPerformance: true
}
});
// Handle success
} catch (error) {
// Hook threw an exception
console.error('RPC call failed:', error);
}
};
}Complete Working Example
Here's a complete example showing all hook features with the simplified pattern:
// rpcHooks.ts
import type { ActionConfig, ValidationConfig } from './generated';
// Define your custom hook context interfaces
export interface ActionHookContext {
trackPerformance?: boolean;
startTime?: number;
correlationId?: string;
}
export interface ValidationHookContext {
formId?: string;
}
// Action hooks - directly use ActionConfig (no generics needed!)
export async function beforeActionRequest(
actionName: string,
config: ActionConfig
): Promise<ActionConfig> {
const ctx = config.hookCtx;
// Add correlation ID and client version headers
const headers: Record<string, string> = {
'X-Client-Version': '1.0.0',
'X-Correlation-ID': ctx?.correlationId || generateRequestId(),
...config.headers
};
// Setup timing for performance tracking
const modifiedCtx = ctx?.trackPerformance
? { ...ctx, startTime: Date.now() }
: ctx;
console.log(`[RPC] ${actionName} started`, {
correlationId: ctx?.correlationId
});
return {
...config,
headers,
...(modifiedCtx && { hookCtx: modifiedCtx })
};
}
function generateRequestId(): string {
return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
export async function afterActionRequest(
actionName: string,
response: Response,
result: any | null,
config: ActionConfig
): Promise<void> {
const ctx = config.hookCtx;
// Track timing (ctx.startTime is automatically properly typed!)
const duration = ctx?.startTime ? Date.now() - ctx.startTime : 0;
// Log result
if (result === null) {
console.error(`[RPC] ${actionName} failed:`, {
status: response.status,
duration: `${duration}ms`
});
} else {
console.log(`[RPC] ${actionName} completed:`, {
success: result.success,
duration: `${duration}ms`
});
}
}
// Validation hooks - directly use ValidationConfig (no generics needed!)
export async function beforeValidationRequest(
actionName: string,
config: ValidationConfig
): Promise<ValidationConfig> {
const ctx = config.hookCtx;
console.log(`[Validation] ${actionName} started`, { formId: ctx?.formId });
return config;
}
export async function afterValidationRequest(
actionName: string,
response: Response,
result: any | null,
config: ValidationConfig
): Promise<void> {
const ctx = config.hookCtx;
console.log(`[Validation] ${actionName} completed`, {
formId: ctx?.formId,
hasErrors: result && !result.success
});
}// Usage in your application
import { createTodo, validateCreateTodo } from './ash_rpc';
// Action with hooks
const result = await createTodo({
fields: ["id", "title", "createdAt"],
input: {
title: "Learn AshTypescript Hooks",
userId: getCurrentUserId()
},
hookCtx: {
trackPerformance: true,
correlationId: 'user-create-todo-123'
}
});
// Validation with hooks
const validationResult = await validateCreateTodo({
input: {
title: "Test Todo",
userId: "123e4567-e89b-12d3-a456-426614174000"
},
hookCtx: {
formId: 'create-todo-form'
}
});Channel Lifecycle Hooks
AshTypescript provides lifecycle hooks for Phoenix Channel-based RPC actions, mirroring the HTTP hooks functionality but adapted for real-time channel communication. These hooks enable the same cross-cutting concerns (logging, telemetry, performance tracking, error monitoring) but for WebSocket-based communication instead of HTTP requests.
Why Use Channel Lifecycle Hooks?
Channel lifecycle hooks provide a centralized way to:
- Log channel messages - Track channel communication for debugging
- Measure performance - Time channel operations and track latency
- Send telemetry - Report metrics to monitoring services
- Handle errors globally - Track channel errors in Sentry, Datadog, etc.
- Add default configuration - Set default timeouts or other options
- Transform messages - Modify config before pushing to channel
Key Differences from HTTP Hooks
Channel hooks differ from HTTP hooks because they work with Phoenix Channel's message-based communication:
| Aspect | HTTP Hooks | Channel Hooks |
|---|---|---|
| Communication | Request/Response (HTTP) | Message-based (WebSocket) |
| API Style | Promise-based | Callback-based |
| Response Types | Success or Error | ok, error, or timeout |
| Hook Names | beforeRequest, afterRequest | beforeChannelPush, afterChannelResponse |
Channel Configuration
Configure channel lifecycle hooks in your application config:
# config/config.exs
config :ash_typescript,
# Channel-based hooks for RPC actions
rpc_action_before_channel_push_hook: "ChannelHooks.beforeChannelPush",
rpc_action_after_channel_response_hook: "ChannelHooks.afterChannelResponse",
# Channel-based hooks for validation actions
rpc_validation_before_channel_push_hook: "ChannelHooks.beforeValidationChannelPush",
rpc_validation_after_channel_response_hook: "ChannelHooks.afterValidationChannelResponse",
# TypeScript types for channel hook context (optional)
rpc_action_channel_hook_context_type: "ChannelHooks.ActionChannelHookContext",
rpc_validation_channel_hook_context_type: "ChannelHooks.ValidationChannelHookContext",
# Import the module containing your channel hook functions
import_into_generated: [
%{
import_name: "ChannelHooks",
file: "./channelHooks"
}
]Configuration Options:
| Config | Purpose | Default |
|---|---|---|
rpc_action_before_channel_push_hook | Function called before channel push for RPC actions | nil (disabled) |
rpc_action_after_channel_response_hook | Function called after channel response for RPC actions | nil (disabled) |
rpc_validation_before_channel_push_hook | Function called before channel push for validations | nil (disabled) |
rpc_validation_after_channel_response_hook | Function called after channel response for validations | nil (disabled) |
rpc_action_channel_hook_context_type | TypeScript type for action channel hook context | "Record<string, any>" |
rpc_validation_channel_hook_context_type | TypeScript type for validation channel hook context | "Record<string, any>" |
Channel Hook Function Signatures
Channel hooks receive the full config object and can access the optional hookCtx from it.
Important: AshTypescript exports ActionChannelConfig and ValidationChannelConfig types from the generated file. These types automatically include your custom hookCtx types based on your configuration settings.
Configuring Custom Channel Hook Context Types
When you configure channel context type settings in your Elixir config, the generated TypeScript interfaces will automatically include these types:
# config/config.exs
config :ash_typescript,
# TypeScript types for channel hook context
rpc_action_channel_hook_context_type: "ChannelHooks.ActionChannelHookContext",
rpc_validation_channel_hook_context_type: "ChannelHooks.ValidationChannelHookContext"With this configuration, the generated ActionChannelConfig and ValidationChannelConfig types will have properly typed hookCtx fields:
// Generated types (in your generated file)
export interface ActionChannelConfig {
// ... other fields ...
hookCtx?: ChannelHooks.ActionChannelHookContext; // ← Your custom type
}
export interface ValidationChannelConfig {
// ... other fields ...
hookCtx?: ChannelHooks.ValidationChannelHookContext; // ← Your custom type
}Implementing Channel Hook Functions
Simply import and use the generated config types directly - no generics needed!
// channelHooks.ts - Define your custom hook context interfaces
export interface ActionChannelHookContext {
correlationId?: string;
trackPerformance?: boolean;
startTime?: number;
}
export interface ValidationChannelHookContext {
formId?: string;
validationLevel?: "strict" | "normal";
}
// Import the generated config types
import type { ActionChannelConfig, ValidationChannelConfig } from './generated';
// Implement your channel hook functions - the hookCtx is already properly typed!
export async function beforeChannelPush(
actionName: string,
config: ActionChannelConfig
): Promise<ActionChannelConfig> {
const ctx = config.hookCtx;
// ctx is automatically typed as ActionChannelHookContext | undefined
if (ctx?.trackPerformance) {
const modifiedCtx = { ...ctx, startTime: Date.now() };
return { ...config, hookCtx: modifiedCtx };
}
return config;
}
export async function afterChannelResponse(
actionName: string,
responseType: "ok" | "error" | "timeout",
data: any, // result (for ok), error (for error), or null (for timeout)
config: ActionChannelConfig
): Promise<void> {
const ctx = config.hookCtx;
// ctx.startTime is properly typed (no type assertion needed!)
if (ctx?.trackPerformance && ctx.startTime) {
const duration = Date.now() - ctx.startTime;
console.log(`[Channel] ${actionName} took ${duration}ms`);
}
}
// Similarly for validation channel hooks
export async function beforeValidationChannelPush(
actionName: string,
config: ValidationChannelConfig
): Promise<ValidationChannelConfig> {
const ctx = config.hookCtx;
if (ctx?.validationLevel === "strict") {
console.log(`[Channel Validation] Strict mode enabled`);
}
return config;
}Key Benefits:
- Type safety - Your custom context fields are properly typed automatically
- IntelliSense - IDE autocomplete works for your custom fields
- No generics needed - The generated types already include your context types
- Simpler code - Direct usage without complex generic constraints
Channel Config Structure
The generated ActionChannelConfig and ValidationChannelConfig interfaces include all available configuration fields:
// Generated ActionChannelConfig interface (in your generated file)
export interface ActionChannelConfig {
// Channel connection (required)
channel: Channel;
// Request parameters (varies by action)
input?: Record<string, any>;
primaryKey?: any;
fields?: Array<string | Record<string, any>>;
filter?: Record<string, any>;
sort?: string;
page?: { limit?: number; offset?: number; count?: boolean };
// Metadata
metadataFields?: Record<string, any>;
// Channel options
timeout?: number;
// Handlers (required for channel operations)
resultHandler: (result: any) => void;
errorHandler?: (error: any) => void;
timeoutHandler?: () => void;
// Multitenancy
tenant?: string;
// Hook context (automatically typed based on your config)
hookCtx?: YourActionChannelHookContext;
}Key Points:
- Channel hooks support async operations (Promise-based)
beforeChannelPushreceives action name and config, returns modified configafterChannelResponsereceives action name, response type, data, and config- Response type distinguishes between three channel outcomes: "ok", "error", "timeout"
- Original config takes precedence over modified config
- Your custom
hookCtxtype is automatically included when you configure context type settings
beforeChannelPush Hook
The beforeChannelPush hook runs before the channel.push() call and can modify the channel message configuration. Common use cases:
Setting Default Timeout
// channelHooks.ts
export interface ActionChannelHookContext {
useDefaultTimeout?: boolean;
customTimeout?: number;
}
export async function beforeChannelPush(
actionName: string,
config: ChannelActionConfig
): Promise<ChannelActionConfig> {
const ctx = config.hookCtx;
// Set default timeout if not specified
if (ctx?.useDefaultTimeout && !config.timeout) {
return {
...config,
timeout: ctx.customTimeout || 10000 // 10 second default
};
}
return config;
}// Usage: Pass timeout preferences via hook context
listTodosChannel({
channel: myChannel,
fields: ["id", "title"],
hookCtx: {
useDefaultTimeout: true,
customTimeout: 15000
},
resultHandler: (result) => console.log(result)
});Logging Channel Messages
export interface ActionChannelHookContext {
correlationId?: string;
trackPerformance?: boolean;
startTime?: number;
}
export async function beforeChannelPush(
actionName: string,
config: ChannelActionConfig
): Promise<ChannelActionConfig> {
const ctx = config.hookCtx;
// Setup timing
if (ctx?.trackPerformance && ctx) {
ctx.startTime = Date.now();
}
console.log(`[Channel] Pushing to channel:`, {
action: actionName,
correlationId: ctx?.correlationId,
timestamp: new Date().toISOString()
});
return config;
}afterChannelResponse Hook
The afterChannelResponse hook runs after the channel response is received (ok, error, or timeout) and is used for side effects. It receives four parameters:
actionName: string- The name of the action being executedresponseType: "ok" | "error" | "timeout"- The type of channel responsedata: any- Response data (result for "ok", error for "error", null for "timeout")config: ChannelActionConfig- The config used for the request
Logging All Channel Responses
export async function afterChannelResponse(
actionName: string,
responseType: "ok" | "error" | "timeout",
data: any,
config: ChannelActionConfig
): Promise<void> {
const ctx = config.hookCtx;
console.log(`[Channel] Response received:`, {
action: actionName,
responseType,
hasData: data !== null,
correlationId: ctx?.correlationId,
timestamp: new Date().toISOString()
});
// Log specific details based on response type
if (responseType === "error") {
console.error(`[Channel] Error in ${actionName}:`, data);
} else if (responseType === "timeout") {
console.warn(`[Channel] Timeout in ${actionName}`);
}
}Performance Timing
export interface ActionChannelHookContext {
startTime?: number;
trackPerformance?: boolean;
correlationId?: string;
}
export async function afterChannelResponse(
actionName: string,
responseType: "ok" | "error" | "timeout",
data: any,
config: ChannelActionConfig
): Promise<void> {
const ctx = config.hookCtx;
if (ctx?.trackPerformance && ctx.startTime) {
const duration = Date.now() - ctx.startTime;
console.log(`[Channel] Performance metrics:`, {
action: actionName,
duration: `${duration}ms`,
responseType,
success: responseType === "ok" && data?.success,
correlationId: ctx?.correlationId
});
// Send to analytics service
trackMetric('channel.rpc.duration', duration, {
action: actionName,
responseType,
success: responseType === "ok"
});
}
}Telemetry Tracking
export async function afterChannelResponse(
actionName: string,
responseType: "ok" | "error" | "timeout",
data: any,
config: ChannelActionConfig
): Promise<void> {
// Send telemetry to monitoring service
sendTelemetry({
event: 'channel.rpc.completed',
action: actionName,
domain: config.domain,
responseType,
success: responseType === "ok" && data?.success,
timestamp: Date.now()
});
// Track specific response types
if (responseType === "timeout") {
sendTelemetry({
event: 'channel.rpc.timeout',
action: actionName,
timestamp: Date.now()
});
}
}Error Monitoring
export async function afterChannelResponse(
actionName: string,
responseType: "ok" | "error" | "timeout",
data: any,
config: ChannelActionConfig
): Promise<void> {
// Track errors in error monitoring service
if (responseType === "error" || responseType === "timeout") {
Sentry.captureMessage('Channel RPC failed', {
level: 'error',
extra: {
action: actionName,
responseType,
data: responseType === "error" ? data : null,
domain: config.domain
}
});
} else if (data && !data.success) {
// Track validation errors from successful channel responses
Sentry.captureMessage('Channel RPC validation error', {
level: 'warning',
extra: {
action: actionName,
errors: data.errors
}
});
}
}Channel Config Precedence Rules
When using beforeChannelPush hooks, the original config always takes precedence over the modified config:
export async function beforeChannelPush(
actionName: string,
config: ChannelActionConfig
): Promise<ChannelActionConfig> {
return {
...config,
timeout: config.timeout ?? 10000 // ← Original timeout takes precedence
};
}Precedence order:
- Original
configvalues (highest priority) - Modified config from
beforeChannelPushhook - No default timeout (lowest priority)
This ensures that per-request customizations always override hook defaults.
Complete Channel Working Example
Here's a complete example showing all channel hook features with the simplified pattern:
// channelHooks.ts
import type { ActionChannelConfig, ValidationChannelConfig } from './generated';
// Define custom hook context interfaces
export interface ActionChannelHookContext {
trackPerformance?: boolean;
startTime?: number;
correlationId?: string;
}
export interface ValidationChannelHookContext {
formId?: string;
validationLevel?: "strict" | "normal";
}
// Action hooks - directly use ActionChannelConfig (no generics needed!)
export async function beforeChannelPush(
actionName: string,
config: ActionChannelConfig
): Promise<ActionChannelConfig> {
const ctx = config.hookCtx;
// Setup timing - properly update context immutably
const modifiedCtx = ctx?.trackPerformance
? { ...ctx, startTime: Date.now() }
: ctx;
console.log(`[Channel] ${actionName} starting`, {
correlationId: ctx?.correlationId
});
return {
...config,
...(modifiedCtx && { hookCtx: modifiedCtx })
};
}
export async function afterChannelResponse(
actionName: string,
responseType: "ok" | "error" | "timeout",
data: any,
config: ActionChannelConfig
): Promise<void> {
const ctx = config.hookCtx;
// Track timing - ctx.startTime is automatically properly typed!
const duration = ctx?.startTime ? Date.now() - ctx.startTime : 0;
// Log result
console.log(`[Channel] ${actionName} completed:`, {
responseType,
duration: `${duration}ms`,
correlationId: ctx?.correlationId
});
// Track errors
if (responseType !== "ok") {
console.error(`[Channel] ${actionName} failed:`, { responseType, data });
}
}
// Validation hooks - directly use ValidationChannelConfig (no generics needed!)
export async function beforeValidationChannelPush(
actionName: string,
config: ValidationChannelConfig
): Promise<ValidationChannelConfig> {
const ctx = config.hookCtx;
console.log(`[Channel Validation] ${actionName} started`, {
formId: ctx?.formId,
validationLevel: ctx?.validationLevel
});
return config;
}
export async function afterValidationChannelResponse(
actionName: string,
responseType: "ok" | "error" | "timeout",
data: any,
config: ValidationChannelConfig
): Promise<void> {
const ctx = config.hookCtx;
console.log(`[Channel Validation] ${actionName} completed`, {
formId: ctx?.formId,
responseType,
hasErrors: responseType === "ok" && data && !data.success
});
}// Usage in your application
import { listTodosChannel, createTodoChannel, validateCreateTodoChannel } from './ash_rpc';
import { Channel } from "phoenix";
// Action with channel hooks
listTodosChannel({
channel: myChannel,
fields: ["id", "title", { user: ["name"] }],
hookCtx: {
trackPerformance: true,
correlationId: 'list-todos-123'
},
resultHandler: (result) => {
if (result.success) {
console.log("Todos loaded:", result.data);
}
}
});
// Validation with channel hooks
validateCreateTodoChannel({
channel: myChannel,
input: {
title: "Test Todo",
userId: "123e4567-e89b-12d3-a456-426614174000"
},
hookCtx: {
formId: 'create-todo-form',
validationLevel: 'strict'
},
resultHandler: (result) => {
if (!result.success) {
console.log("Validation errors:", result.errors);
}
}
});Troubleshooting
HTTP Hooks
Config precedence not working:
// ❌ Wrong: Original config gets overridden
return {
headers: { ...config.headers, 'X-Custom': 'value' },
...config
};
// ✅ Correct: Original config takes precedence
return {
...config,
headers: { 'X-Custom': 'value', ...config.headers }
};Performance timing not working:
// ❌ Wrong: Context is read-only, modifications lost
export function beforeRequest(actionName: string, config: ActionConfig): ActionConfig {
const ctx = config.hookCtx;
ctx.startTime = Date.now(); // Lost!
return config;
}
// ✅ Correct: Return modified context
export function beforeRequest(actionName: string, config: ActionConfig): ActionConfig {
const ctx = config.hookCtx || {};
return {
...config,
hookCtx: { ...ctx, startTime: Date.now() }
};
}Hook not executing:
- Verify hook functions are exported from the configured module
- Check that
import_into_generatedincludes the hooks module - Regenerate types with
mix ash.codegen --dev - Ensure hook function names match the configuration exactly
TypeScript errors with hook context:
// ❌ Wrong: Type assertion on config
const ctx = config.hookCtx as ActionHookContext;
ctx.trackPerformance; // Error if hookCtx is undefined
// ✅ Correct: Optional chaining or type guard
const ctx = config.hookCtx as ActionHookContext | undefined;
if (ctx?.trackPerformance) {
// Safe to use
}Channel Hooks
Config precedence not working:
// ❌ Wrong: Original config gets overridden
return {
timeout: 10000,
...config
};
// ✅ Correct: Original config takes precedence
return {
...config,
timeout: config.timeout ?? 10000
};Hook not executing:
- Verify channel hook functions are exported from the configured module
- Check that
import_into_generatedincludes the channel hooks module - Regenerate types with
mix ash.codegen --dev - Ensure hook function names match the configuration exactly
- Verify that
generate_phx_channel_rpc_actions: trueis set in config
TypeScript errors with channel hook context:
// ❌ Wrong: Type assertion without null check
const ctx = config.hookCtx as ActionChannelHookContext;
ctx.trackPerformance; // Error if hookCtx is undefined
// ✅ Correct: Optional chaining or type guard
const ctx = config.hookCtx as ActionChannelHookContext | undefined;
if (ctx?.trackPerformance) {
// Safe to use
}Response type not being handled:
// ✅ Handle all three response types
export async function afterChannelResponse(
actionName: string,
responseType: "ok" | "error" | "timeout",
data: any,
config: any
): Promise<void> {
switch (responseType) {
case "ok":
// Handle successful response
break;
case "error":
// Handle error response
break;
case "timeout":
// Handle timeout response
break;
}
}