Lifecycle Hooks

View Source

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

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:

ConfigPurposeDefault
rpc_action_before_request_hookFunction called before RPC action requestsnil (disabled)
rpc_action_after_request_hookFunction called after RPC action requestsnil (disabled)
rpc_validation_before_request_hookFunction called before validation requestsnil (disabled)
rpc_validation_after_request_hookFunction called after validation requestsnil (disabled)
rpc_action_hook_context_typeTypeScript type for action hook context"Record<string, any>"
rpc_validation_hook_context_typeTypeScript 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 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

// 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:

  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:

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:

  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:

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
// 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:

AspectHTTP HooksChannel Hooks
CommunicationRequest/Response (HTTP)Message-based (WebSocket)
API StylePromise-basedCallback-based
Response TypesSuccess or Errorok, error, or timeout
Hook NamesbeforeRequest, afterRequestbeforeChannelPush, 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:

ConfigPurposeDefault
rpc_action_before_channel_push_hookFunction called before channel push for RPC actionsnil (disabled)
rpc_action_after_channel_response_hookFunction called after channel response for RPC actionsnil (disabled)
rpc_validation_before_channel_push_hookFunction called before channel push for validationsnil (disabled)
rpc_validation_after_channel_response_hookFunction called after channel response for validationsnil (disabled)
rpc_action_channel_hook_context_typeTypeScript type for action channel hook context"Record<string, any>"
rpc_validation_channel_hook_context_typeTypeScript 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)
  • 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

// 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:

  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

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:

  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:

// 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_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:

// ❌ 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_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:

// ❌ 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;
  }
}