@lingara/api 0.0.0-reserved.0 → 0.1.0-alpha.12

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Spinning Cat Studios
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,194 @@
1
1
  # @lingara/api
2
2
 
3
- Placeholder. The first release is published under the `next` tag: `npm install @lingara/api@next`.
3
+ The official TypeScript library for the [Lingara API](https://getlingara.com),
4
+ for **Node ≥ 22, Deno 2 and Bun 1**. It has **no runtime dependencies**:
5
+ every runtime it targets already ships `fetch`, `ReadableStream`,
6
+ `TextDecoder` and `AbortSignal`, so installing it installs nothing else.
7
+
8
+ ## Not for the browser
9
+
10
+ This library is for your server. The Lingara API authenticates with a client
11
+ secret, and a secret shipped to a browser is published: anyone who opens the
12
+ page can read it and spend your allowance. The API sends no CORS headers for
13
+ third-party origins for the same reason. The
14
+ [authentication guide](https://getlingara.com/guides/authentication) explains
15
+ why credentials stay on a server.
16
+
17
+ So the `Lingara` constructor **refuses a `clientSecret` wherever a DOM
18
+ exists** (`globalThis.document` is defined), and there is no override. If
19
+ that fires in your own tests, run them in a `node` test environment rather
20
+ than `jsdom`. A client built without credentials, for the three public
21
+ operations, is not refused.
22
+
23
+ ## Install
24
+
25
+ ```sh
26
+ npm install @lingara/api
27
+ ```
28
+
29
+ Deno: `import { Lingara } from "npm:@lingara/api";`. Bun: `bun add @lingara/api`.
30
+
31
+ ## Quick start
32
+
33
+ ```ts
34
+ import { Lingara } from "@lingara/api";
35
+
36
+ const client = new Lingara({
37
+ clientId: process.env.LINGARA_CLIENT_ID!,
38
+ clientSecret: process.env.LINGARA_CLIENT_SECRET!,
39
+ // version: "2026-09-knowing-tenpounder", // optional pin
40
+ // onDeprecation: (notice) => metrics.warn(notice),
41
+ });
42
+
43
+ // A stream is an async iterable; the request starts on the first read.
44
+ const ac = new AbortController();
45
+ for await (const ev of client.generateVocabulary({ level: 2, source_lang: "en", target_lang: "zh", count: 8 }, { signal: ac.signal })) {
46
+ if (ev.event === "item") console.log(ev.data.word, ev.data.translation);
47
+ }
48
+
49
+ // A JSON call resolves with the body; `servedVersion` rides beside it.
50
+ const usage = await client.getUsage();
51
+ console.log(usage.servedVersion, usage.allowance);
52
+ ```
53
+
54
+ Thirteen methods, each named after its `operationId`: `generateVocabulary`,
55
+ `createLessonPlan`, `streamLessonPlan`, `sendTutorMessage` and
56
+ `streamEvents` return an `EventStream`; `getLessonPlan`, `getUsage`,
57
+ `listEvents`, `sendEvent`, `getOpenApiDocument`, `getAsyncApiDocument`,
58
+ `listApiVersions` and `getApiVersion` return a promise. The last four need
59
+ no credentials. Every method takes `{ signal?: AbortSignal }` last. The
60
+ `events` and `tailEvents` helpers are under *Webhooks and events* below.
61
+
62
+ - `break` out of a `for await`, or `stream.close()`, closes the connection.
63
+ - A stream's `error` event is thrown as an `ApiError` with `status: 200`;
64
+ `done` ends iteration and is not yielded; `result` and `pending` are
65
+ yielded, then iteration ends.
66
+ - `await stream.servedVersion` gives the `Lingara-Version` echo, and never
67
+ rejects.
68
+
69
+ ## Errors
70
+
71
+ Everything the library throws is a `LingaraError`: `ApiError` (a refusal from
72
+ `/v1`, or a stream's `error` event), `OAuthError` (the token endpoint),
73
+ `MaintenanceError` (a plain-text 503) or `TransportError` (no usable answer:
74
+ `kind` is `connect`, `tls`, `reset`, `timeout`, `stream_ended_early`,
75
+ `malformed_response` or `malformed_event`). Cancelling throws your signal's
76
+ own `reason`, never a `LingaraError`. `instanceof` works even when the ESM
77
+ and CommonJS builds are both loaded in one process.
78
+
79
+ The client secret and access tokens never appear in any rendering of the
80
+ client, its token source or an error; they show as `[REDACTED]`.
81
+
82
+ ## Webhooks and events
83
+
84
+ Every door (a webhook, the feed, the live tail) carries the same event:
85
+ `{id, type, createdAt, apiVersion, subject, data}`, with `data` typed per
86
+ `type`. Delivery is at least once and unordered, so **deduplicate by `id`**.
87
+
88
+ **Verify the raw body first.** Hand `Webhook` the body exactly as it
89
+ arrived, as a string or bytes, never a parsed object: parsing first changes
90
+ the bytes the signature covers. In Express that is
91
+ `express.raw({ type: "application/json" })` on the webhook route; with
92
+ `node:http`, collect the request's chunks.
93
+
94
+ ```ts
95
+ import { UnknownEvent, Webhook, WebhookVerificationError } from "@lingara/api";
96
+
97
+ const webhook = new Webhook(process.env.LINGARA_WEBHOOK_SECRET!); // or [old, new] while rotating
98
+ app.post("/lingara", express.raw({ type: "application/json" }), async (req, res) => {
99
+ try {
100
+ const event = await webhook.verify(req.body, req.headers);
101
+ res.sendStatus(204); // answer fast: within 10 s, or it is retried
102
+ if (event instanceof UnknownEvent) return console.log("newer event type", event.type, event.id);
103
+ if (event.type === "lesson_plan.ready") console.log(event.data.plan_id);
104
+ } catch (e) {
105
+ if (e instanceof WebhookVerificationError) return res.status(400).send(e.reason);
106
+ throw e;
107
+ }
108
+ });
109
+ ```
110
+
111
+ - A secret is `lgr_whsec_…`; anything else is refused when the `Webhook` is
112
+ built. Timestamps more than 300 s from now are refused. A
113
+ `WebhookVerificationError` (`reason`: `missing_header`,
114
+ `malformed_header`, `timestamp_too_old`, `timestamp_too_new`,
115
+ `no_matching_signature`, `malformed_payload`) is deliberately **not** a
116
+ `LingaraError`, so a catch around API calls never swallows a forged
117
+ delivery. `verifySignature` runs the signature checks alone, for a signed
118
+ body that is not an event.
119
+ - **`UnknownEvent`** is an event type newer than this library. It is never an
120
+ error: acknowledge it with a `2xx` (or the sender keeps retrying it for a
121
+ day) and log it, since it means a newer library has more to offer.
122
+ - `parseEvent(json)` turns one event's JSON into the same union.
123
+
124
+ **The feed.** `client.events({ cursor?, start?, types? })` walks every event
125
+ from `cursor` to where the feed is caught up, then ends; it never sleeps or
126
+ polls. Save `feed.cursor` afterwards and pass it back next time. Without a
127
+ cursor, `start` is `"latest"` (from now on, the default) or `"oldest"`
128
+ (everything still kept). A cursor older than 30 days throws `ApiError` with
129
+ `code: "cursor_expired"`: start again without one, or with `start: "oldest"`.
130
+ `listEvents` is the raw one-page operation.
131
+
132
+ **The tail.** `client.tailEvents({ cursor?, start?, types? })` yields live
133
+ events and reconnects by itself from its `cursor` after every ending, with a
134
+ 1, 2, 4 … 30 s backoff. After `tailMaxFailures` (8, about 90 s) failed
135
+ reopens in a row it throws the last failure; catch it and start again from
136
+ `tail.cursor` if your game should wait longer. A feed's `cursor` and a tail's
137
+ are one token, so `tailEvents({ cursor: feed.cursor })` goes from catch-up to
138
+ live with no gap. `streamEvents` is the raw single connection.
139
+
140
+ **Sending events.** `client.sendEvent(InboundEvent.worldContextChanged({…}))`
141
+ returns the `202` answer. Each call carries an `Idempotency-Key`; without
142
+ `idempotencyKey` the library generates one per call and sends it on every
143
+ retry of that call. Supply your own when your game may resend after a crash,
144
+ since a generated key is gone once the call returns. A reused key returns the
145
+ **first** answer, whatever the new body, so never reuse one for a different
146
+ event. With `generate: true`, only `reaction.plan_status === "generating"`
147
+ promises a `lesson_plan.ready` or `.failed` event; a `partial` or `complete`
148
+ plan is readable now, and an event for it may still arrive.
149
+
150
+ **Versions.** `data` is rendered at your OAuth client's pinned version, and
151
+ this library's types describe the version it was generated for (the one its
152
+ version warning names). Pin your client to that version.
153
+
154
+ ## Options
155
+
156
+ | Option | Default |
157
+ |---|---|
158
+ | `clientId`, `clientSecret` | none: a credential-free client |
159
+ | `auth` | `"basic"` (`client_secret_basic`); `"post"` for `client_secret_post` |
160
+ | `scopes` | none: every scope the client is allowed |
161
+ | `tokenSource` | a `ClientCredentials` built from the options above; supply your own `TokenSource` instead of `clientSecret` |
162
+ | `baseUrl` / `tokenUrl` | `https://api.getlingara.com` / `…/oauth/token` |
163
+ | `version` | none: your client's pinned version |
164
+ | `onDeprecation` | one `console.warn` per deprecated version id |
165
+ | `maxAttempts` | `3`; `1` turns retries off |
166
+ | `retryAfterCapSeconds` | `60`: a longer `Retry-After` is thrown, with `retryAfter` set |
167
+ | `streamIdleTimeoutMs` | `120000`, counted only while a read is waiting |
168
+ | `tailMaxFailures` | `8`: consecutive failed reopens before `tailEvents` throws the last |
169
+ | `tokenRequestTimeoutMs` | `30000` |
170
+ | `userAgentSuffix` | none: appended after the library's own token |
171
+ | `fetch` | `globalThis.fetch` |
172
+ | `clock`, `sleeper` | the real ones |
173
+
174
+ `clock` and `sleeper` are **testing seams**: they let a test control refresh
175
+ timing and `Retry-After` sleeps without waiting in real time. Leave them
176
+ alone in production.
177
+
178
+ **The `version` option and the generated-for version.** The library's types
179
+ were generated from one frozen API version, which the warning below names. Leave
180
+ `version` unset and your OAuth client's server-side pin decides what is
181
+ served; the library never sends a default. When a response is served from
182
+ another version, the library logs one `console.warn` per served id: the
183
+ response shapes may differ from the types. Pin the OAuth client (or set
184
+ `version`) to the generated-for version, or upgrade the library.
185
+
186
+ ## The contract
187
+
188
+ This library keeps the contract every official Lingara library keeps, and
189
+ runs its conformance suite on Node 22 and 24, Deno 2 and Bun 1:
190
+ [`conformance/CONTRACT.md`](../conformance/CONTRACT.md).
191
+
192
+ ## Licence
193
+
194
+ MIT.