@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.
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/dist/client.d.ts +36 -0
- package/dist/client.js +68 -0
- package/dist/idempotency.d.ts +13 -0
- package/dist/idempotency.js +14 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +13 -0
- package/dist/oauth.d.ts +39 -0
- package/dist/oauth.js +119 -0
- package/dist/paging.d.ts +25 -0
- package/dist/paging.js +27 -0
- package/dist/problem.d.ts +47 -0
- package/dist/problem.js +98 -0
- package/dist/schema.gen.d.ts +17640 -0
- package/dist/schema.gen.js +6 -0
- package/dist/sse.d.ts +13 -0
- package/dist/sse.js +76 -0
- package/dist/stream.d.ts +86 -0
- package/dist/stream.js +252 -0
- package/dist/token.d.ts +56 -0
- package/dist/token.js +83 -0
- package/dist/transport.d.ts +38 -0
- package/dist/transport.js +115 -0
- package/dist/upload.d.ts +30 -0
- package/dist/upload.js +30 -0
- package/package.json +43 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The mycroblog/my contributors
|
|
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
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
<!-- SPDX-License-Identifier: MIT -->
|
|
2
|
+
|
|
3
|
+
# @mycroblog/mykit
|
|
4
|
+
|
|
5
|
+
The TypeScript client of the client API of [my](https://github.com/mycroblog/my), an ActivityPub microblog server.
|
|
6
|
+
It is [openapi-fetch](https://openapi-ts.dev/openapi-fetch/) typed by the server's OpenAPI spec, plus what every call
|
|
7
|
+
needs: the access token and its renewal, `Accept-Language`, errors, paging, retries, idempotency keys, media upload,
|
|
8
|
+
PKCE, and the event stream. It is ESM only and runs wherever `fetch` and web streams do (Node.js 22 or later, Bun,
|
|
9
|
+
Deno, browsers); it imports no Node.js built-in module.
|
|
10
|
+
|
|
11
|
+
The Go client has the same behavior; the full guide, which applies to both, is the [mykit README](../README.md). This
|
|
12
|
+
page has the TypeScript parts.
|
|
13
|
+
|
|
14
|
+
> **Mastodon client libraries do not work with my.** The client API is not Mastodon's (there is no `/api/v1/apps`).
|
|
15
|
+
> Register an OAuth 2.0 client instead; see [Getting a token](../README.md#getting-a-token).
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm install @mycroblog/mykit
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
The runnable-by-type-check versions of these snippets are in [`examples/`](examples).
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { createClient, NetworkError, ProblemError, staticToken } from "@mycroblog/mykit";
|
|
27
|
+
|
|
28
|
+
const api = createClient({
|
|
29
|
+
server: "https://example.com", // the origin, without a path
|
|
30
|
+
tokenSource: staticToken("access token"),
|
|
31
|
+
acceptLanguage: "en",
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const { data } = await api.POST("/statuses", { body: { text: "Hello, fediverse!", visibility: "public" } });
|
|
35
|
+
console.log(data?.id);
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`api` is an openapi-fetch client: `api.GET`, `api.POST`, and so on take the path of the operation, and the parameters,
|
|
39
|
+
the body, and `data` are typed by the spec. A call that gets an answer of 400 or more **rejects** with a `ProblemError`
|
|
40
|
+
(openapi-fetch's `error` field is not used), so when it resolves, `data` is the answer.
|
|
41
|
+
|
|
42
|
+
## Errors
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
try {
|
|
46
|
+
await api.POST("/statuses", { body: { text: "hi", visibility: "public" } });
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (error instanceof ProblemError && error.type === "/api/v1/problems/blocking") {
|
|
49
|
+
// ...
|
|
50
|
+
} else if (error instanceof NetworkError) {
|
|
51
|
+
// no answer; error.cause is the error of fetch
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`ProblemError` is an [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem: `type`, `title`, `status`, `detail`,
|
|
57
|
+
the extension members in `problem`, and the response `headers`. **Branch on `type` and `status`, never on `title` or
|
|
58
|
+
`detail`**, which the server localizes from `Accept-Language`. An aborted request rejects with the reason of its signal.
|
|
59
|
+
|
|
60
|
+
Middleware of your own that turns an error answer into an error type of yours before the `ProblemError` can read the
|
|
61
|
+
problem with `problemFromResponse(response)`.
|
|
62
|
+
|
|
63
|
+
## Getting a token and saving it
|
|
64
|
+
|
|
65
|
+
mykit never stores tokens. The flow with PKCE, and a `RefreshingTokenSource` whose `onTokens` callback saves the
|
|
66
|
+
rotated refresh token, are in [`examples/oauth.ts`](examples/oauth.ts):
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
const token = await exchange(oauth, code, verifier);
|
|
70
|
+
const api = createClient({
|
|
71
|
+
server: oauth.server,
|
|
72
|
+
tokenSource: new RefreshingTokenSource(oauth, token, async (renewed) => {
|
|
73
|
+
await save(renewed); // your own storage
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The server rotates the refresh token on every use, and a refresh token used twice revokes the whole token family, so
|
|
79
|
+
`onTokens` must save the new tokens. The source renews the token when it expires or after a 401, once per request.
|
|
80
|
+
|
|
81
|
+
A `TokenSource` is any object with `getAccessToken()` and, optionally, `refresh(refused)`.
|
|
82
|
+
|
|
83
|
+
## Paging
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
for await (const status of all(async (cursor) => {
|
|
87
|
+
const { data } = await api.GET("/timelines/home", { params: { query: cursor === undefined ? {} : { cursor } } });
|
|
88
|
+
if (data === undefined) throw new Error("the answer has no body");
|
|
89
|
+
return data;
|
|
90
|
+
})) {
|
|
91
|
+
console.log(status.id);
|
|
92
|
+
if (done()) break; // a timeline has no end
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Streaming
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
const controller = new AbortController();
|
|
100
|
+
await runStream({
|
|
101
|
+
server: "https://example.com",
|
|
102
|
+
tokenSource,
|
|
103
|
+
signal: controller.signal,
|
|
104
|
+
onOpen: (reconnected) => {
|
|
105
|
+
if (reconnected) refetchTimelinesAndNotifications();
|
|
106
|
+
},
|
|
107
|
+
onEvent: (event) => {
|
|
108
|
+
if (event.type === "update") console.log(event.status.id);
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`runStream` connects again whenever the stream ends or fails. **The server replays nothing and the library sends no
|
|
114
|
+
`Last-Event-ID`:** after `onOpen(true)` you read the home timeline and the notifications again yourself. The events are
|
|
115
|
+
`update`, `notification`, `announcement`, `announcement_removed`, and `unknown` (an event of a newer server; ignore it).
|
|
116
|
+
Abort the signal to stop. See [`examples/stream.ts`](examples/stream.ts) and the
|
|
117
|
+
[mykit README](../README.md#streaming) for what makes it stop.
|
|
118
|
+
|
|
119
|
+
## Retries, idempotency keys, uploads, admin operations, stability
|
|
120
|
+
|
|
121
|
+
These work as in the [mykit README](../README.md): 429 and 503 are retried after `Retry-After` for `GET`, `HEAD`, and
|
|
122
|
+
requests with an `Idempotency-Key` (`retry: { disabled, maxRetries, maxWait }`; `maxWait` is in milliseconds); the key
|
|
123
|
+
of a new status is added for you and kept across retries; `uploadMedia(api, { file: blob, description })` uploads a
|
|
124
|
+
`Blob`; the `admin` operations answer 403 to a token of a third-party app; and `/api/v1` follows the
|
|
125
|
+
[stability policy](../README.md#versions-and-api-stability) (a `@deprecated` tag marks what will go).
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
MIT, see [LICENSE](LICENSE).
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type Client } from "openapi-fetch";
|
|
2
|
+
import type { paths } from "./schema.gen.js";
|
|
3
|
+
import type { TokenSource } from "./token.js";
|
|
4
|
+
import { type RetryPolicy, type Sleep } from "./transport.js";
|
|
5
|
+
/** The path of the client API under the server's origin (`servers[0].url` of api/openapi.yaml). */
|
|
6
|
+
export declare const apiBasePath = "/api/v1";
|
|
7
|
+
/** The openapi-fetch client of the client API, with the cross-cutting behaviour of the library. */
|
|
8
|
+
export type Api = Client<paths>;
|
|
9
|
+
/** Configures a client of one server. */
|
|
10
|
+
export type ClientOptions = {
|
|
11
|
+
/** The origin of the server, such as "https://example.com". The client API is under apiBasePath. */
|
|
12
|
+
server: string;
|
|
13
|
+
/** Gives the access token of the requests. Absent sends them without one (anonymous). */
|
|
14
|
+
tokenSource?: TokenSource;
|
|
15
|
+
/**
|
|
16
|
+
* Sent as Accept-Language, so that the server localizes the title and detail of its problems. A function is read
|
|
17
|
+
* for every request, for an app whose locale changes while it runs. Absent leaves it to the server (or the browser).
|
|
18
|
+
*/
|
|
19
|
+
acceptLanguage?: string | (() => string | null | undefined);
|
|
20
|
+
/** The policy for 429 and 503 answers; it retries with the defaults when absent. */
|
|
21
|
+
retry?: RetryPolicy;
|
|
22
|
+
/** Sends the requests; the global `fetch` by default. */
|
|
23
|
+
fetch?: (input: Request) => Promise<Response>;
|
|
24
|
+
/** Waits between retries; for tests. */
|
|
25
|
+
sleep?: Sleep;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* The client of one server's client API: openapi-fetch typed by the spec, every operation as `GET`, `POST`, ... of
|
|
29
|
+
* its path. An answer with a status of 400 or more rejects with a ProblemError, and a request that got no answer
|
|
30
|
+
* with a NetworkError, so `data` is set whenever the call resolves. Middleware added with `use` sees the answer
|
|
31
|
+
* after the token refresh and the retries, and before the ProblemError. A request given its own `fetch` bypasses
|
|
32
|
+
* the token, refresh, and retries.
|
|
33
|
+
*/
|
|
34
|
+
export declare function createClient(options: ClientOptions): Api;
|
|
35
|
+
/** Reads the server's origin: an absolute http or https URL without a path, query, or fragment. */
|
|
36
|
+
export declare function parseOrigin(server: string): string;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
import createFetchClient, {} from "openapi-fetch";
|
|
3
|
+
import { acceptsIdempotencyKey, idempotencyKeyHeader } from "./idempotency.js";
|
|
4
|
+
import { problemFromResponse } from "./problem.js";
|
|
5
|
+
import { createTransport } from "./transport.js";
|
|
6
|
+
/** The path of the client API under the server's origin (`servers[0].url` of api/openapi.yaml). */
|
|
7
|
+
export const apiBasePath = "/api/v1";
|
|
8
|
+
/**
|
|
9
|
+
* The client of one server's client API: openapi-fetch typed by the spec, every operation as `GET`, `POST`, ... of
|
|
10
|
+
* its path. An answer with a status of 400 or more rejects with a ProblemError, and a request that got no answer
|
|
11
|
+
* with a NetworkError, so `data` is set whenever the call resolves. Middleware added with `use` sees the answer
|
|
12
|
+
* after the token refresh and the retries, and before the ProblemError. A request given its own `fetch` bypasses
|
|
13
|
+
* the token, refresh, and retries.
|
|
14
|
+
*/
|
|
15
|
+
export function createClient(options) {
|
|
16
|
+
const origin = parseOrigin(options.server);
|
|
17
|
+
const client = createFetchClient({
|
|
18
|
+
baseUrl: origin + apiBasePath,
|
|
19
|
+
fetch: createTransport({
|
|
20
|
+
fetch: options.fetch ?? ((request) => globalThis.fetch(request)),
|
|
21
|
+
tokenSource: options.tokenSource,
|
|
22
|
+
retry: options.retry,
|
|
23
|
+
...(options.sleep === undefined ? {} : { sleep: options.sleep }),
|
|
24
|
+
}),
|
|
25
|
+
});
|
|
26
|
+
client.use(middleware(options.acceptLanguage));
|
|
27
|
+
return client;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Adds Accept-Language and the Idempotency-Key, and turns an error answer into a ProblemError. It is the first
|
|
31
|
+
* middleware, so its onResponse runs last.
|
|
32
|
+
*/
|
|
33
|
+
function middleware(acceptLanguage) {
|
|
34
|
+
return {
|
|
35
|
+
onRequest({ request, schemaPath }) {
|
|
36
|
+
const language = typeof acceptLanguage === "function" ? acceptLanguage() : acceptLanguage;
|
|
37
|
+
if (!request.headers.has("Accept-Language") && language !== undefined && language !== null && language !== "") {
|
|
38
|
+
request.headers.set("Accept-Language", language);
|
|
39
|
+
}
|
|
40
|
+
if (!request.headers.has(idempotencyKeyHeader) && acceptsIdempotencyKey(request.method, schemaPath)) {
|
|
41
|
+
// One key for every attempt, so that the server applies the request once.
|
|
42
|
+
request.headers.set(idempotencyKeyHeader, crypto.randomUUID());
|
|
43
|
+
}
|
|
44
|
+
return request;
|
|
45
|
+
},
|
|
46
|
+
async onResponse({ response }) {
|
|
47
|
+
if (response.status >= 400)
|
|
48
|
+
throw await problemFromResponse(response);
|
|
49
|
+
return undefined;
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** Reads the server's origin: an absolute http or https URL without a path, query, or fragment. */
|
|
54
|
+
export function parseOrigin(server) {
|
|
55
|
+
if (server === "")
|
|
56
|
+
throw new Error("mykit: server is empty");
|
|
57
|
+
let u;
|
|
58
|
+
try {
|
|
59
|
+
u = new URL(server);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
throw new Error(`mykit: server ${JSON.stringify(server)} is not a URL`);
|
|
63
|
+
}
|
|
64
|
+
if ((u.protocol !== "http:" && u.protocol !== "https:") || (u.pathname !== "/" && u.pathname !== "") || u.search !== "" || u.hash !== "") {
|
|
65
|
+
throw new Error(`mykit: server ${JSON.stringify(server)} is not an origin such as https://example.com`);
|
|
66
|
+
}
|
|
67
|
+
return u.origin;
|
|
68
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** The header that makes a request safe to send again (draft-ietf-httpapi-idempotency-key-header). */
|
|
2
|
+
export declare const idempotencyKeyHeader = "Idempotency-Key";
|
|
3
|
+
/**
|
|
4
|
+
* The operations whose spec takes an Idempotency-Key, as the method and the path of the spec (with its `{param}`s).
|
|
5
|
+
* When the caller sends none, the client adds a new one, so that the request can be retried. A test keeps the list
|
|
6
|
+
* equal to the operations of schema.gen.ts that take the header.
|
|
7
|
+
*/
|
|
8
|
+
export declare const idempotencyKeyOperations: readonly {
|
|
9
|
+
readonly method: string;
|
|
10
|
+
readonly path: string;
|
|
11
|
+
}[];
|
|
12
|
+
/** Whether the request to the spec path `schemaPath` is one of idempotencyKeyOperations. */
|
|
13
|
+
export declare function acceptsIdempotencyKey(method: string, schemaPath: string): boolean;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
/** The header that makes a request safe to send again (draft-ietf-httpapi-idempotency-key-header). */
|
|
3
|
+
export const idempotencyKeyHeader = "Idempotency-Key";
|
|
4
|
+
/**
|
|
5
|
+
* The operations whose spec takes an Idempotency-Key, as the method and the path of the spec (with its `{param}`s).
|
|
6
|
+
* When the caller sends none, the client adds a new one, so that the request can be retried. A test keeps the list
|
|
7
|
+
* equal to the operations of schema.gen.ts that take the header.
|
|
8
|
+
*/
|
|
9
|
+
export const idempotencyKeyOperations = [{ method: "POST", path: "/statuses" }];
|
|
10
|
+
/** Whether the request to the spec path `schemaPath` is one of idempotencyKeyOperations. */
|
|
11
|
+
export function acceptsIdempotencyKey(method, schemaPath) {
|
|
12
|
+
const m = method.toUpperCase();
|
|
13
|
+
return idempotencyKeyOperations.some((op) => op.method === m && op.path === schemaPath);
|
|
14
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { apiBasePath, createClient, type Api, type ClientOptions } from "./client.js";
|
|
2
|
+
export { idempotencyKeyOperations } from "./idempotency.js";
|
|
3
|
+
export { codeChallenge, exchange, newCodeVerifier, OAuthError, tokenPath, type OAuthConfig } from "./oauth.js";
|
|
4
|
+
export { all, type Page, type PageFetcher } from "./paging.js";
|
|
5
|
+
export { NetworkError, ProblemError, problemFromResponse, problemTypeAboutBlank, type Problem } from "./problem.js";
|
|
6
|
+
export type { components, operations, paths } from "./schema.gen.js";
|
|
7
|
+
export { defaultHeartbeatTimeout, HeartbeatTimeoutError, NotEventStreamError, runStream, type StreamEvent, type StreamOptions, } from "./stream.js";
|
|
8
|
+
export { RefreshingTokenSource, staticToken, type OnTokens, type Token, type TokenSource } from "./token.js";
|
|
9
|
+
export type { RetryPolicy, Sleep } from "./transport.js";
|
|
10
|
+
export { uploadMedia, type MediaAttachment, type MediaUploadInput, type MediaUploadPurpose } from "./upload.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// @mycroblog/mykit: the client of the mycroblog/my client API (ADR-0093). The types are generated from the spec
|
|
3
|
+
// (schema.gen.ts); the hand-written layer adds the cross-cutting behaviour only: the access token and its refresh,
|
|
4
|
+
// Accept-Language, RFC 9457 errors, cursor pagination, retries after 429 and 503, the Idempotency-Key, media upload,
|
|
5
|
+
// PKCE, and the client of the event stream.
|
|
6
|
+
export { apiBasePath, createClient } from "./client.js";
|
|
7
|
+
export { idempotencyKeyOperations } from "./idempotency.js";
|
|
8
|
+
export { codeChallenge, exchange, newCodeVerifier, OAuthError, tokenPath } from "./oauth.js";
|
|
9
|
+
export { all } from "./paging.js";
|
|
10
|
+
export { NetworkError, ProblemError, problemFromResponse, problemTypeAboutBlank } from "./problem.js";
|
|
11
|
+
export { defaultHeartbeatTimeout, HeartbeatTimeoutError, NotEventStreamError, runStream, } from "./stream.js";
|
|
12
|
+
export { RefreshingTokenSource, staticToken } from "./token.js";
|
|
13
|
+
export { uploadMedia } from "./upload.js";
|
package/dist/oauth.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Token } from "./token.js";
|
|
2
|
+
/** The path of the server's token endpoint under its origin. */
|
|
3
|
+
export declare const tokenPath = "/oauth/token";
|
|
4
|
+
/** The client's registration on a server's authorization server (ADR-0090). */
|
|
5
|
+
export type OAuthConfig = {
|
|
6
|
+
/** The origin of the server, such as "https://example.com". */
|
|
7
|
+
server: string;
|
|
8
|
+
/** The client_id of the registration, or the URL of the client's metadata document. */
|
|
9
|
+
clientId: string;
|
|
10
|
+
/** The secret of a confidential client; absent for a public client. */
|
|
11
|
+
clientSecret?: string;
|
|
12
|
+
/** The redirect_uri that the authorization request sent. */
|
|
13
|
+
redirectUri?: string;
|
|
14
|
+
/** Sends the requests to the token endpoint; the global `fetch` by default. */
|
|
15
|
+
fetch?: (input: Request) => Promise<Response>;
|
|
16
|
+
};
|
|
17
|
+
/** A new PKCE code_verifier (RFC 7636 §4.1): 32 random bytes, base64url-encoded. */
|
|
18
|
+
export declare function newCodeVerifier(): string;
|
|
19
|
+
/** The S256 code_challenge of a code_verifier (RFC 7636 §4.2), the only method the server accepts. */
|
|
20
|
+
export declare function codeChallenge(verifier: string): Promise<string>;
|
|
21
|
+
/**
|
|
22
|
+
* Exchanges the authorization code that the redirect brought for the tokens, with the code_verifier whose challenge
|
|
23
|
+
* the authorization request sent.
|
|
24
|
+
*/
|
|
25
|
+
export declare function exchange(oauth: OAuthConfig, code: string, verifier: string): Promise<Token>;
|
|
26
|
+
/** An error answer of the token endpoint (RFC 6749 §5.2). */
|
|
27
|
+
export declare class OAuthError extends Error {
|
|
28
|
+
readonly name = "OAuthError";
|
|
29
|
+
/** The HTTP status of the answer. */
|
|
30
|
+
readonly status: number;
|
|
31
|
+
/** The error code, such as "invalid_grant". */
|
|
32
|
+
readonly code: string;
|
|
33
|
+
readonly description: string;
|
|
34
|
+
constructor(status: number, code: string, description?: string);
|
|
35
|
+
}
|
|
36
|
+
/** Posts a grant to the token endpoint, with the client's credentials, and reads the tokens. */
|
|
37
|
+
export declare function tokenRequest(oauth: OAuthConfig, params: Record<string, string>): Promise<Token>;
|
|
38
|
+
/** Whether error is the rejection of an aborted request. */
|
|
39
|
+
export declare function isAbort(error: unknown): boolean;
|
package/dist/oauth.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { NetworkError } from "./problem.js";
|
|
2
|
+
/** The path of the server's token endpoint under its origin. */
|
|
3
|
+
export const tokenPath = "/oauth/token";
|
|
4
|
+
/** Encodes bytes as base64url without padding. */
|
|
5
|
+
function base64url(bytes) {
|
|
6
|
+
let s = "";
|
|
7
|
+
for (const b of bytes)
|
|
8
|
+
s += String.fromCharCode(b);
|
|
9
|
+
return btoa(s).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
10
|
+
}
|
|
11
|
+
/** A new PKCE code_verifier (RFC 7636 §4.1): 32 random bytes, base64url-encoded. */
|
|
12
|
+
export function newCodeVerifier() {
|
|
13
|
+
return base64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
14
|
+
}
|
|
15
|
+
/** The S256 code_challenge of a code_verifier (RFC 7636 §4.2), the only method the server accepts. */
|
|
16
|
+
export async function codeChallenge(verifier) {
|
|
17
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
18
|
+
return base64url(new Uint8Array(digest));
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Exchanges the authorization code that the redirect brought for the tokens, with the code_verifier whose challenge
|
|
22
|
+
* the authorization request sent.
|
|
23
|
+
*/
|
|
24
|
+
export function exchange(oauth, code, verifier) {
|
|
25
|
+
return tokenRequest(oauth, {
|
|
26
|
+
grant_type: "authorization_code",
|
|
27
|
+
code,
|
|
28
|
+
code_verifier: verifier,
|
|
29
|
+
redirect_uri: oauth.redirectUri ?? "",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
/** An error answer of the token endpoint (RFC 6749 §5.2). */
|
|
33
|
+
export class OAuthError extends Error {
|
|
34
|
+
name = "OAuthError";
|
|
35
|
+
/** The HTTP status of the answer. */
|
|
36
|
+
status;
|
|
37
|
+
/** The error code, such as "invalid_grant". */
|
|
38
|
+
code;
|
|
39
|
+
description;
|
|
40
|
+
constructor(status, code, description = "") {
|
|
41
|
+
super(`mykit: oauth: ${code}${description === "" ? "" : `: ${description}`}`);
|
|
42
|
+
this.status = status;
|
|
43
|
+
this.code = code;
|
|
44
|
+
this.description = description;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** The form encoding of RFC 6749 Appendix B, which the client credentials get before they go into Basic. */
|
|
48
|
+
function formEncode(s) {
|
|
49
|
+
return new URLSearchParams({ s }).toString().slice("s=".length);
|
|
50
|
+
}
|
|
51
|
+
/** Posts a grant to the token endpoint, with the client's credentials, and reads the tokens. */
|
|
52
|
+
export async function tokenRequest(oauth, params) {
|
|
53
|
+
const endpoint = new URL(tokenPath, oauth.server).href;
|
|
54
|
+
const form = new URLSearchParams();
|
|
55
|
+
for (const [k, v] of Object.entries(params))
|
|
56
|
+
if (v !== "")
|
|
57
|
+
form.set(k, v);
|
|
58
|
+
const headers = new Headers({ "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" });
|
|
59
|
+
if (oauth.clientSecret === undefined || oauth.clientSecret === "") {
|
|
60
|
+
form.set("client_id", oauth.clientId);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
// RFC 6749 §2.3.1: the credentials are form-encoded before they go into Basic.
|
|
64
|
+
const credentials = `${formEncode(oauth.clientId)}:${formEncode(oauth.clientSecret)}`;
|
|
65
|
+
headers.set("Authorization", `Basic ${btoa(String.fromCharCode(...new TextEncoder().encode(credentials)))}`);
|
|
66
|
+
}
|
|
67
|
+
const fetch = oauth.fetch ?? ((request) => globalThis.fetch(request));
|
|
68
|
+
const now = Date.now();
|
|
69
|
+
let response;
|
|
70
|
+
let body;
|
|
71
|
+
try {
|
|
72
|
+
response = await fetch(new Request(endpoint, { method: "POST", headers, body: form }));
|
|
73
|
+
body = await response.text();
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (isAbort(error))
|
|
77
|
+
throw error;
|
|
78
|
+
throw new NetworkError("POST", endpoint, error);
|
|
79
|
+
}
|
|
80
|
+
if (response.status !== 200) {
|
|
81
|
+
let code = "";
|
|
82
|
+
let description = "";
|
|
83
|
+
if (response.headers.get("Content-Type")?.split(";")[0]?.trim() === "application/json") {
|
|
84
|
+
try {
|
|
85
|
+
const v = JSON.parse(body);
|
|
86
|
+
if (typeof v.error === "string")
|
|
87
|
+
code = v.error;
|
|
88
|
+
if (typeof v.error_description === "string")
|
|
89
|
+
description = v.error_description;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// No error document: the status below.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
throw new OAuthError(response.status, code === "" ? response.statusText || String(response.status) : code, description);
|
|
96
|
+
}
|
|
97
|
+
let tr;
|
|
98
|
+
try {
|
|
99
|
+
tr = JSON.parse(body);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
throw new OAuthError(response.status, "invalid_response", error instanceof Error ? error.message : String(error));
|
|
103
|
+
}
|
|
104
|
+
if (typeof tr.access_token !== "string" || tr.access_token === "" || typeof tr.token_type !== "string" || tr.token_type.toLowerCase() !== "bearer") {
|
|
105
|
+
throw new OAuthError(response.status, "invalid_response", "no bearer access token");
|
|
106
|
+
}
|
|
107
|
+
const token = { accessToken: tr.access_token };
|
|
108
|
+
if (typeof tr.refresh_token === "string" && tr.refresh_token !== "")
|
|
109
|
+
token.refreshToken = tr.refresh_token;
|
|
110
|
+
if (typeof tr.scope === "string")
|
|
111
|
+
token.scope = tr.scope;
|
|
112
|
+
if (typeof tr.expires_in === "number" && tr.expires_in > 0)
|
|
113
|
+
token.expiresAt = now + tr.expires_in * 1000;
|
|
114
|
+
return token;
|
|
115
|
+
}
|
|
116
|
+
/** Whether error is the rejection of an aborted request. */
|
|
117
|
+
export function isAbort(error) {
|
|
118
|
+
return error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError");
|
|
119
|
+
}
|
package/dist/paging.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** A page of a collection with keyset pagination: its items and the cursor of the next page. */
|
|
2
|
+
export type Page<T> = {
|
|
3
|
+
readonly items: readonly T[];
|
|
4
|
+
/** The cursor of the next page; absent, null, or empty on the last page. */
|
|
5
|
+
readonly next_cursor?: string | null;
|
|
6
|
+
};
|
|
7
|
+
/** Fetches the page of a collection that cursor names (undefined for the first page). */
|
|
8
|
+
export type PageFetcher<T> = (cursor: string | undefined) => Promise<Page<T>>;
|
|
9
|
+
/**
|
|
10
|
+
* Iterates the items of a collection with keyset pagination, fetching the pages one by one as the loop needs them by
|
|
11
|
+
* following next_cursor. It stops after the last page, rejects at the first error, and fetches no more when the loop
|
|
12
|
+
* breaks:
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* for await (const status of all(async (cursor) => {
|
|
16
|
+
* const { data } = await api.GET("/timelines/home", { params: { query: { cursor } } });
|
|
17
|
+
* return data!;
|
|
18
|
+
* })) {
|
|
19
|
+
* console.log(status.id);
|
|
20
|
+
* }
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* A timeline has no end: break out of the loop when you have seen enough.
|
|
24
|
+
*/
|
|
25
|
+
export declare function all<T>(fetch: PageFetcher<T>): AsyncGenerator<T, void, undefined>;
|
package/dist/paging.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
/**
|
|
3
|
+
* Iterates the items of a collection with keyset pagination, fetching the pages one by one as the loop needs them by
|
|
4
|
+
* following next_cursor. It stops after the last page, rejects at the first error, and fetches no more when the loop
|
|
5
|
+
* breaks:
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* for await (const status of all(async (cursor) => {
|
|
9
|
+
* const { data } = await api.GET("/timelines/home", { params: { query: { cursor } } });
|
|
10
|
+
* return data!;
|
|
11
|
+
* })) {
|
|
12
|
+
* console.log(status.id);
|
|
13
|
+
* }
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* A timeline has no end: break out of the loop when you have seen enough.
|
|
17
|
+
*/
|
|
18
|
+
export async function* all(fetch) {
|
|
19
|
+
let cursor;
|
|
20
|
+
for (;;) {
|
|
21
|
+
const page = await fetch(cursor);
|
|
22
|
+
yield* page.items;
|
|
23
|
+
if (page.next_cursor === undefined || page.next_cursor === null || page.next_cursor === "")
|
|
24
|
+
return;
|
|
25
|
+
cursor = page.next_cursor;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { components } from "./schema.gen.js";
|
|
2
|
+
/** The RFC 9457 problem document of an answer with a status of 400 or more, with its extension members. */
|
|
3
|
+
export type Problem = components["schemas"]["Problem"] & Record<string, unknown>;
|
|
4
|
+
/** The type of a problem that means no more than its HTTP status (RFC 9457 §4.2.1). */
|
|
5
|
+
export declare const problemTypeAboutBlank = "about:blank";
|
|
6
|
+
/**
|
|
7
|
+
* The error of an answer with a status of 400 or more: its RFC 9457 problem document.
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* try { await api.POST("/statuses", { body }); }
|
|
11
|
+
* catch (e) { if (e instanceof ProblemError && e.type === "/api/v1/problems/blocking") { ... } }
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* Branch on `type` and `status`, never on `title` or `detail`, which the server localizes from Accept-Language. The
|
|
15
|
+
* members beyond the standard ones (such as `reason`) are in `problem`.
|
|
16
|
+
*/
|
|
17
|
+
export declare class ProblemError extends Error {
|
|
18
|
+
readonly name = "ProblemError";
|
|
19
|
+
/** The whole problem document, extension members included. */
|
|
20
|
+
readonly problem: Problem;
|
|
21
|
+
/** The headers of the answer, such as Retry-After and WWW-Authenticate. */
|
|
22
|
+
readonly headers: Headers;
|
|
23
|
+
constructor(problem: Problem, headers: Headers);
|
|
24
|
+
/** The URI that identifies the problem type, `about:blank` when the problem means no more than its status. */
|
|
25
|
+
get type(): string;
|
|
26
|
+
/** The HTTP status of the answer. */
|
|
27
|
+
get status(): number;
|
|
28
|
+
/** The localized summary of the problem type. */
|
|
29
|
+
get title(): string;
|
|
30
|
+
/** The localized explanation of this occurrence. */
|
|
31
|
+
get detail(): string | undefined;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Reads the problem of an error answer. An answer that is no problem document (from a proxy, say) becomes a problem
|
|
35
|
+
* of type about:blank with the answer's status.
|
|
36
|
+
*/
|
|
37
|
+
export declare function problemFromResponse(response: Response): Promise<ProblemError>;
|
|
38
|
+
/**
|
|
39
|
+
* The failure to get an answer from the server: the connection, TLS, or the fetch implementation. Its `cause` is the
|
|
40
|
+
* error of `fetch`. An aborted request is not one: it rejects with the reason of its signal (an AbortError).
|
|
41
|
+
*/
|
|
42
|
+
export declare class NetworkError extends Error {
|
|
43
|
+
readonly name = "NetworkError";
|
|
44
|
+
readonly method: string;
|
|
45
|
+
readonly url: string;
|
|
46
|
+
constructor(method: string, url: string, cause: unknown);
|
|
47
|
+
}
|