@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 +21 -0
- package/README.md +192 -1
- package/dist/index.cjs +1391 -0
- package/dist/index.d.cts +1512 -0
- package/dist/index.d.ts +1512 -0
- package/dist/index.mjs +1368 -0
- package/package.json +56 -4
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1368 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var REDACTED = "[REDACTED]";
|
|
3
|
+
var INSPECT = /* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom");
|
|
4
|
+
function brand(name) {
|
|
5
|
+
return /* @__PURE__ */ Symbol.for(`lingara.error.${name}`);
|
|
6
|
+
}
|
|
7
|
+
function mark(target, name) {
|
|
8
|
+
Object.defineProperty(target, brand(name), { value: true, enumerable: false });
|
|
9
|
+
}
|
|
10
|
+
function hasBrand(value, name) {
|
|
11
|
+
return typeof value === "object" && value !== null && value[brand(name)] === true;
|
|
12
|
+
}
|
|
13
|
+
var LingaraError = class extends Error {
|
|
14
|
+
static [Symbol.hasInstance](value) {
|
|
15
|
+
return hasBrand(value, "LingaraError");
|
|
16
|
+
}
|
|
17
|
+
constructor(message, options) {
|
|
18
|
+
super(message, options);
|
|
19
|
+
this.name = new.target.name;
|
|
20
|
+
mark(this, "LingaraError");
|
|
21
|
+
}
|
|
22
|
+
/** The fields a caller reads, for `JSON.stringify` and inspection. */
|
|
23
|
+
toJSON() {
|
|
24
|
+
return { name: this.name, message: this.message, ...this.fields() };
|
|
25
|
+
}
|
|
26
|
+
[INSPECT]() {
|
|
27
|
+
return `${this.name}: ${this.message} ${JSON.stringify(this.fields())}`;
|
|
28
|
+
}
|
|
29
|
+
fields() {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
var ApiError = class extends LingaraError {
|
|
34
|
+
static [Symbol.hasInstance](value) {
|
|
35
|
+
return hasBrand(value, "ApiError");
|
|
36
|
+
}
|
|
37
|
+
status;
|
|
38
|
+
code;
|
|
39
|
+
retryAfter;
|
|
40
|
+
planId;
|
|
41
|
+
servedVersion;
|
|
42
|
+
constructor(init) {
|
|
43
|
+
super(init.message);
|
|
44
|
+
mark(this, "ApiError");
|
|
45
|
+
this.status = init.status;
|
|
46
|
+
this.code = init.code;
|
|
47
|
+
if (init.retryAfter !== void 0) this.retryAfter = init.retryAfter;
|
|
48
|
+
if (init.planId !== void 0) this.planId = init.planId;
|
|
49
|
+
if (init.servedVersion !== void 0) this.servedVersion = init.servedVersion;
|
|
50
|
+
}
|
|
51
|
+
fields() {
|
|
52
|
+
const { status, code, retryAfter, planId, servedVersion } = this;
|
|
53
|
+
return { status, code, retryAfter, planId, servedVersion };
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
var OAuthError = class extends LingaraError {
|
|
57
|
+
static [Symbol.hasInstance](value) {
|
|
58
|
+
return hasBrand(value, "OAuthError");
|
|
59
|
+
}
|
|
60
|
+
status;
|
|
61
|
+
error;
|
|
62
|
+
description;
|
|
63
|
+
retryAfter;
|
|
64
|
+
constructor(init) {
|
|
65
|
+
super(init.description ? `${init.error}: ${init.description}` : init.error);
|
|
66
|
+
mark(this, "OAuthError");
|
|
67
|
+
this.status = init.status;
|
|
68
|
+
this.error = init.error;
|
|
69
|
+
if (init.description !== void 0) this.description = init.description;
|
|
70
|
+
if (init.retryAfter !== void 0) this.retryAfter = init.retryAfter;
|
|
71
|
+
}
|
|
72
|
+
fields() {
|
|
73
|
+
const { status, error, description, retryAfter } = this;
|
|
74
|
+
return { status, error, description, retryAfter };
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
var MaintenanceError = class extends LingaraError {
|
|
78
|
+
static [Symbol.hasInstance](value) {
|
|
79
|
+
return hasBrand(value, "MaintenanceError");
|
|
80
|
+
}
|
|
81
|
+
/** The response text, at most 1 KiB. */
|
|
82
|
+
body;
|
|
83
|
+
retryAfter;
|
|
84
|
+
constructor(body, retryAfter) {
|
|
85
|
+
super("the Lingara API is under maintenance");
|
|
86
|
+
mark(this, "MaintenanceError");
|
|
87
|
+
this.body = body;
|
|
88
|
+
if (retryAfter !== void 0) this.retryAfter = retryAfter;
|
|
89
|
+
}
|
|
90
|
+
fields() {
|
|
91
|
+
return { body: this.body, retryAfter: this.retryAfter };
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
var TransportError = class extends LingaraError {
|
|
95
|
+
static [Symbol.hasInstance](value) {
|
|
96
|
+
return hasBrand(value, "TransportError");
|
|
97
|
+
}
|
|
98
|
+
kind;
|
|
99
|
+
constructor(kind, cause) {
|
|
100
|
+
super(`transport failure: ${kind}`, cause === void 0 ? void 0 : { cause });
|
|
101
|
+
mark(this, "TransportError");
|
|
102
|
+
this.kind = kind;
|
|
103
|
+
}
|
|
104
|
+
fields() {
|
|
105
|
+
return { kind: this.kind };
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
var MAINTENANCE_BODY_BYTES = 1024;
|
|
109
|
+
async function errorFromResponse(res, ctx) {
|
|
110
|
+
const text = await res.text().catch(() => "");
|
|
111
|
+
if (res.status === 503 && !isJson(res)) {
|
|
112
|
+
return new MaintenanceError(truncateUtf8(text, MAINTENANCE_BODY_BYTES), ctx.retryAfter);
|
|
113
|
+
}
|
|
114
|
+
const body = parseObject(text);
|
|
115
|
+
return ctx.endpoint === "token" ? oauthError(res.status, body, ctx) : apiError(res.status, body, ctx);
|
|
116
|
+
}
|
|
117
|
+
function apiError(status, body, ctx) {
|
|
118
|
+
const code = body?.["code"];
|
|
119
|
+
const message = body?.["error"];
|
|
120
|
+
const envelope2 = typeof code === "string" && typeof message === "string";
|
|
121
|
+
return new ApiError({
|
|
122
|
+
status,
|
|
123
|
+
code: envelope2 ? code : `http_${status}`,
|
|
124
|
+
message: envelope2 ? message : `HTTP ${status}`,
|
|
125
|
+
retryAfter: ctx.retryAfter,
|
|
126
|
+
servedVersion: ctx.servedVersion
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
function oauthError(status, body, ctx) {
|
|
130
|
+
const error = body?.["error"];
|
|
131
|
+
if (typeof error !== "string") return new OAuthError({ status, error: `http_${status}`, retryAfter: ctx.retryAfter });
|
|
132
|
+
const description = body?.["error_description"];
|
|
133
|
+
return new OAuthError({
|
|
134
|
+
status,
|
|
135
|
+
error,
|
|
136
|
+
description: typeof description === "string" ? description : void 0,
|
|
137
|
+
retryAfter: ctx.retryAfter
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
function mediaType(res) {
|
|
141
|
+
return (res.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
|
|
142
|
+
}
|
|
143
|
+
function isJson(res) {
|
|
144
|
+
const type = mediaType(res);
|
|
145
|
+
return type === "application/json" || type.endsWith("+json");
|
|
146
|
+
}
|
|
147
|
+
function parseObject(text) {
|
|
148
|
+
try {
|
|
149
|
+
const value = JSON.parse(text);
|
|
150
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
|
|
151
|
+
} catch {
|
|
152
|
+
return void 0;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function truncateUtf8(text, maxBytes) {
|
|
156
|
+
const bytes = new TextEncoder().encode(text);
|
|
157
|
+
if (bytes.length <= maxBytes) return text;
|
|
158
|
+
return new TextDecoder().decode(bytes.slice(0, maxBytes)).replace(/�$/, "");
|
|
159
|
+
}
|
|
160
|
+
var TLS = /cert|ssl|tls|unable_to_verify/i;
|
|
161
|
+
var CONNECT = /refused|enotfound|eai_again|dns/i;
|
|
162
|
+
var RESET = /reset|epipe|socket|closed|aborted/i;
|
|
163
|
+
function transportKind(err, phase) {
|
|
164
|
+
const text = describe(err).join("\n");
|
|
165
|
+
if (TLS.test(text)) return "tls";
|
|
166
|
+
if (CONNECT.test(text)) return "connect";
|
|
167
|
+
if (RESET.test(text)) return "reset";
|
|
168
|
+
return phase === "fetch" ? "connect" : "reset";
|
|
169
|
+
}
|
|
170
|
+
function describe(err, depth = 0) {
|
|
171
|
+
if (typeof err !== "object" || err === null || depth > 4) return [];
|
|
172
|
+
const e = err;
|
|
173
|
+
const own = [e.code, e.message].filter((v) => typeof v === "string");
|
|
174
|
+
return [...own, ...describe(e.cause, depth + 1)];
|
|
175
|
+
}
|
|
176
|
+
function redactCause(err, secrets, depth = 0) {
|
|
177
|
+
const live = secrets.filter((s) => typeof s === "string" && s.length > 0);
|
|
178
|
+
if (typeof err === "string") return scrub(err, live);
|
|
179
|
+
if (typeof err !== "object" || err === null || depth > 4) return err;
|
|
180
|
+
return redactObject(err, live, depth);
|
|
181
|
+
}
|
|
182
|
+
function redactObject(e, secrets, depth) {
|
|
183
|
+
const copy = new Error(scrub(String(e.message ?? ""), secrets));
|
|
184
|
+
copy.name = typeof e.name === "string" ? e.name : "Error";
|
|
185
|
+
if (typeof e.stack === "string") copy.stack = scrub(e.stack, secrets);
|
|
186
|
+
if (typeof e.code === "string") Object.assign(copy, { code: e.code });
|
|
187
|
+
if (e.cause !== void 0) copy.cause = redactCause(e.cause, secrets, depth + 1);
|
|
188
|
+
return copy;
|
|
189
|
+
}
|
|
190
|
+
function scrub(text, secrets) {
|
|
191
|
+
return secrets.reduce((acc, s) => acc.split(s).join(REDACTED), text);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// src/generated/events.ts
|
|
195
|
+
var UNKNOWN = /* @__PURE__ */ Symbol.for("lingara.event.UnknownEvent");
|
|
196
|
+
var UnknownEvent = class {
|
|
197
|
+
static [Symbol.hasInstance](value) {
|
|
198
|
+
return typeof value === "object" && value !== null && value[UNKNOWN] === true;
|
|
199
|
+
}
|
|
200
|
+
// Nominal, so a known arm is never mistaken for this one by the compiler.
|
|
201
|
+
#unknown = true;
|
|
202
|
+
id;
|
|
203
|
+
type;
|
|
204
|
+
createdAt;
|
|
205
|
+
apiVersion;
|
|
206
|
+
subject;
|
|
207
|
+
/** The raw JSON value. */
|
|
208
|
+
data;
|
|
209
|
+
constructor(fields) {
|
|
210
|
+
Object.defineProperty(this, UNKNOWN, { value: this.#unknown, enumerable: false });
|
|
211
|
+
this.id = fields.id;
|
|
212
|
+
this.type = fields.type;
|
|
213
|
+
this.createdAt = fields.createdAt;
|
|
214
|
+
this.apiVersion = fields.apiVersion;
|
|
215
|
+
this.subject = fields.subject;
|
|
216
|
+
this.data = fields.data;
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
var InboundEvent = {
|
|
220
|
+
/** `world.context_changed`. */
|
|
221
|
+
worldContextChanged: (data) => ({ type: "world.context_changed", data }),
|
|
222
|
+
/** `world.practice_requested`. */
|
|
223
|
+
worldPracticeRequested: (data) => ({ type: "world.practice_requested", data })
|
|
224
|
+
};
|
|
225
|
+
var SHAPES = {
|
|
226
|
+
"lesson_plan.ready": { "type": ["object"], "properties": { "plan_id": { "type": ["string"] }, "status": { "type": ["string"], "enum": ["complete", "partial"] }, "title": { "type": ["string", "null"] }, "source_lang": { "type": ["string"] }, "target_lang": { "type": ["string"] }, "level": { "type": ["integer"] } }, "required": ["plan_id", "status", "title", "source_lang", "target_lang", "level"] },
|
|
227
|
+
"lesson_plan.failed": { "type": ["object"], "properties": { "plan_id": { "type": ["string"] }, "reason": { "type": ["string"], "enum": ["generation_failed", "timed_out"] } }, "required": ["plan_id", "reason"] },
|
|
228
|
+
"usage.threshold_reached": { "type": ["object"], "properties": { "scope": { "type": ["string"], "enum": ["account", "client"] }, "threshold_pct": { "type": ["integer"], "enum": [50, 70, 90, 100] }, "month": { "type": ["string"] }, "client_id": { "type": ["string", "null"] } }, "required": ["scope", "threshold_pct", "month"] },
|
|
229
|
+
"webhook.test": { "type": ["object"] },
|
|
230
|
+
"app.installed": { "type": ["object"], "properties": { "client_id": { "type": ["string"] }, "install_id": { "type": ["string"] }, "context": { "type": ["array"], "items": { "type": ["string"], "enum": ["languages", "plan_summary", "review_due", "tutor_topic"] } }, "tutor_note": { "type": ["boolean"] } }, "required": ["client_id", "install_id", "context", "tutor_note"] },
|
|
231
|
+
"app.uninstalled": { "type": ["object"], "properties": { "client_id": { "type": ["string"] }, "install_id": { "type": ["string"] } }, "required": ["client_id", "install_id"] }
|
|
232
|
+
};
|
|
233
|
+
function isType(type, value) {
|
|
234
|
+
if (type === "null") return value === null;
|
|
235
|
+
if (type === "array") return Array.isArray(value);
|
|
236
|
+
if (type === "object") return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
237
|
+
if (type === "integer") return Number.isInteger(value);
|
|
238
|
+
return typeof value === type;
|
|
239
|
+
}
|
|
240
|
+
function conforms(s, value) {
|
|
241
|
+
if (s.anyOf && !s.anyOf.some((a) => conforms(a, value))) return false;
|
|
242
|
+
if (s.enum && !s.enum.includes(value)) return false;
|
|
243
|
+
if (s.type && !s.type.some((t) => isType(t, value))) return false;
|
|
244
|
+
if (Array.isArray(value)) return !s.items || value.every((v) => conforms(s.items, v));
|
|
245
|
+
if (typeof value !== "object" || value === null) return true;
|
|
246
|
+
const o = value;
|
|
247
|
+
if (!(s.required ?? []).every((k) => Object.hasOwn(o, k))) return false;
|
|
248
|
+
const present = Object.entries(s.properties ?? {}).filter(([k]) => Object.hasOwn(o, k) && (o[k] !== null || s.required?.includes(k)));
|
|
249
|
+
return present.every(([k, p]) => conforms(p, o[k]));
|
|
250
|
+
}
|
|
251
|
+
var ENVELOPE = ["id", "type", "created_at", "api_version", "subject"];
|
|
252
|
+
function envelope(value) {
|
|
253
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TransportError("malformed_event");
|
|
254
|
+
const o = value;
|
|
255
|
+
if (!ENVELOPE.every((k) => typeof o[k] === "string") || !Object.hasOwn(o, "data")) throw new TransportError("malformed_event");
|
|
256
|
+
const [id, type, createdAt, apiVersion, subject] = ENVELOPE.map((k) => o[k]);
|
|
257
|
+
return { id, type, createdAt, apiVersion, subject, data: o["data"] };
|
|
258
|
+
}
|
|
259
|
+
function parseEvent(json) {
|
|
260
|
+
let value = json;
|
|
261
|
+
if (typeof json === "string") {
|
|
262
|
+
try {
|
|
263
|
+
value = JSON.parse(json);
|
|
264
|
+
} catch {
|
|
265
|
+
throw new TransportError("malformed_event");
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const event = envelope(value);
|
|
269
|
+
const s = Object.hasOwn(SHAPES, event.type) ? SHAPES[event.type] : void 0;
|
|
270
|
+
if (s === void 0) return new UnknownEvent(event);
|
|
271
|
+
if (!conforms(s, event.data)) throw new TransportError("malformed_event");
|
|
272
|
+
return event;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// src/events/feed.ts
|
|
276
|
+
function eventsQuery(params) {
|
|
277
|
+
const q = new URLSearchParams();
|
|
278
|
+
if (params.cursor !== void 0) q.set("cursor", params.cursor);
|
|
279
|
+
if (params.start !== void 0) q.set("start", params.start);
|
|
280
|
+
if (params.types !== void 0) q.set("types", params.types.join(","));
|
|
281
|
+
if (params.limit !== void 0) q.set("limit", String(params.limit));
|
|
282
|
+
const text = q.toString();
|
|
283
|
+
return text === "" ? "" : `?${text}`;
|
|
284
|
+
}
|
|
285
|
+
var EventFeed = class {
|
|
286
|
+
#page;
|
|
287
|
+
#types;
|
|
288
|
+
#query;
|
|
289
|
+
#cursor;
|
|
290
|
+
#items = [];
|
|
291
|
+
#next;
|
|
292
|
+
#more = true;
|
|
293
|
+
constructor(params, page) {
|
|
294
|
+
this.#page = page;
|
|
295
|
+
this.#types = params.types;
|
|
296
|
+
this.#cursor = params.cursor;
|
|
297
|
+
const from = params.cursor !== void 0 ? { cursor: params.cursor } : params.start !== void 0 ? { start: params.start } : {};
|
|
298
|
+
this.#query = this.#withTypes(from);
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Where to resume: once a page's last item has been yielded (or the page
|
|
302
|
+
* was empty), that page's `next_cursor`; before the first page, the
|
|
303
|
+
* caller's `cursor`.
|
|
304
|
+
*/
|
|
305
|
+
get cursor() {
|
|
306
|
+
return this.#cursor;
|
|
307
|
+
}
|
|
308
|
+
[Symbol.asyncIterator]() {
|
|
309
|
+
return this;
|
|
310
|
+
}
|
|
311
|
+
async next() {
|
|
312
|
+
for (; ; ) {
|
|
313
|
+
if (this.#items.length > 0) return { done: false, value: this.#take() };
|
|
314
|
+
if (!this.#more) return { done: true, value: void 0 };
|
|
315
|
+
await this.#fetch();
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
async return() {
|
|
319
|
+
this.#more = false;
|
|
320
|
+
this.#items = [];
|
|
321
|
+
return { done: true, value: void 0 };
|
|
322
|
+
}
|
|
323
|
+
#take() {
|
|
324
|
+
let event;
|
|
325
|
+
try {
|
|
326
|
+
event = parseEvent(this.#items.shift());
|
|
327
|
+
} catch (err) {
|
|
328
|
+
this.#more = false;
|
|
329
|
+
this.#items = [];
|
|
330
|
+
throw err;
|
|
331
|
+
}
|
|
332
|
+
if (this.#items.length === 0) this.#cursor = this.#next;
|
|
333
|
+
return event;
|
|
334
|
+
}
|
|
335
|
+
async #fetch() {
|
|
336
|
+
let page;
|
|
337
|
+
try {
|
|
338
|
+
page = await this.#page(this.#query);
|
|
339
|
+
} catch (err) {
|
|
340
|
+
this.#more = false;
|
|
341
|
+
throw err;
|
|
342
|
+
}
|
|
343
|
+
this.#items = [...page.items];
|
|
344
|
+
this.#next = page.next_cursor;
|
|
345
|
+
this.#more = page.has_more;
|
|
346
|
+
this.#query = this.#withTypes({ cursor: page.next_cursor });
|
|
347
|
+
if (this.#items.length === 0) this.#cursor = page.next_cursor;
|
|
348
|
+
}
|
|
349
|
+
#withTypes(query) {
|
|
350
|
+
return this.#types === void 0 ? query : { ...query, types: this.#types };
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
// src/events/send.ts
|
|
355
|
+
function sendEventRequest(event, options) {
|
|
356
|
+
const key = options.idempotencyKey ?? globalThis.crypto.randomUUID();
|
|
357
|
+
return { body: { type: event.type, data: event.data }, headers: { "idempotency-key": key } };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// src/events/tail.ts
|
|
361
|
+
var FIRST_DELAY_S = 1;
|
|
362
|
+
var MAX_DELAY_S = 30;
|
|
363
|
+
function backoffSeconds(failures) {
|
|
364
|
+
return Math.min(MAX_DELAY_S, FIRST_DELAY_S * 2 ** (failures - 1));
|
|
365
|
+
}
|
|
366
|
+
function failureWait(err, capSeconds) {
|
|
367
|
+
if (err instanceof TransportError && err.kind !== "malformed_event") return void 0;
|
|
368
|
+
if (err instanceof ApiError && err.status === 200) return void 0;
|
|
369
|
+
if (!isBusy(err)) throw err;
|
|
370
|
+
if (err.retryAfter !== void 0 && err.retryAfter > capSeconds) throw err;
|
|
371
|
+
return err.retryAfter;
|
|
372
|
+
}
|
|
373
|
+
function isBusy(err) {
|
|
374
|
+
return err instanceof MaintenanceError || err instanceof ApiError && (err.status === 429 || err.status === 503);
|
|
375
|
+
}
|
|
376
|
+
var REOPEN = /* @__PURE__ */ Symbol("lingara.reopen");
|
|
377
|
+
var EventTail = class {
|
|
378
|
+
#init;
|
|
379
|
+
#own = new AbortController();
|
|
380
|
+
#signal;
|
|
381
|
+
#cursor;
|
|
382
|
+
#stream;
|
|
383
|
+
#failures = 0;
|
|
384
|
+
#done = false;
|
|
385
|
+
constructor(init) {
|
|
386
|
+
this.#init = init;
|
|
387
|
+
this.#cursor = init.cursor;
|
|
388
|
+
this.#signal = init.signal ? AbortSignal.any([init.signal, this.#own.signal]) : this.#own.signal;
|
|
389
|
+
}
|
|
390
|
+
/** Where to resume: hand it to `tailEvents` or `events` later. */
|
|
391
|
+
get cursor() {
|
|
392
|
+
return this.#cursor;
|
|
393
|
+
}
|
|
394
|
+
[Symbol.asyncIterator]() {
|
|
395
|
+
return this;
|
|
396
|
+
}
|
|
397
|
+
async next() {
|
|
398
|
+
if (this.#done) return { done: true, value: void 0 };
|
|
399
|
+
try {
|
|
400
|
+
for (; ; ) {
|
|
401
|
+
const step = await this.#step();
|
|
402
|
+
if (step !== REOPEN) return { done: false, value: step };
|
|
403
|
+
}
|
|
404
|
+
} catch (err) {
|
|
405
|
+
await this.close();
|
|
406
|
+
throw err;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
async return() {
|
|
410
|
+
await this.close();
|
|
411
|
+
return { done: true, value: void 0 };
|
|
412
|
+
}
|
|
413
|
+
/** Ends the tail and closes the connection. Idempotent. */
|
|
414
|
+
async close() {
|
|
415
|
+
if (this.#done) return;
|
|
416
|
+
this.#done = true;
|
|
417
|
+
if (!this.#own.signal.aborted) this.#own.abort();
|
|
418
|
+
await this.#stream?.close();
|
|
419
|
+
}
|
|
420
|
+
/** One frame's worth of progress: an event, or a reopen to make. */
|
|
421
|
+
async #step() {
|
|
422
|
+
const stream = this.#stream ??= this.#init.open(this.#cursor, this.#signal);
|
|
423
|
+
let result;
|
|
424
|
+
try {
|
|
425
|
+
result = await stream.next();
|
|
426
|
+
} catch (err) {
|
|
427
|
+
this.#stream = void 0;
|
|
428
|
+
await this.#failed(err);
|
|
429
|
+
return REOPEN;
|
|
430
|
+
}
|
|
431
|
+
this.#cursor = stream.lastEventId ?? this.#cursor;
|
|
432
|
+
this.#failures = 0;
|
|
433
|
+
if (!result.done) return parseEvent(result.value.data);
|
|
434
|
+
this.#stream = void 0;
|
|
435
|
+
return REOPEN;
|
|
436
|
+
}
|
|
437
|
+
async #failed(err) {
|
|
438
|
+
this.#signal.throwIfAborted();
|
|
439
|
+
const wait = failureWait(err, this.#init.retryAfterCapSeconds);
|
|
440
|
+
this.#failures++;
|
|
441
|
+
if (this.#failures >= this.#init.maxFailures) throw err;
|
|
442
|
+
const seconds = wait ?? backoffSeconds(this.#failures);
|
|
443
|
+
await this.#init.sleeper(seconds * 1e3, this.#signal);
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
// src/generated/streams.ts
|
|
448
|
+
var STREAMS = {
|
|
449
|
+
generateVocabulary: {
|
|
450
|
+
method: "POST",
|
|
451
|
+
path: "/v1/vocab/stream",
|
|
452
|
+
requestBody: "VocabRequest",
|
|
453
|
+
union: "GenerateVocabularyEvent",
|
|
454
|
+
events: ["started", "item", "done", "error"],
|
|
455
|
+
ends: { done: "end", error: "raise" }
|
|
456
|
+
},
|
|
457
|
+
createLessonPlan: {
|
|
458
|
+
method: "POST",
|
|
459
|
+
path: "/v1/lesson-plans",
|
|
460
|
+
requestBody: "LessonPlanCreateRequest",
|
|
461
|
+
union: "CreateLessonPlanEvent",
|
|
462
|
+
events: ["started", "phase", "result", "error"],
|
|
463
|
+
ends: { result: "yield", error: "raise" }
|
|
464
|
+
},
|
|
465
|
+
streamLessonPlan: {
|
|
466
|
+
method: "GET",
|
|
467
|
+
path: "/v1/lesson-plans/{id}/stream",
|
|
468
|
+
requestBody: null,
|
|
469
|
+
union: "StreamLessonPlanEvent",
|
|
470
|
+
events: ["started", "phase", "result", "pending", "error"],
|
|
471
|
+
ends: { result: "yield", pending: "yield", error: "raise" }
|
|
472
|
+
},
|
|
473
|
+
sendTutorMessage: {
|
|
474
|
+
method: "POST",
|
|
475
|
+
path: "/v1/tutor/message",
|
|
476
|
+
requestBody: "TutorTurnRequest",
|
|
477
|
+
union: "SendTutorMessageEvent",
|
|
478
|
+
events: ["delta", "notice", "done", "error"],
|
|
479
|
+
ends: { done: "end", error: "raise" }
|
|
480
|
+
},
|
|
481
|
+
streamEvents: {
|
|
482
|
+
method: "GET",
|
|
483
|
+
path: "/v1/events/stream",
|
|
484
|
+
requestBody: null,
|
|
485
|
+
union: "StreamEventsEvent",
|
|
486
|
+
events: ["event", "done", "error"],
|
|
487
|
+
ends: { done: "end", error: "raise" }
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
// src/seams.ts
|
|
492
|
+
var systemClock = { now: () => Date.now() };
|
|
493
|
+
var realSleeper = (ms, signal) => raceAbort(
|
|
494
|
+
new Promise((resolve) => {
|
|
495
|
+
const timer = setTimeout(resolve, ms);
|
|
496
|
+
signal?.addEventListener("abort", () => clearTimeout(timer), { once: true });
|
|
497
|
+
}),
|
|
498
|
+
signal
|
|
499
|
+
);
|
|
500
|
+
function raceAbort(promise, signal) {
|
|
501
|
+
if (!signal) return promise;
|
|
502
|
+
if (signal.aborted) {
|
|
503
|
+
promise.catch(() => void 0);
|
|
504
|
+
return Promise.reject(signal.reason);
|
|
505
|
+
}
|
|
506
|
+
return new Promise((resolve, reject) => {
|
|
507
|
+
const onAbort = () => reject(signal.reason);
|
|
508
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
509
|
+
promise.then(
|
|
510
|
+
(value) => {
|
|
511
|
+
signal.removeEventListener("abort", onAbort);
|
|
512
|
+
resolve(value);
|
|
513
|
+
},
|
|
514
|
+
(error) => {
|
|
515
|
+
signal.removeEventListener("abort", onAbort);
|
|
516
|
+
reject(error);
|
|
517
|
+
}
|
|
518
|
+
);
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// src/retry.ts
|
|
523
|
+
var DELTA_SECONDS = /^\d+$/;
|
|
524
|
+
function parseRetryAfter(value, clock) {
|
|
525
|
+
if (value === null) return void 0;
|
|
526
|
+
const trimmed = value.trim();
|
|
527
|
+
if (DELTA_SECONDS.test(trimmed)) return Number(trimmed);
|
|
528
|
+
const at = Date.parse(trimmed);
|
|
529
|
+
if (Number.isNaN(at)) return void 0;
|
|
530
|
+
return Math.max(0, Math.ceil((at - clock.now()) / 1e3));
|
|
531
|
+
}
|
|
532
|
+
async function withRetries(policy, signal, attempt) {
|
|
533
|
+
for (let tries = 1; ; tries++) {
|
|
534
|
+
const res = await attempt();
|
|
535
|
+
const wait = retryWait(res, policy, tries);
|
|
536
|
+
if (wait === void 0) return res;
|
|
537
|
+
await res.body?.cancel().catch(() => void 0);
|
|
538
|
+
await policy.sleeper(wait * 1e3, signal);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
function retryWait(res, policy, tries) {
|
|
542
|
+
if (res.status !== 429 && res.status !== 503) return void 0;
|
|
543
|
+
if (tries >= policy.maxAttempts) return void 0;
|
|
544
|
+
const seconds = parseRetryAfter(res.headers.get("retry-after"), policy.clock);
|
|
545
|
+
if (seconds === void 0 || seconds > policy.retryAfterCapSeconds) return void 0;
|
|
546
|
+
return seconds;
|
|
547
|
+
}
|
|
548
|
+
async function withTokenRetry(tokens, signal, send) {
|
|
549
|
+
const options = signal ? { signal } : {};
|
|
550
|
+
const first = await tokens.token(options);
|
|
551
|
+
const res = await send(first);
|
|
552
|
+
if (res.status !== 401) return res;
|
|
553
|
+
await res.body?.cancel().catch(() => void 0);
|
|
554
|
+
tokens.invalidate(first);
|
|
555
|
+
return send(await tokens.token(options));
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// src/transport.ts
|
|
559
|
+
async function fetchOnce(req) {
|
|
560
|
+
try {
|
|
561
|
+
return await req.fetch(req.url, req.init);
|
|
562
|
+
} catch (err) {
|
|
563
|
+
throw transportFailure(err, req.init.signal ?? void 0, "fetch", req.secrets);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
function transportFailure(err, signal, phase, secrets) {
|
|
567
|
+
if (signal?.aborted) return signal.reason;
|
|
568
|
+
if (err instanceof TransportError) return err;
|
|
569
|
+
return new TransportError(transportKind(err, phase), redactCause(err, secrets));
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// src/userAgent.ts
|
|
573
|
+
var LIBRARY_VERSION = "0.1.0-alpha.12";
|
|
574
|
+
function runtimeToken(g = globalThis) {
|
|
575
|
+
const bun = g.Bun?.version;
|
|
576
|
+
if (typeof bun === "string") return clean(`bun/${bun}`);
|
|
577
|
+
const deno = g.Deno?.version?.deno;
|
|
578
|
+
if (typeof deno === "string") return clean(`deno/${deno}`);
|
|
579
|
+
const node = g.process?.versions?.node;
|
|
580
|
+
if (typeof node === "string") return clean(`node/${node}`);
|
|
581
|
+
return "unknown";
|
|
582
|
+
}
|
|
583
|
+
function clean(value) {
|
|
584
|
+
return value.replace(/[^\x21-\x28\x2A-\x7E]/g, "");
|
|
585
|
+
}
|
|
586
|
+
function userAgent(suffix) {
|
|
587
|
+
const own = `lingara-typescript/${LIBRARY_VERSION} (${runtimeToken()})`;
|
|
588
|
+
return suffix ? `${own} ${suffix}` : own;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// src/token.ts
|
|
592
|
+
var DEFAULT_TOKEN_URL = "https://api.getlingara.com/oauth/token";
|
|
593
|
+
var ClientCredentials = class {
|
|
594
|
+
clientId;
|
|
595
|
+
#secret;
|
|
596
|
+
#cached;
|
|
597
|
+
#flight;
|
|
598
|
+
#options;
|
|
599
|
+
constructor(options) {
|
|
600
|
+
this.clientId = options.clientId;
|
|
601
|
+
this.#secret = options.clientSecret;
|
|
602
|
+
this.#options = {
|
|
603
|
+
auth: options.auth ?? "basic",
|
|
604
|
+
scopes: options.scopes,
|
|
605
|
+
tokenUrl: options.tokenUrl ?? DEFAULT_TOKEN_URL,
|
|
606
|
+
maxAttempts: options.maxAttempts ?? 3,
|
|
607
|
+
retryAfterCapSeconds: options.retryAfterCapSeconds ?? 60,
|
|
608
|
+
tokenRequestTimeoutMs: options.tokenRequestTimeoutMs ?? 3e4,
|
|
609
|
+
userAgent: userAgent(options.userAgentSuffix),
|
|
610
|
+
clock: options.clock ?? systemClock,
|
|
611
|
+
sleeper: options.sleeper ?? realSleeper,
|
|
612
|
+
fetch: options.fetch ?? ((input, init) => globalThis.fetch(input, init))
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
async token(options = {}) {
|
|
616
|
+
options.signal?.throwIfAborted();
|
|
617
|
+
const cached = this.#cached;
|
|
618
|
+
if (cached && this.#options.clock.now() < cached.staleAt) return cached.token;
|
|
619
|
+
if (!this.#flight) {
|
|
620
|
+
const flight = this.#exchange();
|
|
621
|
+
this.#flight = flight;
|
|
622
|
+
flight.then(
|
|
623
|
+
() => this.#settle(flight),
|
|
624
|
+
() => this.#settle(flight)
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
return raceAbort(this.#flight, options.signal);
|
|
628
|
+
}
|
|
629
|
+
invalidate(token) {
|
|
630
|
+
if (this.#cached?.token === token) this.#cached = void 0;
|
|
631
|
+
}
|
|
632
|
+
/** The cached access token, raw. The one accessor that does not redact. */
|
|
633
|
+
exposeToken() {
|
|
634
|
+
return this.#cached?.token;
|
|
635
|
+
}
|
|
636
|
+
toJSON() {
|
|
637
|
+
return { clientId: this.clientId, clientSecret: REDACTED, token: this.#cached ? REDACTED : void 0 };
|
|
638
|
+
}
|
|
639
|
+
[INSPECT]() {
|
|
640
|
+
return `ClientCredentials ${JSON.stringify(this.toJSON())}`;
|
|
641
|
+
}
|
|
642
|
+
toString() {
|
|
643
|
+
return this[INSPECT]();
|
|
644
|
+
}
|
|
645
|
+
#settle(flight) {
|
|
646
|
+
if (this.#flight === flight) this.#flight = void 0;
|
|
647
|
+
}
|
|
648
|
+
async #exchange() {
|
|
649
|
+
const o = this.#options;
|
|
650
|
+
const policy = { maxAttempts: o.maxAttempts, retryAfterCapSeconds: o.retryAfterCapSeconds, clock: o.clock, sleeper: o.sleeper };
|
|
651
|
+
let sentAt = o.clock.now();
|
|
652
|
+
const res = await withRetries(policy, void 0, () => {
|
|
653
|
+
sentAt = o.clock.now();
|
|
654
|
+
return this.#post();
|
|
655
|
+
});
|
|
656
|
+
if (!res.ok) {
|
|
657
|
+
const retryAfter = parseRetryAfter(res.headers.get("retry-after"), o.clock);
|
|
658
|
+
throw await errorFromResponse(res, { endpoint: "token", retryAfter });
|
|
659
|
+
}
|
|
660
|
+
const grant = await this.#readGrant(res);
|
|
661
|
+
const skew = Math.min(60, grant.expiresIn / 2);
|
|
662
|
+
this.#cached = { token: grant.token, staleAt: sentAt + (grant.expiresIn - skew) * 1e3 };
|
|
663
|
+
return grant.token;
|
|
664
|
+
}
|
|
665
|
+
async #post() {
|
|
666
|
+
const o = this.#options;
|
|
667
|
+
const headers = {
|
|
668
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
669
|
+
accept: "application/json",
|
|
670
|
+
"user-agent": o.userAgent
|
|
671
|
+
};
|
|
672
|
+
const body = new URLSearchParams({ grant_type: "client_credentials" });
|
|
673
|
+
if (o.scopes && o.scopes.length > 0) body.set("scope", o.scopes.join(" "));
|
|
674
|
+
if (o.auth === "post") {
|
|
675
|
+
body.set("client_id", this.clientId);
|
|
676
|
+
body.set("client_secret", this.#secret);
|
|
677
|
+
} else {
|
|
678
|
+
headers["authorization"] = `Basic ${btoa(`${formEncode(this.clientId)}:${formEncode(this.#secret)}`)}`;
|
|
679
|
+
}
|
|
680
|
+
const signal = AbortSignal.timeout(o.tokenRequestTimeoutMs);
|
|
681
|
+
try {
|
|
682
|
+
return await fetchOnce({ fetch: o.fetch, url: o.tokenUrl, init: { method: "POST", headers, body: body.toString(), signal }, secrets: [this.#secret] });
|
|
683
|
+
} catch (err) {
|
|
684
|
+
throw signal.aborted ? new TransportError("timeout") : err;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
async #readGrant(res) {
|
|
688
|
+
let body;
|
|
689
|
+
try {
|
|
690
|
+
body = await res.json();
|
|
691
|
+
} catch (err) {
|
|
692
|
+
throw this.#readFailure(err);
|
|
693
|
+
}
|
|
694
|
+
const grant = body ?? {};
|
|
695
|
+
const { access_token: token, expires_in: expiresIn, token_type: type } = grant;
|
|
696
|
+
const bearer = typeof type === "string" && type.toLowerCase() === "bearer";
|
|
697
|
+
if (typeof token !== "string" || typeof expiresIn !== "number" || !bearer) {
|
|
698
|
+
throw new TransportError("malformed_response");
|
|
699
|
+
}
|
|
700
|
+
return { token, expiresIn };
|
|
701
|
+
}
|
|
702
|
+
#readFailure(err) {
|
|
703
|
+
if (err instanceof SyntaxError) return new TransportError("malformed_response");
|
|
704
|
+
if (err?.name === "TimeoutError") return new TransportError("timeout");
|
|
705
|
+
return transportFailure(err, void 0, "body", [this.#secret]);
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
function formEncode(value) {
|
|
709
|
+
return new URLSearchParams([["", value]]).toString().slice(1);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// src/options.ts
|
|
713
|
+
function checkOptions(options) {
|
|
714
|
+
if (options.tokenSource && options.clientSecret !== void 0) {
|
|
715
|
+
throw new LingaraError("pass either tokenSource or clientSecret, not both");
|
|
716
|
+
}
|
|
717
|
+
if (options.version === "") throw new LingaraError("version must not be empty");
|
|
718
|
+
if (options.clientSecret !== void 0 && globalThis.document !== void 0) {
|
|
719
|
+
throw new LingaraError(
|
|
720
|
+
"a client secret must not be used in a browser: anyone who loads the page can read it. Call the Lingara API from your server."
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
if (options.clientSecret === void 0 !== (options.clientId === void 0) && !options.tokenSource) {
|
|
724
|
+
throw new LingaraError("clientId and clientSecret go together");
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
function retryPolicy(options) {
|
|
728
|
+
return {
|
|
729
|
+
maxAttempts: options.maxAttempts ?? 3,
|
|
730
|
+
retryAfterCapSeconds: options.retryAfterCapSeconds ?? 60,
|
|
731
|
+
clock: options.clock ?? systemClock,
|
|
732
|
+
sleeper: options.sleeper ?? realSleeper
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
function credentialsFrom(options) {
|
|
736
|
+
const { clientId, clientSecret } = options;
|
|
737
|
+
if (clientId === void 0 || clientSecret === void 0) return void 0;
|
|
738
|
+
return new ClientCredentials({ ...options, clientId, clientSecret });
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// src/sse.ts
|
|
742
|
+
var SseParser = class {
|
|
743
|
+
#buffer = "";
|
|
744
|
+
#event = "";
|
|
745
|
+
#data = [];
|
|
746
|
+
#hasData = false;
|
|
747
|
+
// WHATWG's last-event-id buffer: it persists across frames until the next
|
|
748
|
+
// `id` field, and is not reset on dispatch.
|
|
749
|
+
#lastEventId = "";
|
|
750
|
+
// A chunk ended on `\r`: a `\n` opening the next chunk is the same line end.
|
|
751
|
+
#skipLf = false;
|
|
752
|
+
/** Feeds decoded text; returns every frame it completed. */
|
|
753
|
+
push(text) {
|
|
754
|
+
let input = text;
|
|
755
|
+
if (this.#skipLf && input.length > 0) {
|
|
756
|
+
if (input.startsWith("\n")) input = input.slice(1);
|
|
757
|
+
this.#skipLf = false;
|
|
758
|
+
}
|
|
759
|
+
const frames = [];
|
|
760
|
+
const s = this.#buffer + input;
|
|
761
|
+
let start = 0;
|
|
762
|
+
for (; ; ) {
|
|
763
|
+
const end = lineEnd(s, start);
|
|
764
|
+
if (end < 0) break;
|
|
765
|
+
this.#line(s.slice(start, end), frames);
|
|
766
|
+
start = this.#afterEnding(s, end);
|
|
767
|
+
}
|
|
768
|
+
this.#buffer = s.slice(start);
|
|
769
|
+
return frames;
|
|
770
|
+
}
|
|
771
|
+
/** End of input: an undispatched frame is discarded, as WHATWG says. */
|
|
772
|
+
end() {
|
|
773
|
+
this.#buffer = "";
|
|
774
|
+
this.#reset();
|
|
775
|
+
return [];
|
|
776
|
+
}
|
|
777
|
+
#afterEnding(s, end) {
|
|
778
|
+
if (s[end] === "\n") return end + 1;
|
|
779
|
+
if (end + 1 < s.length) return s[end + 1] === "\n" ? end + 2 : end + 1;
|
|
780
|
+
this.#skipLf = true;
|
|
781
|
+
return end + 1;
|
|
782
|
+
}
|
|
783
|
+
#line(line, frames) {
|
|
784
|
+
if (line === "") {
|
|
785
|
+
if (this.#hasData) frames.push(this.#frame());
|
|
786
|
+
this.#reset();
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
if (line.startsWith(":")) return;
|
|
790
|
+
const colon = line.indexOf(":");
|
|
791
|
+
const field = colon < 0 ? line : line.slice(0, colon);
|
|
792
|
+
let value = colon < 0 ? "" : line.slice(colon + 1);
|
|
793
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
794
|
+
this.#field(field, value);
|
|
795
|
+
}
|
|
796
|
+
#field(field, value) {
|
|
797
|
+
if (field === "event") this.#event = value;
|
|
798
|
+
if (field === "data") {
|
|
799
|
+
this.#data.push(value);
|
|
800
|
+
this.#hasData = true;
|
|
801
|
+
}
|
|
802
|
+
if (field === "id" && !value.includes("\0")) this.#lastEventId = value;
|
|
803
|
+
}
|
|
804
|
+
#frame() {
|
|
805
|
+
const frame = { event: this.#event || "message", data: this.#data.join("\n") };
|
|
806
|
+
if (this.#lastEventId !== "") frame.id = this.#lastEventId;
|
|
807
|
+
return frame;
|
|
808
|
+
}
|
|
809
|
+
#reset() {
|
|
810
|
+
this.#event = "";
|
|
811
|
+
this.#data = [];
|
|
812
|
+
this.#hasData = false;
|
|
813
|
+
}
|
|
814
|
+
};
|
|
815
|
+
function lineEnd(s, from) {
|
|
816
|
+
for (let i = from; i < s.length; i++) {
|
|
817
|
+
const c = s[i];
|
|
818
|
+
if (c === "\n" || c === "\r") return i;
|
|
819
|
+
}
|
|
820
|
+
return -1;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// src/stream.ts
|
|
824
|
+
function outcomeOf(operation, event) {
|
|
825
|
+
const ends = STREAMS[operation].ends;
|
|
826
|
+
return Object.hasOwn(ends, event) ? ends[event] : void 0;
|
|
827
|
+
}
|
|
828
|
+
var IDLE = /* @__PURE__ */ Symbol("lingara.idle");
|
|
829
|
+
var CLOSED = /* @__PURE__ */ Symbol("lingara.closed");
|
|
830
|
+
var EventStream = class {
|
|
831
|
+
#init;
|
|
832
|
+
#own = new AbortController();
|
|
833
|
+
#signal;
|
|
834
|
+
#parser = new SseParser();
|
|
835
|
+
#decoder = new TextDecoder("utf-8");
|
|
836
|
+
#frames = [];
|
|
837
|
+
#started;
|
|
838
|
+
#reader;
|
|
839
|
+
#servedVersion;
|
|
840
|
+
#lastEventId;
|
|
841
|
+
#eof = false;
|
|
842
|
+
#done = false;
|
|
843
|
+
constructor(init) {
|
|
844
|
+
this.#init = init;
|
|
845
|
+
this.#signal = init.signal ? AbortSignal.any([init.signal, this.#own.signal]) : this.#own.signal;
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* The `Lingara-Version` echo. Reading it starts the request if iteration
|
|
849
|
+
* has not. Never rejects: `undefined` when the request fails or the stream
|
|
850
|
+
* closes first.
|
|
851
|
+
*/
|
|
852
|
+
get servedVersion() {
|
|
853
|
+
if (this.#done && !this.#started) return Promise.resolve(void 0);
|
|
854
|
+
return this.#start().then(
|
|
855
|
+
(opened) => opened.servedVersion,
|
|
856
|
+
() => void 0
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* The last `id:` the stream has carried, as of the frame last handled
|
|
861
|
+
* (CONTRACT.md K5, Parsing). Only the tail reads it (K5a).
|
|
862
|
+
*/
|
|
863
|
+
get lastEventId() {
|
|
864
|
+
return this.#lastEventId;
|
|
865
|
+
}
|
|
866
|
+
[Symbol.asyncIterator]() {
|
|
867
|
+
return this;
|
|
868
|
+
}
|
|
869
|
+
async next() {
|
|
870
|
+
if (this.#done) return { done: true, value: void 0 };
|
|
871
|
+
try {
|
|
872
|
+
await this.#start();
|
|
873
|
+
for (; ; ) {
|
|
874
|
+
const frame = this.#frames.shift();
|
|
875
|
+
if (frame) {
|
|
876
|
+
const result = this.#handle(frame);
|
|
877
|
+
if (result) return result;
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
880
|
+
if (this.#eof) throw new TransportError("stream_ended_early");
|
|
881
|
+
await this.#read();
|
|
882
|
+
}
|
|
883
|
+
} catch (err) {
|
|
884
|
+
return this.#fail(err);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
async return() {
|
|
888
|
+
await this.close();
|
|
889
|
+
return { done: true, value: void 0 };
|
|
890
|
+
}
|
|
891
|
+
/** Ends the stream and closes the connection. Idempotent. */
|
|
892
|
+
async close() {
|
|
893
|
+
this.#finish(CLOSED);
|
|
894
|
+
}
|
|
895
|
+
#start() {
|
|
896
|
+
this.#started ??= this.#init.open(this.#signal).then((opened) => {
|
|
897
|
+
this.#servedVersion = opened.servedVersion;
|
|
898
|
+
const body = opened.response.body;
|
|
899
|
+
if (!body) throw new TransportError("stream_ended_early");
|
|
900
|
+
this.#reader = body.getReader();
|
|
901
|
+
if (this.#done) this.#reader.cancel().catch(() => void 0);
|
|
902
|
+
return opened;
|
|
903
|
+
});
|
|
904
|
+
return this.#started;
|
|
905
|
+
}
|
|
906
|
+
async #read() {
|
|
907
|
+
const reader = this.#reader;
|
|
908
|
+
const timer = setTimeout(() => this.#own.abort(IDLE), this.#init.idleTimeoutMs);
|
|
909
|
+
try {
|
|
910
|
+
const chunk = await raceAbort(reader.read(), this.#signal);
|
|
911
|
+
if (chunk.done) {
|
|
912
|
+
this.#frames.push(...this.#parser.push(this.#decoder.decode()), ...this.#parser.end());
|
|
913
|
+
this.#eof = true;
|
|
914
|
+
} else {
|
|
915
|
+
this.#frames.push(...this.#parser.push(this.#decoder.decode(chunk.value, { stream: true })));
|
|
916
|
+
}
|
|
917
|
+
} finally {
|
|
918
|
+
clearTimeout(timer);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
#handle(frame) {
|
|
922
|
+
if (frame.id !== void 0) this.#lastEventId = frame.id;
|
|
923
|
+
const events = STREAMS[this.#init.operation].events;
|
|
924
|
+
if (!events.includes(frame.event)) return void 0;
|
|
925
|
+
const data = decode(frame.data);
|
|
926
|
+
const outcome = outcomeOf(this.#init.operation, frame.event);
|
|
927
|
+
if (outcome === "raise") throw this.#streamError(data);
|
|
928
|
+
const value = { event: frame.event, data };
|
|
929
|
+
if (outcome === void 0) return { done: false, value };
|
|
930
|
+
this.#finish(CLOSED);
|
|
931
|
+
if (outcome === "end") return { done: true, value: void 0 };
|
|
932
|
+
return { done: false, value };
|
|
933
|
+
}
|
|
934
|
+
#streamError(data) {
|
|
935
|
+
const d = data ?? {};
|
|
936
|
+
return new ApiError({
|
|
937
|
+
status: 200,
|
|
938
|
+
code: typeof d.code === "string" ? d.code : "stream_error",
|
|
939
|
+
message: typeof d.message === "string" ? d.message : "the stream reported an error",
|
|
940
|
+
planId: typeof d.plan_id === "string" ? d.plan_id : void 0,
|
|
941
|
+
servedVersion: this.#servedVersion
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
#fail(err) {
|
|
945
|
+
const reason = this.#signal.aborted ? this.#signal.reason : void 0;
|
|
946
|
+
this.#finish(CLOSED);
|
|
947
|
+
if (reason === CLOSED) return { done: true, value: void 0 };
|
|
948
|
+
if (reason === IDLE) throw new TransportError("timeout");
|
|
949
|
+
if (reason !== void 0) throw reason;
|
|
950
|
+
if (err instanceof LingaraError) throw err;
|
|
951
|
+
throw new TransportError(transportKind(err, "body"), redactCause(err, []));
|
|
952
|
+
}
|
|
953
|
+
/** Marks the stream done and closes the connection; clears nothing twice. */
|
|
954
|
+
#finish(reason) {
|
|
955
|
+
if (this.#done) return;
|
|
956
|
+
this.#done = true;
|
|
957
|
+
if (!this.#own.signal.aborted) this.#own.abort(reason);
|
|
958
|
+
this.#reader?.cancel().catch(() => void 0);
|
|
959
|
+
}
|
|
960
|
+
};
|
|
961
|
+
function decode(data) {
|
|
962
|
+
try {
|
|
963
|
+
return JSON.parse(data);
|
|
964
|
+
} catch {
|
|
965
|
+
throw new TransportError("malformed_event");
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// src/generated/specVersion.ts
|
|
970
|
+
var GENERATED_FOR_VERSION = "2026-10-affable-towhee";
|
|
971
|
+
|
|
972
|
+
// src/version.ts
|
|
973
|
+
var UNIX_SECONDS = /^@(-?\d+)$/;
|
|
974
|
+
var IMF_FIXDATE = /^[A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT$/;
|
|
975
|
+
var LINK_TARGET = /^\s*<([^>]*)>/;
|
|
976
|
+
function parseDeprecation(value) {
|
|
977
|
+
const match = UNIX_SECONDS.exec(value.trim());
|
|
978
|
+
return match ? new Date(Number(match[1]) * 1e3) : void 0;
|
|
979
|
+
}
|
|
980
|
+
function parseSunset(value) {
|
|
981
|
+
const trimmed = value.trim();
|
|
982
|
+
if (!IMF_FIXDATE.test(trimmed)) return void 0;
|
|
983
|
+
const at = Date.parse(trimmed);
|
|
984
|
+
return Number.isNaN(at) ? void 0 : new Date(at);
|
|
985
|
+
}
|
|
986
|
+
function parseLink(raw, requestUrl) {
|
|
987
|
+
const target = LINK_TARGET.exec(raw)?.[1];
|
|
988
|
+
if (target === void 0) return { raw };
|
|
989
|
+
try {
|
|
990
|
+
return { raw, url: new URL(target, requestUrl) };
|
|
991
|
+
} catch {
|
|
992
|
+
return { raw };
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
function deprecationNotice(headers, requestUrl) {
|
|
996
|
+
const deprecation = headers.get("deprecation");
|
|
997
|
+
if (deprecation === null) return void 0;
|
|
998
|
+
const notice = { deprecation };
|
|
999
|
+
const version = headers.get("lingara-version");
|
|
1000
|
+
const sunset = headers.get("sunset");
|
|
1001
|
+
const link = headers.get("link");
|
|
1002
|
+
const deprecatedAt = parseDeprecation(deprecation);
|
|
1003
|
+
if (version !== null) notice.version = version;
|
|
1004
|
+
if (deprecatedAt) notice.deprecatedAt = deprecatedAt;
|
|
1005
|
+
if (sunset !== null) {
|
|
1006
|
+
notice.sunset = sunset;
|
|
1007
|
+
const sunsetAt = parseSunset(sunset);
|
|
1008
|
+
if (sunsetAt) notice.sunsetAt = sunsetAt;
|
|
1009
|
+
}
|
|
1010
|
+
if (link !== null) notice.link = parseLink(link, requestUrl);
|
|
1011
|
+
return notice;
|
|
1012
|
+
}
|
|
1013
|
+
var VersionObserver = class {
|
|
1014
|
+
#hook;
|
|
1015
|
+
#warned = /* @__PURE__ */ new Set();
|
|
1016
|
+
// Its own set: sharing `#warned` would let a version that is both
|
|
1017
|
+
// deprecated and mismatched warn only once in total.
|
|
1018
|
+
#mismatched = /* @__PURE__ */ new Set();
|
|
1019
|
+
constructor(hook) {
|
|
1020
|
+
this.#hook = hook;
|
|
1021
|
+
}
|
|
1022
|
+
/** Returns the `Lingara-Version` echo, after reporting any deprecation or mismatch. */
|
|
1023
|
+
observe(res, requestUrl) {
|
|
1024
|
+
const notice = deprecationNotice(res.headers, requestUrl);
|
|
1025
|
+
if (notice) this.#report(notice);
|
|
1026
|
+
const served = res.headers.get("lingara-version") ?? void 0;
|
|
1027
|
+
if (served !== void 0) this.#checkGenerated(served);
|
|
1028
|
+
return served;
|
|
1029
|
+
}
|
|
1030
|
+
#checkGenerated(served) {
|
|
1031
|
+
if (served === GENERATED_FOR_VERSION || this.#mismatched.has(served)) return;
|
|
1032
|
+
this.#mismatched.add(served);
|
|
1033
|
+
console.warn(
|
|
1034
|
+
`Lingara API version ${served} served this response, but this library's types were generated for ${GENERATED_FOR_VERSION}; response shapes may differ. Pin the OAuth client to ${GENERATED_FOR_VERSION} or upgrade the library.`
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
#report(notice) {
|
|
1038
|
+
if (!this.#hook) {
|
|
1039
|
+
const id = notice.version ?? "";
|
|
1040
|
+
if (this.#warned.has(id)) return;
|
|
1041
|
+
this.#warned.add(id);
|
|
1042
|
+
const sunset = notice.sunset ? `; sunset ${notice.sunset}` : "";
|
|
1043
|
+
console.warn(`Lingara API version ${id || "(unnamed)"} is deprecated${sunset}. See GET /v1/versions.`);
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
try {
|
|
1047
|
+
this.#hook(notice);
|
|
1048
|
+
} catch (err) {
|
|
1049
|
+
console.debug("Lingara deprecation hook threw", err);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
};
|
|
1053
|
+
|
|
1054
|
+
// src/client.ts
|
|
1055
|
+
var DEFAULT_BASE_URL = "https://api.getlingara.com";
|
|
1056
|
+
var Lingara = class {
|
|
1057
|
+
clientId;
|
|
1058
|
+
#tokens;
|
|
1059
|
+
#baseUrl;
|
|
1060
|
+
#version;
|
|
1061
|
+
#versions;
|
|
1062
|
+
#policy;
|
|
1063
|
+
#idleMs;
|
|
1064
|
+
#tailMaxFailures;
|
|
1065
|
+
#userAgent;
|
|
1066
|
+
#fetch;
|
|
1067
|
+
constructor(options = {}) {
|
|
1068
|
+
checkOptions(options);
|
|
1069
|
+
this.clientId = options.clientId;
|
|
1070
|
+
this.#tokens = options.tokenSource ?? credentialsFrom(options);
|
|
1071
|
+
this.#baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
1072
|
+
this.#version = options.version;
|
|
1073
|
+
this.#versions = new VersionObserver(options.onDeprecation);
|
|
1074
|
+
this.#policy = retryPolicy(options);
|
|
1075
|
+
this.#idleMs = options.streamIdleTimeoutMs ?? 12e4;
|
|
1076
|
+
this.#tailMaxFailures = options.tailMaxFailures ?? 8;
|
|
1077
|
+
this.#userAgent = userAgent(options.userAgentSuffix);
|
|
1078
|
+
this.#fetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
1079
|
+
}
|
|
1080
|
+
/** The token source in use, if the client has credentials. */
|
|
1081
|
+
get tokenSource() {
|
|
1082
|
+
return this.#tokens;
|
|
1083
|
+
}
|
|
1084
|
+
// --- streams -------------------------------------------------------------
|
|
1085
|
+
generateVocabulary(body, options = {}) {
|
|
1086
|
+
return this.#stream("generateVocabulary", { body }, options);
|
|
1087
|
+
}
|
|
1088
|
+
createLessonPlan(body, options = {}) {
|
|
1089
|
+
return this.#stream("createLessonPlan", { body }, options);
|
|
1090
|
+
}
|
|
1091
|
+
streamLessonPlan(params, options = {}) {
|
|
1092
|
+
return this.#stream("streamLessonPlan", { id: params.id }, options);
|
|
1093
|
+
}
|
|
1094
|
+
sendTutorMessage(body, options = {}) {
|
|
1095
|
+
return this.#stream("sendTutorMessage", { body }, options);
|
|
1096
|
+
}
|
|
1097
|
+
/** One connection under K5; `tailEvents` is the one that reconnects. */
|
|
1098
|
+
streamEvents(params = {}, options = {}) {
|
|
1099
|
+
const headers = params.lastEventId === void 0 ? {} : { "last-event-id": params.lastEventId };
|
|
1100
|
+
return this.#stream("streamEvents", { query: eventsQuery(params), headers }, options);
|
|
1101
|
+
}
|
|
1102
|
+
// --- events (ADR 30.9.26aa) ----------------------------------------------
|
|
1103
|
+
/** Every event from `cursor` (or `start`) to where the feed is caught up; never polls. */
|
|
1104
|
+
events(params = {}, options = {}) {
|
|
1105
|
+
return new EventFeed(params, (query) => this.listEvents(query, options));
|
|
1106
|
+
}
|
|
1107
|
+
/** Live events, reconnecting from `cursor` after every ending (CONTRACT.md K5a). */
|
|
1108
|
+
tailEvents(params = {}, options = {}) {
|
|
1109
|
+
const query = eventsQuery(params.cursor === void 0 ? { start: params.start, types: params.types } : { types: params.types });
|
|
1110
|
+
return new EventTail({
|
|
1111
|
+
open: (cursor, signal) => {
|
|
1112
|
+
const headers = cursor === void 0 ? {} : { "last-event-id": cursor };
|
|
1113
|
+
return this.#stream("streamEvents", { query, headers, retries: false }, { signal });
|
|
1114
|
+
},
|
|
1115
|
+
cursor: params.cursor,
|
|
1116
|
+
sleeper: this.#policy.sleeper,
|
|
1117
|
+
retryAfterCapSeconds: this.#policy.retryAfterCapSeconds,
|
|
1118
|
+
maxFailures: this.#tailMaxFailures,
|
|
1119
|
+
signal: options.signal
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
/** `{type, data}` with one `Idempotency-Key` across K4's attempts; the `202` body. */
|
|
1123
|
+
sendEvent(event, options = {}) {
|
|
1124
|
+
const { body, headers } = sendEventRequest(event, options);
|
|
1125
|
+
return this.#json("/v1/events", true, options, { method: "POST", body, headers });
|
|
1126
|
+
}
|
|
1127
|
+
// --- JSON ----------------------------------------------------------------
|
|
1128
|
+
getLessonPlan(params, options = {}) {
|
|
1129
|
+
return this.#json(`/v1/lesson-plans/${encodeURIComponent(params.id)}`, true, options);
|
|
1130
|
+
}
|
|
1131
|
+
getUsage(options = {}) {
|
|
1132
|
+
return this.#json("/v1/usage", true, options);
|
|
1133
|
+
}
|
|
1134
|
+
/** One page of events; `events()` walks them. */
|
|
1135
|
+
listEvents(params = {}, options = {}) {
|
|
1136
|
+
return this.#json(`/v1/events${eventsQuery(params)}`, true, options);
|
|
1137
|
+
}
|
|
1138
|
+
getOpenApiDocument(options = {}) {
|
|
1139
|
+
return this.#json("/v1/openapi.json", false, options);
|
|
1140
|
+
}
|
|
1141
|
+
getAsyncApiDocument(options = {}) {
|
|
1142
|
+
return this.#json("/v1/asyncapi.json", false, options);
|
|
1143
|
+
}
|
|
1144
|
+
listApiVersions(options = {}) {
|
|
1145
|
+
return this.#json("/v1/versions", false, options);
|
|
1146
|
+
}
|
|
1147
|
+
getApiVersion(params, options = {}) {
|
|
1148
|
+
return this.#json(`/v1/versions/${encodeURIComponent(params.id)}`, false, options);
|
|
1149
|
+
}
|
|
1150
|
+
// --- rendering: the secret and tokens never appear ------------------------
|
|
1151
|
+
toJSON() {
|
|
1152
|
+
const secret = this.#tokens instanceof ClientCredentials ? REDACTED : void 0;
|
|
1153
|
+
return { clientId: this.clientId, clientSecret: secret, baseUrl: this.#baseUrl, version: this.#version };
|
|
1154
|
+
}
|
|
1155
|
+
[INSPECT]() {
|
|
1156
|
+
return `Lingara ${JSON.stringify(this.toJSON())}`;
|
|
1157
|
+
}
|
|
1158
|
+
toString() {
|
|
1159
|
+
return this[INSPECT]();
|
|
1160
|
+
}
|
|
1161
|
+
// --- the pipeline --------------------------------------------------------
|
|
1162
|
+
#stream(op, input, options) {
|
|
1163
|
+
const route = STREAMS[op];
|
|
1164
|
+
const path = (input.id === void 0 ? route.path : route.path.replace("{id}", encodeURIComponent(input.id))) + (input.query ?? "");
|
|
1165
|
+
const req = { method: route.method, path, body: input.body, accept: "text/event-stream", needsToken: true, headers: input.headers };
|
|
1166
|
+
const open = (signal) => this.#openStream({ ...req, retries: input.retries ?? true, signal });
|
|
1167
|
+
return new EventStream({ operation: op, open, signal: options.signal, idleTimeoutMs: this.#idleMs });
|
|
1168
|
+
}
|
|
1169
|
+
async #openStream(req) {
|
|
1170
|
+
const res = await this.#send(req);
|
|
1171
|
+
if (mediaType(res) !== "text/event-stream") {
|
|
1172
|
+
await res.body?.cancel().catch(() => void 0);
|
|
1173
|
+
throw new TransportError("malformed_response");
|
|
1174
|
+
}
|
|
1175
|
+
const servedVersion = this.#versions.observe(res, this.#baseUrl + req.path);
|
|
1176
|
+
return { response: res, servedVersion };
|
|
1177
|
+
}
|
|
1178
|
+
async #json(path, needsToken, options, extra = {}) {
|
|
1179
|
+
const res = await this.#send({ method: "GET", path, accept: "application/json", needsToken, signal: options.signal, ...extra });
|
|
1180
|
+
const servedVersion = this.#versions.observe(res, this.#baseUrl + path);
|
|
1181
|
+
let body;
|
|
1182
|
+
try {
|
|
1183
|
+
body = await res.json();
|
|
1184
|
+
} catch (err) {
|
|
1185
|
+
if (err instanceof SyntaxError) throw new TransportError("malformed_response");
|
|
1186
|
+
throw transportFailure(err, options.signal, "body", []);
|
|
1187
|
+
}
|
|
1188
|
+
if (servedVersion !== void 0 && typeof body === "object" && body !== null) {
|
|
1189
|
+
Object.defineProperty(body, "servedVersion", { value: servedVersion, enumerable: false });
|
|
1190
|
+
}
|
|
1191
|
+
return body;
|
|
1192
|
+
}
|
|
1193
|
+
/** Auth, retries and error mapping; resolves with a 2xx response. */
|
|
1194
|
+
async #send(req) {
|
|
1195
|
+
const url = this.#baseUrl + req.path;
|
|
1196
|
+
const once = (token) => fetchOnce({ fetch: this.#fetch, url, init: this.#init(req, token), secrets: [token] });
|
|
1197
|
+
const attempt = req.retries === false ? once : (token) => withRetries(this.#policy, req.signal, () => once(token));
|
|
1198
|
+
let res;
|
|
1199
|
+
if (!req.needsToken) res = await attempt();
|
|
1200
|
+
else if (this.#tokens) res = await withTokenRetry(this.#tokens, req.signal, attempt);
|
|
1201
|
+
else throw new LingaraError("this operation needs credentials: construct the client with clientId and clientSecret");
|
|
1202
|
+
if (res.ok) return res;
|
|
1203
|
+
const retryAfter = parseRetryAfter(res.headers.get("retry-after"), this.#policy.clock);
|
|
1204
|
+
const servedVersion = res.headers.get("lingara-version") ?? void 0;
|
|
1205
|
+
throw await errorFromResponse(res, { endpoint: "v1", retryAfter, servedVersion });
|
|
1206
|
+
}
|
|
1207
|
+
#init(req, token) {
|
|
1208
|
+
const headers = { accept: req.accept, "user-agent": this.#userAgent, ...req.headers };
|
|
1209
|
+
if (token !== void 0) headers["authorization"] = `Bearer ${token}`;
|
|
1210
|
+
if (this.#version !== void 0) headers["lingara-version"] = this.#version;
|
|
1211
|
+
const init = { method: req.method, headers };
|
|
1212
|
+
if (req.body !== void 0) {
|
|
1213
|
+
headers["content-type"] = "application/json";
|
|
1214
|
+
init.body = JSON.stringify(req.body);
|
|
1215
|
+
}
|
|
1216
|
+
if (req.signal) init.signal = req.signal;
|
|
1217
|
+
return init;
|
|
1218
|
+
}
|
|
1219
|
+
};
|
|
1220
|
+
|
|
1221
|
+
// src/events/webhook.ts
|
|
1222
|
+
var MESSAGES = {
|
|
1223
|
+
missing_header: "a webhook-id, webhook-timestamp or webhook-signature header is missing",
|
|
1224
|
+
malformed_header: "webhook-timestamp is not a whole number of seconds",
|
|
1225
|
+
timestamp_too_old: "webhook-timestamp is more than 300 s old",
|
|
1226
|
+
timestamp_too_new: "webhook-timestamp is more than 300 s in the future",
|
|
1227
|
+
no_matching_signature: "no signature matches the body",
|
|
1228
|
+
malformed_payload: "the signed body is not a Lingara event"
|
|
1229
|
+
};
|
|
1230
|
+
var BRAND = /* @__PURE__ */ Symbol.for("lingara.webhook.WebhookVerificationError");
|
|
1231
|
+
var WebhookVerificationError = class extends Error {
|
|
1232
|
+
static [Symbol.hasInstance](value) {
|
|
1233
|
+
return typeof value === "object" && value !== null && value[BRAND] === true;
|
|
1234
|
+
}
|
|
1235
|
+
reason;
|
|
1236
|
+
constructor(reason) {
|
|
1237
|
+
super(MESSAGES[reason]);
|
|
1238
|
+
this.name = "WebhookVerificationError";
|
|
1239
|
+
this.reason = reason;
|
|
1240
|
+
Object.defineProperty(this, BRAND, { value: true, enumerable: false });
|
|
1241
|
+
}
|
|
1242
|
+
};
|
|
1243
|
+
var PREFIX = "lgr_whsec_";
|
|
1244
|
+
var MIN_KEY_BYTES = 24;
|
|
1245
|
+
var SIGNATURE_BYTES = 32;
|
|
1246
|
+
var TOLERANCE_S = 300;
|
|
1247
|
+
var BASE64 = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
1248
|
+
var DIGITS = /^[0-9]+$/;
|
|
1249
|
+
function strictBase64(text) {
|
|
1250
|
+
if (!BASE64.test(text) || text.length % 4 !== 0) return void 0;
|
|
1251
|
+
try {
|
|
1252
|
+
return Uint8Array.from(atob(text), (c) => c.charCodeAt(0));
|
|
1253
|
+
} catch {
|
|
1254
|
+
return void 0;
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
function keyOf(secret) {
|
|
1258
|
+
const key = secret.startsWith(PREFIX) ? strictBase64(secret.slice(PREFIX.length)) : void 0;
|
|
1259
|
+
if (key === void 0 || key.length < MIN_KEY_BYTES) {
|
|
1260
|
+
throw new LingaraError("a webhook secret is lgr_whsec_ followed by padded base64 of at least 24 bytes");
|
|
1261
|
+
}
|
|
1262
|
+
return key;
|
|
1263
|
+
}
|
|
1264
|
+
function header(headers, name) {
|
|
1265
|
+
if (isFetchHeaders(headers)) return headers.get(name) ?? void 0;
|
|
1266
|
+
const entry = Object.entries(headers).find(([k]) => k.toLowerCase() === name);
|
|
1267
|
+
const value = entry?.[1];
|
|
1268
|
+
return typeof value === "string" || value === void 0 ? value : value.join(" ");
|
|
1269
|
+
}
|
|
1270
|
+
function isFetchHeaders(headers) {
|
|
1271
|
+
return typeof headers.get === "function";
|
|
1272
|
+
}
|
|
1273
|
+
function sameBytes(a, b) {
|
|
1274
|
+
if (a.length !== SIGNATURE_BYTES || b.length !== SIGNATURE_BYTES) return false;
|
|
1275
|
+
let diff = 0;
|
|
1276
|
+
for (let i = 0; i < SIGNATURE_BYTES; i++) diff |= a[i] ^ b[i];
|
|
1277
|
+
return diff === 0;
|
|
1278
|
+
}
|
|
1279
|
+
function v1Signatures(list) {
|
|
1280
|
+
return list.split(" ").filter((e) => e.startsWith("v1,")).map((e) => strictBase64(e.slice(3))).filter((s) => s !== void 0);
|
|
1281
|
+
}
|
|
1282
|
+
function signedContent(id, timestamp, body) {
|
|
1283
|
+
const encoder = new TextEncoder();
|
|
1284
|
+
const head = encoder.encode(`${id}.${timestamp}.`);
|
|
1285
|
+
const rest = typeof body === "string" ? encoder.encode(body) : body;
|
|
1286
|
+
const out = new Uint8Array(head.length + rest.length);
|
|
1287
|
+
out.set(head);
|
|
1288
|
+
out.set(rest, head.length);
|
|
1289
|
+
return out;
|
|
1290
|
+
}
|
|
1291
|
+
var Webhook = class {
|
|
1292
|
+
#secrets;
|
|
1293
|
+
#clock;
|
|
1294
|
+
#keys;
|
|
1295
|
+
constructor(secret, options = {}) {
|
|
1296
|
+
const secrets = typeof secret === "string" ? [secret] : secret;
|
|
1297
|
+
if (secrets.length === 0) throw new LingaraError("a Webhook needs at least one secret");
|
|
1298
|
+
this.#secrets = secrets.map(keyOf);
|
|
1299
|
+
this.#clock = options.clock ?? systemClock;
|
|
1300
|
+
}
|
|
1301
|
+
/** Signature checks, then the body parsed into an `Event` whose `id` is `webhook-id`. */
|
|
1302
|
+
async verify(body, headers) {
|
|
1303
|
+
const signed = await this.#check(body, headers);
|
|
1304
|
+
let event;
|
|
1305
|
+
try {
|
|
1306
|
+
event = parseEvent(typeof body === "string" ? body : new TextDecoder("utf-8", { fatal: true }).decode(body));
|
|
1307
|
+
} catch {
|
|
1308
|
+
throw new WebhookVerificationError("malformed_payload");
|
|
1309
|
+
}
|
|
1310
|
+
if (event.id !== signed.id) throw new WebhookVerificationError("malformed_payload");
|
|
1311
|
+
return event;
|
|
1312
|
+
}
|
|
1313
|
+
/** The signature checks alone, for a signed body that is not an event (an app-kit request). */
|
|
1314
|
+
async verifySignature(body, headers) {
|
|
1315
|
+
await this.#check(body, headers);
|
|
1316
|
+
}
|
|
1317
|
+
toJSON() {
|
|
1318
|
+
return { secrets: this.#secrets.map(() => REDACTED) };
|
|
1319
|
+
}
|
|
1320
|
+
toString() {
|
|
1321
|
+
return `Webhook ${JSON.stringify(this.toJSON())}`;
|
|
1322
|
+
}
|
|
1323
|
+
async #check(body, headers) {
|
|
1324
|
+
const signed = this.#headers(headers);
|
|
1325
|
+
const content = signedContent(signed.id, signed.timestamp, body);
|
|
1326
|
+
const candidates = v1Signatures(signed.signature);
|
|
1327
|
+
const keys = await this.#cryptoKeys();
|
|
1328
|
+
const expected = await Promise.all(keys.map((k) => globalThis.crypto.subtle.sign("HMAC", k, content)));
|
|
1329
|
+
const match = expected.some((e) => candidates.some((c) => sameBytes(new Uint8Array(e), c)));
|
|
1330
|
+
if (!match) throw new WebhookVerificationError("no_matching_signature");
|
|
1331
|
+
return signed;
|
|
1332
|
+
}
|
|
1333
|
+
#headers(headers) {
|
|
1334
|
+
const id = header(headers, "webhook-id");
|
|
1335
|
+
const timestamp = header(headers, "webhook-timestamp");
|
|
1336
|
+
const signature = header(headers, "webhook-signature");
|
|
1337
|
+
if (id === void 0 || timestamp === void 0 || signature === void 0) throw new WebhookVerificationError("missing_header");
|
|
1338
|
+
if (!DIGITS.test(timestamp)) throw new WebhookVerificationError("malformed_header");
|
|
1339
|
+
const age = this.#clock.now() / 1e3 - Number(timestamp);
|
|
1340
|
+
if (age > TOLERANCE_S) throw new WebhookVerificationError("timestamp_too_old");
|
|
1341
|
+
if (age < -TOLERANCE_S) throw new WebhookVerificationError("timestamp_too_new");
|
|
1342
|
+
return { id, timestamp, signature };
|
|
1343
|
+
}
|
|
1344
|
+
#cryptoKeys() {
|
|
1345
|
+
const algorithm = { name: "HMAC", hash: "SHA-256" };
|
|
1346
|
+
this.#keys ??= Promise.all(this.#secrets.map((k) => globalThis.crypto.subtle.importKey("raw", k, algorithm, false, ["sign"])));
|
|
1347
|
+
return this.#keys;
|
|
1348
|
+
}
|
|
1349
|
+
};
|
|
1350
|
+
export {
|
|
1351
|
+
ApiError,
|
|
1352
|
+
ClientCredentials,
|
|
1353
|
+
DEFAULT_BASE_URL,
|
|
1354
|
+
DEFAULT_TOKEN_URL,
|
|
1355
|
+
EventFeed,
|
|
1356
|
+
EventStream,
|
|
1357
|
+
EventTail,
|
|
1358
|
+
InboundEvent,
|
|
1359
|
+
Lingara,
|
|
1360
|
+
LingaraError,
|
|
1361
|
+
MaintenanceError,
|
|
1362
|
+
OAuthError,
|
|
1363
|
+
TransportError,
|
|
1364
|
+
UnknownEvent,
|
|
1365
|
+
Webhook,
|
|
1366
|
+
WebhookVerificationError,
|
|
1367
|
+
parseEvent
|
|
1368
|
+
};
|