TypeScript client for accessing TLE Community.
TypeScript 100%
<1%

README.md

TLE Community Client #

TypeScript client for accessing TLE Community.

Spec Discrepancies #

This client's types are generated from @tlecommunity/api-spec (OpenAPI spec for the /v2 Rails API) via openapi-typescript (npm run generate:types → lib/types/schema.ts). Where the spec conflicts with this client's existing, tested behavior, the tested behavior wins. Differences found so far, for whoever owns the spec/backend. Everything below was re-verified against both the spec and the live server as of @tlecommunity/api-spec 1.4.0, with later releases checked entry-by-entry as they land; entries resolved by a release (and the accompanying backend work) have been removed. 1.4.0 normalised every id field to integer; the client already sent/typed ids as number, and fixNumbers coerces the ids the legacy server still stringifies on the wire, so that change was a no-op here.

  1. Numeric ids are typed as string in every request body schema (building_id, body_id, ship_id, spy_ids, etc. - 1,208 of them), but the server accepts (and this client sends) number. Kept as number throughout.
  2. There is no generic /v2/building/* path — every building type has its own module slug (apple, shipyard, planetarycommand, ...), so the generic Building class is only ever constructed with a concrete slug. planetarycommand gets its own subclass because it has 3 methods the generic class doesn't (view_plans, view_incoming_supply_chains, subsidize_pod_cooldown).
  3. shipyard/get_buildable's buildable.*.can is typed boolean but the server sends the IntBool convention (0/1) used pervasively elsewhere in this same API - including, since 1.1.0, building.can and park.can_throw.
  4. reason fields hold a [code, message] pair (sometimes a 3-element [code, message, detail]), the empty string, or the literal number 0 for "no reason". 1.4.0 made building.downgrade.reason an array to match building.upgrade.reason, but neither the array nor the string declaration covers the 0 case, so the schema validator still widens every node named reason to accept array/number/string/null.
  5. Fields of any shape — arrays, objects, and plain strings — are sent as null instead of []/{}/"" when empty/absent — a common Rails-ism the spec marks as nullable in exactly one place (stats/empire_rank's alliance_id/alliance_name), and even there the server sends a number rather than the declared ["string", "null"].
  6. The buildings_list component wraps the id-keyed building map in a second buildings level. It's {buildings: {<id>: {...}}} but is $ref'd as the buildings property in 628 places, so the spec asks for result.buildings.buildings where the server sends result.buildings. Affects body/get_buildings and every build/upgrade/downgrade/ demolish/repair response. New in 1.1.0.
  7. /v2/map/get_star_map's star items omit the spec-required bodies field entirely (rather than sending []) when a star has no bodies.
  8. /v2/empire/boost_* responses are the full {boosts} map, exactly like view_boosts, not the {food_boost}/{ore_boost}/... single-field shape the spec declares for each.
  9. /v2/stats/alliance_rank errors on its own default sort. Called with no sort_by the server raises Can't locate object method "influence desc,population desc" via package Lacuna::DB::Result::Log::Alliance; every explicit sort_by value works. The spec documents sort_by as optional, so this client's AllianceRankParams requires it.
  10. building.demolish's response is documented but unverified. The spec says {status, buildings}; nothing here demolishes a building to check, so DemolishResponse mirrors the spec on trust.
  11. /v2/empire/change_password returns error 1002 ("Method ... is not recognized by the v2 API's argument map") - the backend migration off legacy Perl hasn't wired this route up yet. The legacy JSON-RPC endpoint serves it fine, so it's the /v2 route specifically that's missing. Expected current backend state per CLAUDE.md, not a client bug; empire.test.ts asserts the 1002 outright, so it fails once the route lands. Every other endpoint this entry used to list - archaeology/get_ores_available_for_processing, trade/view_market, transporter/view_market, empire/get_invite_friend_url, empire/get_species_templates and every inbox/* method - is wired up as of 1.2.0 and now has a real test.
  12. /v2/entertainment/get_lottery_voting_options triggers a captcha challenge (error 1016). The client surfaces the challenge to subscribers (see "Error handling" below) along with a retry callback, but it ships no captcha solver, so the test still skips rather than fails.
  13. /v2/empire/update_species is documented as requiring a session but must be called without one. It identifies the empire by empire_id (there is no session to be had until the empire is founded), and the v2 layer prepends the session id to the positional argument list whenever one is sent, which shifts every argument along by one. updateSpecies therefore uses callWithoutSession.
  14. /v2/empire/redefine_species_limits is documented as not requiring a session but does - the limits are computed from the current empire. Its reason is null rather than absent when a redefinition is allowed, which is why the schema validator now widens reason nodes to accept null alongside array/number/string.
  15. /v2/empire/set_status_message returns the status block itself ({empire, server}, exactly like get_status), not the {status} wrapper the spec declares.
  16. /v2/empire/authorize_sitters returns sitters, not auths - it's view_authorized_sitters' response plus rejected_ids, and those rejected ids are the bare ids/names as passed, not the {id, reason} objects the spec declares. (Last verified before the endpoint started demanding a captcha, which the test suite can't solve.)
  17. The empire profile methods send fields the spec doesn't document, and document fields they don't always send. view_profile adds skip_excavator_destroyed, skip_excavator_artifact, skip_excavator_replace_msg and dont_replace_excavator; view_public_profile adds alliance and omits the spec-required public on each medal (the spec reuses view_profile's medal schema, but every medal in a public profile is public by definition).
  18. map star influence is spec-required number but arrives null for stars outside any station's jurisdiction - get_star_map's stars[].influence and get_star / get_star_by_name / get_star_by_xy's star.influence. New in 1.4.0 (the field was added that release). The client types it number | null.
  19. map/view_laws law items still carry the misspelled key descripition. 1.4.0 renamed it to description and made it required in the spec, but the live server hasn't been updated. (parliament's own view_laws already sends description.)
  20. wasterecycling / wasteexchanger view's recycle block is spec-too-strict. seconds_remaining, water, energy and ore are only present while a recycling run is in progress, but the spec marks them required; and seconds_per_resource is fractional (e.g. 1.86) while 1.4.0 types it integer (should be number). The client types the four conditional fields optional and seconds_per_resource as number.
  21. body/get_buildable diverges from its spec on both sides. The tag param is spec-optional but the /v2 layer rejects the call without it (pass '' for "no filter"), and the response omits the spec-required top-level max_items_in_build_queue, sending build_queue: {max, current} instead.
  22. Building-list rows omit the spec-required per-row id. 1.4.0 added a required id to each buildings_list / buildings_resources_list entry, but the server still only carries the id as the map key. body.getBuildings, body.getBuildingsResources and the Building build/upgrade/ downgrade/demolish/repair methods splice the key back onto each row (util.spliceBuildingIds), so callers see a self-describing row and the schema check passes.
  23. Status-block body lists carry an undocumented type. Every entry of get_status's empire.bodies.colonies / mystations / ourstations / babies.*.planets (the bodies_list component, new in 1.6.10) includes the body's image type (e.g. p35), which the spec doesn't declare. The client types it on BodiesList.

Most of #1, #3, #4 and #5 are handled automatically by the schema validator behind the tests (lib/__utils__/validate-schema.ts) rather than by one-off client code, since they're systemic issues with how the spec describes the wire format. The rest are allowlisted per-call via expectMatchesApiSchema's fourth argument, which fails the test if an allowlisted error stops occurring — so a fix upstream surfaces here rather than going unnoticed.

Error handling #

Calls never throw. Every endpoint method resolves to a ServerResponse: { result } on success, { error: { code, message, data } } on failure. The client itself logs nothing about errors - what an error means is the caller's decision.

Subscribing to responses #

lacuna.onResponse(handler) subscribes to every response the client receives, successful or not, and returns a function that unsubscribes:

const unsubscribe = lacuna.onResponse(({ request, response, attempt, retry }) => {
  console.log(request.module, request.method, response.error ?? 'ok');
});

The handler receives the request that produced the response, the resolved ServerResponse, the attempt number (0 on the original call), and a retry callback that re-issues the same request.

For an RPC limit error (1010, "slow down") or a captcha prompt (1016) the originating call waits for every handler to finish before returning, and a ServerResponse a handler returns replaces what the caller receives. So returning await retry() makes the caller see the retry's result instead of the error - which is what lets a long-running script survive hitting the server's per-minute request limit. Every other response is published without blocking, so the happy path pays no handler latency.

Handlers are called in the order they were subscribed, and every one of them is called even after another has already resolved the error - subscribers see all responses, including the attempts that were recovered from. Handlers after the one that recovered are given the resolved response, so a handler that keys off response.error (as the built-in one does) won't retry something that already succeeded.

A retry re-publishes its own response as a fresh event with attempt + 1, so a handler that retries on some condition is simply called again while that condition holds. retry() stops issuing requests after 10 attempts as a backstop against a handler that retries unconditionally.

Built-in RPC limit handler #

lacuna.enableRpcLimitHandler({ maxRetries: 5, baseDelayMs: 1000, maxDelayMs: 60000 });

Logs each occurrence and retries with a progressive backoff (1s, 2s, 4s, 8s, 16s by default, clamped at maxDelayMs) until the call succeeds or maxRetries is reached, at which point the error is returned unchanged. All options are optional; the values above are the defaults. Returns a function that turns the handler back off.

Logging goes through lacuna.log, whose default level (warn) prints to the console; lacuna.log.setLogLevel('error') silences it.

Captcha prompts are deliberately not covered - the library can't solve an image. They're published like any other blocking error, so a consumer can solve one with lacuna.captcha.fetch() / lacuna.captcha.solve() and then call retry().

License #

GNU General Public License v3.0