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.
- Numeric ids are typed as
stringin 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 asnumberthroughout. - There is no generic
/v2/building/*path — every building type has its own module slug (apple,shipyard,planetarycommand, ...), so the genericBuildingclass is only ever constructed with a concrete slug.planetarycommandgets its own subclass because it has 3 methods the generic class doesn't (view_plans,view_incoming_supply_chains,subsidize_pod_cooldown). shipyard/get_buildable'sbuildable.*.canis typedbooleanbut the server sends the IntBool convention (0/1) used pervasively elsewhere in this same API - including, since 1.1.0,building.canandpark.can_throw.reasonfields hold a[code, message]pair (sometimes a 3-element[code, message, detail]), the empty string, or the literal number0for "no reason". 1.4.0 madebuilding.downgrade.reasonan array to matchbuilding.upgrade.reason, but neither thearraynor thestringdeclaration covers the0case, so the schema validator still widens every node namedreasonto accept array/number/string/null.- Fields of any shape — arrays, objects, and plain strings — are sent as
nullinstead of[]/{}/""when empty/absent — a common Rails-ism the spec marks as nullable in exactly one place (stats/empire_rank'salliance_id/alliance_name), and even there the server sends a number rather than the declared["string", "null"]. - The
buildings_listcomponent wraps the id-keyed building map in a secondbuildingslevel. It's{buildings: {<id>: {...}}}but is$ref'd as thebuildingsproperty in 628 places, so the spec asks forresult.buildings.buildingswhere the server sendsresult.buildings. Affectsbody/get_buildingsand everybuild/upgrade/downgrade/demolish/repairresponse. New in 1.1.0. /v2/map/get_star_map's star items omit the spec-requiredbodiesfield entirely (rather than sending[]) when a star has no bodies./v2/empire/boost_*responses are the full{boosts}map, exactly likeview_boosts, not the{food_boost}/{ore_boost}/... single-field shape the spec declares for each./v2/stats/alliance_rankerrors on its own default sort. Called with nosort_bythe server raisesCan't locate object method "influence desc,population desc" via package Lacuna::DB::Result::Log::Alliance; every explicitsort_byvalue works. The spec documentssort_byas optional, so this client'sAllianceRankParamsrequires it.building.demolish's response is documented but unverified. The spec says{status, buildings}; nothing here demolishes a building to check, soDemolishResponsemirrors the spec on trust./v2/empire/change_passwordreturns 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/v2route specifically that's missing. Expected current backend state per CLAUDE.md, not a client bug;empire.test.tsasserts 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_templatesand everyinbox/*method - is wired up as of 1.2.0 and now has a real test./v2/entertainment/get_lottery_voting_optionstriggers 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./v2/empire/update_speciesis documented as requiring a session but must be called without one. It identifies the empire byempire_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.updateSpeciestherefore usescallWithoutSession./v2/empire/redefine_species_limitsis documented as not requiring a session but does - the limits are computed from the current empire. Itsreasonisnullrather than absent when a redefinition is allowed, which is why the schema validator now widensreasonnodes to acceptnullalongside array/number/string./v2/empire/set_status_messagereturns the status block itself ({empire, server}, exactly likeget_status), not the{status}wrapper the spec declares./v2/empire/authorize_sittersreturnssitters, notauths- it'sview_authorized_sitters' response plusrejected_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.)- The empire profile methods send fields the spec doesn't document, and
document fields they don't always send.
view_profileaddsskip_excavator_destroyed,skip_excavator_artifact,skip_excavator_replace_msganddont_replace_excavator;view_public_profileaddsallianceand omits the spec-requiredpublicon each medal (the spec reusesview_profile's medal schema, but every medal in a public profile is public by definition). mapstarinfluenceis spec-requirednumberbut arrivesnullfor stars outside any station's jurisdiction -get_star_map'sstars[].influenceandget_star/get_star_by_name/get_star_by_xy'sstar.influence. New in 1.4.0 (the field was added that release). The client types itnumber | null.map/view_lawslaw items still carry the misspelled keydescripition. 1.4.0 renamed it todescriptionand made it required in the spec, but the live server hasn't been updated. (parliament's ownview_lawsalready sendsdescription.)wasterecycling/wasteexchangerview'srecycleblock is spec-too-strict.seconds_remaining,water,energyandoreare only present while a recycling run is in progress, but the spec marks them required; andseconds_per_resourceis fractional (e.g.1.86) while 1.4.0 types itinteger(should benumber). The client types the four conditional fields optional andseconds_per_resourceasnumber.body/get_buildablediverges from its spec on both sides. Thetagparam is spec-optional but the/v2layer rejects the call without it (pass''for "no filter"), and the response omits the spec-required top-levelmax_items_in_build_queue, sendingbuild_queue: {max, current}instead.- Building-list rows omit the spec-required per-row
id. 1.4.0 added a requiredidto eachbuildings_list/buildings_resources_listentry, but the server still only carries the id as the map key.body.getBuildings,body.getBuildingsResourcesand theBuildingbuild/upgrade/downgrade/demolish/repairmethods splice the key back onto each row (util.spliceBuildingIds), so callers see a self-describing row and the schema check passes. - Status-block body lists carry an undocumented
type. Every entry ofget_status'sempire.bodies.colonies/mystations/ourstations/babies.*.planets(thebodies_listcomponent, new in 1.6.10) includes the body's image type (e.g.p35), which the spec doesn't declare. The client types it onBodiesList.
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