Packages
ash_typescript
0.11.3
0.17.3
0.17.2
0.17.1
0.17.0
0.16.0
0.15.3
0.15.2
retired
0.15.1
retired
0.15.0
0.14.4
0.14.3
0.14.2
0.14.1
0.14.0
retired
0.13.2
0.13.1
0.13.0
0.12.1
0.12.0
0.11.6
0.11.5
0.11.4
0.11.3
0.11.2
0.11.1
0.11.0
0.10.2
0.10.1
0.10.0
0.9.1
0.9.0
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.1
0.7.0
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.0
0.4.0
0.3.3
0.3.2
0.3.1
0.2.0
0.1.2
0.1.0
Generate type-safe TypeScript clients directly from your Ash resources and actions, ensuring end-to-end type safety between your backend and frontend.
Current section
Files
Jump to
Current section
Files
documentation/topics/lifecycle-hooks.md
<!--
SPDX-FileCopyrightText: 2025 Torkild G. Kjevik
SPDX-FileCopyrightText: 2025 ash_typescript contributors <https://github.com/ash-project/ash_typescript/graphs.contributors>
SPDX-License-Identifier: MIT
-->
# Lifecycle Hooks
AshTypescript 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](#http-lifecycle-hooks)
- [Why Use HTTP Lifecycle Hooks?](#why-use-http-lifecycle-hooks)
- [HTTP Configuration](#http-configuration)
- [Hook Types: Actions vs Validations](#hook-types-actions-vs-validations)
- [Hook Function Signatures](#hook-function-signatures)
- [beforeRequest Hook](#beforerequest-hook)
- [afterRequest Hook](#afterrequest-hook)
- [Config Precedence Rules](#config-precedence-rules)
- [Exception Handling](#exception-handling)
- [Complete Working Example](#complete-working-example)
- [Channel Lifecycle Hooks](#channel-lifecycle-hooks)
- [Why Use Channel Lifecycle Hooks?](#why-use-channel-lifecycle-hooks)
- [Key Differences from HTTP Hooks](#key-differences-from-http-hooks)
- [Channel Configuration](#channel-configuration)
- [Channel Hook Function Signatures](#channel-hook-function-signatures)
- [beforeChannelPush Hook](#beforechannelpush-hook)
- [afterChannelResponse Hook](#afterchannelresponse-hook)
- [Channel Config Precedence Rules](#channel-config-precedence-rules)
- [Complete Channel Working Example](#complete-channel-working-example)
- [Troubleshooting](#troubleshooting)
## 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:
```elixir
# 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:
```elixir
# 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:
```typescript
// 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!
```typescript
// 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:
```typescript
// This type is exported from your generated file
export interface ActionConfig {
// Request data
input?: Record<string, any>;
identity?: 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 `config` object as a parameter
- Hook context is accessed via `config.hookCtx` (optional)
- `beforeRequest` returns a modified config object
- `afterRequest` returns nothing (void) - it's for side effects only
- Hooks run unconditionally when configured (not gated by `hookCtx` presence)
### beforeRequest Hook
The `beforeRequest` hook runs **before the HTTP request** and can modify the request configuration. Common use cases:
#### Adding Authentication Tokens
```typescript
// 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.
```typescript
// 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
```typescript
// 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)}`;
}
```
```typescript
// Usage: Pass correlation ID for distributed tracing
const todos = await listTodos({
fields: ["id", "title"],
hookCtx: {
correlationId: 'user-dashboard-load-456'
}
});
```
#### Request Timing Setup
```typescript
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
```typescript
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:
1. `response: Response` - The raw HTTP response object
2. `result: any | null` - Parsed JSON result (null when `response.ok` is false)
3. `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:
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
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:
```typescript
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:**
1. Original `config` values used in action (highest priority)
2. Modified config from `beforeRequest` hook
3. 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:
```typescript
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:**
1. **Error Boundaries** - Let framework error boundaries catch and display errors
2. **Global Error Handlers** - Centralized error handling in your app
3. **Fail-Fast Validation** - Stop execution on critical errors
```typescript
// 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:
```typescript
// 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
});
}
```
```typescript
// 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:
```elixir
# 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:
```elixir
# 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:
```typescript
// 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!
```typescript
// 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:
```typescript
// Generated ActionChannelConfig interface (in your generated file)
export interface ActionChannelConfig {
// Channel connection (required)
channel: Channel;
// Request parameters (varies by action)
input?: Record<string, any>;
identity?: 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)
- `beforeChannelPush` receives action name and config, returns modified config
- `afterChannelResponse` receives 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 `hookCtx` type 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
```typescript
// 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;
}
```
```typescript
// 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
```typescript
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:
1. `actionName: string` - The name of the action being executed
2. `responseType: "ok" | "error" | "timeout"` - The type of channel response
3. `data: any` - Response data (result for "ok", error for "error", null for "timeout")
4. `config: ChannelActionConfig` - The config used for the request
#### Logging All Channel Responses
```typescript
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
```typescript
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
```typescript
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
```typescript
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:
```typescript
export async function beforeChannelPush(
actionName: string,
config: ChannelActionConfig
): Promise<ChannelActionConfig> {
return {
...config,
timeout: config.timeout ?? 10000 // ← Original timeout takes precedence
};
}
```
**Precedence order:**
1. Original `config` values (highest priority)
2. Modified config from `beforeChannelPush` hook
3. 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:
```typescript
// 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
});
}
```
```typescript
// 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:**
```typescript
// ❌ 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:**
```typescript
// ❌ 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_generated` includes the hooks module
- Regenerate types with `mix ash.codegen --dev`
- Ensure hook function names match the configuration exactly
**TypeScript errors with hook context:**
```typescript
// ❌ 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:**
```typescript
// ❌ 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_generated` includes 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: true` is set in config
**TypeScript errors with channel hook context:**
```typescript
// ❌ 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:**
```typescript
// ✅ 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;
}
}
```