Skip to content

Cursor-based Pagination Example ​

Token optional Runs nightly against live ESI

ESI uses cursor-based pagination for newer routes like Freelance Jobs. Instead of page numbers and x-pages headers, the API returns opaque before and after cursor tokens in the response body.

Key concepts:

  • Tokens are opaque strings — never parse or validate them.
  • An empty result array signals the beginning/end of the dataset.
  • Results are sorted by "last modified", so the after token lets you poll for changes efficiently.
  • Duplicates across pages are expected when records are modified between requests.

See: https://developers.eveonline.com/blog/changing-pagination-turning-a-new-page

Calls: freelanceJobs.getFreelanceJobs · freelanceJobs.getFreelanceJobById

Run it ​

bash
git clone https://github.com/lgriffin/ESI.ts.git && cd ESI.ts
npm ci
npm run example:cursor-pagination

examples/cursor-pagination.ts on GitHub. The imports below are written as a consumer of the package writes them; the file in the repository imports from ../src.

Source ​

ts
import {
  createConsoleLogger,
  EsiClient,
  FreelanceJobsListing,
  fetchAllCursorPages,
} from '@lgriffin/esi.ts';

// The program's own output. The client logs through the same console sink
// at ESI_LOG_LEVEL (default warn), so its diagnostics stay out of the way.
const log = createConsoleLogger('info');
const esiLog = createConsoleLogger();

// ─── Example 1: Fetch the public freelance jobs listing ──────────────────────
async function fetchFirstPage() {
  log.info('=== Fetch First Page of Freelance Jobs ===\n');

  const client = new EsiClient({ logger: esiLog });

  try {
    const result: FreelanceJobsListing =
      await client.freelanceJobs.getFreelanceJobs();

    log.info(`  Fetched ${result.freelance_jobs.length} jobs`);
    log.info(`  Cursor before: ${result.cursor?.before}`);
    log.info(`  Cursor after:  ${result.cursor?.after}`);
    log.info('');

    for (const job of result.freelance_jobs.slice(0, 3)) {
      const pct = ((job.progress.current / job.progress.desired) * 100).toFixed(
        1,
      );
      log.info(`  ${job.name}`);
      log.info(`    State: ${job.state} | Progress: ${pct}%`);
      if (job.reward) {
        log.info(
          `    Reward: ${(job.reward.remaining / 1_000_000).toFixed(0)}M ISK remaining`,
        );
      }
    }
    if (result.freelance_jobs.length > 3) {
      log.info(`  ... and ${result.freelance_jobs.length - 3} more`);
    }
  } finally {
    await client.shutdown();
  }
  log.info('');
}

// ─── Example 2: Manual cursor pagination ─────────────────────────────────────
async function manualCursorPagination() {
  log.info('=== Manual Cursor Pagination ===\n');

  const client = new EsiClient({ logger: esiLog });
  let totalJobs = 0;
  let pageCount = 0;
  let afterToken: string | undefined;

  try {
    while (pageCount < 3) {
      // limit to 3 pages for the demo
      const result = await client.freelanceJobs.getFreelanceJobs(
        undefined,
        afterToken,
      );
      pageCount++;

      log.info(`  Page ${pageCount}: ${result.freelance_jobs.length} jobs`);

      if (result.freelance_jobs.length === 0) {
        log.info('  End of dataset reached.');
        break;
      }

      totalJobs += result.freelance_jobs.length;

      if (!result.cursor?.after) {
        log.info('  No more pages.');
        break;
      }

      afterToken = result.cursor?.after ?? undefined;
    }

    log.info(`\n  Total jobs fetched: ${totalJobs} across ${pageCount} pages`);
  } finally {
    await client.shutdown();
  }
  log.info('');
}

// ─── Example 3: Auto-fetch all with fetchAllCursorPages ──────────────────────
async function autoFetchAll() {
  log.info('=== Auto-fetch All Freelance Jobs ===\n');

  const client = new EsiClient({ logger: esiLog });

  try {
    const allJobs = await fetchAllCursorPages(
      (before, after) => client.freelanceJobs.getFreelanceJobs(before, after),
      (response) => response.freelance_jobs,
      (response) => response.cursor ?? {},
    );

    log.info(`  Fetched ${allJobs.length} total freelance jobs`);

    // Show some stats
    const active = allJobs.filter((j) => j.state === 'Active').length;
    log.info(`  Active: ${active}`);
  } finally {
    await client.shutdown();
  }
  log.info('');
}

// ─── Example 4: Fetch a specific job's details ───────────────────────────────
async function fetchJobDetail() {
  log.info('=== Fetch Job Detail ===\n');

  const client = new EsiClient({ logger: esiLog });

  try {
    // First get a job ID from the listing
    const listing = await client.freelanceJobs.getFreelanceJobs();
    if (listing.freelance_jobs.length === 0) {
      log.info('  No jobs found.');
      return;
    }

    const jobId = listing.freelance_jobs[0]!.id;
    const detail = await client.freelanceJobs.getFreelanceJobById(jobId);

    log.info(`  Job: ${detail.name}`);
    log.info(`  Career: ${detail.details.career}`);
    log.info(`  Creator: ${detail.details.creator.character.name}`);
    log.info(`  Corporation: ${detail.details.creator.corporation.name}`);
    log.info(`  Method: ${detail.configuration.method}`);
    log.info(`  Expires: ${detail.details.expires ?? 'no expiry'}`);
    if (detail.contribution) {
      log.info(
        `  Max participants: ${detail.contribution.max_committed_participants}`,
      );
    }
    const broadcast = detail.access_and_visibility.broadcast_locations ?? [];
    if (broadcast.length > 0) {
      const locations = broadcast.map((l) => l.name).join(', ');
      log.info(`  Broadcast locations: ${locations}`);
    }
  } finally {
    await client.shutdown();
  }
  log.info('');
}

// ─── Example 5: Polling for changes ──────────────────────────────────────────
async function pollingPattern() {
  log.info('=== Polling Pattern (Incremental Updates) ===\n');

  log.info(`  // After initial scan, save the final cursor token:
  let savedCursor = lastPage.cursor.after;

  // Later: check for updates (hours, days, or weeks later)
  const updates = await client.freelanceJobs.getFreelanceJobs(undefined, savedCursor);
  if (updates.freelance_jobs.length > 0) {
      // Process changed records — duplicates are expected for modified records
      savedCursor = updates.cursor.after;
  }

  // Character/Corporation endpoints require auth (ESI_ACCESS_TOKEN):
  // const myJobs = await client.freelanceJobs.getCharacterFreelanceJobs(charId);
  // const corpJobs = await client.freelanceJobs.getCorporationFreelanceJobs(corpId);
`);
}

// ─── Run all examples ────────────────────────────────────────────────────────
async function main() {
  log.info('Freelance Jobs & Cursor Pagination Examples\n');
  log.info('These examples use the live ESI Freelance Jobs endpoints.');
  log.info('Public endpoints (no auth needed): listing + detail');
  log.info('Character/Corporation endpoints require ESI_ACCESS_TOKEN.\n');

  await fetchFirstPage();
  await manualCursorPagination();
  await autoFetchAll();
  await fetchJobDetail();
  await pollingPattern();

  log.info('Done!');
}

if (require.main === module) {
  main();
}

Released under the GPL-3.0-or-later license.