@lukoweb/apitogo 0.2.43 → 0.2.45

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.
@@ -1,106 +1,179 @@
1
- import { Spinner } from "@lukoweb/apitogo/components";
2
- import {
3
- Alert,
4
- AlertDescription,
5
- AlertTitle,
6
- } from "@lukoweb/apitogo/ui/Alert.js";
7
- import type { UseMutationResult } from "@tanstack/react-query";
8
- import { SendIcon, UnplugIcon } from "lucide-react";
9
- import { Button } from "../../../../ui/Button.js";
10
- import { cn } from "../../../../util/cn.js";
11
- import type { PlaygroundResult } from "../Playground.js";
12
- import ResponseStatusBar from "./ResponseStatusBar.js";
13
- import { ResponseTab } from "./ResponseTab.js";
14
-
15
- export const ResultPanel = ({
16
- queryMutation,
17
- showLongRunningWarning,
18
- onCancel,
19
- tip,
20
- isFinished,
21
- progress,
22
- }: {
23
- // biome-ignore lint/suspicious/noExplicitAny: Allow any type
24
- queryMutation: UseMutationResult<PlaygroundResult, Error, any>;
25
- showLongRunningWarning?: boolean;
26
- onCancel?: () => void;
27
- isFinished: boolean;
28
- progress: number;
29
- tip?: React.ReactNode;
30
- }) => {
31
- return (
32
- <div className="flex flex-col overflow-y-auto h-[80vh] bg-muted/50">
33
- {(queryMutation.isPending || queryMutation.data) && (
34
- <ResponseStatusBar
35
- status={queryMutation.data?.status}
36
- time={queryMutation.data?.time}
37
- size={queryMutation.data?.size}
38
- isFinished={isFinished}
39
- progress={progress}
40
- />
41
- )}
42
- {queryMutation.error ? (
43
- <div className="max-w-2/3 mx-auto mt-20">
44
- <Alert>
45
- <UnplugIcon size={24} strokeWidth={1.5} className="me-5" />
46
- <AlertTitle>Request failed</AlertTitle>
47
- <AlertDescription>
48
- {queryMutation.error.message ||
49
- String(queryMutation.error) ||
50
- "Unexpected error"}
51
- </AlertDescription>
52
- </Alert>
53
- </div>
54
- ) : queryMutation.data ? (
55
- <ResponseTab
56
- request={queryMutation.data.request}
57
- size={queryMutation.data.size}
58
- headers={queryMutation.data.headers}
59
- body={queryMutation.data.body}
60
- isBinary={queryMutation.data.isBinary}
61
- fileName={queryMutation.data.fileName}
62
- blob={queryMutation.data.blob}
63
- />
64
- ) : queryMutation.isPending ? (
65
- <div className="grid place-items-center h-full">
66
- <div className="flex flex-col gap-2 items-center mt-20">
67
- <Spinner />
68
- <div
69
- className={cn(
70
- "opacity-0 pointer-events-none transition-opacity h-20 text-sm text-muted-foreground duration-300 flex flex-col gap-2 items-center",
71
- showLongRunningWarning && "opacity-100 pointer-events-auto",
72
- )}
73
- >
74
- Looks like the request is taking longer than expected.
75
- <Button
76
- type="button"
77
- onClick={onCancel}
78
- size="sm"
79
- className="w-fit"
80
- variant="outline"
81
- >
82
- Cancel
83
- </Button>
84
- </div>
85
- </div>
86
- </div>
87
- ) : (
88
- <div className="h-full grid place-items-center">
89
- <div className="flex flex-col gap-4 items-center">
90
- <SendIcon
91
- size={64}
92
- className="text-muted-foreground"
93
- strokeWidth={1.2}
94
- />
95
- <span className="text-[16px] font-semibold text-muted-foreground">
96
- Send your first request
97
- </span>
98
- {tip}
99
- </div>
100
- </div>
101
- )}
102
- </div>
103
- );
104
- };
105
-
106
- export default ResultPanel;
1
+ import { Spinner } from "@lukoweb/apitogo/components";
2
+ import {
3
+ Alert,
4
+ AlertDescription,
5
+ AlertTitle,
6
+ } from "@lukoweb/apitogo/ui/Alert.js";
7
+ import type { UseMutationResult } from "@tanstack/react-query";
8
+ import {
9
+ HourglassIcon,
10
+ KeyRoundIcon,
11
+ SendIcon,
12
+ UnplugIcon,
13
+ } from "lucide-react";
14
+ import { Button } from "../../../../ui/Button.js";
15
+ import { cn } from "../../../../util/cn.js";
16
+ import type { PlaygroundResult } from "../Playground.js";
17
+ import { WARM_UP_STATUSES } from "../warmUpRetry.js";
18
+ import ResponseStatusBar from "./ResponseStatusBar.js";
19
+ import { ResponseTab } from "./ResponseTab.js";
20
+
21
+ /** Where a signed-in developer gets an API key on an APItoGo portal with plans. */
22
+ const API_KEYS_PATH = "/account/credentials";
23
+
24
+ /** What a refused or failed answer means, and what to do about it. */
25
+ const ResultHint = ({
26
+ status,
27
+ hasIdentities,
28
+ }: {
29
+ status: number;
30
+ hasIdentities: boolean;
31
+ }) => {
32
+ if (status === 401 || status === 403)
33
+ return (
34
+ <Alert className="mx-4 mt-3 w-auto">
35
+ <KeyRoundIcon size={20} strokeWidth={1.5} />
36
+ <AlertTitle>This API needs an API key</AlertTitle>
37
+ <AlertDescription>
38
+ {hasIdentities ? (
39
+ <>
40
+ Choose your key under Authentication above and send the request
41
+ again. No key yet? Create one on the{" "}
42
+ <a href={API_KEYS_PATH} className="underline">
43
+ API keys page
44
+ </a>
45
+ : a key you create there is offered here in this browser.
46
+ </>
47
+ ) : (
48
+ <>
49
+ Create a key on the{" "}
50
+ <a href={API_KEYS_PATH} className="underline">
51
+ API keys page
52
+ </a>{" "}
53
+ (sign in first if you have not). It is then offered under
54
+ Authentication here.
55
+ </>
56
+ )}
57
+ </AlertDescription>
58
+ </Alert>
59
+ );
60
+ if (WARM_UP_STATUSES.includes(status))
61
+ return (
62
+ <Alert className="mx-4 mt-3 w-auto">
63
+ <HourglassIcon size={20} strokeWidth={1.5} />
64
+ <AlertTitle>The API is still starting</AlertTitle>
65
+ <AlertDescription>
66
+ An API that was idle takes a few seconds to wake up. Send the request
67
+ again in a moment.
68
+ </AlertDescription>
69
+ </Alert>
70
+ );
71
+ return null;
72
+ };
73
+
74
+ export const ResultPanel = ({
75
+ queryMutation,
76
+ showLongRunningWarning,
77
+ isWarmingUp = false,
78
+ hasIdentities = false,
79
+ onCancel,
80
+ tip,
81
+ isFinished,
82
+ progress,
83
+ }: {
84
+ // biome-ignore lint/suspicious/noExplicitAny: Allow any type
85
+ queryMutation: UseMutationResult<PlaygroundResult, Error, any>;
86
+ showLongRunningWarning?: boolean;
87
+ /** The API said it is still starting, and the request is sent again in a moment. */
88
+ isWarmingUp?: boolean;
89
+ /** The playground offers at least one identity (a key or the signed-in user) under Authentication. */
90
+ hasIdentities?: boolean;
91
+ onCancel?: () => void;
92
+ isFinished: boolean;
93
+ progress: number;
94
+ tip?: React.ReactNode;
95
+ }) => {
96
+ return (
97
+ <div className="flex flex-col overflow-y-auto h-[80vh] bg-muted/50">
98
+ {(queryMutation.isPending || queryMutation.data) && (
99
+ <ResponseStatusBar
100
+ status={queryMutation.data?.status}
101
+ time={queryMutation.data?.time}
102
+ size={queryMutation.data?.size}
103
+ isFinished={isFinished}
104
+ progress={progress}
105
+ />
106
+ )}
107
+ {queryMutation.error ? (
108
+ <div className="max-w-2/3 mx-auto mt-20">
109
+ <Alert>
110
+ <UnplugIcon size={24} strokeWidth={1.5} className="me-5" />
111
+ <AlertTitle>Request failed</AlertTitle>
112
+ <AlertDescription>
113
+ {queryMutation.error.message ||
114
+ String(queryMutation.error) ||
115
+ "Unexpected error"}
116
+ </AlertDescription>
117
+ </Alert>
118
+ </div>
119
+ ) : queryMutation.data ? (
120
+ <>
121
+ <ResultHint
122
+ status={queryMutation.data.status}
123
+ hasIdentities={hasIdentities}
124
+ />
125
+ <ResponseTab
126
+ request={queryMutation.data.request}
127
+ size={queryMutation.data.size}
128
+ headers={queryMutation.data.headers}
129
+ body={queryMutation.data.body}
130
+ isBinary={queryMutation.data.isBinary}
131
+ fileName={queryMutation.data.fileName}
132
+ blob={queryMutation.data.blob}
133
+ />
134
+ </>
135
+ ) : queryMutation.isPending ? (
136
+ <div className="grid place-items-center h-full">
137
+ <div className="flex flex-col gap-2 items-center mt-20">
138
+ <Spinner />
139
+ <div
140
+ className={cn(
141
+ "opacity-0 pointer-events-none transition-opacity h-20 text-sm text-muted-foreground duration-300 flex flex-col gap-2 items-center",
142
+ showLongRunningWarning && "opacity-100 pointer-events-auto",
143
+ )}
144
+ >
145
+ {isWarmingUp
146
+ ? "The API is waking up. Trying again in a few seconds…"
147
+ : "Looks like the request is taking longer than expected."}
148
+ <Button
149
+ type="button"
150
+ onClick={onCancel}
151
+ size="sm"
152
+ className="w-fit"
153
+ variant="outline"
154
+ >
155
+ Cancel
156
+ </Button>
157
+ </div>
158
+ </div>
159
+ </div>
160
+ ) : (
161
+ <div className="h-full grid place-items-center">
162
+ <div className="flex flex-col gap-4 items-center">
163
+ <SendIcon
164
+ size={64}
165
+ className="text-muted-foreground"
166
+ strokeWidth={1.2}
167
+ />
168
+ <span className="text-[16px] font-semibold text-muted-foreground">
169
+ Send your first request
170
+ </span>
171
+ {tip}
172
+ </div>
173
+ </div>
174
+ )}
175
+ </div>
176
+ );
177
+ };
178
+
179
+ export default ResultPanel;
@@ -0,0 +1,43 @@
1
+ /** What a gateway answers while the API behind it is still starting: it is not ready yet, or it did not answer in time. */
2
+ export const WARM_UP_STATUSES = [502, 503, 504];
3
+
4
+ const WARM_UP_RETRY_DELAY_MS = 5000;
5
+
6
+ const wait = (ms: number, signal?: AbortSignal) =>
7
+ new Promise<void>((resolve, reject) => {
8
+ const timer = setTimeout(resolve, ms);
9
+ signal?.addEventListener("abort", () => {
10
+ clearTimeout(timer);
11
+ reject(signal.reason);
12
+ });
13
+ });
14
+
15
+ /**
16
+ * Sends the request, and once more after a short wait when the answer says the API is still starting. A hosted API
17
+ * that slept goes on answering 502/504 (or, behind an older gateway, a response the browser reports as a network
18
+ * error) until it is up, which takes a few seconds. A cancelled request is never retried.
19
+ */
20
+ export async function fetchWithWarmUpRetry(
21
+ request: Request,
22
+ init: RequestInit,
23
+ options: { delayMs?: number; onRetry?: () => void } = {},
24
+ ): Promise<Response> {
25
+ const retry = async () => {
26
+ options.onRetry?.();
27
+ await wait(
28
+ options.delayMs ?? WARM_UP_RETRY_DELAY_MS,
29
+ init.signal ?? undefined,
30
+ );
31
+ return fetch(request, init);
32
+ };
33
+
34
+ // A request body can be read once, so the first try sends a copy.
35
+ let response: Response;
36
+ try {
37
+ response = await fetch(request.clone(), init);
38
+ } catch (error) {
39
+ if (init.signal?.aborted || !(error instanceof TypeError)) throw error;
40
+ return retry();
41
+ }
42
+ return WARM_UP_STATUSES.includes(response.status) ? retry() : response;
43
+ }