@lessly/sdk
v0.6.0
Published
Client SDK for the Lessly public edge — one credential, generated from the public catalog.
Readme
@lepsto/sdk
The client SDK for the Lepsto public edge (https://public.lepsto.com). One credential issued
for your product reaches every Lepsto extension that product has published.
- Zero runtime dependencies.
- ESM and CommonJS, with types.
- Node >= 20 and modern browsers (
fetchcomes from the platform).
Entry points
@lepsto/sdk publishes eight entry points. The root builds the whole catalog's client; the
other seven are per-toolkit bundles, so importing one brings nothing else with it:
| subpath | what it is |
|---|---|
| @lepsto/sdk | the whole catalog in one client |
| @lepsto/sdk/mail | Lepsto Mail |
| @lepsto/sdk/realtime | Lepsto Realtime, browser and server-agnostic |
| @lepsto/sdk/realtime/server | Realtime's server-credentialled half |
| @lepsto/sdk/users | Lepsto Users |
| @lepsto/sdk/users/react | the React bindings |
| @lepsto/sdk/users/react/ui | the prebuilt React components |
| @lepsto/sdk/users/server | Users' server-credentialled half |
A toolkit with no subpath of its own is reached through the root client, MCP or REST.
import { createRealtime } from '@lepsto/sdk/realtime';
const realtime = createRealtime({
productId: 'prod_…',
credential: { kind: 'publishableKey', value: 'lpk_…' },
});
await realtime.messages.publish({ channel: 'room:42', data: { hello: 'world' } });The root entry (import { createLepsto } from '@lepsto/sdk') builds the whole catalog's client in
one object. A subpath is what you want when bundle size matters, because the root materialises
every toolkit eagerly.
…/server
A toolkit that has server-side operations publishes them separately:
import { createRealtimeServer } from '@lepsto/sdk/realtime/server';These carry server credentials and Node-only code. They cannot be bundled for a browser: the
package declares a browser export condition that resolves every */server subpath to a stub which
throws on import, so a bundler targeting the browser fails loudly instead of shipping a server key
to every visitor.
Credentials
Each credential travels in its own header, and the header is part of the credential rather than something the transport guesses:
| kind | header |
|---|---|
| publishableKey | x-publishable-key |
| apiKey (server only) | x-api-key |
| accessToken | authorization: Bearer and x-access-token |
| bearer | authorization: Bearer |
Retries are off by default — most calls are non-idempotent and a blind repeat is a race — and
are opted into per call or per client with a RetryPolicy. DEFAULT_RETRY is a policy you can opt
into without inventing numbers; it is not applied unless you ask for it.
@lepsto/sdk/realtime/server is the exception, and it earns it: messages.publish generates an
Idempotency-Key for every call, so a repeat of a publish the service already accepted is
deduplicated rather than delivered twice — which is what makes retrying it safe enough to have on.
Retry-After reaches you on the error in every case, whether or not anything acts on it.
Contributing
Several teams share this package. CONTRIBUTING.md has the rules: where hand-written toolkit code
goes, what a subpath may import, when a subpath may declare its own dependency, and why every
hand-written change needs a changeset.
Install
npm install @lepsto/sdkUse
import { createLepsto, LepstoApiError } from '@lepsto/sdk';
const lepsto = createLepsto({
productId: 'prod_…',
publicKey: process.env.LEPSTO_PUBLIC_KEY,
});
try {
const { domains } = await lepsto.mail.domains.list({});
} catch (err) {
if (err instanceof LepstoApiError) console.error(err.status, err.code, err.body);
}Which namespaces the root client carries is decided by the catalog the version was generated from:
namespaces, extensions and catalogVersion are exported so a caller can read that off the
package itself rather than guess.
createLepsto is the only root entry point. It takes one options object:
createLepsto({ productId, publicKey?, baseUrl?, fetch?, WebSocket? })baseUrl defaults to https://public.lepsto.com and is how you point the client somewhere else —
a local gateway, or the edge addressed directly. fetch and WebSocket are escape hatches for
non-standard runtimes. WebSocket is used only by the streaming accessors (see Streaming);
fetch is used by everything, streaming included — a connect mints its ticket over HTTP.
One credential at the root
publicKey is the root client's credential, and there is no separate key per extension and no
client secret: the public edge resolves the key to your product and applies the scope it was
issued with. (The subpaths take the fuller credential / credentials form from
Credentials above; publicKey is that same thing spelled for a surface that
needs exactly one.) Every operation is declared in the catalog as either
mode: "open"— callable without a key, sopublicKeymay be omitted entirely, ormode: "keyed"— requires the public key.
A keyed operation sends Authorization: Bearer <your public key>. An open one sends no
credential header at all — not an empty one, not the key "just in case": sending a key where the
edge does not ask for it leaks it into every log and proxy along the way. There are no cookies, no
CSRF token and no X-Product-Id header; the product is in the path.
Calling a keyed operation on a client built without a publicKey fails locally, before any
network call:
const lepsto = createLepsto({ productId: 'prod_…' }); // no key
await lepsto.realtime.messages.publish({ channel: 'c', payload: {} });
// LepstoApiError: status 0, code 'public_key_required'status: 0 means the request never left the process. You get the actual mistake — "this operation
needs a key and this client has none" — instead of a 403 from the edge that you would have to
interpret.
productId is substituted into each route's {productId} placeholder at request time; the catalog
never resolves it, because generation time does not know which product a caller holds a key for.
Errors
Every failure is a LepstoApiError:
class LepstoApiError extends Error {
status: number; // HTTP status, or 0 when the SDK refused to send the request
code?: string; // what to branch on
body: unknown; // the edge's response body, verbatim
retryAfter?: number; // seconds, from `Retry-After` on a 429
stage?: 'preflight' | 'mint' | 'upgrade'; // streaming only — see below
}A code the edge sent always wins. When it sent none, the status supplies the one its contract
implies:
| status | code | what happened |
| ------ | --------------------------- | ------------------------------------------------------ |
| 0 | public_key_required | keyed operation, no key — refused locally, no request |
| 401 | public_key_invalid | the key is unknown or revoked |
| 403 | public_key_invalid | the key is not scoped for this operation (…_scope) |
| 404 | operation_not_allowlisted | the operation is not published for this product |
| 413 | body_too_large | over the extension's max_body_bytes |
| 429 | rate_limited | rate limited; retryAfter carries Retry-After |
Anything else — a 500, a proxy's own error page — keeps code: undefined. Those come from below the
edge, and naming them would claim we understood a failure we did not.
Nothing retries unless you asked for it (see Credentials for the one exception).
retryAfter is reported so you can decide; a library that backs off on your behalf makes latency
decisions it has no standing to make.
Published from production only
@lepsto/sdk is generated and published exclusively from the production catalog. Staging runs
the exact same pipeline with --dry-run: it fetches, generates, type-checks, classifies the semver
change and writes out/result.json, then stops without publishing.
This is deliberate. Staging carries toolkits that do not exist in production (the playground among
them) and leads production by whatever has not been promoted yet. Publishing a staging build under
any dist-tag would ship customers operations they cannot call. There is no prerelease channel, and
the pipeline rejects any --channel value but latest.
Only kind: "sdk" operations are generated
The public catalog serves three surfaces, and every operation says which one it belongs to:
sdk (callable from this client), embed (an embeddable widget route) and asset (a static
asset route). The generator emits the sdk operations and skips the other two without
complaining — they are not errors, they are someone else's operations riding the same catalog.
The run's summary line counts both, so a client that shrank is always explainable from the log:
generated src/gen from …/public-catalog.json: 42 sdk operations, skipped 3 embed + 1 assetAn operation with no kind at all comes from a catalog older than spec v3 and is treated as
sdk. An unrecognised kind is a hard error, exactly like an unrecognised mode: the contract
moved upstream and we would otherwise guess.
Publishing a new version is a deliberate step
After a toolkit promotes a public operation to production, someone must run the release. There
is no trigger. Run the Release @lepsto/sdk GitHub Actions workflow: Actions → Release
@lepsto/sdk → Run workflow, dry_run on for a rehearsal, off to publish. It builds the
commit, publishes, and opens a pull request carrying the version bump, the changelog entry and the
consumed changesets — that pull request is part of the release and has to be merged, or the
next run announces the same changes again.
If nobody runs it, nothing tells you. The published package silently lags the live API:
customers install an @lepsto/sdk that does not expose operations that exist, and get no error, no
warning and no hint of why. No build fails and no alert fires — the gap just grows with every
promotion until someone notices by hand. That is why this step also lives in the toolkit
production-release checklist, not only here.
Running it twice is harmless: the pipeline compares the generated package against the last published one by content and publishes nothing when they match.
A new version can take a few minutes to appear in npm view, so retry before you call it missing.
docs/publishing.md has the full procedure — what to check first, how to
read out/result.json, what each failure means, and the Cloud Build fallback for when the
workflow is unavailable.
Streaming
An operation with a ws binding mounts next to its namespace's regular accessors, with a Connect
suffix, and returns the platform socket for you to listen on:
const stream = await lepsto.realtime.channel.subscribeConnect({ channel: 'room-1' });
stream.socket.addEventListener('message', (e) => console.log(e.data));
stream.close();Why these accessors are async
Opening a stream is two calls, not one. The SDK first mints a ticket over plain HTTP, sending
your public key as an Authorization header, and only the ticket goes into the WebSocket URL:
POST https://public.lepsto.com/_lessly/ws-ticket/{productId}/{extension}{wsPath}
Authorization: Bearer lpk_… → { "ticket": "v1.…", "expires_in": 30 }
wss://public.lepsto.com/{productId}/{extension}{wsPath}?ticket=v1.…The key never enters a URL: URLs are written to every access log, proxy trace and CDN record
between you and the edge, and those outlive the browser tab that held the key. The ticket is
harmless there instead — it lives 30 seconds and opens exactly one operation, bound to that
product, extension and path. A ticket for /rooms/lobby opens nothing else.
For the same reason the SDK never stores one: a fresh ticket is minted immediately before every connect. There is no ticket cache and no refresh-ahead — minting is one cheap call, and a stored ticket would be a credential lying around for no benefit.
The returned promise resolves once the edge has accepted the upgrade, so a stream you are handed
is a stream you can send on. It rejects with a LepstoApiError otherwise, and the two failures are
distinguishable:
- the mint was refused — the error carries the edge's HTTP status and code
(
public_key_required,public_key_invalid,public_key_scope,not_found,upgrade_required,rate_limited,service_unavailable). Arate_limitedmint (429, a per-IP budget on the extension) also fillserr.retryAfterwith theRetry-Afterseconds — reported, never acted on: this SDK does not retry for you; - the upgrade was refused —
status: 0and the gateway's close code (ws_ticket_expired,ws_ticket_invalid,ws_ticket_required,origin_not_allowed, …), witherr.bodycarrying the raw{ closeCode, reason }. A code this SDK has not seen before is reported as the edge sent it rather than translated;ws_upgrade_failedmeans the close frame named no code at all.
Every LepstoApiError from a stream also carries err.stage — 'preflight', 'mint' or
'upgrade' — which says where it failed, and that is the part that does not change when the edge
renumbers a status. What to do with it: preflight is a mistake in your call (you asked for a keyed
operation without a key), so retrying is pointless; mint is an ordinary HTTP failure, and a retry
is meaningful when its code says so (service_unavailable, yes; public_key_invalid, no);
upgrade depends on the code — ws_ticket_expired is fixed by connecting again and
service_unavailable by retrying with backoff, while the rest mean the URL or the configuration is
wrong. On a plain HTTP call stage is undefined: there is one call and nothing to disambiguate.
Nothing retries on your behalf.
Two of the upgrade codes surprise people, because they arrive after a mint that succeeded:
origin_not_allowed— the browser'sOriginis not in your product's public CORS policy. The SDK does not sendOrigin; the browser does. Add your site's domain to the product's allowed origins. This is the most likely first failure in a browser, and the credential is not the problem.service_unavailable— the edge cannot sign or verify tickets right now. Transient, and not a statement about your path: the operation is published, the gateway is having a moment.
A keyed stream opened without a key hits the same local pre-flight as a keyed request, before any network call. Frames are delivered raw: their framing is protocol-specific and documented by the operation, so the SDK does not invent a shape for them.
The gateway side of this contract is in review and not yet deployed, so these accessors are built against the frozen contract rather than against a live edge.
Development
npm ci
npm run typecheck
npm test
npm run build
# regenerate the committed src/gen — the catalog is a required argument, name it
npm run generate -- fixtures/public-catalog.json
# see what a populated catalog generates, in out/gen-sample
npm run generate:sample
# exercise the whole pipeline against the fixture, without publishing
npm run ci -- --channel latest --catalog-url "file://$PWD/fixtures/public-catalog.sample.json" --dry-runsrc/gen is generated output — do not edit it by hand. src/runtime is the hand-written, shipped
runtime, and src/extras is the hand-written half of a namespace (see
src/extras/README.md). The committed src/gen is a snapshot — today
built from fixtures/public-catalog.json, so the tree typechecks — not the shipped
surface: a release regenerates it from the live catalog. Which fixture is which is CONTRIBUTING.md
§ 8; see docs/publishing.md before
changing what is committed.
