@coder/backstage-plugin-coder
Version:
Create and manage Coder workspaces from Backstage
220 lines (217 loc) • 7.33 kB
JavaScript
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