CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/npm-ky

Tiny and elegant HTTP client based on the Fetch API

Pending
Quality

Pending

Does it follow best practices?

Impact

Pending

No eval scenarios have been run

SecuritybySnyk

Pending

The risk profile of this skill

Overview
Eval results
Files

hooks.mddocs/

Hooks System

Extensible lifecycle hooks for request modification, response processing, error handling, and retry customization. Hooks enable powerful middleware-like functionality for request/response transformation.

Capabilities

Hooks Interface

Configure lifecycle hooks that run at different stages of the request process.

interface Hooks {
  /** Modify request before sending */
  beforeRequest?: BeforeRequestHook[];
  /** Modify request before retry attempts */
  beforeRetry?: BeforeRetryHook[];
  /** Process response after receiving */
  afterResponse?: AfterResponseHook[];
  /** Modify HTTPError before throwing */
  beforeError?: BeforeErrorHook[];
}

type BeforeRequestHook = (
  request: KyRequest,
  options: NormalizedOptions
) => Request | Response | void | Promise<Request | Response | void>;

type BeforeRetryState = {
  request: KyRequest;
  options: NormalizedOptions;
  error: Error;
  retryCount: number;
};

type BeforeRetryHook = (options: BeforeRetryState) => typeof stop | void | Promise<typeof stop | void>;

type AfterResponseHook = (
  request: KyRequest,
  options: NormalizedOptions,
  response: KyResponse
) => Response | void | Promise<Response | void>;

type BeforeErrorHook = (error: HTTPError) => HTTPError | Promise<HTTPError>;

Before Request Hooks

Modify requests before they are sent, or provide cached responses.

type BeforeRequestHook = (
  request: KyRequest,
  options: NormalizedOptions
) => Request | Response | void | Promise<Request | Response | void>;

Usage Examples:

import ky from "ky";

// Add authentication headers
const authClient = ky.create({
  hooks: {
    beforeRequest: [
      (request) => {
        const token = localStorage.getItem("authToken");
        if (token) {
          request.headers.set("Authorization", `Bearer ${token}`);
        }
      }
    ]
  }
});

// Add request logging
const loggedClient = ky.create({
  hooks: {
    beforeRequest: [
      (request, options) => {
        console.log(`→ ${request.method} ${request.url}`);
        console.log("Headers:", Object.fromEntries(request.headers));
        if (options.json) {
          console.log("JSON Body:", options.json);
        }
      }
    ]
  }
});

// Request transformation
const transformClient = ky.create({
  hooks: {
    beforeRequest: [
      (request) => {
        // Add API version header
        request.headers.set("API-Version", "2.0");
        
        // Add request ID for tracing
        request.headers.set("X-Request-ID", crypto.randomUUID());
        
        // Add client information
        request.headers.set("User-Agent", "MyApp/1.0");
      }
    ]
  }
});

// Return cached response
const cacheClient = ky.create({
  hooks: {
    beforeRequest: [
      async (request) => {
        const cacheKey = `${request.method}:${request.url}`;
        const cached = localStorage.getItem(cacheKey);
        
        if (cached) {
          const { data, timestamp } = JSON.parse(cached);
          const age = Date.now() - timestamp;
          
          // Use cache if less than 5 minutes old
          if (age < 5 * 60 * 1000) {
            return new Response(JSON.stringify(data), {
              status: 200,
              headers: { "Content-Type": "application/json" }
            });
          }
        }
      }
    ]
  }
});

// Modify request based on conditions
const conditionalClient = ky.create({
  hooks: {
    beforeRequest: [
      (request, options) => {
        // Add compression header for large requests
        if (options.json && JSON.stringify(options.json).length > 1000) {
          request.headers.set("Accept-Encoding", "gzip, deflate, br");
        }
        
        // Switch to alternative endpoint for specific paths
        if (request.url.includes("/v1/")) {
          const newUrl = request.url.replace("/v1/", "/v2/");
          return new Request(newUrl, request);
        }
      }
    ]
  }
});

