Initial Version
This commit is contained in:
161
core/src/utils/helpers/fetch-wrapper-core.ts
Normal file
161
core/src/utils/helpers/fetch-wrapper-core.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Core fetch wrapper - reusable across modules
|
||||
* Does not depend on stores to avoid bundling issues in library builds
|
||||
*/
|
||||
|
||||
export interface FetchWrapperOptions {
|
||||
/**
|
||||
* Optional callback to handle logout on auth failure
|
||||
* If not provided, only logs error without redirecting
|
||||
*/
|
||||
onLogout?: () => void | Promise<void>;
|
||||
/**
|
||||
* Enable automatic retry of failed requests after token refresh
|
||||
* @default true
|
||||
*/
|
||||
autoRetry?: boolean;
|
||||
}
|
||||
|
||||
// Mutex to prevent multiple simultaneous refresh attempts
|
||||
class RefreshMutex {
|
||||
private promise: Promise<boolean> | null = null;
|
||||
|
||||
async acquire(): Promise<boolean> {
|
||||
if (this.promise) {
|
||||
return this.promise;
|
||||
}
|
||||
|
||||
this.promise = this.performRefresh();
|
||||
const result = await this.promise;
|
||||
this.promise = null;
|
||||
return result;
|
||||
}
|
||||
|
||||
private async performRefresh(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch('/security/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Token refresh failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tokenRefreshMutex = new RefreshMutex();
|
||||
|
||||
export interface RequestCallOptions {
|
||||
/**
|
||||
* Override autoRetry for this specific request
|
||||
* @default true
|
||||
*/
|
||||
autoRetry?: boolean;
|
||||
/**
|
||||
* Skip calling onLogout callback on 401/403 errors
|
||||
* Useful for authentication endpoints where 401 means invalid credentials, not session expiry
|
||||
* @default false
|
||||
*/
|
||||
skipLogoutOnError?: boolean;
|
||||
}
|
||||
|
||||
export function createFetchWrapper(options: FetchWrapperOptions = {}) {
|
||||
const { autoRetry: defaultAutoRetry = true } = options;
|
||||
|
||||
return {
|
||||
get: request('GET', options, defaultAutoRetry),
|
||||
post: request('POST', options, defaultAutoRetry),
|
||||
put: request('PUT', options, defaultAutoRetry),
|
||||
delete: request('DELETE', options, defaultAutoRetry)
|
||||
};
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
body?: string;
|
||||
credentials: 'include';
|
||||
}
|
||||
|
||||
function request(method: string, options: FetchWrapperOptions, defaultAutoRetry: boolean) {
|
||||
return async (url: string, body?: object, callOptions?: RequestCallOptions): Promise<any> => {
|
||||
const autoRetry = callOptions?.autoRetry ?? defaultAutoRetry;
|
||||
|
||||
const requestOptions: RequestOptions = {
|
||||
method,
|
||||
headers: getHeaders(url),
|
||||
credentials: 'include'
|
||||
};
|
||||
|
||||
if (body) {
|
||||
requestOptions.headers['Content-Type'] = 'application/json';
|
||||
requestOptions.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, requestOptions);
|
||||
|
||||
if (response.status === 401 && autoRetry) {
|
||||
// Try to refresh the token
|
||||
const refreshSuccess = await tokenRefreshMutex.acquire();
|
||||
|
||||
if (refreshSuccess) {
|
||||
// Retry the original request with the new token
|
||||
const retryResponse = await fetch(url, requestOptions);
|
||||
return handleResponse(retryResponse, options, callOptions?.skipLogoutOnError);
|
||||
}
|
||||
}
|
||||
|
||||
return handleResponse(response, options, callOptions?.skipLogoutOnError);
|
||||
} catch (error) {
|
||||
console.error('API error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getHeaders(_url: string): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
// Add CSRF token if available
|
||||
const csrfToken = getCsrfTokenFromCookie();
|
||||
if (csrfToken) {
|
||||
headers['X-CSRF-TOKEN'] = csrfToken;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function getCsrfTokenFromCookie(): string | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
|
||||
const csrfCookie = document.cookie
|
||||
.split('; ')
|
||||
.find(row => row.startsWith('X-CSRF-TOKEN='));
|
||||
|
||||
return csrfCookie ? csrfCookie.split('=')[1] : null;
|
||||
}
|
||||
|
||||
async function handleResponse(response: Response, options: FetchWrapperOptions, skipLogoutOnError?: boolean): Promise<any> {
|
||||
const text = await response.text();
|
||||
const data = text && JSON.parse(text);
|
||||
|
||||
if (!response.ok) {
|
||||
if ([401, 403].includes(response.status) && !skipLogoutOnError) {
|
||||
// Call logout callback if provided
|
||||
if (options.onLogout) {
|
||||
await options.onLogout();
|
||||
} else {
|
||||
console.error('Authentication failed. Please log in again.');
|
||||
}
|
||||
}
|
||||
|
||||
const error: string = (data && data.message) || response.statusText;
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
10
core/src/utils/helpers/fetch-wrapper.ts
Normal file
10
core/src/utils/helpers/fetch-wrapper.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useUserStore } from '@KTXC/stores/userStore';
|
||||
import { createFetchWrapper } from './fetch-wrapper-core';
|
||||
|
||||
// Create fetch wrapper with user store logout callback
|
||||
export const fetchWrapper = createFetchWrapper({
|
||||
onLogout: () => {
|
||||
const { logout } = useUserStore();
|
||||
logout();
|
||||
}
|
||||
});
|
||||
6
core/src/utils/helpers/shared.ts
Normal file
6
core/src/utils/helpers/shared.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Shared utilities entry point for external modules
|
||||
* This file is built separately and exposed via import map
|
||||
*/
|
||||
|
||||
export { createFetchWrapper, type FetchWrapperOptions } from './fetch-wrapper-core';
|
||||
103
core/src/utils/modules.ts
Normal file
103
core/src/utils/modules.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { App } from 'vue';
|
||||
import { router } from '@KTXC/router';
|
||||
import { useModuleStore } from '@KTXC/stores/moduleStore';
|
||||
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
||||
|
||||
function installModuleCSS(moduleHandle: string, cssPaths: string | string[]): void {
|
||||
const cssFiles = Array.isArray(cssPaths) ? cssPaths : [cssPaths];
|
||||
cssFiles.forEach((cssFile: string) => {
|
||||
const cssPath = `/modules/${moduleHandle}/${cssFile}`;
|
||||
const existingLink = document.querySelector(`link[href="${cssPath}"]`);
|
||||
if (!existingLink) {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = cssPath;
|
||||
link.onload = () => {
|
||||
console.log(`Module Loader - Loaded CSS for ${moduleHandle}: ${cssFile}`);
|
||||
};
|
||||
link.onerror = () => {
|
||||
console.error(`Module Loader - Failed to load CSS for ${moduleHandle}: ${cssPath}`);
|
||||
};
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function installModuleRoutes(moduleHandle: string, routes: any[]): void {
|
||||
routes.forEach((route: any) => {
|
||||
// Prefix route name with module handle for safety
|
||||
const prefixedRoute = {
|
||||
...route,
|
||||
path: `/m/${moduleHandle}${route.path}`
|
||||
};
|
||||
// Prefix the route name if it exists
|
||||
if (route.name) {
|
||||
prefixedRoute.name = `${moduleHandle}.${route.name}`;
|
||||
}
|
||||
// Recursively prefix child route names
|
||||
if (route.children && Array.isArray(route.children)) {
|
||||
prefixedRoute.children = route.children.map((child: any) => ({
|
||||
...child,
|
||||
name: child.name ? `${moduleHandle}.${child.name}` : undefined
|
||||
}));
|
||||
}
|
||||
|
||||
router.addRoute('private', prefixedRoute);
|
||||
});
|
||||
}
|
||||
|
||||
function installModuleIntegrations(
|
||||
moduleHandle: string,
|
||||
integrations: Record<string, any[]>
|
||||
): void {
|
||||
const integrationStore = useIntegrationStore();
|
||||
integrationStore.registerModuleIntegrations(moduleHandle, integrations);
|
||||
}
|
||||
|
||||
export async function initializeModules(app: App): Promise<void> {
|
||||
const moduleStore = useModuleStore();
|
||||
|
||||
// First, dynamically load modules based on moduleStore boot paths
|
||||
const availableModules = moduleStore.modules;
|
||||
const loadPromises: Promise<void>[] = [];
|
||||
|
||||
for (const [moduleId, moduleInfo] of Object.entries(availableModules)) {
|
||||
if (moduleInfo.handle && moduleInfo.boot && !moduleInfo.booted) {
|
||||
const moduleHandle = moduleInfo.handle;
|
||||
const moduleUrl = `/modules/${moduleInfo.handle}/${moduleInfo.boot}`;
|
||||
console.log(`Module Loader - Loading ${moduleInfo.handle} from ${moduleUrl}`);
|
||||
|
||||
const loadPromise = import(/* @vite-ignore */ moduleUrl)
|
||||
.then((module) => {
|
||||
// Load CSS if module explicitly exports css path(s)
|
||||
if (module.css) {
|
||||
installModuleCSS(moduleInfo.handle, module.css);
|
||||
}
|
||||
// install module
|
||||
console.log(`Module Loader - Installing ${moduleInfo.handle}`);
|
||||
if (module.default && typeof module.default.install === 'function') {
|
||||
app.use(module.default);
|
||||
}
|
||||
// prefix routes with /m/{moduleHandle}
|
||||
console.log(`Module Loader - Installing Routes ${moduleInfo.handle}`);
|
||||
if (module.routes) {
|
||||
installModuleRoutes(moduleHandle, module.routes);
|
||||
}
|
||||
// register integrations
|
||||
console.log(`Module Loader - Installing Integrations ${moduleInfo.handle}`);
|
||||
if (module.integrations) {
|
||||
installModuleIntegrations(moduleHandle, module.integrations);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Failed to load module ${moduleId} from ${moduleUrl}:`, error);
|
||||
});
|
||||
loadPromises.push(loadPromise);
|
||||
} else if (!moduleInfo.boot) {
|
||||
console.warn(`No boot path specified for module: ${moduleId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all dynamic loading to complete
|
||||
await Promise.all(loadPromises);
|
||||
}
|
||||
Reference in New Issue
Block a user