@mycroblog/mykit 0.0.0-placeholder.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.
@@ -0,0 +1,6 @@
1
+ // SPDX-License-Identifier: MIT
2
+ /**
3
+ * This file was auto-generated by openapi-typescript.
4
+ * Do not make direct changes to the file.
5
+ */
6
+ export {};
package/dist/sse.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ /** One dispatched event: its type (`message` when the stream named none) and its data lines joined by LF. */
2
+ export type ServerSentEvent = {
3
+ event: string;
4
+ data: string;
5
+ };
6
+ /** Parses an event stream that arrives in pieces; feed it text with `push`, then call `end`. */
7
+ export declare class EventStreamParser {
8
+ #private;
9
+ /** Parses the next piece of text and returns the events it completed. */
10
+ push(text: string): ServerSentEvent[];
11
+ /** Ends the stream. An event that no blank line completed is discarded, as the standard says. */
12
+ end(): ServerSentEvent[];
13
+ }
package/dist/sse.js ADDED
@@ -0,0 +1,76 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // The parser of a text/event-stream body, as the HTML Standard interprets one ("Server-sent events",
3
+ // §9.2.6 Interpreting an event stream: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation).
4
+ // The stream client (stream.ts) reads the body with fetch, since EventSource cannot send the Authorization
5
+ // header, so it parses the body itself. The client API sends no `id` and no `retry`
6
+ // (it does not resume with Last-Event-ID), so those fields are ignored like any unknown field.
7
+ /** The end of a line: CRLF, LF or CR. */
8
+ const lineEnd = /\r\n|\n|\r/;
9
+ /** Parses an event stream that arrives in pieces; feed it text with `push`, then call `end`. */
10
+ export class EventStreamParser {
11
+ #buffer = "";
12
+ #started = false;
13
+ #event = "";
14
+ #data = "";
15
+ /** Parses the next piece of text and returns the events it completed. */
16
+ push(text) {
17
+ let input = this.#buffer + text;
18
+ if (!this.#started && input.length > 0) {
19
+ this.#started = true;
20
+ // A leading byte order mark is not part of the stream.
21
+ if (input.startsWith(""))
22
+ input = input.slice(1);
23
+ }
24
+ const events = [];
25
+ for (;;) {
26
+ const match = lineEnd.exec(input);
27
+ if (match === null)
28
+ break;
29
+ // A CR at the very end may be the first half of a CRLF that the next piece completes.
30
+ if (match[0] === "\r" && match.index === input.length - 1)
31
+ break;
32
+ const event = this.#line(input.slice(0, match.index));
33
+ if (event !== null)
34
+ events.push(event);
35
+ input = input.slice(match.index + match[0].length);
36
+ }
37
+ this.#buffer = input;
38
+ return events;
39
+ }
40
+ /** Ends the stream. An event that no blank line completed is discarded, as the standard says. */
41
+ end() {
42
+ const events = this.#buffer.endsWith("\r") ? this.push("\n") : [];
43
+ this.#buffer = "";
44
+ this.#event = "";
45
+ this.#data = "";
46
+ return events;
47
+ }
48
+ /** Processes one line; a blank line dispatches the event collected so far. */
49
+ #line(line) {
50
+ if (line === "")
51
+ return this.#dispatch();
52
+ // A comment (": heartbeat").
53
+ if (line.startsWith(":"))
54
+ return null;
55
+ const colon = line.indexOf(":");
56
+ const field = colon === -1 ? line : line.slice(0, colon);
57
+ let value = colon === -1 ? "" : line.slice(colon + 1);
58
+ if (value.startsWith(" "))
59
+ value = value.slice(1);
60
+ if (field === "event")
61
+ this.#event = value;
62
+ else if (field === "data")
63
+ this.#data += `${value}\n`;
64
+ return null;
65
+ }
66
+ #dispatch() {
67
+ const data = this.#data;
68
+ const event = this.#event === "" ? "message" : this.#event;
69
+ this.#event = "";
70
+ this.#data = "";
71
+ // An event without data is not dispatched.
72
+ if (data === "")
73
+ return null;
74
+ return { event, data: data.slice(0, -1) };
75
+ }
76
+ }
@@ -0,0 +1,86 @@
1
+ import { type ClientOptions } from "./client.js";
2
+ import type { components } from "./schema.gen.js";
3
+ import { type ServerSentEvent } from "./sse.js";
4
+ type Status = components["schemas"]["Status"];
5
+ type Notification = components["schemas"]["Notification"];
6
+ type Announcement = components["schemas"]["Announcement"];
7
+ /**
8
+ * An event of the stream, with its data parsed. `unknown` is an event that this version of the library does not know,
9
+ * which a newer server may send: `event` is its name (`message` when it has none) and `data` its data, unparsed.
10
+ */
11
+ export type StreamEvent = {
12
+ type: "update";
13
+ status: Status;
14
+ } | {
15
+ type: "notification";
16
+ notification: Notification;
17
+ } | {
18
+ type: "announcement";
19
+ announcement: Announcement;
20
+ } | {
21
+ type: "announcement_removed";
22
+ id: string;
23
+ } | {
24
+ type: "unknown";
25
+ event: string;
26
+ data: string;
27
+ };
28
+ /** How long a stream may stay silent before the client drops it: two of the server's heartbeats, 30 seconds apart. */
29
+ export declare const defaultHeartbeatTimeout = 60000;
30
+ /** Configures runStream: the client's options (but its retry policy), and the stream's. */
31
+ export type StreamOptions = Omit<ClientOptions, "retry"> & {
32
+ /** Ends the stream; runStream resolves once it aborts. */
33
+ signal: AbortSignal;
34
+ /**
35
+ * Called whenever a stream opens: `reconnected` is false for the first one and true for every later one. The server
36
+ * replays nothing, so after a reconnection read the home timeline and the notifications again for what happened
37
+ * while the stream was closed.
38
+ */
39
+ onOpen?: (reconnected: boolean) => void;
40
+ /** Receives the events in the order they arrive. */
41
+ onEvent: (event: StreamEvent) => void;
42
+ /**
43
+ * Receives every failure that the client recovers from by connecting again (a network error, a 429, a 5xx, a silent
44
+ * stream) and every event whose data it cannot read, which it skips.
45
+ */
46
+ onError?: (error: unknown) => void;
47
+ /**
48
+ * How long the server may send nothing, heartbeats included, before the client drops the stream and connects again,
49
+ * in milliseconds; defaultHeartbeatTimeout when absent.
50
+ */
51
+ heartbeatTimeout?: number;
52
+ };
53
+ /** The failure of a stream on which nothing arrived for the heartbeat timeout. */
54
+ export declare class HeartbeatTimeoutError extends Error {
55
+ readonly name = "HeartbeatTimeoutError";
56
+ constructor(timeout: number);
57
+ }
58
+ /** The failure of a 2xx answer to GET /streaming that is no event stream, such as a page of a proxy. */
59
+ export declare class NotEventStreamError extends Error {
60
+ readonly name = "NotEventStreamError";
61
+ }
62
+ /**
63
+ * The wait before the next connection: a second after a stream that ended normally, doubling with each failure in a
64
+ * row up to a minute, and spread over its upper half at random so that the clients a server restart disconnected do
65
+ * not all come back at once.
66
+ */
67
+ export declare function streamDelay(failures: number): number;
68
+ /**
69
+ * Reads GET /streaming until `signal` aborts. It sends the access token of the TokenSource and, after a 401, refreshes
70
+ * it once per connection. It never sends Last-Event-ID: the server does not resume a stream. Whenever the stream ends
71
+ * or fails, it connects again: after a 429 (too many streams) or a 503 it waits for Retry-After, and otherwise for an
72
+ * exponential backoff with jitter. A stream that is silent for the heartbeat timeout counts as failed.
73
+ *
74
+ * It resolves once `signal` aborts, and rejects without connecting again when the server refuses the request for good:
75
+ * a ProblemError of a 4xx other than 408 and 429, such as a 401 that a refresh did not cure or a 403 for a missing
76
+ * scope (read:statuses and read:notifications), or a failure of the TokenSource other than a network one, such as an
77
+ * OAuthError of a refresh token that was revoked.
78
+ */
79
+ export declare function runStream(options: StreamOptions): Promise<void>;
80
+ /**
81
+ * Types an event by its name: the data of `update`, `notification`, and `announcement` is the server's Status,
82
+ * Notification, and Announcement, and that of `announcement_removed` the announcement's ID. An unknown name is an
83
+ * `unknown` event. It throws for a known event whose data is no JSON object with an `id`.
84
+ */
85
+ export declare function parseStreamEvent({ event, data }: ServerSentEvent): StreamEvent;
86
+ export {};
package/dist/stream.js ADDED
@@ -0,0 +1,252 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // The client of GET /api/v1/streaming (streamEvents): the Server-Sent Events of the user's new home entries,
3
+ // notifications, and announcements. It reads the body of fetch, since EventSource cannot send the Authorization header.
4
+ import { apiBasePath, parseOrigin } from "./client.js";
5
+ import { isAbort, OAuthError } from "./oauth.js";
6
+ import { NetworkError, ProblemError, problemFromResponse } from "./problem.js";
7
+ import { EventStreamParser } from "./sse.js";
8
+ import { parseRetryAfter, sleepSignal } from "./transport.js";
9
+ /** How long a stream may stay silent before the client drops it: two of the server's heartbeats, 30 seconds apart. */
10
+ export const defaultHeartbeatTimeout = 60_000;
11
+ /** The failure of a stream on which nothing arrived for the heartbeat timeout. */
12
+ export class HeartbeatTimeoutError extends Error {
13
+ name = "HeartbeatTimeoutError";
14
+ constructor(timeout) {
15
+ super(`mykit: no heartbeat from the stream for ${String(timeout)} ms`);
16
+ }
17
+ }
18
+ /** The failure of a 2xx answer to GET /streaming that is no event stream, such as a page of a proxy. */
19
+ export class NotEventStreamError extends Error {
20
+ name = "NotEventStreamError";
21
+ }
22
+ /** The wait after a stream that ended, before connecting again. */
23
+ const baseDelay = 1_000;
24
+ /** The longest wait between failed connections. */
25
+ const maxDelay = 60_000;
26
+ /**
27
+ * The wait before the next connection: a second after a stream that ended normally, doubling with each failure in a
28
+ * row up to a minute, and spread over its upper half at random so that the clients a server restart disconnected do
29
+ * not all come back at once.
30
+ */
31
+ export function streamDelay(failures) {
32
+ const delay = Math.min(maxDelay, baseDelay * 2 ** Math.min(failures, 10));
33
+ return delay / 2 + Math.random() * (delay / 2);
34
+ }
35
+ /**
36
+ * Reads GET /streaming until `signal` aborts. It sends the access token of the TokenSource and, after a 401, refreshes
37
+ * it once per connection. It never sends Last-Event-ID: the server does not resume a stream. Whenever the stream ends
38
+ * or fails, it connects again: after a 429 (too many streams) or a 503 it waits for Retry-After, and otherwise for an
39
+ * exponential backoff with jitter. A stream that is silent for the heartbeat timeout counts as failed.
40
+ *
41
+ * It resolves once `signal` aborts, and rejects without connecting again when the server refuses the request for good:
42
+ * a ProblemError of a 4xx other than 408 and 429, such as a 401 that a refresh did not cure or a 403 for a missing
43
+ * scope (read:statuses and read:notifications), or a failure of the TokenSource other than a network one, such as an
44
+ * OAuthError of a refresh token that was revoked.
45
+ */
46
+ export async function runStream(options) {
47
+ const url = `${parseOrigin(options.server)}${apiBasePath}/streaming`;
48
+ const fetch = options.fetch ?? ((request) => globalThis.fetch(request));
49
+ const sleep = options.sleep ?? sleepSignal;
50
+ const heartbeat = options.heartbeatTimeout ?? defaultHeartbeatTimeout;
51
+ const { signal } = options;
52
+ let opened = false;
53
+ let failures = 0;
54
+ // signal may abort while the stream is awaited, which narrowing cannot see.
55
+ const aborted = () => signal.aborted;
56
+ while (!aborted()) {
57
+ let wait = null;
58
+ try {
59
+ await connect(url, fetch, heartbeat, options, opened, () => {
60
+ opened = true;
61
+ failures = 0;
62
+ });
63
+ }
64
+ catch (error) {
65
+ if (aborted())
66
+ return;
67
+ if (!recoverable(error))
68
+ throw error;
69
+ options.onError?.(error);
70
+ failures++;
71
+ if (error instanceof ProblemError && (error.status === 429 || error.status === 503)) {
72
+ wait = parseRetryAfter(error.headers.get("Retry-After"), Date.now());
73
+ }
74
+ }
75
+ if (aborted())
76
+ return;
77
+ try {
78
+ await sleep(wait ?? streamDelay(failures), signal);
79
+ }
80
+ catch (error) {
81
+ if (aborted())
82
+ return;
83
+ throw error;
84
+ }
85
+ }
86
+ }
87
+ /** Whether connecting again may cure error: a failure of the network or of the server, a silent stream, a 408 or 429. */
88
+ function recoverable(error) {
89
+ if (error instanceof ProblemError)
90
+ return error.status >= 500 || error.status === 408 || error.status === 429;
91
+ if (error instanceof OAuthError)
92
+ return error.status >= 500 || error.status === 429;
93
+ return error instanceof NetworkError || error instanceof HeartbeatTimeoutError || error instanceof NotEventStreamError;
94
+ }
95
+ /**
96
+ * Opens one stream and reads it to its end, calling onOpened once it opened. It resolves when the server ends the
97
+ * stream and rejects with the failure that ended it or kept it from opening.
98
+ */
99
+ async function connect(url, fetch, heartbeat, options, reconnected, onOpened) {
100
+ // The watchdog covers the connection too: a server that never answers is as silent as one that stopped.
101
+ const silence = new AbortController();
102
+ let reader = null;
103
+ let timer;
104
+ const arm = () => {
105
+ clearTimeout(timer);
106
+ timer = setTimeout(() => {
107
+ silence.abort(new HeartbeatTimeoutError(heartbeat));
108
+ }, heartbeat);
109
+ };
110
+ const disarm = () => {
111
+ clearTimeout(timer);
112
+ };
113
+ const signal = AbortSignal.any([options.signal, silence.signal]);
114
+ // A body that the fetch does not tie to the signal stops here too.
115
+ const stop = () => void reader?.cancel().catch(() => undefined);
116
+ signal.addEventListener("abort", stop, { once: true });
117
+ const silent = (error) => (silence.signal.aborted ? silence.signal.reason : error);
118
+ arm();
119
+ try {
120
+ let response;
121
+ try {
122
+ response = await open(url, fetch, options, signal);
123
+ }
124
+ catch (error) {
125
+ throw silent(error);
126
+ }
127
+ const body = response.body;
128
+ disarm();
129
+ options.onOpen?.(reconnected);
130
+ onOpened();
131
+ arm();
132
+ if (body === null)
133
+ return;
134
+ reader = body.getReader();
135
+ const decoder = new TextDecoder("utf-8");
136
+ const parser = new EventStreamParser();
137
+ try {
138
+ for (;;) {
139
+ const { done, value } = await reader.read();
140
+ if (options.signal.aborted)
141
+ return;
142
+ if (silence.signal.aborted)
143
+ throw silence.signal.reason;
144
+ if (done)
145
+ break;
146
+ disarm();
147
+ dispatch(parser.push(decoder.decode(value, { stream: true })), options);
148
+ arm();
149
+ }
150
+ dispatch([...parser.push(decoder.decode()), ...parser.end()], options);
151
+ // The server ended the stream (its shutdown, a token that stopped being valid, a client that fell behind).
152
+ }
153
+ catch (error) {
154
+ if (options.signal.aborted)
155
+ throw error;
156
+ throw silent(isAbort(error) ? error : new NetworkError("GET", url, error));
157
+ }
158
+ finally {
159
+ await reader.cancel().catch(() => undefined);
160
+ }
161
+ }
162
+ finally {
163
+ disarm();
164
+ signal.removeEventListener("abort", stop);
165
+ }
166
+ }
167
+ /** Sends GET /streaming with the access token, refreshing it once after a 401, and returns an event stream's answer. */
168
+ async function open(url, fetch, options, signal) {
169
+ const { tokenSource } = options;
170
+ let renewed = null;
171
+ for (let refreshed = false;; refreshed = true) {
172
+ const headers = new Headers({ Accept: "text/event-stream", "Cache-Control": "no-cache" });
173
+ const language = typeof options.acceptLanguage === "function" ? options.acceptLanguage() : options.acceptLanguage;
174
+ if (language !== undefined && language !== null && language !== "")
175
+ headers.set("Accept-Language", language);
176
+ const token = renewed ?? (await tokenSource?.getAccessToken()) ?? null;
177
+ if (token !== null)
178
+ headers.set("Authorization", `Bearer ${token}`);
179
+ let response;
180
+ try {
181
+ response = await fetch(new Request(url, { headers, signal }));
182
+ }
183
+ catch (error) {
184
+ if (isAbort(error) || signal.aborted)
185
+ throw signal.aborted ? signal.reason : error;
186
+ throw new NetworkError("GET", url, error);
187
+ }
188
+ if (response.status === 401 && token !== null && !refreshed && tokenSource?.refresh !== undefined) {
189
+ renewed = await tokenSource.refresh(token);
190
+ if (renewed !== null) {
191
+ await response.body?.cancel().catch(() => undefined);
192
+ continue;
193
+ }
194
+ }
195
+ if (response.status >= 400)
196
+ throw await problemFromResponse(response);
197
+ if (!isEventStream(response)) {
198
+ await response.body?.cancel().catch(() => undefined);
199
+ throw new NotEventStreamError(`mykit: GET ${url} answered ${response.headers.get("Content-Type") ?? "no content type"}, not an event stream`);
200
+ }
201
+ return response;
202
+ }
203
+ }
204
+ /** Whether `response` is an event stream. */
205
+ function isEventStream(response) {
206
+ return response.headers.get("Content-Type")?.split(";")[0]?.trim().toLowerCase() === "text/event-stream";
207
+ }
208
+ /** Hands the events to onEvent, typed by their names; one whose data it cannot read goes to onError instead. */
209
+ function dispatch(events, options) {
210
+ for (const e of events) {
211
+ let parsed;
212
+ try {
213
+ parsed = parseStreamEvent(e);
214
+ }
215
+ catch (error) {
216
+ options.onError?.(error);
217
+ continue;
218
+ }
219
+ options.onEvent(parsed);
220
+ }
221
+ }
222
+ /** The events of the spec, whose data is an object with an `id`. */
223
+ const knownEvents = new Set(["update", "notification", "announcement", "announcement_removed"]);
224
+ /**
225
+ * Types an event by its name: the data of `update`, `notification`, and `announcement` is the server's Status,
226
+ * Notification, and Announcement, and that of `announcement_removed` the announcement's ID. An unknown name is an
227
+ * `unknown` event. It throws for a known event whose data is no JSON object with an `id`.
228
+ */
229
+ export function parseStreamEvent({ event, data }) {
230
+ if (!knownEvents.has(event))
231
+ return { type: "unknown", event, data };
232
+ let value;
233
+ try {
234
+ value = JSON.parse(data);
235
+ }
236
+ catch (error) {
237
+ throw new Error(`mykit: the data of a stream event "${event}" is no JSON`, { cause: error });
238
+ }
239
+ if (typeof value !== "object" || value === null || typeof value["id"] !== "string") {
240
+ throw new Error(`mykit: the data of a stream event "${event}" has no id`);
241
+ }
242
+ switch (event) {
243
+ case "update":
244
+ return { type: "update", status: value };
245
+ case "notification":
246
+ return { type: "notification", notification: value };
247
+ case "announcement":
248
+ return { type: "announcement", announcement: value };
249
+ default:
250
+ return { type: "announcement_removed", id: value.id };
251
+ }
252
+ }
@@ -0,0 +1,56 @@
1
+ import { type OAuthConfig } from "./oauth.js";
2
+ /** An OAuth 2.0 access token and the refresh token that renews it. */
3
+ export type Token = {
4
+ accessToken: string;
5
+ /**
6
+ * Renews the access token. The server rotates it on every use (RFC 9700 §4.14): the previous one stops working, so
7
+ * the new one must be saved.
8
+ */
9
+ refreshToken?: string;
10
+ /** When the access token expires, in milliseconds since the Unix epoch; absent means unknown. */
11
+ expiresAt?: number;
12
+ /** The space-separated scopes of the token. */
13
+ scope?: string;
14
+ };
15
+ /**
16
+ * Gives the access token of a request; null sends it without one. The library never stores tokens itself.
17
+ *
18
+ * `refresh`, when present, renews the token after the server refused it with a 401: the client calls it once per
19
+ * request, with the access token the server refused, and sends the request again with the new one. Null leaves the
20
+ * 401 as it is.
21
+ */
22
+ export interface TokenSource {
23
+ getAccessToken(): Promise<string | null>;
24
+ refresh?(refused: string): Promise<string | null>;
25
+ }
26
+ /**
27
+ * A TokenSource of one access token that is never renewed, for tests and short tasks. Use a RefreshingTokenSource
28
+ * for anything that runs longer than the token lives (an hour).
29
+ */
30
+ export declare function staticToken(accessToken: string): TokenSource;
31
+ /** Receives the tokens of every renewal; it must save the rotated refresh token. */
32
+ export type OnTokens = (token: Token) => void | Promise<void>;
33
+ /**
34
+ * The TokenSource of a client that holds a refresh token: it renews the access token with the refresh_token grant
35
+ * when it expires or the server refuses it. Renewals are serialized, so that concurrent requests that meet a 401
36
+ * renew it once: a used refresh token sent again revokes the whole token family. Every renewal passes the new tokens
37
+ * to `onTokens`; when it throws, the new tokens are kept in the source and the request that needed them fails with
38
+ * its error.
39
+ */
40
+ export declare class RefreshingTokenSource implements TokenSource {
41
+ #private;
42
+ /**
43
+ * Starts from `token` (from `exchange`, or saved before) and renews it through the token endpoint of `oauth`.
44
+ * `now` is for tests.
45
+ */
46
+ constructor(oauth: OAuthConfig, token: Token | null, onTokens?: OnTokens, now?: () => number);
47
+ /** The current tokens, or null before the first. */
48
+ get token(): Token | null;
49
+ /** The current access token, renewed first when it has expired. */
50
+ getAccessToken(): Promise<string | null>;
51
+ /**
52
+ * Renews the token after the server refused `refused`. When another request renewed it since `refused` was handed
53
+ * out, it returns that token instead of renewing again.
54
+ */
55
+ refresh(refused: string): Promise<string | null>;
56
+ }
package/dist/token.js ADDED
@@ -0,0 +1,83 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import { tokenRequest } from "./oauth.js";
3
+ /** How long before its expiry a token counts as expired, so that it does not expire on the way. */
4
+ const expirySkewMs = 30_000;
5
+ /** Whether the access token is set and not about to expire at now. */
6
+ function valid(token, now) {
7
+ return token !== null && token.accessToken !== "" && (token.expiresAt === undefined || now + expirySkewMs < token.expiresAt);
8
+ }
9
+ /**
10
+ * A TokenSource of one access token that is never renewed, for tests and short tasks. Use a RefreshingTokenSource
11
+ * for anything that runs longer than the token lives (an hour).
12
+ */
13
+ export function staticToken(accessToken) {
14
+ return {
15
+ getAccessToken: () => Promise.resolve(accessToken),
16
+ };
17
+ }
18
+ /**
19
+ * The TokenSource of a client that holds a refresh token: it renews the access token with the refresh_token grant
20
+ * when it expires or the server refuses it. Renewals are serialized, so that concurrent requests that meet a 401
21
+ * renew it once: a used refresh token sent again revokes the whole token family. Every renewal passes the new tokens
22
+ * to `onTokens`; when it throws, the new tokens are kept in the source and the request that needed them fails with
23
+ * its error.
24
+ */
25
+ export class RefreshingTokenSource {
26
+ #oauth;
27
+ #onTokens;
28
+ #now;
29
+ #token;
30
+ /** The renewal in flight, which the requests that need a token meanwhile wait for. */
31
+ #renewal = null;
32
+ /**
33
+ * Starts from `token` (from `exchange`, or saved before) and renews it through the token endpoint of `oauth`.
34
+ * `now` is for tests.
35
+ */
36
+ constructor(oauth, token, onTokens, now = Date.now) {
37
+ this.#oauth = oauth;
38
+ this.#token = token;
39
+ this.#onTokens = onTokens;
40
+ this.#now = now;
41
+ }
42
+ /** The current tokens, or null before the first. */
43
+ get token() {
44
+ return this.#token;
45
+ }
46
+ /** The current access token, renewed first when it has expired. */
47
+ async getAccessToken() {
48
+ if (this.#renewal !== null)
49
+ return (await this.#renewal).accessToken;
50
+ if (valid(this.#token, this.#now()))
51
+ return this.#token.accessToken;
52
+ return (await this.#renew()).accessToken;
53
+ }
54
+ /**
55
+ * Renews the token after the server refused `refused`. When another request renewed it since `refused` was handed
56
+ * out, it returns that token instead of renewing again.
57
+ */
58
+ async refresh(refused) {
59
+ if (this.#renewal !== null)
60
+ return (await this.#renewal).accessToken;
61
+ if (valid(this.#token, this.#now()) && this.#token.accessToken !== refused)
62
+ return this.#token.accessToken;
63
+ return (await this.#renew()).accessToken;
64
+ }
65
+ /** Runs the refresh_token grant, once for every caller that asks while it runs. */
66
+ #renew() {
67
+ this.#renewal ??= this.#grant().finally(() => {
68
+ this.#renewal = null;
69
+ });
70
+ return this.#renewal;
71
+ }
72
+ async #grant() {
73
+ const current = this.#token;
74
+ if (current?.refreshToken === undefined || current.refreshToken === "") {
75
+ throw new Error("mykit: the token has expired and there is no refresh token");
76
+ }
77
+ const token = await tokenRequest(this.#oauth, { grant_type: "refresh_token", refresh_token: current.refreshToken });
78
+ token.refreshToken ??= current.refreshToken;
79
+ this.#token = token;
80
+ await this.#onTokens?.(token);
81
+ return token;
82
+ }
83
+ }
@@ -0,0 +1,38 @@
1
+ import type { TokenSource } from "./token.js";
2
+ /**
3
+ * Decides how a request answered with 429 or 503 is sent again. Only requests that are safe to repeat are retried:
4
+ * GET, HEAD, and requests with an Idempotency-Key.
5
+ */
6
+ export type RetryPolicy = {
7
+ /** Turns the retries off: the 429 or 503 becomes a ProblemError at once. */
8
+ disabled?: boolean;
9
+ /** How many times a request is sent again; 3 by default. */
10
+ maxRetries?: number;
11
+ /**
12
+ * The longest the client waits before a retry, in milliseconds; one minute by default. When Retry-After asks for
13
+ * longer, the answer becomes a ProblemError instead.
14
+ */
15
+ maxWait?: number;
16
+ };
17
+ /** Waits for ms milliseconds, or rejects with the reason of signal when it aborts first. */
18
+ export type Sleep = (ms: number, signal: AbortSignal) => Promise<void>;
19
+ export type TransportOptions = {
20
+ fetch: (input: Request) => Promise<Response>;
21
+ tokenSource: TokenSource | undefined;
22
+ retry: RetryPolicy | undefined;
23
+ sleep?: Sleep;
24
+ };
25
+ /**
26
+ * The fetch of the generated client. For each request it adds the access token; after a 401 it refreshes the token
27
+ * once and sends the request again; after a 429 or 503 it waits and retries the requests that are safe to repeat;
28
+ * and it turns a failure to get an answer into a NetworkError. Every middleware of the client sees only the last
29
+ * answer.
30
+ */
31
+ export declare function createTransport({ fetch, tokenSource, retry, sleep }: TransportOptions): (request: Request) => Promise<Response>;
32
+ /**
33
+ * Reads a Retry-After header, in seconds or an HTTP date, as the wait from now in milliseconds; null when it is absent
34
+ * or unreadable.
35
+ */
36
+ export declare function parseRetryAfter(retryAfter: string | null, now: number): number | null;
37
+ /** Waits for ms, or rejects with the reason of signal when it aborts first. */
38
+ export declare function sleepSignal(ms: number, signal: AbortSignal): Promise<void>;