Skip to content

Many characters, one runtime ​

Implements: ARCH-08 · SEC-02 · DES-01. See CHARTER.md Part 2 and Part 6. Specification: tests/bdd/features/core/0056-shared-runtime.feature.

An application that acts for more than one character (a corporation tool, a market bot, an alliance auth service) has one relationship with ESI, not one per character. ESI meters every request by the calling IP, and the error budget that a 420 exhausts is the application's, not a character's. @lgriffin/esi.ts/client is built around that fact: one runtime, and a view over it for each identity.

ts
import { createEsi } from '@lgriffin/esi.ts/client';

const esi = createEsi({ userAgent: 'fleet-tool/2.1 (ops@example.com)' });

const status = await esi.public.status.get();
const wallet = await esi
  .as(tokens.identity(characterId))
  .character(characterId)
  .wallet.get();

The rest of this guide explains each part of that, how it relates to EsiClient and EsiTokenManager.createClient, and how to move over.

The runtime ​

createEsi(options) builds one request pipeline: rate limiter, error budget, circuit breaker (when enabled), in-flight deduplicator, ETag cache and transport. Every view hands its requests to that one pipeline. The options are the pipeline options EsiClient takes, with two differences:

  • userAgent is required. CCP asks every caller to identify itself, so a misbehaving application can be contacted rather than blocked. EsiClient leaves it optional for compatibility; createEsi refuses to construct without one, at construction rather than on every request, and refuses a value that is not a legal header value for the same reason (0055-request-headers.feature).
  • There is no token option. Tokens belong to identities, below. The runtime itself never holds one.
ts
import { createEsi } from '@lgriffin/esi.ts/client';

const esi = createEsi({
  userAgent: 'fleet-tool/2.1 (ops@example.com)',
  tenant: 'singularity',
  enableCircuitBreaker: true,
  retryConfig: { maxRetries: 3, baseDelayMs: 1000, maxDelayMs: 30_000 },
  logLevel: 'info',
});

The runtime is frozen: it has no setters, so nothing about it changes after construction. Build a second runtime for a second tenant or a second application identity.

The public view ​

esi.public reaches every operation that needs no SSO scope, arranged by resource:

ts
const status = await esi.public.status.get();
const alliance = await esi.public.alliance(99000001).get();
for await (const order of esi.public
  .market(regionId)
  .orders.get({ order_type: 'sell' })) {
  // one order at a time, every page followed
}

Its type is PublicScopeTree, generated from the ESI OpenAPI document with the authenticated operations left out. A call that needs a scope does not compile:

ts
// @ts-expect-error the wallet needs esi-wallet.read_character_wallet.v1
esi.public.character(characterId).wallet.get();

That is the answer to #183: the split between public and authenticated is a type, so a missing token is a compile error, not a 401 in production. The runtime guards the same line for a caller who casts past the type: an authenticated operation forced through the public view is rejected with NO_AUTH_TOKEN before any request is sent.

The public view sends no Authorization header, whatever tokens the runtime's other views hold. A public request is never attributed to a character.

Two operations are not in ESI's specification and come from scripts/spec/operation-overlay.json instead: esi.public.meta.openapiJson.get() resolves with the parsed specification document and esi.public.meta.openapiYaml.get() with the YAML text, unparsed, asked for with Accept: application/yaml, text/yaml, text/plain, as MetaClient.getOpenApiJson() and getOpenApiYaml() on the custom clients do.

When an operation the spec marks deprecated is called, through any view, the runtime logs one warning through its logger per call (per iteration for a paginated one): Operation '<operationId>' is deprecated., followed by the message, replacement and sunset date when scripts/spec/operation-overlay.json records them. The same guidance is on OperationMeta.deprecation and in the operation's @deprecated JSDoc. The custom clients log the same warning, worded by the same helper, as Endpoint '<method>' is deprecated. from the endpoint definition's DeprecationInfo. No operation is deprecated in the current specification.

Identities and as() ​

esi.as(identity) returns a view over the same runtime that sends as one identity. Its type is ScopeTree, the full tree, so every operation is there. The view is immutable: it holds the identity it was built with, and the same Identity object gets the same view back, so esi.as(identity) in a request handler costs nothing after the first call.

An identity is anything with these members:

ts
interface Identity {
  readonly characterId?: number;
  accessToken(): Promise<string>;
  refreshAccessToken?(): Promise<string>;
}

Before each authenticated request the view calls accessToken(), so a holder that knows its token is about to expire can refresh first. When ESI answers 401, the view calls refreshAccessToken() and retries once; an identity without it cannot refresh, and the 401 is the caller's answer after a single request.

Three ways to get one:

From EsiTokenManager. The manager holds one refresh token per character and keeps the access token fresh. tokens.identity(characterId) hands out the stored token, refreshing it first when it is inside the refresh skew, and refreshes through SSO after a 401. Nothing is looked up until a request needs it, so an unknown character fails at the request, not when the identity is made.

ts
const view = esi.as(tokens.identity(characterId));
const balance = await view.character(characterId).wallet.get();

From a raw access token. For a script that was handed a token, or a token another system keeps fresh:

ts
import { identityFromToken } from '@lgriffin/esi.ts/client';

const view = esi.as(identityFromToken(token));

It cannot refresh. When the token expires, calls fail with a 401 and the caller makes a new identity.

From a TokenProvider. The same function the legacy onTokenRefresh option takes: it returns the current token, refreshing first when it knows the old one has expired. The view asks it before each request, before each page of a paginated operation, and again after a 401.