Before Retry Hooks

Customize retry behavior and modify requests before retry attempts.

interface BeforeRetryState {
  request: KyRequest;
  options: NormalizedOptions;
  error: Error;
  retryCount: number;
}

type BeforeRetryHook = (state: BeforeRetryState) => typeof stop | void | Promise<typeof stop | void>;

Usage Examples:

import ky from "ky";

// Token refresh on authentication errors
const authRetryClient = ky.create({
  hooks: {
    beforeRetry: [
      async ({ request, options, error, retryCount }) => {
        if (error instanceof HTTPError && error.response.status === 401) {
          console.log(`Authentication failed, refreshing token (attempt ${retryCount})`);
          
          try {
            const newToken = await refreshAuthToken();
            request.headers.set("Authorization", `Bearer ${newToken}`);
            localStorage.setItem("authToken", newToken);
          } catch (refreshError) {
            console.error("Token refresh failed:", refreshError);
            return ky.stop; // Stop retrying
          }
        }
      }
    ]
  }
});

// Intelligent retry decisions
const smartRetryClient = ky.create({
  hooks: {
    beforeRetry: [
      async ({ request, options, error, retryCount }) => {
        console.log(`Retry attempt ${retryCount} for ${request.method} ${request.url}`);
        
        // Stop retrying after business hours for non-critical requests
        const now = new Date();
        const hour = now.getHours();
        const isBusinessHours = hour >= 9 && hour < 17;
        
        if (!isBusinessHours && retryCount > 2) {
          console.log("Outside business hours, stopping retries");
          return ky.stop;
        }
        
        // Check system health before retrying
        if (error instanceof HTTPError && error.response.status >= 500) {
          try {
            const healthCheck = await ky.get("https://api.example.com/health", {
              timeout: 2000
            });
            
            if (!healthCheck.ok) {
              console.log("System unhealthy, stopping retries");
              return ky.stop;
            }
          } catch {
            console.log("Health check failed, stopping retries");
            return ky.stop;
          }
        }
      }
    ]
  }
});

// Dynamic request modification
const dynamicRetryClient = ky.create({
  hooks: {
    beforeRetry: [
      ({ request, error, retryCount }) => {
        // Reduce timeout on retry
        if (retryCount > 1) {
          const newTimeout = Math.max(5000 - (retryCount * 1000), 1000);
          // Note: Can't modify timeout here, but can log strategy
          console.log(`Retry ${retryCount}: would use timeout ${newTimeout}ms`);
        }
        
        // Switch to backup endpoint
        if (retryCount > 2 && request.url.includes("api.example.com")) {
          const backupUrl = request.url.replace("api.example.com", "backup-api.example.com");
          // Create new request with backup URL
          Object.defineProperty(request, "url", { value: backupUrl });
        }
        
        // Add retry metadata
        request.headers.set("X-Retry-Count", retryCount.toString());
        request.headers.set("X-Original-Error", error.message);
      }
    ]
  }
});

// Circuit breaker pattern
let failureCount = 0;
let lastFailureTime = 0;
const CIRCUIT_BREAKER_THRESHOLD = 5;
const CIRCUIT_BREAKER_TIMEOUT = 60000; // 1 minute

const circuitBreakerClient = ky.create({
  hooks: {
    beforeRetry: [
      ({ error, retryCount }) => {
        const now = Date.now();
        
        // Reset failure count after timeout
        if (now - lastFailureTime > CIRCUIT_BREAKER_TIMEOUT) {
          failureCount = 0;
        }
        
        // Increment failure count
        if (error instanceof HTTPError && error.response.status >= 500) {
          failureCount++;
          lastFailureTime = now;
        }
        
        // Stop retrying if circuit breaker is open
        if (failureCount >= CIRCUIT_BREAKER_THRESHOLD) {
          console.log("Circuit breaker open, stopping retries");
          return ky.stop;
        }
      }
    ]
  }
});

After Response Hooks

Process and potentially modify responses after they are received.

