main
js 285 lines 7.8 KB
Raw
1 /**
2 * Call a JSON-in JSON-out API endpoint
3 * Data is automatically serialized
4 * @param {string} endpoint - The API endpoint to call
5 * @param {any} data - The data to send to the API
6 * @returns {Promise<any>} The JSON response from the API
7 */
8 export async function callJsonApi(endpoint, data) {
9 const apiUrl = _normalizeApiUrl(endpoint);
10
11 /** @type {{ endpoint: string, data: any, response: Response | null, result: any, error: Error | null }} */
12 const ctx = {
13 endpoint,
14 data,
15 response: null,
16 result: null,
17 error: null,
18 };
19
20 if (await _shouldCallApiExtensions(apiUrl)) {
21 const extensions = await _getExtensions();
22 await extensions.callJsExtensions("json_api_call_before", ctx);
23 }
24
25 const response = await fetchApi(ctx.endpoint, {
26 method: "POST",
27 headers: {
28 "Content-Type": "application/json",
29 },
30 credentials: "same-origin",
31 body: JSON.stringify(ctx.data),
32 });
33 ctx.response = response;
34
35 if (!response.ok) {
36 const error = await response.text();
37 ctx.error = new Error(error);
38
39 if (await _shouldCallApiExtensions(apiUrl)) {
40 const extensions = await _getExtensions();
41 await extensions.callJsExtensions("json_api_call_error", ctx);
42 }
43
44 if (ctx.error) throw ctx.error;
45
46 return ctx.result;
47 }
48
49 ctx.result = await response.json();
50
51 if (await _shouldCallApiExtensions(apiUrl)) {
52 const extensions = await _getExtensions();
53 await extensions.callJsExtensions("json_api_call_after", ctx);
54 }
55
56 return ctx.result;
57 }
58
59 /**
60 * Fetch wrapper for A0 APIs that ensures token exchange
61 * Automatically adds CSRF token to request headers
62 * @param {string} url - The URL to fetch
63 * @param {Object} [request] - The fetch request options
64 * @returns {Promise<Response>} The fetch response
65 */
66 export async function fetchApi(url, request) {
67 async function _wrap(retry) {
68 // get the CSRF token
69 const token = await getCsrfToken();
70
71 // create a new request object if none was provided
72 const finalRequest = request || {};
73
74 // ensure headers object exists
75 finalRequest.headers = finalRequest.headers || {};
76
77 // add the CSRF token to the headers
78 finalRequest.headers["X-CSRF-Token"] = token;
79
80 // perform the fetch with the updated request
81 const apiUrl = _normalizeApiUrl(url);
82
83 /** @type {{ url: string, apiUrl: string, request: any, response: Response | null, retry: boolean }} */
84 const ctx = {
85 url,
86 apiUrl,
87 request: finalRequest,
88 response: null,
89 retry,
90 };
91
92 if (await _shouldCallApiExtensions(apiUrl)) {
93 const extensions = await _getExtensions();
94 await extensions.callJsExtensions("fetch_api_call_before", ctx);
95 }
96
97 const response = ctx.response || (await fetch(ctx.apiUrl, ctx.request));
98 ctx.response = response;
99
100 if (await _shouldCallApiExtensions(apiUrl)) {
101 const extensions = await _getExtensions();
102 await extensions.callJsExtensions("fetch_api_call_after", ctx);
103 }
104
105 const finalResponse = ctx.response;
106
107 // check if there was an CSRF error
108 if (finalResponse.status === 403 && retry) {
109 // retry the request with new token
110 csrfToken = null;
111 return await _wrap(false);
112 }
113
114 if (redirect(finalResponse)) return;
115
116 // return the response
117 return finalResponse;
118 }
119
120 // perform the request
121 const response = await _wrap(true);
122
123 // return the response
124 return response;
125 }
126
127 // csrf token stored locally
128 let csrfToken = null;
129 let csrfTokenPromise = null;
130 let runtimeIdCache = null;
131 const CSRF_TIMEOUT_MS = 5000;
132 const CSRF_SLOW_WARN_MS = 1500;
133
134 export function getRuntimeId() {
135 if (runtimeIdCache) return runtimeIdCache;
136 const injected =
137 globalThis.runtimeInfo &&
138 typeof globalThis.runtimeInfo.id === "string" &&
139 globalThis.runtimeInfo.id.length > 0
140 ? globalThis.runtimeInfo.id
141 : null;
142 return injected;
143 }
144
145 export function invalidateCsrfToken() {
146 csrfToken = null;
147 csrfTokenPromise = null;
148 }
149
150 /**
151 * Get the CSRF token for API requests
152 * Caches the token after first request
153 * @returns {Promise<string>} The CSRF token
154 */
155 export async function getCsrfToken() {
156 if (csrfToken) return csrfToken;
157 if (csrfTokenPromise) return await csrfTokenPromise;
158
159 csrfTokenPromise = (async () => {
160 const startedAt = Date.now();
161 const controller =
162 typeof AbortController !== "undefined" ? new AbortController() : null;
163 let timeoutId = null;
164 let timeoutPromise = null;
165 let response;
166
167 try {
168 if (controller) {
169 timeoutId = setTimeout(() => controller.abort(), CSRF_TIMEOUT_MS);
170 } else {
171 timeoutPromise = new Promise((_, reject) => {
172 timeoutId = setTimeout(() => {
173 reject(new Error("CSRF token request timed out"));
174 }, CSRF_TIMEOUT_MS);
175 });
176 }
177
178 /** @type {RequestInit} */
179 const fetchOptions = { credentials: "same-origin" };
180 if (controller) {
181 fetchOptions.signal = controller.signal;
182 }
183
184 const fetchPromise = fetch("/api/csrf_token", fetchOptions);
185 response = timeoutPromise
186 ? await Promise.race([fetchPromise, timeoutPromise])
187 : await fetchPromise;
188 } catch (error) {
189 if (error && error["name"] === "AbortError") {
190 throw new Error("CSRF token request timed out");
191 }
192 throw error;
193 } finally {
194 if (timeoutId) {
195 clearTimeout(timeoutId);
196 }
197 }
198
199 if (redirect(response)) return;
200
201 const json = await response.json();
202 if (json.ok) {
203 const runtimeId =
204 typeof json.runtime_id === "string" && json.runtime_id.length > 0
205 ? json.runtime_id
206 : null;
207
208 csrfToken = json.token;
209 if (runtimeId) {
210 runtimeIdCache = runtimeId;
211 }
212 const injectedRuntimeId =
213 globalThis.runtimeInfo &&
214 typeof globalThis.runtimeInfo.id === "string" &&
215 globalThis.runtimeInfo.id.length > 0
216 ? globalThis.runtimeInfo.id
217 : null;
218 const cookieRuntimeId = runtimeId || injectedRuntimeId;
219 if (cookieRuntimeId) {
220 const _secureFlag =
221 window.location.protocol === "https:" ? "; Secure" : "";
222 document.cookie = `csrf_token_${cookieRuntimeId}=${csrfToken}; SameSite=Lax; Path=/${_secureFlag}`;
223 } else {
224 console.warn("CSRF runtime id missing; skipping cookie name binding.");
225 }
226 const elapsedMs = Date.now() - startedAt;
227 if (
228 elapsedMs > CSRF_SLOW_WARN_MS &&
229 globalThis.runtimeInfo?.isDevelopment
230 ) {
231 console.warn(`CSRF token request took ${elapsedMs}ms`);
232 }
233 return csrfToken;
234 } else {
235 if (json.error) alert(json.error);
236 throw new Error(json.error || "Failed to get CSRF token");
237 }
238 })();
239
240 try {
241 return await csrfTokenPromise;
242 } finally {
243 csrfTokenPromise = null;
244 }
245 }
246
247
248
249 let _extensionsModule = null;
250
251 async function _getExtensions() {
252 if (!_extensionsModule) _extensionsModule = await import("./extensions.js");
253 return _extensionsModule;
254 }
255
256 async function _shouldCallApiExtensions(apiUrl) {
257 const extensions = await _getExtensions();
258 const excluded = extensions.API_EXTENSION_EXCLUDED_ENDPOINTS;
259 return !(excluded instanceof Set && excluded.has(apiUrl));
260 }
261
262 function _normalizeApiUrl(url) {
263 return url.startsWith("/api/") || url.startsWith("api/")
264 ? `/${url.replace(/^\/+/, "")}`
265 : `/api/${url.replace(/^\/+/, "")}`;
266 }
267
268 function redirect(response) {
269 if (!response.redirected) return false;
270
271 const _redirectUrl = new URL(response.url);
272 if (
273 _redirectUrl.origin === window.location.origin &&
274 _redirectUrl.pathname === "/login"
275 ) {
276 const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
277 if (currentUrl && currentUrl !== "/login") {
278 _redirectUrl.searchParams.set("next", currentUrl);
279 }
280 window.location.href = _redirectUrl.toString();
281 return true;
282 }
283
284 return false;
285 }