UNPKG

@coder/backstage-plugin-coder

Version:

Create and manage Coder workspaces from Backstage

220 lines (217 loc) 7.33 kB
import { AxiosError } from 'axios'; import { createApiRef } from '@backstage/core-plugin-api'; import { CODER_API_REF_ID_PREFIX } from '../typesConstants.esm.js'; import { createCoderApi } from './vendoredSdk/index.esm.js'; const CODER_AUTH_HEADER_KEY = "Coder-Session-Token"; const DEFAULT_REQUEST_TIMEOUT_MS = 2e4; const sharedCleanupAbortReason = new DOMException( "Coder Client instance has been manually cleaned up", "AbortError" ); class CoderClientWrapper { urlSync; identityApi; requestTimeoutMs; cleanupController; trackedEjectionIds; loadedSessionToken; api; constructor(inputs) { const { initialToken, apis: { urlSync, identityApi }, requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS } = inputs; this.urlSync = urlSync; this.identityApi = identityApi; this.loadedSessionToken = initialToken; this.requestTimeoutMs = requestTimeoutMs; this.cleanupController = new AbortController(); this.trackedEjectionIds = /* @__PURE__ */ new Set(); this.api = this.createBackstageCoderApi(); this.addBaseRequestInterceptors(); } addRequestInterceptor(requestInterceptor, errorInterceptor) { const axios = this.api.getAxiosInstance(); const ejectionId = axios.interceptors.request.use( requestInterceptor, errorInterceptor ); this.trackedEjectionIds.add(ejectionId); return ejectionId; } removeRequestInterceptorById(ejectionId) { const axios = this.api.getAxiosInstance(); axios.interceptors.request.eject(ejectionId); if (!this.trackedEjectionIds.has(ejectionId)) { return false; } this.trackedEjectionIds.delete(ejectionId); return true; } addBaseRequestInterceptors() { const baseRequestInterceptor = async (config) => { const proxyApiEndpoint = await this.urlSync.getApiEndpoint(); const bearerToken = (await this.identityApi.getCredentials()).token; config.baseURL = proxyApiEndpoint; config.signal = this.getTimeoutAbortSignal(); if (config.headers[CODER_AUTH_HEADER_KEY] === void 0) { config.headers[CODER_AUTH_HEADER_KEY] = this.loadedSessionToken; } if (bearerToken) { config.headers.Authorization = `Bearer ${bearerToken}`; } return config; }; const baseErrorInterceptor = (error) => { const errorIsFromCleanup = error instanceof DOMException && error.name === sharedCleanupAbortReason.name && error.message === sharedCleanupAbortReason.message; if (errorIsFromCleanup) { return void 0; } return error; }; this.addRequestInterceptor(baseRequestInterceptor, baseErrorInterceptor); } createBackstageCoderApi() { const baseApi = createCoderApi(); const getWorkspaces = async (request) => { const workspacesRes = await baseApi.getWorkspaces(request); const remapped = await this.remapWorkspaceIconUrls( workspacesRes.workspaces ); return { ...workspacesRes, workspaces: remapped }; }; const getWorkspacesByRepo = async (request, config) => { if (config.repoUrl === void 0) { return { workspaces: [], count: 0 }; } const stringUrl = config.repoUrl; const responses = await Promise.allSettled( config.repoUrlParamKeys.map((key) => { const patchedRequest = { ...request, q: appendParamToQuery(request.q, key, stringUrl) }; return baseApi.getWorkspaces(patchedRequest); }) ); const uniqueWorkspaces = /* @__PURE__ */ new Map(); for (const res of responses) { if (res.status === "rejected") { continue; } for (const workspace of res.value.workspaces) { uniqueWorkspaces.set(workspace.id, workspace); } } const serialized = [...uniqueWorkspaces.values()]; return { workspaces: serialized, count: serialized.length }; }; return { ...baseApi, getWorkspaces, getWorkspacesByRepo }; } /** * Creates a combined abort signal that will abort when the client is cleaned * up, but will also enforce request timeouts */ getTimeoutAbortSignal() { const timeoutController = new AbortController(); const timeoutId = window.setTimeout(() => { const reason = new DOMException("Signal timed out", "TimeoutException"); timeoutController.abort(reason); }, this.requestTimeoutMs); const cleanupSignal = this.cleanupController.signal; cleanupSignal.addEventListener( "abort", () => { window.clearTimeout(timeoutId); timeoutController.abort(cleanupSignal.reason); }, // Attaching the timeoutController signal here makes it so that if the // timeout resolves, this event listener will automatically be removed { signal: timeoutController.signal } ); return timeoutController.signal; } async remapWorkspaceIconUrls(workspaces) { const assetsRoute = await this.urlSync.getAssetsEndpoint(); return workspaces.map((ws) => { const templateIconUrl = ws.template_icon; if (!templateIconUrl.startsWith("/")) { return ws; } return { ...ws, template_icon: `${assetsRoute}${templateIconUrl}` }; }); } /* *************************************************************************** * All public functions should be defined as arrow functions to ensure they * can be passed around React without risk of losing their `this` context ****************************************************************************/ syncToken = async (newToken) => { const validationId = this.addRequestInterceptor((config) => { config.headers[CODER_AUTH_HEADER_KEY] = newToken; return config; }); try { const dummyUser = await this.api.getAuthenticatedUser(); assertValidUser(dummyUser); this.loadedSessionToken = newToken; return true; } catch (err) { const tokenIsInvalid = err instanceof AxiosError && err.response?.status === 401; if (tokenIsInvalid) { return false; } throw err; } finally { this.removeRequestInterceptorById(validationId); } }; getLoadedToken = () => { return this.loadedSessionToken; }; setToken = (newToken) => { this.loadedSessionToken = newToken; }; } function appendParamToQuery(query, key, value) { if (!key || !value) { return ""; } const keyValuePair = `param:"${key}=${value}"`; if (!query) { return keyValuePair; } if (query.includes(keyValuePair)) { return query; } return `${query} ${keyValuePair}`; } function assertValidUser(value) { if (value === null || typeof value !== "object") { throw new Error("Returned JSON value is not an object"); } const hasFields = "id" in value && typeof value.id === "string" && "username" in value && typeof value.username === "string"; if (!hasFields) { throw new Error( "User object is missing expected fields for authentication request" ); } } const coderClientWrapperApiRef = createApiRef({ id: `${CODER_API_REF_ID_PREFIX}.coder-client` }); export { CODER_AUTH_HEADER_KEY, CoderClientWrapper, coderClientWrapperApiRef }; //# sourceMappingURL=CoderClient.esm.js.map