type AfterResponseHook = (
  request: KyRequest,
  options: NormalizedOptions,
  response: KyResponse
) => Response | void | Promise<Response | void>;

Usage Examples:

import ky from "ky";

// Response logging
const loggedClient = ky.create({
  hooks: {
    afterResponse: [
      (request, options, response) => {
        console.log(`← ${response.status} ${request.method} ${request.url}`);
        console.log("Response Headers:", Object.fromEntries(response.headers));
        
        // Log timing information
        const duration = Date.now() - (request as any).startTime;
        console.log(`Duration: ${duration}ms`);
      }
    ]
  }
});

// Response caching
const cachingClient = ky.create({
  hooks: {
    afterResponse: [
      async (request, options, response) => {
        // Only cache successful GET requests
        if (request.method === "GET" && response.ok) {
          const cacheKey = `${request.method}:${request.url}`;
          const data = await response.clone().json();
          
          localStorage.setItem(cacheKey, JSON.stringify({
            data,
            timestamp: Date.now(),
            headers: Object.fromEntries(response.headers)
          }));
        }
      }
    ]
  }
});

// Response transformation
const transformResponseClient = ky.create({
  hooks: {
    afterResponse: [
      async (request, options, response) => {
        // Unwrap API responses
        if (response.headers.get("content-type")?.includes("application/json")) {
          const data = await response.clone().json();
          
          // If response has a "data" wrapper, unwrap it
          if (data && typeof data === "object" && "data" in data) {
            return new Response(JSON.stringify(data.data), {
              status: response.status,
              statusText: response.statusText,
              headers: response.headers
            });
          }
        }
      }
    ]
  }
});

// Automatic retry on specific conditions
const conditionalRetryClient = ky.create({
  hooks: {
    afterResponse: [
      async (request, options, response) => {
        // Retry on 403 with token refresh
        if (response.status === 403) {
          const newToken = await refreshAuthToken();
          
          // Create new request with fresh token
          const newRequest = new Request(request, {
            headers: {
              ...Object.fromEntries(request.headers),
              "Authorization": `Bearer ${newToken}`
            }
          });
          
          // Retry the request
          return ky(newRequest, options);
        }
      }
    ]
  }
});

// Response monitoring and metrics
const metricsClient = ky.create({
  hooks: {
    afterResponse: [
      (request, options, response) => {
        // Send metrics to monitoring service
        const metrics = {
          method: request.method,
          url: request.url,
          status: response.status,
          duration: Date.now() - (request as any).startTime,
          size: response.headers.get("content-length") || 0
        };
        
        // Send to analytics (non-blocking)
        sendMetrics(metrics).catch(console.warn);
        
        // Update performance counters
        updatePerformanceCounters(metrics);
      }
    ]
  }
});

Before Error Hooks

Modify HTTPError objects before they are thrown.

type BeforeErrorHook = (error: HTTPError) => HTTPError | Promise<HTTPError>;

Usage Examples:

import ky from "ky";

// Enhanced error information
const enhancedErrorClient = ky.create({
  hooks: {
    beforeError: [
      async (error) => {
        // Add response body to error for debugging
        if (error.response.body) {
          try {
            const responseText = await error.response.clone().text();
            error.message += `\nResponse body: ${responseText}`;
          } catch {
            // Ignore if body can't be read
          }
        }
        
        // Add request information
        error.message += `\nRequest: ${error.request.method} ${error.request.url}`;
        
        // Add timestamp
        error.message += `\nTimestamp: ${new Date().toISOString()}`;
        
        return error;
      }
    ]
  }
});

// Custom error types
class APIError extends Error {
  constructor(
    message: string,
    public code: string,
    public status: number
  ) {
    super(message);
    this.name = "APIError";
  }
}

