Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/customer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"type-check": "tsc --noEmit"
},
"dependencies": {
"@compasser/api": "workspace:^",
"@compasser/design-system": "workspace:*",
"@tanstack/react-query": "^5.90.21",
"next": "16.1.6",
Expand Down
1 change: 1 addition & 0 deletions apps/owner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"type-check": "tsc --noEmit"
},
"dependencies": {
"@compasser/api": "workspace:^",
"@compasser/design-system": "workspace:*",
"@tanstack/react-query": "^5.90.21",
"next": "16.1.6",
Expand Down
24 changes: 24 additions & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "@compasser/api",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"type-check": "tsc --noEmit"
},
"dependencies": {
"@tanstack/react-query": "^5.90.21",
"axios": "^1.14.0"
},
"peerDependencies": {
"react": "^19",
"react-dom": "^19"
},
"devDependencies": {
"@compasser/typescript-config": "workspace:*",
"typescript": "^5.9.2"
}
}
120 changes: 120 additions & 0 deletions packages/api/src/core/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import axios, { AxiosError } from "axios";
import type { InternalAxiosRequestConfig } from "axios";
import type { CompasserApi, CreateCompasserApiOptions, TokenPair } from "./types";

interface FailedQueueItem {
resolve: (token: string) => void;
reject: (error: unknown) => void;
}

const processQueue = (
queue: FailedQueueItem[],
error: unknown,
token: string | null,
) => {
queue.forEach((item) => {
if (error) {
item.reject(error);
return;
}

item.resolve(token ?? "");
});
};

export const createCompasserApi = (
options: CreateCompasserApiOptions,
): CompasserApi => {
const authScheme = options.authScheme ?? "Bearer";
const withCredentials = options.withCredentials ?? true;

const publicClient = axios.create({
baseURL: options.baseURL,
withCredentials,
});

const privateClient = axios.create({
baseURL: options.baseURL,
withCredentials,
});

privateClient.interceptors.request.use(
(config) => {
const accessToken = options.tokenStore.getAccessToken();
if (accessToken) {
config.headers.Authorization = `${authScheme} ${accessToken}`;
}
return config;
},
(error) => Promise.reject(error),
);

let isRefreshing = false;
let failedQueue: FailedQueueItem[] = [];

privateClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};

if (
error.response?.status !== 401 ||
originalRequest?._retry ||
!options.refreshTokens
) {
return Promise.reject(error);
}

if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({
resolve: (token) => {
originalRequest.headers.Authorization = `${authScheme} ${token}`;
resolve(privateClient(originalRequest));
},
reject,
});
});
}

originalRequest._retry = true;
isRefreshing = true;

try {
const nextTokens: TokenPair = await options.refreshTokens({
publicClient,
refreshToken: options.tokenStore.getRefreshToken(),
originalRequest,
});

options.tokenStore.setTokens(nextTokens);
privateClient.defaults.headers.common.Authorization =
`${authScheme} ${nextTokens.accessToken}`;

processQueue(failedQueue, null, nextTokens.accessToken);
failedQueue = [];

originalRequest.headers.Authorization =
`${authScheme} ${nextTokens.accessToken}`;

return privateClient(originalRequest);
} catch (refreshError) {
processQueue(failedQueue, refreshError, null);
failedQueue = [];
options.tokenStore.clearTokens();
options.onAuthFailure?.(refreshError);
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
},
);

return {
publicClient,
privateClient,
tokenStore: options.tokenStore,
};
};
135 changes: 135 additions & 0 deletions packages/api/src/core/mutation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import {
mutationOptions,
type MutationKey,
type QueryClient,
type QueryKey,
} from "@tanstack/react-query";

type MaybePromise<T> = T | Promise<T>;

export type CacheAction<TData, TVariables> =
| {
type: "set";
queryKey: QueryKey;
value: unknown | ((data: TData, variables: TVariables) => unknown);
}
| {
type: "merge";
queryKey: QueryKey;
updater: (
oldData: unknown,
data: TData,
variables: TVariables,
) => unknown;
}
| {
type: "invalidate";
queryKey: QueryKey;
}
| {
type: "remove";
queryKey: QueryKey;
};

export interface CreateMutationWithCacheParams<
TData,
TVariables,
TContext = unknown,
TError = unknown,
> {
queryClient: QueryClient;
mutationKey?: MutationKey;
mutationFn: (variables: TVariables) => Promise<TData>;
getActions?: (
data: TData,
variables: TVariables,
) => CacheAction<TData, TVariables>[];
onMutate?: (variables: TVariables) => MaybePromise<TContext>;
onError?: (
error: TError,
variables: TVariables,
context: TContext | undefined,
) => MaybePromise<void>;
onSuccess?: (
data: TData,
variables: TVariables,
context: TContext | undefined,
) => MaybePromise<void>;
onSettled?: (
data: TData | undefined,
error: TError | null,
variables: TVariables,
context: TContext | undefined,
) => MaybePromise<void>;
}

