pokenode-ts

repository·main·Indexed 43 days ago

https://github.com/gabb-c/pokenode-ts

A zero-dependency, fully typed TypeScript client for the PokéAPI (version 2.3.1). It provides structured access to Pokémon data via specialized clients like PokemonClient, MainClient, BerryClient, and EvolutionClient, featuring built-in caching, pagination, and end-to-end typing that mirrors the PokéAPI schema.

Tokens
44.3K
Snippets
170
Records
207
Agent score
54%

What's inside pokenode-ts

  1. Overview of Pokenode-ts

    main

    Pokenode-ts is a fully typed PokéAPI client designed with zero runtime dependencies. It is built on native fetch, making it compatible with a wide range of environments including Node.js, Deno, Bun, browsers, and edge runtimes like Cloudflare Workers.

    Key capabilities include:

    • End-to-end Typing: Every endpoint and field is typed according to the PokéAPI schema.
    • Link Resolution: Utilities to resolve { name, url } references into full objects using resolve() or resolveAll().
    • Pagination: Built-in support for walking through entire API sections (e.g., all Pokémon) using paginate().
    • Caching: In-memory caching is enabled by default to respect PokéAPI's fair-use policy, with support for custom storage (Redis, KV, localStorage).
    • Resilience: Supports opt-in retries with jittered backoff, timeouts, and request cancellation via a .with() pattern.
  2. Pick the right client for your needs

    main

    Pokenode-ts provides twelve specialized clients. You can import only the one you need to allow for tree-shaking.

    If you need to work across multiple sections, use MainClient. It bundles all twelve clients and provides a single shared cache, ensuring that a resource fetched through one client is served from memory if requested by another.

    import { MainClient } from 'pokenode-ts';
    
    const api = new MainClient();
    
    // Access specific clients via the main instance
    const pokemon = await api.pokemon.getPokemonByName('luxray');
    const berry = await api.berry.getBerryByName('cheri');
  3. Use revalidation to reduce bandwidth

    main

    When revalidate is enabled, the client uses ETag headers to check if a cached resource is still fresh instead of downloading it again. This is useful for data that changes infrequently.

    How it works:

    • The client stores the ETag and the response body.
    • On an expired entry, the client sends a request with the validator.
    • If the API returns 304 Not Modified, the cached body is reused, and the cache is refilled from memory without a full download.
    • If the API returns 200, both the body and ETag are replaced.

    Note: EtagStore is a separate mechanism from CacheStore. While CacheStore manages the actual resource data, EtagStore only manages the validators used for revalidation.

    import { MainClient, EtagStore } from 'pokenode-ts';
    
    // Basic revalidation
    const api = new MainClient({ revalidate: true });
    
    // Sized revalidation: limits how many URLs are remembered
    const sized = new MainClient({ 
      revalidate: new EtagStore({ maxEntries: 2000 }) 
    });
  4. How to walk a recursive evolution chain

    main

    An EvolutionChain.chain is a recursive ChainLink structure. Each link contains the species at that stage and an evolves_to array containing the subsequent links. Because some Pokémon have branching evolutions (e.g., Eevee), evolves_to is an array rather than a single object.

    You can traverse this structure recursively to collect all species in a chain.

    import { EvolutionClient, type ChainLink } from 'pokenode-ts';
    
    const names = (link: ChainLink): string[] => [
      link.species.name,
      ...link.evolves_to.flatMap(names),
    ];
    
    const chain = await new EvolutionClient().getEvolutionChainById(67);
    
    console.log(names(chain.chain)); // eevee and every evolution it branches into
  5. Handle types that did not exist in older generations

    main

    When querying type relations for a generation before a specific type was introduced (e.g., asking for steel relations in generation-i), the library returns undefined rather than a default value.

    Important Note on Defenders: The generation scope only affects the attacking type. The defender is passed as a list of links which do not carry generation metadata. If you need to simulate a matchup using a Pokémon's historical typing, you must use the past_types property from the Pokémon resource.

    // Returns undefined because Steel did not exist in Gen I
    relationsFor(steel, 'generation-i'); 
    
    effectiveness(steel, [rockLink], { generation: 'generation-i' }); // undefined
  6. Understand the difference between Conditions and Condition Values

    main

    When working with encounter data, it is important to distinguish between a condition and a condition value:

    • Condition: The variable being measured (e.g., time, season, swarm).
    • Condition Value: A specific setting or state of that variable (e.g., time-day, season-winter, swarm-yes).

    Note that encounters are described in terms of values, not the abstract conditions themselves.

  7. Understand the logging event types and payloads

    main

    The client emits structured log events. Every request event is eventually closed by exactly one response, cancelled, or error event. You can filter logs by the event field to perform specific actions like metrics collection.

    eventMethodFields
    requestdebugmethod, url
    responsedebugurl, status, source, durationMs
    retrydebugurl, attempt, delayMs, status
    cancelleddebugurl, reason, durationMs
    errorerrorurl, err, error

    Note on Retries: The retry event only appears if retry is configured. It signals an attempt that was followed by another attempt. The final attempt in a sequence will report as a standard response or error.

    Note on Cancellations: cancelled is logged at the debug level (not error) to prevent timeouts or aborted scopes from artificially inflating your error rates. The reason field contains the signal.reason or a TimeoutError.

    const logger = {
      debug: (payload) => {
        if (payload.event === 'response') metrics.histogram('pokeapi.duration', payload.durationMs);
      },
      error: ({ url }) => metrics.increment('pokeapi.errors', { url }),
    };
  8. Distinguish between API errors and transport errors

    main

    It is important to distinguish between errors returned by the PokéAPI and errors caused by the network or runtime. isPokenodeError only returns true for non-2xx HTTP responses. Other failures propagate as standard runtime errors.

    | Failure | What you catch |
    | --- | --- |
    | 404, 500, any non-2xx | `PokenodeError` |
    | Offline, DNS failure, refused connection | The runtime's `TypeError` from `fetch` |
    | A signal you supplied aborting | `DOMException` named `AbortError` |
    | `AbortSignal.timeout()` firing | `DOMException` named `TimeoutError` |
    | Malformed URL passed to `getResourceByUrl` | `TypeError` |
  9. Handle lists without names (APIResourceList)

    main

    Some PokéAPI sections (e.g., machine, contest-effect, super-contest-effect, evolution-chain, and characteristic) do not provide names for their entries, only URLs.

    Methods for these sections return an APIResourceList<T> instead of a NamedAPIResourceList<T>. The entries in an APIResourceList are simple objects containing only a { url }. You follow them using the same getResourceByUrl pattern:

    const machines = await new MachineClient().listMachines();
    //    ^? APIResourceList<Machine>
    
    const machine = await utility.getResourceByUrl(machines.results[0]);
    //    ^? Machine
  10. How scoped clients share transport and cache

    main

    When you use .with() to derive a client, the new client shares the underlying transport of the original client. This includes:

    • The cache
    • ETag validators
    • In-flight requests

    Because they share in-flight requests, a scoped call will join an identical unscoped request already on the wire instead of starting a new one.

    const scoped = api.with({ timeout: 2000 });
    
    // This results in only one round trip, not two.
    await Promise.all([api.berry.getBerryById(1), scoped.berry.getBerryById(1)]);
    const scoped = api.with({ timeout: 2000 });
    
    // One round trip, not two.
    await Promise.all([api.berry.getBerryById(1), scoped.berry.getBerryById(1)]);