const customErrorClient = ky.create({
  hooks: {
    beforeError: [
      async (error) => {
        try {
          const errorData = await error.response.clone().json();
          
          // Transform to custom error type
          if (errorData.code && errorData.message) {
            const customError = new APIError(
              errorData.message,
              errorData.code,
              error.response.status
            );
            
            // Copy properties from original error
            (customError as any).response = error.response;
            (customError as any).request = error.request;
            (customError as any).options = error.options;
            
            return customError as any;
          }
        } catch {
          // Fall back to original error if parsing fails
        }
        
        return error;
      }
    ]
  }
});

// Error reporting and logging
const reportingClient = ky.create({
  hooks: {
    beforeError: [
      (error) => {
        // Log error details
        console.error("HTTP Error:", {
          method: error.request.method,
          url: error.request.url,
          status: error.response.status,
          statusText: error.response.statusText,
          message: error.message
        });
        
        // Report to error tracking service
        if (error.response.status >= 500) {
          reportError({
            type: "http_error",
            status: error.response.status,
            url: error.request.url,
            method: error.request.method,
            message: error.message,
            timestamp: new Date().toISOString()
          });
        }
        
        return error;
      }
    ]
  }
});

// User-friendly error messages
const userFriendlyClient = ky.create({
  hooks: {
    beforeError: [
      (error) => {
        // Map status codes to user-friendly messages
        const userMessages: Record<number, string> = {
          400: "The request was invalid. Please check your input.",
          401: "Authentication required. Please log in.",
          403: "You don't have permission to access this resource.",
          404: "The requested resource was not found.",
          429: "Too many requests. Please try again later.",
          500: "Server error. Please try again later.",
          502: "Service temporarily unavailable.",
          503: "Service under maintenance. Please try again later."
        };
        
        const userMessage = userMessages[error.response.status];
        if (userMessage) {
          error.message = userMessage;
        }
        
        return error;
      }
    ]
  }
});

Multiple Hooks

Chain multiple hooks for complex request/response processing.

Usage Examples:

import ky from "ky";

// Multiple hooks in sequence
const multiHookClient = ky.create({
  hooks: {
    beforeRequest: [
      // 1. Add authentication
      (request) => {
        const token = getAuthToken();
        if (token) {
          request.headers.set("Authorization", `Bearer ${token}`);
        }
      },
      // 2. Add tracing
      (request) => {
        request.headers.set("X-Trace-ID", generateTraceId());
      },
      // 3. Add timing
      (request) => {
        (request as any).startTime = Date.now();
      }
    ],
    afterResponse: [
      // 1. Log response
      (request, options, response) => {
        const duration = Date.now() - (request as any).startTime;
        console.log(`${request.method} ${request.url} - ${response.status} (${duration}ms)`);
      },
      // 2. Cache if appropriate
      async (request, options, response) => {
        if (request.method === "GET" && response.ok) {
          await cacheResponse(request.url, response.clone());
        }
      },
      // 3. Update metrics
      (request, options, response) => {
        updateRequestMetrics({
          method: request.method,
          status: response.status,
          duration: Date.now() - (request as any).startTime
        });
      }
    ]
  }
});

Types

interface Hooks {
  beforeRequest?: BeforeRequestHook[];
  beforeRetry?: BeforeRetryHook[];
  afterResponse?: AfterResponseHook[];
  beforeError?: BeforeErrorHook[];
}

type BeforeRequestHook = (
  request: KyRequest,
  options: NormalizedOptions
) => Request | Response | void | Promise<Request | Response | void>;

interface BeforeRetryState {
  request: KyRequest;
  options: NormalizedOptions;
  error: Error;
  retryCount: number;
}

type BeforeRetryHook = (state: BeforeRetryState) => typeof stop | void | Promise<typeof stop | void>;

type AfterResponseHook = (
  request: KyRequest,
  options: NormalizedOptions,
  response: KyResponse
) => Response | void | Promise<Response | void>;

type BeforeErrorHook = (error: HTTPError) => HTTPError | Promise<HTTPError>;

// Special symbol for stopping retries
declare const stop: unique symbol;

docs

configuration.md

errors.md

hooks.md

http-methods.md

index.md

instances.md

responses.md

retry.md

tile.json