const runCacheActions = async <TData, TVariables>(
queryClient: QueryClient,
actions: CacheAction<TData, TVariables>[],
data: TData,
variables: TVariables,
) => {
for (const action of actions) {
if (action.type === "set") {
const nextValue =
typeof action.value === "function"
? action.value(data, variables)
: action.value;

queryClient.setQueryData(action.queryKey, nextValue);
continue;
}

if (action.type === "merge") {
queryClient.setQueryData(action.queryKey, (oldData: unknown) =>
action.updater(oldData, data, variables),
);
continue;
}

if (action.type === "invalidate") {
await queryClient.invalidateQueries({
queryKey: action.queryKey,
});
continue;
}

if (action.type === "remove") {
queryClient.removeQueries({
queryKey: action.queryKey,
});
}
}
};

export const createMutationWithCache = <
TData,
TVariables,
TContext = unknown,
TError = unknown,
>({
queryClient,
mutationKey,
mutationFn,
getActions,
onMutate,
onError,
onSuccess,
onSettled,
}: CreateMutationWithCacheParams<TData, TVariables, TContext, TError>) =>
mutationOptions<TData, TError, TVariables, TContext>({
...(mutationKey ? { mutationKey } : {}),
mutationFn,
onMutate,
onError,
onSuccess: async (data, variables, context) => {
const actions = getActions?.(data, variables) ?? [];

if (actions.length > 0) {
await runCacheActions(queryClient, actions, data, variables);
}

await onSuccess?.(data, variables, context);
},
onSettled,
});
8 changes: 8 additions & 0 deletions packages/api/src/core/query-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { QueryClient } from "@tanstack/react-query";

export const invalidatePrefix = async (
queryClient: QueryClient,
queryKey: readonly unknown[],
) => {
await queryClient.invalidateQueries({ queryKey });
};
33 changes: 33 additions & 0 deletions packages/api/src/core/token-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { TokenPair, TokenStore } from "./types";

export interface LocalStorageTokenStoreOptions {
accessTokenKey?: string;
refreshTokenKey?: string;
}

const isBrowser = () =>
typeof window !== "undefined" && typeof window.localStorage !== "undefined";

export const createLocalStorageTokenStore = (
options: LocalStorageTokenStoreOptions = {},
): TokenStore => {
const accessTokenKey = options.accessTokenKey ?? "accessToken";
const refreshTokenKey = options.refreshTokenKey ?? "refreshToken";

return {
getAccessToken: () => (isBrowser() ? window.localStorage.getItem(accessTokenKey) : null),
getRefreshToken: () => (isBrowser() ? window.localStorage.getItem(refreshTokenKey) : null),
setTokens: ({ accessToken, refreshToken }: TokenPair) => {
if (!isBrowser()) return;
window.localStorage.setItem(accessTokenKey, accessToken);
if (refreshToken) {
window.localStorage.setItem(refreshTokenKey, refreshToken);
}
},
clearTokens: () => {
if (!isBrowser()) return;
window.localStorage.removeItem(accessTokenKey);
window.localStorage.removeItem(refreshTokenKey);
},
};
};
43 changes: 43 additions & 0 deletions packages/api/src/core/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { AxiosInstance, InternalAxiosRequestConfig } from "axios";

export interface ApiResponse<T> {
success: boolean;
code: string;
message: string;
data: T;
}

export type JsonValue = Record<string, unknown>;

export interface TokenPair {
accessToken: string;
refreshToken?: string;
}

export interface TokenStore {
getAccessToken: () => string | null;
getRefreshToken: () => string | null;
setTokens: (tokens: TokenPair) => void;
clearTokens: () => void;
}

export interface RefreshTokensContext {
publicClient: AxiosInstance;
refreshToken: string | null;
originalRequest: InternalAxiosRequestConfig & { _retry?: boolean };
}

export interface CreateCompasserApiOptions {
baseURL: string;
tokenStore: TokenStore;
withCredentials?: boolean;
refreshTokens?: (context: RefreshTokensContext) => Promise<TokenPair>;
onAuthFailure?: (error: unknown) => void;
authScheme?: string;
}

export interface CompasserApi {
publicClient: AxiosInstance;
privateClient: AxiosInstance;
tokenStore: TokenStore;
}
Loading
Loading