/**
* HackerNews API Client Service
*
* Provides methods to interact with the HackerNews Algolia API.
* Uses native fetch API (Node.js 18+) with timeout handling.
*
* @see https://hn.algolia.com/api
*/
import type { HNUser, ItemResult, SearchResult } from "../types/index.js";
/** Base URL for HackerNews Algolia API */
const HN_API_BASE_URL = "https://hn.algolia.com/api/v1";
/** Default request timeout in milliseconds */
const DEFAULT_TIMEOUT = 5000;
/**
* Search parameters for HackerNews API
*/
export interface SearchParams {
query: string;
tags?: string[] | undefined;
numericFilters?: string[] | undefined;
page?: number | undefined;
hitsPerPage?: number | undefined;
}
/**
* HackerNews API client for making requests to the Algolia API
*/
export class HNAPIClient {
private readonly baseUrl: string;
private readonly timeout: number;
/**
* Create a new HNAPIClient instance
*
* @param baseUrl - Base URL for the API (default: HN_API_BASE_URL)
* @param timeout - Request timeout in milliseconds (default: 5000ms)
*/
constructor(baseUrl: string = HN_API_BASE_URL, timeout: number = DEFAULT_TIMEOUT) {
this.baseUrl = baseUrl;
this.timeout = timeout;
}
/**
* Fetch data from the API with timeout handling
*
* @param url - Full URL to fetch
* @returns Parsed JSON response
* @throws Error on network failure, timeout, or non-OK response
*/
private async fetchWithTimeout(url: string): Promise<unknown> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const response = await fetch(url, {
signal: controller.signal,
headers: {
Accept: "application/json",
},
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error) {
if (error.name === "AbortError") {
throw new Error(`Request timeout after ${this.timeout}ms`);
}
throw error;
}
throw new Error("Unknown error during fetch");
}
}
/**
* Build query string from search parameters
*
* @param params - Search parameters
* @returns URL-encoded query string
*/
private buildQueryString(params: Partial<SearchParams>): string {
const queryParams = new URLSearchParams();
if (params.query !== undefined) {
queryParams.append("query", params.query);
}
if (params.tags && params.tags.length > 0) {
queryParams.append("tags", params.tags.join(","));
}
if (params.numericFilters && params.numericFilters.length > 0) {
queryParams.append("numericFilters", params.numericFilters.join(","));
}
if (params.page !== undefined) {
queryParams.append("page", params.page.toString());
}
if (params.hitsPerPage !== undefined) {
queryParams.append("hitsPerPage", params.hitsPerPage.toString());
}
return queryParams.toString();
}
/**
* Search HackerNews by relevance
*
* @param params - Search parameters
* @returns Search results with hits, pagination, and metadata
* @throws Error on API failure or network issues
*
* @example
* ```typescript
* const results = await client.search({
* query: "AI",
* tags: ["story"],
* numericFilters: ["points>=100"],
* page: 0,
* hitsPerPage: 20
* });
* ```
*/
async search(params: SearchParams): Promise<SearchResult> {
const queryString = this.buildQueryString(params);
const url = `${this.baseUrl}/search?${queryString}`;
return (await this.fetchWithTimeout(url)) as SearchResult;
}
/**
* Search HackerNews by date (most recent first)
*
* @param params - Search parameters (query can be empty for all posts)
* @returns Search results sorted by creation date
* @throws Error on API failure or network issues
*
* @example
* ```typescript
* const results = await client.searchByDate({
* query: "",
* tags: ["story"],
* page: 0
* });
* ```
*/
async searchByDate(params: Partial<SearchParams>): Promise<SearchResult> {
const queryString = this.buildQueryString(params);
const url = `${this.baseUrl}/search_by_date?${queryString}`;
return (await this.fetchWithTimeout(url)) as SearchResult;
}
/**
* Get a specific item by ID with nested children
*
* @param itemId - HackerNews item ID
* @returns Item with nested comment tree
* @throws Error if item not found or API failure
*
* @example
* ```typescript
* const item = await client.getItem("38456789");
* console.log(item.title);
* console.log(item.children.length); // Number of top-level comments
* ```
*/
async getItem(itemId: string): Promise<ItemResult> {
const url = `${this.baseUrl}/items/${itemId}`;
const rawItem = await this.fetchWithTimeout(url);
// Transform the raw API response to match our ItemResult type
// The HN API returns id as number, but we want string
return this.transformItemResult(rawItem);
}
/**
* Transform raw API item to ItemResult type (recursively)
* Converts id from number to string for all items in the tree
*/
private transformItemResult(item: unknown): ItemResult {
const itemObj = item as ItemResult & { children?: unknown[] };
return {
...itemObj,
id: String(itemObj.id),
children: (itemObj.children || []).map((child: unknown) => this.transformItemResult(child)),
} as ItemResult;
}
/**
* Get a user's profile information
*
* @param username - HackerNews username
* @returns User profile with karma, about, and creation date
* @throws Error if user not found or API failure
*
* @example
* ```typescript
* const user = await client.getUser("pg");
* console.log(`Karma: ${user.karma}`);
* ```
*/
async getUser(username: string): Promise<HNUser> {
const url = `${this.baseUrl}/users/${username}`;
return (await this.fetchWithTimeout(url)) as HNUser;
}
}
/**
* Create a singleton instance of the HN API client
*/
export const hnApi = new HNAPIClient();