@fleetless/sdk 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1217 -0
- package/dist/index.cjs +3662 -0
- package/dist/index.d.cts +1681 -0
- package/dist/index.d.ts +1681 -0
- package/dist/index.js +3631 -0
- package/package.json +42 -0
package/README.md
ADDED
|
@@ -0,0 +1,1217 @@
|
|
|
1
|
+
# @fleetless/sdk
|
|
2
|
+
|
|
3
|
+
The official TypeScript SDK for Fleetless client apps (spec §14.3):
|
|
4
|
+
framework-agnostic, TypeScript-first, ESM+CJS. It runs unchanged in a
|
|
5
|
+
browser and in Node — nothing in it assumes either one exists.
|
|
6
|
+
|
|
7
|
+
W3 scope: **auth** (login, silent refresh, logout) and **datapoints** (get,
|
|
8
|
+
subscribe with automatic reconnect). W4 adds the **command surface** —
|
|
9
|
+
**actions** (invoke, cancel, subscribe to feedback/progress/result),
|
|
10
|
+
**services** (call) and **publishers** (publish) — all correlated by a
|
|
11
|
+
`request_id` over the same realtime channel. W5 adds **cameras** (snapshot
|
|
12
|
+
bytes + age, and a refcounted LiveKit live session) — plain REST, no
|
|
13
|
+
realtime channel involved. W6 adds **history** (recorded samples and
|
|
14
|
+
window-aggregated buckets for a `retention: true` datapoint) — also plain REST.
|
|
15
|
+
W6b adds addressing: a cancel can name the job it means, a camera live
|
|
16
|
+
session can be released on its own, and a call can carry its own patience —
|
|
17
|
+
see each section below for what that changes and what it does not. W6c adds
|
|
18
|
+
self-registration and account recovery — `auth.register` /
|
|
19
|
+
`auth.confirmRegistration`, `auth.changePassword`, `auth.requestPasswordReset` /
|
|
20
|
+
`confirmPasswordReset` — and surfaces `rate_limited` on every unauthenticated
|
|
21
|
+
route, which this SDK never retries automatically. W7a adds
|
|
22
|
+
`assets.prepareUrdfScene`: authenticated loading for **everything** three.js
|
|
23
|
+
fetches to render a robot, not only meshes — a `<texture>` in the URDF and an
|
|
24
|
+
image a `.dae` references internally, both unreachable through
|
|
25
|
+
`createMeshLoader`'s per-loader hook.
|
|
26
|
+
Presence arrives in a later wave, built on the same client core.
|
|
27
|
+
|
|
28
|
+
**A slug names a place a job may be running, not the job itself.** Two
|
|
29
|
+
`invoke`s on the same slug, one after another, are two different jobs that
|
|
30
|
+
happen to share an address — `subscribe` and the plain `cancel()` both work
|
|
31
|
+
*by slug* on purpose (§11.3: state is observed by slug), and that is a
|
|
32
|
+
feature, not an oversight. But it means a caller who means one *specific*
|
|
33
|
+
job — the one whose id they were handed — has to say so explicitly, which is
|
|
34
|
+
what job-id addressing (below) is for. Conflating the two is how a cancel
|
|
35
|
+
sent a moment too late ends up stopping someone else's job instead of doing
|
|
36
|
+
nothing.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
npm i @fleetless/sdk
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quick start
|
|
45
|
+
|
|
46
|
+
Five minutes from install to a live value, assuming you already have an app
|
|
47
|
+
identifier and an end user's credentials from the [Fleetless
|
|
48
|
+
Console](https://console.fleetless.dev) (or your own dev stack).
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { createClient } from '@fleetless/sdk'
|
|
52
|
+
|
|
53
|
+
const client = createClient({
|
|
54
|
+
apiUrl: 'https://api.fleetless.dev',
|
|
55
|
+
appIdentifier: 'fleet-ops', // the app's identifier, shown in the console
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
await client.auth.login('user@example.com', 'correct-horse-battery')
|
|
59
|
+
|
|
60
|
+
// One-shot read.
|
|
61
|
+
const battery = await client.datapoints.get('robot-id', 'battery-percentage')
|
|
62
|
+
console.log(battery.value, battery.timestamp_ms)
|
|
63
|
+
|
|
64
|
+
// Live updates. The current value arrives immediately, then every change.
|
|
65
|
+
const subscription = client.datapoints.subscribe('robot-id', 'battery-percentage', {
|
|
66
|
+
onEvent(event) {
|
|
67
|
+
console.log(event.value, event.timestamp_ms)
|
|
68
|
+
},
|
|
69
|
+
onError(error) {
|
|
70
|
+
console.error(error.code, error.message)
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// Later:
|
|
75
|
+
subscription.unsubscribe()
|
|
76
|
+
await client.auth.logout()
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Against a local dev stack (`./infra/dev.sh` from the umbrella repo), point
|
|
80
|
+
`apiUrl` at `http://localhost:8080` — `ws://localhost:8080/realtime` is
|
|
81
|
+
derived automatically (see [Configuration](#configuration)).
|
|
82
|
+
|
|
83
|
+
## Auth
|
|
84
|
+
|
|
85
|
+
Fleetless has two kinds of caller (spec §3.4), and the SDK supports both:
|
|
86
|
+
|
|
87
|
+
### End users — `tokenStore`
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
const client = createClient({ apiUrl, appIdentifier })
|
|
91
|
+
await client.auth.login(email, password) // -> stores a session
|
|
92
|
+
await client.auth.me() // -> ClientIdentity, without decoding a token yourself
|
|
93
|
+
const { revoked } = await client.auth.logout() // revokes server-side, then clears the local store
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- **Silent refresh is automatic.** A REST call that meets an expired access
|
|
97
|
+
token refreshes once and retries transparently. Concurrent calls made
|
|
98
|
+
while a refresh is already in flight share that one refresh instead of
|
|
99
|
+
each firing their own.
|
|
100
|
+
- **`logout()` never rejects.** It always clears the local store — a user
|
|
101
|
+
who presses "log out" ends up logged out locally regardless of the
|
|
102
|
+
network — but the server-side revoke can still fail (offline, a dead
|
|
103
|
+
cloud). `revoked: false` means the refresh family may still be alive
|
|
104
|
+
server-side even though this client has forgotten it; a kiosk or shared
|
|
105
|
+
workstation might want to warn the user or retry, everyone else can
|
|
106
|
+
ignore the return value.
|
|
107
|
+
- **What `logout()` does and does not invalidate.** It revokes the whole
|
|
108
|
+
refresh-token family server-side immediately (a stolen refresh token
|
|
109
|
+
stops working) and closes this client's live realtime connection, if it
|
|
110
|
+
has one. It does **not** invalidate the access token already handed out —
|
|
111
|
+
access-token checks are a signed JWT verified without a server-side
|
|
112
|
+
lookup, so logout has nothing on that token to flip. A token stolen
|
|
113
|
+
before logout keeps working on REST, and can still open a *new* realtime
|
|
114
|
+
connection, until it expires on its own — **at most 15 minutes.** That's
|
|
115
|
+
the same stateless-JWT tradeoff that lets a role change or a block take
|
|
116
|
+
effect on the very next request without forcing a fresh token — a
|
|
117
|
+
deliberate boundary, not a bug, but one a kiosk or shared workstation
|
|
118
|
+
needs to plan around.
|
|
119
|
+
- **Token storage is pluggable.** By default, sessions live in memory and
|
|
120
|
+
are lost on reload. Implement `TokenStore` to persist one — localStorage,
|
|
121
|
+
a cookie, a native keystore:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import type { TokenStore, StoredSession } from '@fleetless/sdk'
|
|
125
|
+
|
|
126
|
+
const localStorageTokenStore: TokenStore = {
|
|
127
|
+
load: () => {
|
|
128
|
+
const raw = localStorage.getItem('fleetless-session')
|
|
129
|
+
return raw ? (JSON.parse(raw) as StoredSession) : null
|
|
130
|
+
},
|
|
131
|
+
save: (session) => {
|
|
132
|
+
if (session) localStorage.setItem('fleetless-session', JSON.stringify(session))
|
|
133
|
+
else localStorage.removeItem('fleetless-session')
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const client = createClient({ apiUrl, appIdentifier, tokenStore: localStorageTokenStore })
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Hosted login (W7b)
|
|
141
|
+
|
|
142
|
+
A third way to authenticate an end user, alongside `login()` above: redirect
|
|
143
|
+
them to a Fleetless-hosted login page (spec §3.4, §17) instead of collecting
|
|
144
|
+
a password in your own app. The page can federate to your own IdP if you've
|
|
145
|
+
configured one for this app, and the flow is Authorization Code + PKCE
|
|
146
|
+
(OAuth 2.1 — no `implicit` grant, no `plain` challenge). It's also MCP's own
|
|
147
|
+
front door (§17), but nothing about using it from this SDK depends on that.
|
|
148
|
+
|
|
149
|
+
**Getting a `clientId`.** Register your app's own OAuth client in the
|
|
150
|
+
console, under **App Settings → OAuth clients** — name it and list the
|
|
151
|
+
redirect URIs it's allowed to use; the panel shows you the `client_id` you
|
|
152
|
+
need below. For scripting (a seed, a gate, CI) the same thing is
|
|
153
|
+
`POST /api/apps/:id/oauth-clients` (developer-authenticated:
|
|
154
|
+
`{ client_name, redirect_uris }`, answers with the `client_id`) — what the
|
|
155
|
+
console panel calls underneath. `clientId` is not your `appIdentifier` —
|
|
156
|
+
they're deliberately different identifiers.
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
// 1. Send the browser there. beginHostedLogin makes no network call — it
|
|
160
|
+
// just builds the URL and generates the values PKCE and CSRF protection
|
|
161
|
+
// need.
|
|
162
|
+
const request = await client.auth.beginHostedLogin({
|
|
163
|
+
clientId: 'oauth-client-id-from-the-console',
|
|
164
|
+
redirectUri: 'https://your-app.example.com/callback',
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
// Persist these two values before navigating away — a full page redirect
|
|
168
|
+
// does not preserve any in-memory state, so this SDK hands them back to you
|
|
169
|
+
// instead of holding them itself. sessionStorage survives the round trip
|
|
170
|
+
// and clears itself when the tab closes.
|
|
171
|
+
sessionStorage.setItem('fleetless_state', request.state)
|
|
172
|
+
sessionStorage.setItem('fleetless_code_verifier', request.codeVerifier)
|
|
173
|
+
window.location.href = request.url
|
|
174
|
+
|
|
175
|
+
// 2. On the redirectUri page, once the hosted flow has finished. CHECK FOR
|
|
176
|
+
// AN ERROR FIRST — the callback can legitimately carry ?error=... instead
|
|
177
|
+
// of a code (RFC 6749 §5.2), and reading params.get('code')! unconditionally
|
|
178
|
+
// throws on that assertion instead of showing the user why they aren't
|
|
179
|
+
// signed in. Not a bug: an account with no membership in this app, or a
|
|
180
|
+
// federated IdP that's down, both land here as an error, not a crash.
|
|
181
|
+
const params = new URLSearchParams(window.location.search)
|
|
182
|
+
|
|
183
|
+
// Read once, clear once, before anything else — these are single-use
|
|
184
|
+
// values for this one attempt regardless of what happens below, and doing
|
|
185
|
+
// this first (rather than after an await that might throw) means the
|
|
186
|
+
// cleanup can't be skipped.
|
|
187
|
+
const expectedState = sessionStorage.getItem('fleetless_state')
|
|
188
|
+
const codeVerifier = sessionStorage.getItem('fleetless_code_verifier')
|
|
189
|
+
sessionStorage.removeItem('fleetless_state')
|
|
190
|
+
sessionStorage.removeItem('fleetless_code_verifier')
|
|
191
|
+
|
|
192
|
+
if (params.has('error')) {
|
|
193
|
+
// params.get('fleetless_code') is set for five W7b-specific outcomes with
|
|
194
|
+
// an actionable meaning (e.g. identity_not_provisioned: "ask an app owner
|
|
195
|
+
// to invite you"; idp_unavailable: "try again shortly") — absent for a
|
|
196
|
+
// plain protocol-shaped refusal (access_denied, ...), where
|
|
197
|
+
// error_description is all there is. See docs/recipes/hosted-login/ for
|
|
198
|
+
// the full table and what to tell the user for each one.
|
|
199
|
+
handleHostedLoginError(params.get('error')!, params.get('error_description'), params.get('fleetless_code'))
|
|
200
|
+
} else if (!expectedState || !codeVerifier) {
|
|
201
|
+
// Nothing was persisted for THIS attempt — a callback landing in a
|
|
202
|
+
// different tab or window, a re-opened redirect URL, or a restored
|
|
203
|
+
// session all read back as null here, and none of them is an attack.
|
|
204
|
+
// completeHostedLogin tells this apart from a real mismatch itself
|
|
205
|
+
// (no_hosted_login_attempt vs state_mismatch) — mirrored here because
|
|
206
|
+
// there's no codeVerifier to even attempt the call with.
|
|
207
|
+
handleHostedLoginError('no_hosted_login_attempt', 'This login was not started in this browser tab.', null)
|
|
208
|
+
} else {
|
|
209
|
+
try {
|
|
210
|
+
await client.auth.completeHostedLogin({
|
|
211
|
+
code: params.get('code')!,
|
|
212
|
+
state: params.get('state')!,
|
|
213
|
+
expectedState,
|
|
214
|
+
codeVerifier,
|
|
215
|
+
clientId: 'oauth-client-id-from-the-console',
|
|
216
|
+
redirectUri: 'https://your-app.example.com/callback',
|
|
217
|
+
})
|
|
218
|
+
// From here on client.auth.me() / logout() / silent refresh behave
|
|
219
|
+
// exactly as they do after login() — completeHostedLogin stores the
|
|
220
|
+
// identical session shape, through the same tokenStore.
|
|
221
|
+
} catch (error) {
|
|
222
|
+
// completeHostedLogin's own refusals (state_mismatch; invalid_grant for
|
|
223
|
+
// an expired or already-used code) surface as a thrown FleetlessError
|
|
224
|
+
// here, not a redirect — the one point in this flow where a thrown
|
|
225
|
+
// error, not a query parameter, is how a refusal reaches your app.
|
|
226
|
+
const code = error instanceof Error && 'code' in error ? String(error.code) : 'unknown'
|
|
227
|
+
handleHostedLoginError(code, error instanceof Error ? error.message : String(error), null)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
- **A `fleetless_code`-less `error` is a protocol-shaped outcome** (e.g.
|
|
233
|
+
`access_denied` for "the user declined consent") with no Fleetless-specific
|
|
234
|
+
remedy beyond `error_description`. The five `fleetless_code` values,
|
|
235
|
+
what each means and what your app can tell the user, are documented in
|
|
236
|
+
[`docs/recipes/hosted-login/`](../docs/recipes/hosted-login/README.md) —
|
|
237
|
+
not duplicated here to avoid two tables drifting apart.
|
|
238
|
+
|
|
239
|
+
- **`sessionStorage.getItem(...)!` is the same tell as `params.get('code')!`
|
|
240
|
+
above, and this snippet had both, found by Momus-W7b's review the same
|
|
241
|
+
day the first one was fixed.** A non-null assertion is where a writer
|
|
242
|
+
knows a value can be absent and decides not to say so — reach for a
|
|
243
|
+
presence check (`if (!expectedState || !codeVerifier)` above) instead of
|
|
244
|
+
`!` anywhere a value crossed a boundary this code doesn't control
|
|
245
|
+
(a query string, `sessionStorage`, a thrown error's `.message`), not just
|
|
246
|
+
in the one place a reviewer already looked.
|
|
247
|
+
|
|
248
|
+
- **This SDK never touches `sessionStorage`, or any storage, for you.**
|
|
249
|
+
`beginHostedLogin` returns `state`/`codeVerifier` rather than holding them
|
|
250
|
+
itself, because the redirect back is a fresh page load — nothing this SDK
|
|
251
|
+
keeps in memory survives it. Persist them however fits your app; a popup
|
|
252
|
+
flow that never truly navigates away can just keep them in a variable
|
|
253
|
+
instead.
|
|
254
|
+
- **`completeHostedLogin` refuses before any network call** (`state_mismatch`)
|
|
255
|
+
if `state` doesn't match what `beginHostedLogin` returned for this
|
|
256
|
+
attempt — it will not complete a login your app did not itself start.
|
|
257
|
+
- **Errors from this exchange use OAuth's own vocabulary, not this SDK's
|
|
258
|
+
usual `code`/`message` pairs.** `completeHostedLogin` can throw a
|
|
259
|
+
`FleetlessError` whose `.code` is e.g. `invalid_grant` (an expired or
|
|
260
|
+
already-used `code`) straight from RFC 6749 §5.2 — the exchange happens
|
|
261
|
+
against `/oauth/token`, a standards-compliant endpoint any OAuth client
|
|
262
|
+
can drive, not this SDK's own API.
|
|
263
|
+
- **Never call `completeHostedLogin` a second time for the same redirect
|
|
264
|
+
while a first call is still in flight** — no timeout, framework retry,
|
|
265
|
+
or double-click guard should ever resubmit it (W7c; `completeHostedLogin`
|
|
266
|
+
itself makes exactly one request and has no retry of its own, so this is
|
|
267
|
+
entirely a call-site discipline). A second presentation of the same
|
|
268
|
+
authorization code revokes the whole refresh-token family server-side,
|
|
269
|
+
on purpose — a plain race looks identical to theft on the wire, and the
|
|
270
|
+
platform treats it as theft. What actually happens: one of the two
|
|
271
|
+
requests gets back `200` with a refresh token that is already dead, the
|
|
272
|
+
access token keeps working for the rest of its short TTL, and the
|
|
273
|
+
failure only surfaces at this session's first silent refresh — as
|
|
274
|
+
`token_revoked`, possibly minutes later, with nothing pointing back at
|
|
275
|
+
the retry that caused it. Guard the call site (an in-flight flag,
|
|
276
|
+
disabling the button) — this method cannot protect you from itself being
|
|
277
|
+
called twice.
|
|
278
|
+
|
|
279
|
+
### Registration and account recovery (W6c)
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
// Self-registration into this app's pool — if the app has it enabled for
|
|
283
|
+
// this address's domain. Sets the password now; does NOT log you in — see
|
|
284
|
+
// below for why. Returns { mail } so you can show "check your email".
|
|
285
|
+
await client.auth.register('user@example.com', 'correct-horse-battery')
|
|
286
|
+
await client.auth.confirmRegistration(tokenFromTheEmailedLink) // -> now logged in
|
|
287
|
+
|
|
288
|
+
// Changing your own password while logged in.
|
|
289
|
+
await client.auth.changePassword('current-password', 'new-correct-horse-battery')
|
|
290
|
+
|
|
291
|
+
// Forgot it entirely: request a link, then use the token it contains.
|
|
292
|
+
await client.auth.requestPasswordReset('user@example.com')
|
|
293
|
+
await client.auth.confirmPasswordReset(tokenFromTheEmailedLink, 'new-correct-horse-battery')
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
- **`register` does not log you in.** It sets the password and mails a
|
|
297
|
+
confirmation link; `confirmRegistration(token)` is the call that proves
|
|
298
|
+
you own the address and returns a session. This is deliberate, not a
|
|
299
|
+
missing convenience: a domain filter gates *which domains* may register,
|
|
300
|
+
never *whether the caller owns the address* — minting a session straight
|
|
301
|
+
out of `register` would let anybody who knows an allowed domain register
|
|
302
|
+
as somebody else at it and receive whatever role the app assigns, which on
|
|
303
|
+
this platform can mean permission to move a robot. **A deployment with no
|
|
304
|
+
SMTP configured cannot complete a self-registration at all** — unlike an
|
|
305
|
+
invitation, where a developer can hand the link over directly, there is no
|
|
306
|
+
other channel for the confirmation link. If you're testing against a
|
|
307
|
+
mail-less dev cloud, that is why nothing arrives.
|
|
308
|
+
- **`register` does not take a role.** The app's own self-registration
|
|
309
|
+
setting decides it (§3.2: a pool member gets exactly one role per app,
|
|
310
|
+
the app owner's to configure, not a self-registering stranger's to pick).
|
|
311
|
+
It fails the same way whether the app has self-registration turned off or
|
|
312
|
+
the address's domain isn't on the allowed list — **those two cases are
|
|
313
|
+
indistinguishable on purpose**, the same reasoning as the reset request
|
|
314
|
+
below. Don't build a UI that tries to tell them apart. And `register`
|
|
315
|
+
resolves identically for an address that is new and one that already has
|
|
316
|
+
an account — same account-enumeration reasoning as `requestPasswordReset`;
|
|
317
|
+
`mail` in the response describes this deployment's mail configuration, not
|
|
318
|
+
whether the address exists, so it's safe to read either way.
|
|
319
|
+
- **`confirmRegistration`'s token is single-use and expires**, and both
|
|
320
|
+
cases answer `token_spent` — same as `confirmPasswordReset` below.
|
|
321
|
+
- **`changePassword` re-issues your session rather than leaving it alone.**
|
|
322
|
+
It returns fresh tokens, which this method stores for you exactly like
|
|
323
|
+
`login` does — you don't need to do anything with the return value. Every
|
|
324
|
+
*other* session of this identity is revoked; only the one that made this
|
|
325
|
+
call keeps working, and it keeps working because it was just handed new
|
|
326
|
+
credentials, not because the old ones were spared. Make sure your UI
|
|
327
|
+
surfaces the "your other sessions are about to end" consequence *before*
|
|
328
|
+
the user confirms, not after they get a "why was I logged out?" support
|
|
329
|
+
ticket.
|
|
330
|
+
- **`requestPasswordReset` answers the same way for a known and an unknown
|
|
331
|
+
address, always.** This is not a bug and not something you can detect or
|
|
332
|
+
work around: it is the one place in this SDK where telling the two apart
|
|
333
|
+
would let a stranger enumerate accounts by their email address, so the
|
|
334
|
+
server (and this method) simply never says. Do not add a "no such
|
|
335
|
+
account" branch to your UI here — there is nothing in the response for
|
|
336
|
+
it to key off, on purpose. Show a generic "check your email" message
|
|
337
|
+
regardless of the outcome.
|
|
338
|
+
- **`confirmPasswordReset`'s token is single-use and expires**, and both
|
|
339
|
+
cases answer `token_spent` — deliberately one code for both, so a caller
|
|
340
|
+
cannot tell "already used" from "too late" and learn anything about the
|
|
341
|
+
token's history. The recovery is the same either way: request a new link.
|
|
342
|
+
Succeeding revokes every other session, exactly like `changePassword`.
|
|
343
|
+
- **`register` and `requestPasswordReset` are both behind the rate limiter**
|
|
344
|
+
(W6c) that guards every unauthenticated route — see
|
|
345
|
+
[Errors](#errors) below for how `rate_limited` surfaces and why this SDK
|
|
346
|
+
never retries it for you.
|
|
347
|
+
- **`register`, `confirmRegistration` and `changePassword` are not available
|
|
348
|
+
on a `serverKey` client** — they throw immediately, same as
|
|
349
|
+
`login`/`logout`. A server key has no "own password" to change; a
|
|
350
|
+
successful `confirmRegistration`/`changePassword` returns tokens with
|
|
351
|
+
nowhere to store them (no token store on a serverKey client); and
|
|
352
|
+
self-registration is specifically the act of a *user* signing themselves
|
|
353
|
+
up — a backend creating pool members programmatically is a different act
|
|
354
|
+
with a different audit trail. `requestPasswordReset`/`confirmPasswordReset`
|
|
355
|
+
work on either kind of client — neither touches a session or a token
|
|
356
|
+
store, so there is nothing structural to forbid.
|
|
357
|
+
- **These are the end-user routes (`/api/client/password/...`).** The
|
|
358
|
+
developer-facing console has its own equivalents under `/api/auth/`; this
|
|
359
|
+
SDK never speaks to that identity space.
|
|
360
|
+
|
|
361
|
+
### Server-side callers — `serverKey`
|
|
362
|
+
|
|
363
|
+
For your own backend, automation, or CI — full app rights, no login step:
|
|
364
|
+
|
|
365
|
+
```ts
|
|
366
|
+
const client = createClient({ apiUrl, appIdentifier, serverKey: 'flk_...' })
|
|
367
|
+
await client.datapoints.get('robot-id', 'battery-percentage') // works immediately
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
A `serverKey` client has no session to manage: `auth.login`/`auth.logout`
|
|
371
|
+
throw if called. A rejected server key does not refresh — there is nothing
|
|
372
|
+
to refresh into — it just fails.
|
|
373
|
+
|
|
374
|
+
`tokenStore` and `serverKey` are mutually exclusive; pick one per client.
|
|
375
|
+
|
|
376
|
+
## Datapoints
|
|
377
|
+
|
|
378
|
+
```ts
|
|
379
|
+
client.datapoints.get(robotId, slug) // -> Promise<DatapointValue>
|
|
380
|
+
client.datapoints.subscribe(robotId, slug, { onEvent, onError? }) // -> { unsubscribe() }
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
- **Your session survives a laptop lid being closed.** If a client reconnects
|
|
384
|
+
after being offline longer than the access token's lifetime, the
|
|
385
|
+
reconnect's authentication is refused as expired — the SDK refreshes
|
|
386
|
+
silently and retries the connection once before giving up, the same
|
|
387
|
+
courtesy a REST call gets. You do not need to detect this or re-login by
|
|
388
|
+
hand.
|
|
389
|
+
- `subscribe` shares one WebSocket connection across every subscription on a
|
|
390
|
+
client. It reconnects with exponential backoff on a network drop, and
|
|
391
|
+
every active subscription is re-established automatically once the
|
|
392
|
+
connection (and, if the access token has since expired, a silent refresh)
|
|
393
|
+
comes back — you do not need to resubscribe yourself.
|
|
394
|
+
- A subscription refused by the server (role does not grant the slug, an
|
|
395
|
+
unknown slug, ...) calls `onError` once with a `FleetlessError`; it never
|
|
396
|
+
calls `onEvent`. Note that Fleetless deliberately answers `forbidden` both
|
|
397
|
+
for "your role does not grant this" and for "no such slug" — the SDK does
|
|
398
|
+
not invent a distinction the API does not make.
|
|
399
|
+
|
|
400
|
+
## Actions
|
|
401
|
+
|
|
402
|
+
```ts
|
|
403
|
+
const job = await client.actions.invoke(robotId, 'dock', { 'target_pose.position.x': 1.0 }) // -> Job, id is informative
|
|
404
|
+
const sub = client.actions.subscribe(robotId, 'dock', {
|
|
405
|
+
onJob(event) {
|
|
406
|
+
console.log(event.job.state, event.feedback, event.progress) // feedback/progress arrive as the action reports them
|
|
407
|
+
if (event.job.state === 'succeeded') console.log(event.job.result)
|
|
408
|
+
},
|
|
409
|
+
onError(error) {
|
|
410
|
+
console.error(error.code, error.message)
|
|
411
|
+
},
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
// The operator's stop button: whatever is running on this slug, stop it.
|
|
415
|
+
const stopped = await client.actions.cancel(robotId, 'dock')
|
|
416
|
+
|
|
417
|
+
// Cancel the job you invoked, specifically — not whatever else might be
|
|
418
|
+
// running there by the time this arrives:
|
|
419
|
+
const cancelled = await client.actions.cancel(robotId, 'dock', job.id)
|
|
420
|
+
// cancelled is the Job that was actually stopped, or null if nothing
|
|
421
|
+
// matched. Naming a job.id that is no longer running rejects `not_found` —
|
|
422
|
+
// it never falls back to stopping whatever *is* running instead, because
|
|
423
|
+
// naming an id already said which one you meant.
|
|
424
|
+
|
|
425
|
+
sub.unsubscribe()
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
> **Breaking in this release (W6b):** `actions.cancel`'s third argument
|
|
429
|
+
> changed. It used to be `cancel(robotId, slug, options?)`; it is now
|
|
430
|
+
> `cancel(robotId, slug, jobId?, options?)`, and `options` moved to the
|
|
431
|
+
> **fourth** argument. If your code passes an options object third —
|
|
432
|
+
> `cancel(robotId, slug, { timeoutMs: 5000 })` — that object now lands in
|
|
433
|
+
> the `jobId` position instead. In TypeScript this is a compile error. In
|
|
434
|
+
> plain JavaScript it throws `invalid_option` immediately, before anything
|
|
435
|
+
> is sent, naming the mistake in the message — it does not silently send a
|
|
436
|
+
> malformed request. Update the call to `cancel(robotId, slug, undefined,
|
|
437
|
+
> { timeoutMs: 5000 })`, or to `cancel(robotId, slug, job.id)` if you meant
|
|
438
|
+
> to address a specific job all along.
|
|
439
|
+
|
|
440
|
+
- **`invoke` resolves as soon as the job exists** — the returned job id is
|
|
441
|
+
informative (spec §11.3), not the result. Feedback, progress and the
|
|
442
|
+
eventual result arrive separately, over `subscribe`.
|
|
443
|
+
- **State is observed by slug, not by job id.** `subscribe(robotId, slug,
|
|
444
|
+
...)` shows whatever job is currently running there — including a job
|
|
445
|
+
invoked by a different caller — which is also what makes late delivery
|
|
446
|
+
after a reconnect and two observers watching the same job both work
|
|
447
|
+
without any special-casing on your part. This does **not** change with
|
|
448
|
+
job-id cancel below: a slug is still a place a job may be running, not the
|
|
449
|
+
job itself, and `subscribe` still watches the place.
|
|
450
|
+
- **One job runs per action slug.** A second `invoke` while one is already
|
|
451
|
+
running rejects `busy`, and `error.details.running` is the `Job` that is
|
|
452
|
+
already in flight — enough to show the user what is happening, or to
|
|
453
|
+
`subscribe` and wait for it, without guessing.
|
|
454
|
+
- **`cancel` addresses either a slug or a specific job (W6b).**
|
|
455
|
+
`cancel(robotId, slug)` is the blunt operator form: stop whatever is
|
|
456
|
+
running there, whoever started it. `cancel(robotId, slug, job.id)` is a
|
|
457
|
+
caller cancelling the specific job it invoked — the difference matters
|
|
458
|
+
because a plain slug-cancel that arrives a moment after its own job ended
|
|
459
|
+
will happily stop whoever's job runs next on that slug, and a caller who
|
|
460
|
+
holds a `job.id` usually wants to rule that out. There is no automatic
|
|
461
|
+
fallback from the id form to the slug form on `not_found`: if you want
|
|
462
|
+
"stop whatever's there" behaviour, ask for it explicitly by omitting the id,
|
|
463
|
+
rather than relying on a refused id to degrade into it.
|
|
464
|
+
- `subscribe` is reference-counted per `(robotId, slug)`, exactly like
|
|
465
|
+
`datapoints.subscribe`: two callers on the same key share one wire
|
|
466
|
+
subscription, and unsubscribing one never affects the other.
|
|
467
|
+
- **`params` is flat, keyed by the exact `parameterSpec.name` the developer
|
|
468
|
+
declared** — `'target_pose.position.x'`, not `{ target_pose: { position: {
|
|
469
|
+
x: 1.0 } } }`. Do not nest it yourself: a field a spec did not declare
|
|
470
|
+
cannot be set at all (that is what §4.4 parameter checking *is*), and a
|
|
471
|
+
`parameter_invalid` refusal names the `field` it rejected using this same
|
|
472
|
+
flat key — nesting it client-side would make the refusal point at a path
|
|
473
|
+
you never typed.
|
|
474
|
+
- **`options.patienceMs` bounds goal *acceptance* only (W6b)**, not the whole
|
|
475
|
+
job: `client.actions.invoke(robotId, 'dock', params, { patienceMs: 5_000 })`
|
|
476
|
+
makes the platform give up if no action server accepts the goal within 5s
|
|
477
|
+
(`goal_timeout`), but once accepted the job runs as long as it runs — you
|
|
478
|
+
observe it via `subscribe`, you never await it. Omit it and you get the
|
|
479
|
+
platform default (currently 15s), unchanged from before this option
|
|
480
|
+
existed. Outside the platform's `[1s, 120s]` bounds the call is refused
|
|
481
|
+
with `validation_error` — this SDK does not clamp your number to fit, and
|
|
482
|
+
does not retry with a different one on your behalf. The floor exists
|
|
483
|
+
because a too-short patience does not just fail politely: it made the
|
|
484
|
+
bridge report `goal_timeout` and then issue a corrective cancel against a
|
|
485
|
+
goal an action server accepted a moment later, so an unreachable deadline
|
|
486
|
+
was causing a real cancellation on the robot, repeatably.
|
|
487
|
+
|
|
488
|
+
## Services
|
|
489
|
+
|
|
490
|
+
```ts
|
|
491
|
+
const result = await client.services.call(robotId, 'get-status', { verbose: true })
|
|
492
|
+
|
|
493
|
+
// patienceMs bounds the *whole* wait here — unlike an action, a service has
|
|
494
|
+
// no further state to observe once the platform gives up on it:
|
|
495
|
+
const status = await client.services.call(robotId, 'get-status', {}, { patienceMs: 5_000 })
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
Same flat-key convention for `params` as `actions.invoke`.
|
|
499
|
+
|
|
500
|
+
A service call is a job underneath — the same disconnect survival as an
|
|
501
|
+
action (spec §6.1: "an action goal or a service call") — but that is
|
|
502
|
+
deliberately invisible here: `call` waits for it internally and resolves
|
|
503
|
+
with the result directly, or rejects with the typed error it failed with.
|
|
504
|
+
There is nothing to subscribe to for a service: no feedback, no progress,
|
|
505
|
+
no cancel.
|
|
506
|
+
|
|
507
|
+
`options.patienceMs` (W6b) is the platform's own patience for this call —
|
|
508
|
+
distinct from `options.timeoutMs`, which is this SDK's own local bound on the
|
|
509
|
+
**whole call** (default 30s): the ack that a job was created, plus however
|
|
510
|
+
much of that budget is left for the job to then reach a terminal state — not
|
|
511
|
+
two separate 30-second windows back to back (D10). Setting `timeoutMs: 5000`
|
|
512
|
+
bounds total latency at ~5s, once, not ~5s-then-another-5s if the ack happens
|
|
513
|
+
to arrive late. `patienceMs` and `timeoutMs` still bound different things and
|
|
514
|
+
neither derives from the other when both are set: a short `timeoutMs` with a
|
|
515
|
+
long `patienceMs` gives up locally while the platform keeps trying; a short
|
|
516
|
+
`patienceMs` with a long `timeoutMs` means the platform's own refusal
|
|
517
|
+
(`goal_timeout` or similar) usually arrives well before your local timeout
|
|
518
|
+
would have fired anyway.
|
|
519
|
+
|
|
520
|
+
## Publishers
|
|
521
|
+
|
|
522
|
+
```ts
|
|
523
|
+
await client.publishers.publish(robotId, 'cmd-vel', { 'linear.x': 0.2, 'angular.z': 0 })
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
`message` follows the same flat, `parameterSpec.name`-keyed convention as
|
|
527
|
+
`actions.invoke`'s `params` — **not** a nested ROS message tree. (The
|
|
528
|
+
publisher's *failsafe* message, by contrast, is a whole nested message body —
|
|
529
|
+
but that one is authored once by the developer in the console, never sent by
|
|
530
|
+
a caller.)
|
|
531
|
+
|
|
532
|
+
**Publishing is a plain method call — no more, no less.** There is
|
|
533
|
+
deliberately no deadman switch, rate governor, or "takt" helper in this SDK
|
|
534
|
+
(spec §14.3): the safety pattern for *how often* and *when* to publish is
|
|
535
|
+
your app's responsibility, not something this library does for you. Believing
|
|
536
|
+
the SDK protects you here is more dangerous than knowing it does not.
|
|
537
|
+
|
|
538
|
+
What the platform *does* guarantee (spec §6.4, §7.2) is the bridge's own
|
|
539
|
+
failsafe: the developer configures a `timeout_ms` and a failsafe message when
|
|
540
|
+
exposing the publisher, and if messages stop arriving for any reason —
|
|
541
|
+
including your process crashing — **the bridge itself** publishes that
|
|
542
|
+
failsafe message on the topic. That is the platform's safety primitive, not
|
|
543
|
+
this SDK; build your app as if it is the only thing standing between a
|
|
544
|
+
dropped connection and a robot that keeps moving, because for the interval up
|
|
545
|
+
to `timeout_ms` it is.
|
|
546
|
+
|
|
547
|
+
Publishing is also implicitly exclusive: whoever last published holds the
|
|
548
|
+
publisher until they have been quiet for the configured `quiet_timeout_ms`.
|
|
549
|
+
A `publish` call while someone else holds it rejects `publisher_busy`.
|
|
550
|
+
|
|
551
|
+
## Jobs
|
|
552
|
+
|
|
553
|
+
```ts
|
|
554
|
+
const jobs = await client.jobs.list(robotId) // -> Job[]
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
Every job the platform currently believes this robot has — at most one per
|
|
558
|
+
slug, the same shape `actions.subscribe`/`services.call` observe, just
|
|
559
|
+
gathered across the whole robot instead of one named slug at a time (W7,
|
|
560
|
+
register row 2k).
|
|
561
|
+
|
|
562
|
+
`actions`/`services` require knowing the slug first, which is not always
|
|
563
|
+
true: a bridge reconnecting can name a job the cloud only *adopted*, and a
|
|
564
|
+
configuration change can leave a job on a slug the published document no
|
|
565
|
+
longer contains. Both are jobs no slug can name up front — `jobs.list` is how
|
|
566
|
+
you find them anyway, e.g. to reconcile local UI state against "what is this
|
|
567
|
+
robot actually doing" on load, rather than polling every slug you happen to
|
|
568
|
+
know about.
|
|
569
|
+
|
|
570
|
+
Grant-filtered like everything else: an end user or server key sees only
|
|
571
|
+
jobs on slugs their role grants; a developer session sees every job on the
|
|
572
|
+
robot. `[]` means the robot is doing nothing, not "we did not look" — the
|
|
573
|
+
array is never `null`.
|
|
574
|
+
|
|
575
|
+
**Ordered newest first by `started_at`, `job.seq` as the tiebreaker for two
|
|
576
|
+
jobs minted in the same millisecond** — but for an adopted job, `started_at`
|
|
577
|
+
is adoption time, not when it actually started on the robot, so read this as
|
|
578
|
+
newest-*known*-first: a job the robot has been running for an hour can sit
|
|
579
|
+
above one started a minute ago if the hour-long one was only just adopted.
|
|
580
|
+
|
|
581
|
+
## Cameras
|
|
582
|
+
|
|
583
|
+
```ts
|
|
584
|
+
const cameras = await client.cameras.list(robotId) // -> CameraDescriptor[]
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
### Snapshot — always on, independent of live
|
|
588
|
+
|
|
589
|
+
```ts
|
|
590
|
+
const snap = await client.cameras.snapshot(robotId, 'front')
|
|
591
|
+
if (snap.image === null) {
|
|
592
|
+
// Nothing captured yet (e.g. right after a fresh publish) — a state, not
|
|
593
|
+
// an error. Don't treat this as a failure.
|
|
594
|
+
} else {
|
|
595
|
+
console.log(`${snap.mime}, ${snap.age_ms}ms old`) // an <img> src, a file, whatever you need
|
|
596
|
+
}
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
- **`age_ms` is the cloud's own figure — always trust it, never recompute
|
|
600
|
+
it** as `Date.now() - snap.timestamp_ms`. The cloud is the one clock that
|
|
601
|
+
knows how long it has actually held the frame; your own clock skew would
|
|
602
|
+
quietly turn "how old is this?" into a lie.
|
|
603
|
+
- A snapshot keeps updating on the camera's configured interval **whether or
|
|
604
|
+
not anyone is watching live** (spec §10), and keeps being served — with a
|
|
605
|
+
growing `age_ms` — even while the robot's bridge is offline. It never goes
|
|
606
|
+
stale silently: you always know exactly how old what you have is.
|
|
607
|
+
- Polling for "did a newer frame arrive?" without re-downloading the image
|
|
608
|
+
every time: use `client.cameras.snapshotMeta(robotId, slug)`, which returns
|
|
609
|
+
everything `snapshot` does except `image`.
|
|
610
|
+
|
|
611
|
+
### Live — on demand, refcounted, and it costs you a release
|
|
612
|
+
|
|
613
|
+
```ts
|
|
614
|
+
import { Room } from 'livekit-client' // your choice of LiveKit client SDK
|
|
615
|
+
|
|
616
|
+
const room = new Room()
|
|
617
|
+
const session = await client.cameras.live(robotId, 'front')
|
|
618
|
+
await room.connect(session.url, session.token)
|
|
619
|
+
// ... attach room's video track to your <video> element, however you render it
|
|
620
|
+
|
|
621
|
+
// When you're done watching — **both calls, together**:
|
|
622
|
+
await room.disconnect()
|
|
623
|
+
await session.release()
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
- **The first `live()` call on a camera starts the robot publishing; the
|
|
627
|
+
last viewer leaving stops it** (spec §10) — refcounted in the cloud, not
|
|
628
|
+
in this SDK.
|
|
629
|
+
- **Each `session` releases only its own hold (W6b).** `session.session_id`
|
|
630
|
+
identifies *this* call's hold, and `release()` sends exactly that id.
|
|
631
|
+
Before W6b a `DELETE` carried no id and released **every** hold your
|
|
632
|
+
logged-in identity had on that camera — so two tabs open on the same
|
|
633
|
+
stream were one hold as far as the server could tell, and closing either
|
|
634
|
+
tab stopped the robot for both. The survivor kept rendering a frozen frame
|
|
635
|
+
rather than an ended session, because a LiveKit token is checked when a
|
|
636
|
+
`Room` joins and never again — see `expires_at` below. Two independent
|
|
637
|
+
`live()` calls (two tabs, two component instances, whatever your app's
|
|
638
|
+
shape is) now get two independent sessions, and releasing one never
|
|
639
|
+
touches the other.
|
|
640
|
+
- **`session.release()` alone does not stop the stream.** The cloud treats
|
|
641
|
+
LiveKit room *participation* as the source of truth for who is still
|
|
642
|
+
watching, not the `DELETE` this method sends — a browser tab that
|
|
643
|
+
crashes or is force-closed cannot be relied on to tell the cloud
|
|
644
|
+
anything, but LiveKit's own server notices a participant leaving without
|
|
645
|
+
needing its cooperation. `release()` is a courteous fast path that speeds
|
|
646
|
+
the stream stopping up; disconnecting your `Room` is what actually makes
|
|
647
|
+
it stop. **Always call both together**, as in the example above — a
|
|
648
|
+
cleanup that only releases while the `Room` stays connected leaks a
|
|
649
|
+
robot streaming to nobody.
|
|
650
|
+
- The same pairing applies to a framework's unmount hook:
|
|
651
|
+
|
|
652
|
+
```ts
|
|
653
|
+
import type { CameraLiveSession } from '@fleetless/sdk'
|
|
654
|
+
|
|
655
|
+
useEffect(() => {
|
|
656
|
+
let cancelled = false
|
|
657
|
+
let session: CameraLiveSession | undefined
|
|
658
|
+
const room = new Room()
|
|
659
|
+
|
|
660
|
+
client.cameras.live(robotId, 'front').then(async (s) => {
|
|
661
|
+
if (cancelled) {
|
|
662
|
+
// Unmounted before the request resolved — release immediately,
|
|
663
|
+
// there is no UI left to show video in.
|
|
664
|
+
void s.release()
|
|
665
|
+
return
|
|
666
|
+
}
|
|
667
|
+
session = s
|
|
668
|
+
await room.connect(s.url, s.token)
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
return () => {
|
|
672
|
+
cancelled = true
|
|
673
|
+
void room.disconnect()
|
|
674
|
+
void session?.release()
|
|
675
|
+
}
|
|
676
|
+
}, [robotId])
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
- **`session.expires_at` is a join deadline, not a session backstop.**
|
|
680
|
+
LiveKit checks a token when a `Room` connects, not while it stays
|
|
681
|
+
connected — so a `Room` that joined before `expires_at` keeps streaming
|
|
682
|
+
right past that timestamp, untouched. It only bounds how long an *unused*
|
|
683
|
+
token sits around; it does **not** bound a crashed process or a `kill -9`
|
|
684
|
+
after joining. What actually ends an already-joined session is the pair
|
|
685
|
+
of calls above, the cloud noticing (via its own reconciliation) that this
|
|
686
|
+
viewer's room participation ended, or revocation kicking the participant.
|
|
687
|
+
Do not design around this field as if it protected you from a forgotten
|
|
688
|
+
cleanup.
|
|
689
|
+
- `release()` is safe to call more than once and never rejects — it is a
|
|
690
|
+
courtesy notification, not the thing that actually stops the stream (see
|
|
691
|
+
above), so there is nothing more useful a caller could do with a failed
|
|
692
|
+
one. That also makes it safe to use directly as a cleanup callback
|
|
693
|
+
without wrapping it in a `try`/`catch`.
|
|
694
|
+
- **No video widget, no teleop-style helper here** (spec §14.3 keeps this
|
|
695
|
+
SDK thin): `live()` hands back exactly what a LiveKit client needs
|
|
696
|
+
(`url`, `token`) and stops — rendering the video, choosing a UI library,
|
|
697
|
+
and reconnect/backoff for the *media* connection are yours, the same way
|
|
698
|
+
`publishers.publish` hands you a plain method call with no rate governor
|
|
699
|
+
built in.
|
|
700
|
+
|
|
701
|
+
## Assets
|
|
702
|
+
|
|
703
|
+
```ts
|
|
704
|
+
const { assets, urdf, urdf_available } = await client.assets.list(robotId)
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
The asset store (spec §4.6): a robot's URDF and the meshes it references,
|
|
708
|
+
each addressed by uuid, unmodifiable, and reachable only with the same
|
|
709
|
+
`Authorization` header as everything else — no signed URL, no token in the
|
|
710
|
+
query string. Syncing a URDF from the connected bridge is a console action,
|
|
711
|
+
Owner-tier, and out of scope for this SDK on purpose (§4.6 says so
|
|
712
|
+
explicitly) — this surface is for *reading* what has already been synced.
|
|
713
|
+
|
|
714
|
+
`urdf.missing` names the `package://` URIs the sync could not resolve. Show
|
|
715
|
+
the URIs, not just the count — "2 Meshes fehlen" sends a developer looking
|
|
716
|
+
through a workspace by hand; the URIs are what they can act on.
|
|
717
|
+
`urdf_available` is `true`/`false` when a bridge is connected and reporting,
|
|
718
|
+
`null` when none is — "no robot is online to ask" and "the robot has no
|
|
719
|
+
URDF" are different facts a caller needs to tell apart.
|
|
720
|
+
|
|
721
|
+
```ts
|
|
722
|
+
const { body, mime } = await client.assets.get(robotId, assetId)
|
|
723
|
+
const xml = await client.assets.urdf(robotId) // -> string, ready for URDFLoader.parse()
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
`get` answers one asset's raw bytes — a mesh, or anything else by id, the
|
|
727
|
+
same way `createMeshLoader` reaches one internally. `urdf` is a special case
|
|
728
|
+
of the same read: XML text with every `package://` mesh URI already
|
|
729
|
+
rewritten to an absolute Fleetless asset URL, decoded because every consumer
|
|
730
|
+
needs it as a string for `URDFLoader.parse(xml)`.
|
|
731
|
+
|
|
732
|
+
Neither absorbs a "nothing yet" state the way `cameras.snapshot` absorbs
|
|
733
|
+
`no_snapshot_yet` — a missing or too-large asset is a real failure here, not
|
|
734
|
+
a state a renderer should quietly treat as empty, so both throw a
|
|
735
|
+
`FleetlessError` (`asset_missing`, `asset_too_large`, `forbidden`) like
|
|
736
|
+
every other route.
|
|
737
|
+
|
|
738
|
+
### Rendering a textured robot — `prepareUrdfScene`
|
|
739
|
+
|
|
740
|
+
`createMeshLoader` (below) only ever solved meshes, because `loadMeshCb` is
|
|
741
|
+
the one override hook `urdf-loader` has — and three.js's `TextureLoader` has
|
|
742
|
+
no equivalent hook at all. So a `<material><texture>` in the URDF, and an
|
|
743
|
+
image a `.dae` references *internally* (`<init_from>textures/skin.png`,
|
|
744
|
+
resolved by the renderer against wherever it loaded the `.dae` from), were
|
|
745
|
+
both unreachable: rewritten to a real asset URL, fetched by three.js with no
|
|
746
|
+
credential, and refused.
|
|
747
|
+
|
|
748
|
+
`prepareUrdfScene` covers all three cases — mesh, URDF-level texture,
|
|
749
|
+
`.dae`-internal texture — with one mechanism: three.js's
|
|
750
|
+
`LoadingManager.setURLModifier`. Every load the manager oversees, including
|
|
751
|
+
the images `ColladaLoader` fetches on its own account, is routed through it
|
|
752
|
+
first.
|
|
753
|
+
|
|
754
|
+
```ts
|
|
755
|
+
import URDFLoader from 'urdf-loader'
|
|
756
|
+
import * as THREE from 'three'
|
|
757
|
+
|
|
758
|
+
const manager = new THREE.LoadingManager()
|
|
759
|
+
const { urdfText, missing, dispose } = await client.assets.prepareUrdfScene(robotId, manager)
|
|
760
|
+
|
|
761
|
+
if (missing.length > 0) {
|
|
762
|
+
// Same strings assets.list()'s urdf.missing reports — decide whether to
|
|
763
|
+
// warn, block, or just render what resolved. Not thrown.
|
|
764
|
+
console.warn(`This robot will render incompletely — unresolved: ${missing.join(', ')}`)
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const loader = new URDFLoader(manager)
|
|
768
|
+
const robot = loader.parse(urdfText)
|
|
769
|
+
scene.add(robot)
|
|
770
|
+
|
|
771
|
+
// Once the scene has finished loading (or on unmount):
|
|
772
|
+
dispose()
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
What it does: fetches the robot's asset list, fetches every mesh and texture
|
|
776
|
+
asset **with the `Authorization` header** into a `Blob`, maps each asset's
|
|
777
|
+
name to a `blob:` URL, and installs `manager.setURLModifier` to resolve
|
|
778
|
+
against that map.
|
|
779
|
+
|
|
780
|
+
**Only claims what it owns — everything else passes through unchanged.**
|
|
781
|
+
`manager` is frequently your own scene-wide `LoadingManager`, shared for an
|
|
782
|
+
HDRI, an environment map, a font atlas, a ground texture — none of which
|
|
783
|
+
have anything to do with this robot. So the rule is: a `package://`
|
|
784
|
+
reference, or a root-relative path whose leading segment names a ROS package
|
|
785
|
+
this robot's assets (or `missing`) actually mention, is this method's to
|
|
786
|
+
resolve or refuse. Anything else — a relative path, a root-relative path
|
|
787
|
+
under a different namespace, a `data:`/`blob:` URL — is left completely
|
|
788
|
+
alone, exactly as if `prepareUrdfScene` had never been called. **The one
|
|
789
|
+
exception is an absolute `http(s)` URL**, refused regardless of namespace: a
|
|
790
|
+
hostile URDF naming an attacker's host directly (bypassing `package://`
|
|
791
|
+
entirely) must never reach a real network request, so this is the one case
|
|
792
|
+
this method cannot afford to leave ambiguous.
|
|
793
|
+
|
|
794
|
+
Anything this method owns and cannot resolve — a reference the store never
|
|
795
|
+
got, or that attacker-named absolute URL — resolves to a shared, empty,
|
|
796
|
+
page-local `blob:` URL, never the original string. A loader handed empty
|
|
797
|
+
bytes fails to parse them, which is the same loud failure a genuinely
|
|
798
|
+
missing mesh already produces; nothing quietly renders wrong and nothing
|
|
799
|
+
quietly reaches the network. `dispose()` revokes every real asset `blob:`
|
|
800
|
+
URL this call created — not that shared one, which stays valid for the life
|
|
801
|
+
of the page, which is what lets the installed modifier keep refusing owned
|
|
802
|
+
references correctly even after `dispose()`, rather than reverting to raw,
|
|
803
|
+
unrefused passthrough.
|
|
804
|
+
|
|
805
|
+
This closes a real gap, not a hypothetical one: `URDFLoader.resolvePath()`
|
|
806
|
+
passes a non-`package://` filename through unchanged, so a `<mesh
|
|
807
|
+
filename="http://attacker.example/x.stl">` on a robot's ROS graph would
|
|
808
|
+
otherwise reach the browser's own `fetch` verbatim. No credential travels
|
|
809
|
+
with it — three.js does not carry the bearer token — but it is still a
|
|
810
|
+
real, attacker-named network request made by every viewer who renders the
|
|
811
|
+
URDF. A `blob:` URL created in this page cannot resolve to any other origin
|
|
812
|
+
by construction, so refusing this way removes the network request entirely
|
|
813
|
+
rather than merely declining to authenticate it.
|
|
814
|
+
|
|
815
|
+
**`urdf-loader` resolves `package://` itself, before the URL modifier ever
|
|
816
|
+
sees it.** `URDFLoader.parse()`'s own `resolvePath()` rewrites
|
|
817
|
+
`package://pkg/rel` to the root-relative `/pkg/rel` under its default
|
|
818
|
+
`packages: ''`, and *that* rewritten form is what actually reaches the URL
|
|
819
|
+
modifier — not the original string. `prepareUrdfScene` registers both forms
|
|
820
|
+
per asset, so the example above works with a plain `new URDFLoader(manager)`
|
|
821
|
+
and no extra configuration. If you set `loader.packages` to something other
|
|
822
|
+
than the default, results depend on what it produces; setting it to
|
|
823
|
+
`(pkg) => \`package://${pkg}\`` reconstructs the original name and is
|
|
824
|
+
covered too.
|
|
825
|
+
|
|
826
|
+
**A `.dae`'s own internal references get a second-chance, normalized lookup
|
|
827
|
+
too.** three.js builds the request for one by plain string concatenation
|
|
828
|
+
(`this.path + url`, no `..`/`.` collapsing), while `asset.name` carries the
|
|
829
|
+
*normalized* tail — so `../textures/skin.png` or `./textures/skin.png`
|
|
830
|
+
(ordinary exporter output; Blender writes `./` routinely) would otherwise
|
|
831
|
+
miss the direct lookup. Handled the same way for you: nothing to configure.
|
|
832
|
+
|
|
833
|
+
The browser **never** fetches an asset directly, for anything this method
|
|
834
|
+
owns — every read goes through this SDK with the bearer token first, same
|
|
835
|
+
as everything else in this package, and the cloud enforces `assets` exactly
|
|
836
|
+
as it does today. Pre-fetch
|
|
837
|
+
is unavoidable, not a design shortcut: a URL modifier cannot be asynchronous,
|
|
838
|
+
so bytes have to already be in memory before `URDFLoader.parse` runs. Fetches
|
|
839
|
+
are bounded to `options.concurrency` (default 6, must be a positive integer —
|
|
840
|
+
`0` or negative throws `invalid_option` rather than silently fetching nothing
|
|
841
|
+
and returning a scene that renders completely blank with no error to explain
|
|
842
|
+
why) and scoped to the mesh and texture assets in the robot's own list —
|
|
843
|
+
which already *is* what its current URDF references (a re-sync reconciles,
|
|
844
|
+
so a mesh the URDF stops naming stops belonging to the robot).
|
|
845
|
+
|
|
846
|
+
`options.signal` cancels the whole call (register row 244, W7c) — pass an
|
|
847
|
+
`AbortController`'s signal if the caller might navigate away or switch
|
|
848
|
+
robots mid-load. Checked before the first request and forwarded straight to
|
|
849
|
+
every underlying `fetch()`, so an abort tears down connections already in
|
|
850
|
+
flight rather than merely stopping new ones from starting; every `blob:`
|
|
851
|
+
URL already created before the abort is revoked, and the rejection is always
|
|
852
|
+
`FleetlessError('aborted', ...)` regardless of which stage the abort landed
|
|
853
|
+
in or which shape the runtime's own `fetch()` rejects an aborted request
|
|
854
|
+
with.
|
|
855
|
+
|
|
856
|
+
**Do not also install `createMeshLoader` on the same manager.** They read
|
|
857
|
+
two different URDF sources — this method fetches the URDF's *raw* bytes,
|
|
858
|
+
`createMeshLoader` is meant to pair with `urdf()`'s cloud-rewritten text.
|
|
859
|
+
**Not "double-fetches every mesh"** — a first version of this sentence said
|
|
860
|
+
that, and it was traced and found wrong (Momus-W7a review): what actually
|
|
861
|
+
happens is asymmetric breakage, not duplication. Paired with this method's
|
|
862
|
+
raw text, `createMeshLoader` gets urdf-loader's own `resolvePath()` output
|
|
863
|
+
rather than an absolute Fleetless URL and 404s every mesh while textures
|
|
864
|
+
still work; paired with `urdf()`'s rewritten text instead, meshes load and
|
|
865
|
+
every texture 401s, because `createMeshLoader` bypasses this method's URL
|
|
866
|
+
modifier entirely for meshes and there is no equivalent hook for a texture.
|
|
867
|
+
A developer who combined them by accident would have debugged the wrong
|
|
868
|
+
symptom either way — **enforced now, not only documented**: installing both
|
|
869
|
+
on the same manager fails loudly, before either ever touches the network,
|
|
870
|
+
rather than silently producing one of those two broken scenes. Use
|
|
871
|
+
`prepareUrdfScene` for anything rendered with `urdf-loader`/three.js;
|
|
872
|
+
`createMeshLoader` stays useful only for a mesh-only, non-three.js
|
|
873
|
+
`loadMeshCb`-shaped renderer.
|
|
874
|
+
|
|
875
|
+
### The mesh callback — `createMeshLoader`
|
|
876
|
+
|
|
877
|
+
**Superseded by `prepareUrdfScene` above for three.js/`urdf-loader`
|
|
878
|
+
consumers — reach for it there first, especially for anything with
|
|
879
|
+
textures.** Kept because it is renderer-agnostic (any `loadMeshCb`-shaped
|
|
880
|
+
consumer, not only three.js) and mesh-only apps may not need the wider
|
|
881
|
+
mechanism. Do not install both on one load — see the warning above.
|
|
882
|
+
|
|
883
|
+
An `<img>` tag and the default three.js loaders cannot set an
|
|
884
|
+
`Authorization` header, and the platform deliberately has no signed URLs and
|
|
885
|
+
no token in the query string (see the reasoning in `@fleetless/contracts`'
|
|
886
|
+
`assets.ts`) — so every app would otherwise write this glue itself, and each
|
|
887
|
+
one differently. `createMeshLoader` is that glue, shaped to drop straight
|
|
888
|
+
into [`urdf-loader`](https://github.com/gkjohnson/urdf-loaders)'s own
|
|
889
|
+
`loadMeshCb` hook:
|
|
890
|
+
|
|
891
|
+
```ts
|
|
892
|
+
import URDFLoader from 'urdf-loader'
|
|
893
|
+
|
|
894
|
+
const loader = new URDFLoader()
|
|
895
|
+
loader.loadMeshCb = client.assets.createMeshLoader(robotId, loader.defaultMeshLoader.bind(loader))
|
|
896
|
+
|
|
897
|
+
const xml = await client.assets.urdf(robotId)
|
|
898
|
+
const robot = loader.parse(xml)
|
|
899
|
+
scene.add(robot)
|
|
900
|
+
```
|
|
901
|
+
|
|
902
|
+
`createMeshLoader(robotId, delegate, options?)` returns a function with
|
|
903
|
+
`loadMeshCb`'s own four-argument signature
|
|
904
|
+
(`path, manager, material, onComplete`) — assign it directly, do not wrap
|
|
905
|
+
it. `delegate` gets the same four arguments and does the actual
|
|
906
|
+
format-specific parsing (STL/OBJ/DAE/GLTF); in practice it is
|
|
907
|
+
`loader.defaultMeshLoader.bind(loader)`, urdf-loader's own built-in mesh
|
|
908
|
+
parser, not something an app writes from scratch.
|
|
909
|
+
|
|
910
|
+
What this method does that a plain loader can't: it fetches `path` with the
|
|
911
|
+
`Authorization` header attached, wraps the bytes in a `Blob`, and calls
|
|
912
|
+
`delegate` with an **object URL** substituted for `path` — so `delegate`
|
|
913
|
+
never touches the network, and every format-specific parser downstream
|
|
914
|
+
works unmodified against a same-origin `blob:` URL. The object URL is
|
|
915
|
+
revoked the moment `delegate` reports success or failure, or after
|
|
916
|
+
`options.timeoutMs` (default 30s) if it never reports at all — a URDF pulls
|
|
917
|
+
in thirty-odd meshes and this callback can run for days in a long-lived
|
|
918
|
+
dashboard, so a delegate that hangs or never calls back must not leak the
|
|
919
|
+
URL or stall the load forever. A delegate that throws synchronously is
|
|
920
|
+
caught the same way, resolving `onComplete(null, err)` rather than
|
|
921
|
+
propagating into whatever called this callback (typically three.js's own
|
|
922
|
+
URDF traversal).
|
|
923
|
+
|
|
924
|
+
**`path` is only ever fetched if its origin matches this client's own
|
|
925
|
+
`apiUrl`.** A URDF is ROS graph input, not first-party data — anything on a
|
|
926
|
+
robot's ROS graph can publish one — and the cloud only rewrites `package://`
|
|
927
|
+
URIs; a `<mesh filename="https://...">` naming a URL outright is served
|
|
928
|
+
back unchanged. Without this check, a hostile URDF could point a mesh
|
|
929
|
+
anywhere and this callback would fetch it with the caller's own bearer
|
|
930
|
+
token attached. A mismatched origin is refused before any network call —
|
|
931
|
+
`onComplete(null, err)` with `err.code === 'untrusted_absolute_url'` — never
|
|
932
|
+
fetched anonymously either, so a malicious reference fails loudly instead
|
|
933
|
+
of quietly making an unexpected request on the app's behalf.
|
|
934
|
+
|
|
935
|
+
## History
|
|
936
|
+
|
|
937
|
+
Reads recorded data for a datapoint that was published with `retention: true`
|
|
938
|
+
(spec §8) — plain REST, no realtime channel, the same way `cameras.snapshot`
|
|
939
|
+
is. You do not need to learn the underlying query grammar; `history()` takes
|
|
940
|
+
a plain options object and hands back a typed, discriminated result.
|
|
941
|
+
|
|
942
|
+
### Range — relative or absolute, always a string
|
|
943
|
+
|
|
944
|
+
```ts
|
|
945
|
+
// Relative to now:
|
|
946
|
+
await client.datapoints.history(robotId, 'battery-percentage', { from: 'now-30s' })
|
|
947
|
+
await client.datapoints.history(robotId, 'battery-percentage', { from: 'now-1h', to: 'now-30m' })
|
|
948
|
+
|
|
949
|
+
// Absolute unix milliseconds, for a report over a fixed window:
|
|
950
|
+
await client.datapoints.history(robotId, 'battery-percentage', { from: '1700000000000', to: '1700003600000' })
|
|
951
|
+
```
|
|
952
|
+
|
|
953
|
+
`from` (required) and `to` (optional, defaults to now) each accept `now-30s`
|
|
954
|
+
/ `now-5m` / `now-1h`, or absolute unix milliseconds — **always as a
|
|
955
|
+
string**, whichever form you use. The SDK does not accept a `Date` or a
|
|
956
|
+
`number` and stringify it for you: that would be a decision about which of
|
|
957
|
+
the two forms you meant, made silently on your behalf, and the next person
|
|
958
|
+
reading the actual wire traffic would have no way to tell which of you made
|
|
959
|
+
it. Convert yourself, explicitly, at the call site.
|
|
960
|
+
|
|
961
|
+
### Two result shapes, chosen by whether you asked for aggregation
|
|
962
|
+
|
|
963
|
+
```ts
|
|
964
|
+
// Raw samples: leave `aggregate` out.
|
|
965
|
+
const samples = await client.datapoints.history(robotId, 'battery-percentage', { from: 'now-1h' })
|
|
966
|
+
console.log(samples.kind) // 'samples'
|
|
967
|
+
for (const s of samples.samples) console.log(s.timestamp_ms, s.value)
|
|
968
|
+
if (samples.truncated) {
|
|
969
|
+
// `limit` was hit — say so, rather than let a short array look like a quiet period.
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// Aggregated buckets: pass `aggregate`. `window` and `agg` always travel
|
|
973
|
+
// together — the API refuses one without the other, so this SDK models them
|
|
974
|
+
// as one object instead of two independent optional fields you could set
|
|
975
|
+
// only one of.
|
|
976
|
+
const buckets = await client.datapoints.history(robotId, 'battery-percentage', {
|
|
977
|
+
from: 'now-1h',
|
|
978
|
+
aggregate: { window: '1m', agg: 'avg' },
|
|
979
|
+
})
|
|
980
|
+
console.log(buckets.kind) // 'buckets'
|
|
981
|
+
for (const b of buckets.buckets) {
|
|
982
|
+
if (b.sample_count === 0) {
|
|
983
|
+
// Genuinely empty — nothing was recorded in this bucket. Draw a break in
|
|
984
|
+
// the line here.
|
|
985
|
+
} else if (b.value === null) {
|
|
986
|
+
// Samples DID land here, none of them numeric — a string, a bool, a whole
|
|
987
|
+
// message. There is data in this interval; it just has no height. Do not
|
|
988
|
+
// draw this as a gap: that is the same lie as reporting an empty bucket,
|
|
989
|
+
// and it is what this branch exists to prevent.
|
|
990
|
+
console.log(b.bucket_start_ms, `${b.sample_count} samples, none numeric`)
|
|
991
|
+
} else {
|
|
992
|
+
console.log(b.bucket_start_ms, b.value, b.sample_count)
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
```
|
|
996
|
+
|
|
997
|
+
`history` is overloaded on whether `aggregate` is present: passing it gets
|
|
998
|
+
you back a `HistoryBucketsResponse` (`kind: 'buckets'`) at the type level
|
|
999
|
+
already, no narrowing required, because you already know that's what you
|
|
1000
|
+
asked for; leaving it out gets you `HistorySamplesResponse` (`kind:
|
|
1001
|
+
'samples'`). Both still carry `kind` at runtime, so code that holds the
|
|
1002
|
+
result in a variable typed as the union can branch on it the same way.
|
|
1003
|
+
|
|
1004
|
+
`aggregate.field` names a numeric field inside an object value (`'pose.x'`,
|
|
1005
|
+
the same flat-key convention as `actions.invoke`'s `params`) — only needed
|
|
1006
|
+
when the datapoint's own value isn't itself a number.
|
|
1007
|
+
|
|
1008
|
+
**Buckets align to wall-clock UTC, not to your `from`.** For a clean width
|
|
1009
|
+
like `10s`/`1m`/`1h`, `bucket_start_ms` lands on :00/:10/:20 seconds,
|
|
1010
|
+
top-of-minute, top-of-hour — never at `from`, `from + window`, `from +
|
|
1011
|
+
2*window`, etc. Strictly the boundaries are counted from a fixed origin
|
|
1012
|
+
(2000-01-03) rather than from midnight, which is the same thing for any width
|
|
1013
|
+
that divides evenly into an hour; a width like `45s` is legal and its
|
|
1014
|
+
boundaries will not look like round clock times. This is deliberate: `from` is often relative (`now-30s`), so
|
|
1015
|
+
a chart re-polling the same query on an interval gets a slightly different
|
|
1016
|
+
absolute `from` on every poll — under `from`-aligned buckets that would
|
|
1017
|
+
reshuffle every boundary each time, drawing visibly jittery bars for no
|
|
1018
|
+
reason in the data. Wall-clock alignment stays stable across repeated polls
|
|
1019
|
+
of the same window width.
|
|
1020
|
+
|
|
1021
|
+
One consequence worth knowing: **the first and last bucket in a response can
|
|
1022
|
+
be partial.** `bucket_start_ms` is the wall-clock boundary that contains the
|
|
1023
|
+
earliest/latest in-range sample, and that boundary can fall before `from` or
|
|
1024
|
+
extend past `to` — but `sample_count`/`value` only ever reflect samples that
|
|
1025
|
+
actually matched the queried range. A low count on an edge bucket is that
|
|
1026
|
+
boundary effect, not a gap in what was recorded.
|
|
1027
|
+
|
|
1028
|
+
Two more things that are easy to assume and wrong:
|
|
1029
|
+
|
|
1030
|
+
- **A truncated samples response says *why*.** `truncated_by` is `'limit'`
|
|
1031
|
+
when the row cap cut it and `'bytes'` when the response-size budget did.
|
|
1032
|
+
The remedies differ: raising `limit` fixes the first and does nothing for
|
|
1033
|
+
the second, which needs a narrower range or a numeric `field` so whole
|
|
1034
|
+
messages are not carried. `null` when nothing was cut.
|
|
1035
|
+
- **`value * sample_count` is not a sum.** `value` aggregates only the numeric
|
|
1036
|
+
samples; `sample_count` counts all of them. The two agree only for a
|
|
1037
|
+
datapoint whose values are always numbers.
|
|
1038
|
+
- **The two paths do not currently share a range convention.** Raw samples are
|
|
1039
|
+
inclusive of `to`; buckets are half-open and exclude it. A sample landing
|
|
1040
|
+
exactly on an absolute `to` therefore appears in one and not the other. This
|
|
1041
|
+
is a known inconsistency, not a design: both become half-open, and this
|
|
1042
|
+
paragraph moves with them.
|
|
1043
|
+
|
|
1044
|
+
### Errors specific to history
|
|
1045
|
+
|
|
1046
|
+
```ts
|
|
1047
|
+
try {
|
|
1048
|
+
await client.datapoints.history(robotId, 'robot-details', { from: 'now-1h' })
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
if (error instanceof FleetlessError) {
|
|
1051
|
+
switch (error.code) {
|
|
1052
|
+
case 'not_recorded':
|
|
1053
|
+
// The slug is granted, but was never marked `retention: true`. An
|
|
1054
|
+
// empty samples array would look identical to "recorded, but
|
|
1055
|
+
// nothing happened in this window" — and the two need opposite
|
|
1056
|
+
// fixes, so the API refuses instead of answering with silence.
|
|
1057
|
+
break
|
|
1058
|
+
case 'not_aggregatable':
|
|
1059
|
+
// `aggregate` was requested on a value that isn't a number, and no
|
|
1060
|
+
// numeric `aggregate.field` was given. Refused rather than coerced
|
|
1061
|
+
// — an average of strings or booleans is a number that means
|
|
1062
|
+
// nothing, and it would chart as confidently as a real one.
|
|
1063
|
+
break
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
```
|
|
1068
|
+
|
|
1069
|
+
Neither of these is absorbed into a quiet empty result the way
|
|
1070
|
+
`cameras.snapshot` absorbs `no_snapshot_yet` into `image: null` — that
|
|
1071
|
+
absorption is right for a snapshot, where an empty answer is the truth; here
|
|
1072
|
+
it would erase the distinction between "turn recording on" and "look at a
|
|
1073
|
+
different window," so both reach you as a thrown `FleetlessError` instead.
|
|
1074
|
+
|
|
1075
|
+
## Errors
|
|
1076
|
+
|
|
1077
|
+
Every rejected call throws (or, for a subscription, hands to `onError`) a
|
|
1078
|
+
`FleetlessError`:
|
|
1079
|
+
|
|
1080
|
+
```ts
|
|
1081
|
+
import { FleetlessError } from '@fleetless/sdk'
|
|
1082
|
+
|
|
1083
|
+
try {
|
|
1084
|
+
await client.datapoints.get(robotId, 'robot-details')
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
if (error instanceof FleetlessError) {
|
|
1087
|
+
switch (error.code) {
|
|
1088
|
+
case 'forbidden':
|
|
1089
|
+
// your role does not grant this slug
|
|
1090
|
+
break
|
|
1091
|
+
case 'token_expired':
|
|
1092
|
+
case 'token_revoked':
|
|
1093
|
+
// the session could not be refreshed — send the user back to login
|
|
1094
|
+
break
|
|
1095
|
+
default:
|
|
1096
|
+
console.error(error.code, error.message, error.details)
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
```
|
|
1101
|
+
|
|
1102
|
+
`code` is the stable, machine-readable value from the spec's error culture
|
|
1103
|
+
(§11.5) — branch on it, never on `message`, which is for logs and humans
|
|
1104
|
+
only. It's typed as `ErrorCode | SdkErrorCode | (string & {})`, so a
|
|
1105
|
+
`switch` gets autocomplete on both sets while still accepting a code newer
|
|
1106
|
+
than your installed version.
|
|
1107
|
+
|
|
1108
|
+
**None of these codes ever come from the server** — `SDK_ERROR_CODES` lists
|
|
1109
|
+
them:
|
|
1110
|
+
|
|
1111
|
+
- `no_session` — a call was made, or a subscription attempted, with nobody
|
|
1112
|
+
logged in. Call `auth.login()` first.
|
|
1113
|
+
- `no_websocket` — no `WebSocket` implementation is available in this
|
|
1114
|
+
environment. Pass one via the `WebSocket` option.
|
|
1115
|
+
|
|
1116
|
+
Both fire *before* a request ever reaches the network, and are named
|
|
1117
|
+
apart from the server's own `unauthorized` so you can always tell "the
|
|
1118
|
+
server refused me" from "the SDK refused before asking."
|
|
1119
|
+
|
|
1120
|
+
- `unparseable_error` — the opposite direction: a response *did* come back,
|
|
1121
|
+
its body just wasn't shaped like §11.5's error format, so there is no
|
|
1122
|
+
server `code` to relay.
|
|
1123
|
+
- `command_timeout` — an `invoke`/`cancel`/`publish`/`services.call` got no
|
|
1124
|
+
reply within its timeout (default 10s, 30s for `services.call`; override
|
|
1125
|
+
with `{ timeoutMs }`). The server may still be working on it — this only
|
|
1126
|
+
means the SDK stopped waiting.
|
|
1127
|
+
- `command_outcome_unknown` — worse than a timeout, and told apart from it
|
|
1128
|
+
on purpose: the realtime connection was replaced by a reconnect before any
|
|
1129
|
+
reply to your command arrived, so a reply can now never come — the
|
|
1130
|
+
server, if it answered at all, answered a socket that no longer exists.
|
|
1131
|
+
**The SDK never retries automatically** (that could invoke an action
|
|
1132
|
+
twice); recover by reading the job — `actions.subscribe(robotId, slug,
|
|
1133
|
+
...)` shows you whatever is actually running, regardless of which
|
|
1134
|
+
connection asked for it.
|
|
1135
|
+
- `unexpected_response` — the server said the command succeeded but left out
|
|
1136
|
+
something it is defined to always return (e.g. no job on a successful
|
|
1137
|
+
`invoke`, or W6c: a REST route documented as always returning a body —
|
|
1138
|
+
`login`, `me`, `register`, all the others except the handful documented as
|
|
1139
|
+
answering `204`/a bodyless `202` — coming back with an empty one). A
|
|
1140
|
+
contract violation the SDK noticed, not a refusal.
|
|
1141
|
+
- `untrusted_absolute_url` (W7) — `assets.createMeshLoader`'s callback
|
|
1142
|
+
received an absolute URL (from a URDF's rewritten mesh URIs) whose origin
|
|
1143
|
+
does not match this client's own `apiUrl`, and refused to fetch it. Fires
|
|
1144
|
+
before the network call, so no `Authorization` header is ever built for
|
|
1145
|
+
it, let alone attached — a URDF is ROS graph input, not first-party data,
|
|
1146
|
+
and this SDK will not send your credential to whatever host it happens to
|
|
1147
|
+
name. Surfaced through `createMeshLoader`'s `onComplete(null, err)`, the
|
|
1148
|
+
same channel as a network failure.
|
|
1149
|
+
- `no_urdf_synced` (W7a) — `assets.prepareUrdfScene` found no `kind: 'urdf'`
|
|
1150
|
+
row in `assets.list()`. Thrown before any asset fetch — the fix is to sync
|
|
1151
|
+
a URDF first (console, Owner-tier), which this error says directly rather
|
|
1152
|
+
than surfacing later as a confusing failure from `URDFLoader.parse`.
|
|
1153
|
+
- `aborted` (W7c) — `assets.prepareUrdfScene`'s `options.signal` fired,
|
|
1154
|
+
either before the call started or mid-flight. One code regardless of when
|
|
1155
|
+
the abort landed, rather than whatever shape the runtime's own `fetch()`
|
|
1156
|
+
happens to reject an aborted request with.
|
|
1157
|
+
|
|
1158
|
+
### `rate_limited` — surfaced, never retried (W6c)
|
|
1159
|
+
|
|
1160
|
+
Unlike the codes above, `rate_limited` **does** come from the server — every
|
|
1161
|
+
unauthenticated route (`login`, `register`, `requestPasswordReset`, and the
|
|
1162
|
+
rest) sits behind a limiter that refuses before it ever checks a password,
|
|
1163
|
+
so a flood of attempts cannot be turned into CPU spent hashing them.
|
|
1164
|
+
|
|
1165
|
+
```ts
|
|
1166
|
+
import type { RateLimitDetails } from '@fleetless/sdk'
|
|
1167
|
+
|
|
1168
|
+
try {
|
|
1169
|
+
await client.auth.login(email, password)
|
|
1170
|
+
} catch (error) {
|
|
1171
|
+
if (error instanceof FleetlessError && error.code === 'rate_limited') {
|
|
1172
|
+
const { retry_after_ms } = error.details as RateLimitDetails
|
|
1173
|
+
// wait retry_after_ms, then let the USER retry — see below
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
```
|
|
1177
|
+
|
|
1178
|
+
- **It carries exactly one number, `retry_after_ms`** — the same way `busy`
|
|
1179
|
+
carries `error.details.running` (see [Actions](#actions)): when to come
|
|
1180
|
+
back, and nothing else. No window, no attempt count, no ceiling — none of
|
|
1181
|
+
that changes what an honest caller does, and all of it would help a
|
|
1182
|
+
dishonest one, so the server never sends it and there is nothing more to
|
|
1183
|
+
read off `error.details`.
|
|
1184
|
+
- **This SDK never retries a `rate_limited` response for you, anywhere, and
|
|
1185
|
+
never will.** A client library that retries a rate limit automatically is
|
|
1186
|
+
exactly the client behaviour the limit exists to stop — building that in
|
|
1187
|
+
would turn every app built on this SDK into the attack. Back off using
|
|
1188
|
+
`retry_after_ms` yourself, and only after the person at the keyboard asks
|
|
1189
|
+
for it again — not on a timer that fires unattended.
|
|
1190
|
+
|
|
1191
|
+
## Configuration
|
|
1192
|
+
|
|
1193
|
+
```ts
|
|
1194
|
+
createClient({
|
|
1195
|
+
apiUrl: string, // e.g. 'https://api.fleetless.dev'
|
|
1196
|
+
appIdentifier: string, // the app's identifier, from the console
|
|
1197
|
+
tokenStore?: TokenStore, // default: in-memory
|
|
1198
|
+
serverKey?: string, // 'flk_...' — alternative to tokenStore
|
|
1199
|
+
realtimeUrl?: string, // default: derived from apiUrl (ws(s) + /realtime)
|
|
1200
|
+
fetch?: typeof fetch, // default: the global fetch
|
|
1201
|
+
WebSocket?: typeof WebSocket, // default: the global WebSocket
|
|
1202
|
+
})
|
|
1203
|
+
```
|
|
1204
|
+
|
|
1205
|
+
`fetch`/`WebSocket` are injectable for testing and for runtimes without a
|
|
1206
|
+
global implementation of one of them — the SDK never assumes a browser.
|
|
1207
|
+
|
|
1208
|
+
## Contributing
|
|
1209
|
+
|
|
1210
|
+
```sh
|
|
1211
|
+
pnpm i
|
|
1212
|
+
pnpm test # vitest, against a fake fetch and a fake WebSocket — no cloud needed
|
|
1213
|
+
pnpm build # tsup -> dist/ (ESM + CJS + .d.ts)
|
|
1214
|
+
```
|
|
1215
|
+
|
|
1216
|
+
`@fleetless/contracts` is the source of truth for every wire type — never
|
|
1217
|
+
redefine a shape the SDK reads from or writes to the API; import it.
|