ts
import { identityFromProvider } from '@lgriffin/esi.ts/client';

const view = esi.as(identityFromProvider(myRefreshFunction, { characterId }));

characterId on an identity is for diagnostics: the cache keys itself by the character the token names, not by this field.

What the views share ​

Everything but the token. One request through any view moves the same rate-limit window, spends the same error budget, and counts against the same circuit. An endpoint that opens its circuit through one character's view is rejected, without a request, through the public view and every other view until the reset timeout passes.

The ETag cache is shared too, in two ways:

  • Public entries are shared across views. The server status fetched through one character's view is served to the public view, and to every other view, from the cache for as long as the spec TTL says it is fresh.
  • Authenticated entries are kept apart by identity. Two characters in one corporation ask the same corporation wallet URL and get answers scoped to their own roles, so each identity has its own entry. The key carries the character the token names, read from the SSO token's sub claim, so a token refresh keeps the character's entries and its ETags: the first request under the new token is a conditional request answered by a 304, not a full download. A token that names no character is keyed by a hash of the token instead. The token itself never appears in a key (SECURITY.md, Cache isolation).

The in-flight deduplicator draws the same line: identical public GETs from two views share one request; identical authenticated GETs share one only when they are for the same character.

When an application is done with a runtime, esi.shutdown() stops the cache's and circuit breaker's cleanup timers and drops the in-flight table, once for the runtime rather than once per view. The timers never keep a process alive on their own, so a script that forgets it still exits; the call is for an application that replaces a runtime and wants nothing of the old one left running. Calling it twice is harmless, and a view still answers afterwards.

Metadata, safe mode, diagnostics and your own middleware ​

esi.withMetadata() and esi.withSafeMode() return the same public and as(identity) views over the same clients, resolving { data, meta } or { ok, data | error } instead of the bare body (paginated operations still yield items). esi.diagnostics, and the shortcuts getCacheStats(), clearCache(), updateCacheConfig(), getCircuitBreakerStats() and resetCircuitBreaker(), answer for the whole runtime.

EsiOptions takes instances as well as configuration: cache (a CacheStore, such as a Redis-backed store, or an ICache), rateLimiter, circuitBreaker (which turns the breaker on), deduplicator (which turns deduplication on) and clock (the Clock port: cache age, retry back-off, rate-limit waits and the circuit reset timeout read it; the cleanup timers and request timeout stay on real time). shutdown() leaves a supplied cache, circuit breaker or deduplicator to its owner. A plain CacheStore cannot list its keys, so after a write the runtime invalidates only the entries written in this process. The custom clients take the same five options in EsiClientConfig and have the same diagnostics and views (USAGE.md), so an EsiClient and a runtime given one cache, rateLimiter and circuitBreaker share them.

Revoked tokens, scheduled refresh and concurrency ​

With a token manager, a character whose refresh token SSO has revoked throws TokenRevokedError from accessToken(), so the call fails before any request. Catch it with isTokenRevoked from @lgriffin/esi.ts/errors, remove the character and send them through login again.

tokens.refreshAll() on a schedule keeps every stored token fresh so that no request has to wait on SSO. It is unchanged by the runtime; a view picks up the refreshed token at its next request, because it asks the identity each time.

Concurrent requests through one view are safe. A refresh coalesces: two requests that hit 401 at the same time cause one SSO call, and both retry with its result. Concurrent requests through views for different characters are independent, apart from the shared budgets above.

Testing a view ​

A test hands the runtime a mock transport instead of the network: createMockTransport() from @lgriffin/esi.ts/testing answers requests from a table of routes and records what each view sent, so a test can assert which token went out and which requests were made. The README's quick start runs one; TESTING.md has the route options.

Moving from EsiTokenManager.createClient ​

tokens.createClient(id) builds a complete EsiClient per character, each with its own rate limiter, error budget and cache. It still works and behaves as before, but 11.0.0 marks it @deprecated, as it does EsiApiFactory's nine named create*Client methods (use EsiApiFactory.createClient(type) or EsiClientBuilder for those). Removing them needs a future major release, and none is scheduled. The rest of the custom clients are supported and stay the product (CLIENT-ARCHITECTURE.md); identity views for them are on that plan. To move an application to one runtime today:

BeforeAfter
const client = await tokens.createClient(id, { userAgent })const esi = createEsi({ userAgent }), once
client.wallet.getCharacterWallet(id)esi.as(tokens.identity(id)).character(id).wallet.get()
client.status.getStatus()esi.public.status.get()
client.market.streamRegionOrders(regionId)for await (const o of esi.public.market(regionId).orders.get())
client.shutdown() per characteresi.shutdown(), once

Two differences to know before switching:

  • The operations are the generated ones. They follow the ESI OpenAPI document exactly: resource-shaped names (character(id).wallet.get() rather than wallet.getCharacterWallet(id)), spec-typed responses, and every paginated operation returned as an AsyncIterable that follows the pages. They are not validated by the hand-written Zod schemas; PipelineTransport returns the body typed from the spec. Runtime validation for the generated operations is Phase 3 of ROADMAP.md.
  • A view is not an EsiClient. It has no withMetadata(), withSafeMode() or getCacheStats(), and shutdown() is on the runtime, not the view. Diagnostics for the runtime are on the roadmap with the rest of Phase 4.

examples/multi-character.ts shows several characters' wallets through one runtime, and examples/public-vs-authenticated.ts shows the type split with the @ts-expect-error line.

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