@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,115 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import { idempotencyKeyHeader } from "./idempotency.js";
3
+ import { isAbort } from "./oauth.js";
4
+ import { NetworkError } from "./problem.js";
5
+ const defaultMaxRetries = 3;
6
+ const defaultMaxWait = 60_000;
7
+ /**
8
+ * The fetch of the generated client. For each request it adds the access token; after a 401 it refreshes the token
9
+ * once and sends the request again; after a 429 or 503 it waits and retries the requests that are safe to repeat;
10
+ * and it turns a failure to get an answer into a NetworkError. Every middleware of the client sees only the last
11
+ * answer.
12
+ */
13
+ export function createTransport({ fetch, tokenSource, retry, sleep = sleepSignal }) {
14
+ const maxRetries = retry?.disabled === true ? 0 : (retry?.maxRetries ?? defaultMaxRetries);
15
+ const maxWait = retry?.maxWait ?? defaultMaxWait;
16
+ return async (request) => {
17
+ const method = request.method.toUpperCase();
18
+ const retryable = method === "GET" || method === "HEAD" || request.headers.has(idempotencyKeyHeader);
19
+ // An unsent copy to send again: a body can be read only once.
20
+ const copy = request.clone();
21
+ let refreshed = false;
22
+ let renewed = null;
23
+ let retries = 0;
24
+ for (let attempt = 0;; attempt++) {
25
+ const r = attempt === 0 ? request : copy.clone();
26
+ let token = null;
27
+ if (tokenSource !== undefined) {
28
+ token = renewed ?? (await tokenSource.getAccessToken());
29
+ renewed = null;
30
+ if (token !== null)
31
+ r.headers.set("Authorization", `Bearer ${token}`);
32
+ }
33
+ let response;
34
+ try {
35
+ response = await fetch(r);
36
+ }
37
+ catch (error) {
38
+ if (isAbort(error) || request.signal.aborted)
39
+ throw error;
40
+ throw new NetworkError(method, request.url, error);
41
+ }
42
+ if (response.status === 401 && token !== null && !refreshed && tokenSource?.refresh !== undefined) {
43
+ refreshed = true;
44
+ renewed = await tokenSource.refresh(token);
45
+ if (renewed === null)
46
+ return response;
47
+ await discard(response);
48
+ continue;
49
+ }
50
+ if ((response.status === 429 || response.status === 503) && retryable && retries < maxRetries) {
51
+ const wait = retryDelay(response.headers.get("Retry-After"), retries, maxWait, Date.now());
52
+ if (wait === null)
53
+ return response;
54
+ await discard(response);
55
+ retries++;
56
+ await sleep(wait, request.signal);
57
+ continue;
58
+ }
59
+ return response;
60
+ }
61
+ };
62
+ }
63
+ /** Cancels the body of an answer that is not returned. */
64
+ async function discard(response) {
65
+ try {
66
+ await response.body?.cancel();
67
+ }
68
+ catch {
69
+ // Nothing to release.
70
+ }
71
+ }
72
+ /**
73
+ * How long to wait, in milliseconds, before retry number n+1 (n from 0): what Retry-After asks for (seconds or an
74
+ * HTTP date), or without it an exponential backoff from one second with jitter. It is null when the wait is longer
75
+ * than limit.
76
+ */
77
+ function retryDelay(retryAfter, n, limit, now) {
78
+ if (retryAfter !== null && retryAfter !== "") {
79
+ const ms = parseRetryAfter(retryAfter, now) ?? 1000;
80
+ return ms <= limit ? ms : null;
81
+ }
82
+ const base = 1000 * 2 ** Math.min(n, 10);
83
+ return Math.min(base + Math.random() * (base / 2), limit);
84
+ }
85
+ /**
86
+ * Reads a Retry-After header, in seconds or an HTTP date, as the wait from now in milliseconds; null when it is absent
87
+ * or unreadable.
88
+ */
89
+ export function parseRetryAfter(retryAfter, now) {
90
+ const value = retryAfter?.trim() ?? "";
91
+ if (/^\d+$/.test(value))
92
+ return Number(value) * 1000;
93
+ if (value === "")
94
+ return null;
95
+ const at = Date.parse(value);
96
+ return Number.isNaN(at) ? null : Math.max(at - now, 0);
97
+ }
98
+ /** Waits for ms, or rejects with the reason of signal when it aborts first. */
99
+ export function sleepSignal(ms, signal) {
100
+ return new Promise((resolve, reject) => {
101
+ if (signal.aborted) {
102
+ reject(signal.reason);
103
+ return;
104
+ }
105
+ const onAbort = () => {
106
+ clearTimeout(timer);
107
+ reject(signal.reason);
108
+ };
109
+ const timer = setTimeout(() => {
110
+ signal.removeEventListener("abort", onAbort);
111
+ resolve();
112
+ }, ms);
113
+ signal.addEventListener("abort", onAbort, { once: true });
114
+ });
115
+ }
@@ -0,0 +1,30 @@
1
+ import type { Api } from "./client.js";
2
+ import type { components } from "./schema.gen.js";
3
+ /** What the server makes of an uploaded file, which picks the sizes of its variants. */
4
+ export type MediaUploadPurpose = components["schemas"]["MediaUpload"]["purpose"];
5
+ /** A media file as the server describes it. */
6
+ export type MediaAttachment = components["schemas"]["MediaAttachment"];
7
+ /** What uploadMedia uploads. */
8
+ export type MediaUploadInput = {
9
+ /** The image, video, or audio file. Its type is the part's Content-Type, which the server checks against the content. */
10
+ file: Blob;
11
+ /** The file name, such as "photo.jpg"; the name of a File, or "file", by default. */
12
+ fileName?: string;
13
+ /** The poster of a video or audio file. */
14
+ thumbnail?: Blob;
15
+ /** The file name of the thumbnail; the name of a File, or "thumbnail", by default. */
16
+ thumbnailName?: string;
17
+ /** The alternative text. */
18
+ description?: string;
19
+ /** What the file is for; attachment by default. */
20
+ purpose?: MediaUploadPurpose;
21
+ };
22
+ /**
23
+ * Uploads a media file (uploadMedia) as multipart/form-data. The answer is the file in its current state, usually
24
+ * processing: poll `GET /media/{id}` until it is ready or failed. A Blob can be read again, so the upload is sent
25
+ * again after a 401 like any other request; it is not retried after a 429 or 503, as POST /media takes no
26
+ * Idempotency-Key.
27
+ */
28
+ export declare function uploadMedia(api: Api, input: MediaUploadInput, init?: {
29
+ signal?: AbortSignal;
30
+ }): Promise<MediaAttachment>;
package/dist/upload.js ADDED
@@ -0,0 +1,30 @@
1
+ function nameOf(blob, name, fallback) {
2
+ if (name !== undefined)
3
+ return name;
4
+ return typeof File !== "undefined" && blob instanceof File && blob.name !== "" ? blob.name : fallback;
5
+ }
6
+ /**
7
+ * Uploads a media file (uploadMedia) as multipart/form-data. The answer is the file in its current state, usually
8
+ * processing: poll `GET /media/{id}` until it is ready or failed. A Blob can be read again, so the upload is sent
9
+ * again after a 401 like any other request; it is not retried after a 429 or 503, as POST /media takes no
10
+ * Idempotency-Key.
11
+ */
12
+ export async function uploadMedia(api, input, init) {
13
+ const form = new FormData();
14
+ form.append("file", input.file, nameOf(input.file, input.fileName, "file"));
15
+ if (input.thumbnail !== undefined)
16
+ form.append("thumbnail", input.thumbnail, nameOf(input.thumbnail, input.thumbnailName, "thumbnail"));
17
+ if (input.description !== undefined && input.description !== "")
18
+ form.append("description", input.description);
19
+ if (input.purpose !== undefined)
20
+ form.append("purpose", input.purpose);
21
+ const { data } = await api.POST("/media", {
22
+ // The body sent is the FormData of bodySerializer; the typed body only satisfies the spec's shape.
23
+ body: { file: "", purpose: input.purpose ?? "attachment" },
24
+ bodySerializer: () => form,
25
+ ...(init?.signal === undefined ? {} : { signal: init.signal }),
26
+ });
27
+ if (data === undefined)
28
+ throw new Error("mykit: uploadMedia: the answer has no body");
29
+ return data;
30
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@mycroblog/mykit",
3
+ "version": "0.0.0-placeholder.0",
4
+ "description": "The client of the mycroblog/my client API: typed requests, tokens, errors, paging, and retries",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/mycroblog/my.git",
9
+ "directory": "pkg/mykit/ts"
10
+ },
11
+ "type": "module",
12
+ "sideEffects": false,
13
+ "exports": {
14
+ ".": {
15
+ "@mycroblog/source": "./src/index.ts",
16
+ "types": "./dist/index.d.ts",
17
+ "default": "./dist/index.js"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "engines": {
24
+ "node": ">=22"
25
+ },
26
+ "scripts": {
27
+ "typecheck": "tsc -p tsconfig.lib.json --incremental --tsBuildInfoFile node_modules/.cache/tsc/tsconfig.lib.tsbuildinfo && tsc -p tsconfig.test.json --incremental --tsBuildInfoFile node_modules/.cache/tsc/tsconfig.test.tsbuildinfo && tsc -p tsconfig.examples.json --incremental --tsBuildInfoFile node_modules/.cache/tsc/tsconfig.examples.tsbuildinfo",
28
+ "lint": "oxlint",
29
+ "test": "bun test",
30
+ "build": "rm -rf dist && tsc -p tsconfig.build.json"
31
+ },
32
+ "dependencies": {
33
+ "openapi-fetch": "0.17.0"
34
+ },
35
+ "devDependencies": {
36
+ "@types/bun": "1.4.2",
37
+ "@typescript/native": "npm:typescript@7.0.2",
38
+ "openapi-typescript": "7.13.0",
39
+ "oxlint": "1.87.0",
40
+ "oxlint-tsgolint": "7.0.2003",
41
+ "typescript": "npm:@typescript/typescript6@6.0.2"
42
+ }
43
+ }