create-nativecore 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -14
- package/bin/index.mjs +402 -431
- package/package.json +3 -2
- package/template/.env.example +28 -0
- package/template/.htmlhintrc +14 -0
- package/template/api/data/dashboard.json +11 -0
- package/template/api/data/users.json +18 -0
- package/template/api/mockApi.js +161 -0
- package/template/assets/icon.svg +13 -0
- package/template/assets/logo.svg +25 -0
- package/template/eslint.config.js +94 -0
- package/template/index.html +137 -0
- package/template/manifest.json +19 -0
- package/template/public/.well-known/security.txt +9 -0
- package/template/public/_headers +24 -0
- package/template/public/_redirects +14 -0
- package/template/public/assets/icon.svg +13 -0
- package/template/public/assets/logo.svg +25 -0
- package/template/public/manifest.json +19 -0
- package/template/public/robots.txt +13 -0
- package/template/public/sitemap.xml +27 -0
- package/template/scripts/build-for-bots.mjs +121 -0
- package/template/scripts/convert-to-ts.mjs +106 -0
- package/template/scripts/fix-encoding.mjs +38 -0
- package/template/scripts/fix-svg-paths.mjs +32 -0
- package/template/scripts/generate-cf-router.mjs +52 -0
- package/template/scripts/inject-dev-tools.mjs +41 -0
- package/template/scripts/inject-version.mjs +65 -0
- package/template/scripts/make-component.mjs +445 -0
- package/template/scripts/make-component.mjs.backup +432 -0
- package/template/scripts/make-controller.mjs +119 -0
- package/template/scripts/make-core-component.mjs +303 -0
- package/template/scripts/make-view.mjs +346 -0
- package/template/scripts/minify.mjs +71 -0
- package/template/scripts/prepare-static-assets.mjs +141 -0
- package/template/scripts/prompt-bot-build.mjs +223 -0
- package/template/scripts/remove-component.mjs +170 -0
- package/template/scripts/remove-core-component.mjs +156 -0
- package/template/scripts/remove-dev.mjs +13 -0
- package/template/scripts/remove-view.mjs +200 -0
- package/template/scripts/strip-dev-blocks.mjs +30 -0
- package/template/scripts/watch-compile.mjs +69 -0
- package/template/server.js +1066 -0
- package/template/src/app.ts +115 -0
- package/template/src/components/appRegistry.ts +8 -0
- package/template/src/components/core/app-footer.ts +27 -0
- package/template/src/components/core/app-header.ts +175 -0
- package/template/src/components/core/app-sidebar.ts +238 -0
- package/template/src/components/core/loading-spinner.ts +25 -0
- package/template/src/components/core/nc-a.ts +313 -0
- package/template/src/components/core/nc-accordion.ts +186 -0
- package/template/src/components/core/nc-alert.ts +153 -0
- package/template/src/components/core/nc-animation.ts +1150 -0
- package/template/src/components/core/nc-autocomplete.ts +271 -0
- package/template/src/components/core/nc-avatar-group.ts +113 -0
- package/template/src/components/core/nc-avatar.ts +148 -0
- package/template/src/components/core/nc-badge.ts +86 -0
- package/template/src/components/core/nc-bottom-nav.ts +214 -0
- package/template/src/components/core/nc-breadcrumb.ts +96 -0
- package/template/src/components/core/nc-button.ts +307 -0
- package/template/src/components/core/nc-card.ts +160 -0
- package/template/src/components/core/nc-checkbox.ts +282 -0
- package/template/src/components/core/nc-chip.ts +115 -0
- package/template/src/components/core/nc-code.ts +314 -0
- package/template/src/components/core/nc-collapsible.ts +154 -0
- package/template/src/components/core/nc-color-picker.ts +268 -0
- package/template/src/components/core/nc-copy-button.ts +119 -0
- package/template/src/components/core/nc-date-picker.ts +443 -0
- package/template/src/components/core/nc-div.ts +280 -0
- package/template/src/components/core/nc-divider.ts +81 -0
- package/template/src/components/core/nc-drawer.ts +230 -0
- package/template/src/components/core/nc-dropdown.ts +178 -0
- package/template/src/components/core/nc-empty-state.ts +134 -0
- package/template/src/components/core/nc-file-upload.ts +354 -0
- package/template/src/components/core/nc-form.ts +312 -0
- package/template/src/components/core/nc-image.ts +184 -0
- package/template/src/components/core/nc-input.ts +383 -0
- package/template/src/components/core/nc-kbd.ts +48 -0
- package/template/src/components/core/nc-menu-item.ts +193 -0
- package/template/src/components/core/nc-menu.ts +376 -0
- package/template/src/components/core/nc-modal.ts +238 -0
- package/template/src/components/core/nc-nav-item.ts +151 -0
- package/template/src/components/core/nc-number-input.ts +350 -0
- package/template/src/components/core/nc-otp-input.ts +235 -0
- package/template/src/components/core/nc-pagination.ts +178 -0
- package/template/src/components/core/nc-popover.ts +260 -0
- package/template/src/components/core/nc-progress-circular.ts +119 -0
- package/template/src/components/core/nc-progress.ts +134 -0
- package/template/src/components/core/nc-radio.ts +235 -0
- package/template/src/components/core/nc-rating.ts +266 -0
- package/template/src/components/core/nc-rich-text.ts +283 -0
- package/template/src/components/core/nc-scroll-top.ts +116 -0
- package/template/src/components/core/nc-select.ts +452 -0
- package/template/src/components/core/nc-skeleton.ts +107 -0
- package/template/src/components/core/nc-slider.ts +285 -0
- package/template/src/components/core/nc-snackbar.ts +230 -0
- package/template/src/components/core/nc-splash.ts +343 -0
- package/template/src/components/core/nc-stepper.ts +247 -0
- package/template/src/components/core/nc-switch.ts +281 -0
- package/template/src/components/core/nc-tab-item.ts +138 -0
- package/template/src/components/core/nc-table.ts +279 -0
- package/template/src/components/core/nc-tabs.ts +554 -0
- package/template/src/components/core/nc-tag-input.ts +279 -0
- package/template/src/components/core/nc-textarea.ts +216 -0
- package/template/src/components/core/nc-time-picker.ts +438 -0
- package/template/src/components/core/nc-timeline.ts +186 -0
- package/template/src/components/core/nc-tooltip.ts +143 -0
- package/template/src/components/frameworkRegistry.ts +68 -0
- package/template/src/components/preloadRegistry.ts +28 -0
- package/template/src/components/registry.ts +8 -0
- package/template/src/components/ui/dashboard-signal-lab.ts +284 -0
- package/template/src/constants/apiEndpoints.ts +27 -0
- package/template/src/constants/errorMessages.ts +23 -0
- package/template/src/constants/index.ts +8 -0
- package/template/src/constants/routePaths.ts +15 -0
- package/template/src/constants/storageKeys.ts +18 -0
- package/template/src/controllers/dashboard.controller.ts +200 -0
- package/template/src/controllers/home.controller.ts +21 -0
- package/template/src/controllers/index.ts +11 -0
- package/template/src/controllers/login.controller.ts +131 -0
- package/template/src/core/component.ts +354 -0
- package/template/src/core/errorHandler.ts +85 -0
- package/template/src/core/gpu-animation.ts +604 -0
- package/template/src/core/http.ts +173 -0
- package/template/src/core/lazyComponents.ts +90 -0
- package/template/src/core/router.ts +642 -0
- package/template/src/core/signals.ts +146 -0
- package/template/src/core/state.ts +248 -0
- package/template/src/dev/component-editor.ts +1363 -0
- package/template/src/dev/component-overlay.ts +278 -0
- package/template/src/dev/context-menu.ts +223 -0
- package/template/src/dev/denc-tools.ts +250 -0
- package/template/src/dev/hmr.ts +189 -0
- package/template/src/dev/nfbs.code-workspace +27 -0
- package/template/src/dev/outline-panel.ts +1247 -0
- package/template/src/middleware/auth.middleware.ts +23 -0
- package/template/src/routes/routes.ts +38 -0
- package/template/src/services/api.service.ts +394 -0
- package/template/src/services/auth.service.ts +176 -0
- package/template/src/services/index.ts +8 -0
- package/template/src/services/logger.service.ts +74 -0
- package/template/src/services/storage.service.ts +88 -0
- package/template/src/stores/appStore.ts +57 -0
- package/template/src/stores/uiStore.ts +36 -0
- package/template/src/styles/core-variables.css +219 -0
- package/template/src/styles/core.css +710 -0
- package/template/src/styles/main.css +3164 -0
- package/template/src/styles/variables.css +152 -0
- package/template/src/types/global.d.ts +47 -0
- package/template/src/utils/cacheBuster.ts +20 -0
- package/template/src/utils/dom.ts +149 -0
- package/template/src/utils/events.ts +203 -0
- package/template/src/utils/form.ts +176 -0
- package/template/src/utils/formatters.ts +169 -0
- package/template/src/utils/helpers.ts +195 -0
- package/template/src/utils/markdown.ts +307 -0
- package/template/src/utils/sidebar.ts +96 -0
- package/template/src/utils/smoothScroll.ts +85 -0
- package/template/src/utils/templates.ts +23 -0
- package/template/src/utils/validation.ts +73 -0
- package/template/src/views/protected/dashboard.html +293 -0
- package/template/src/views/public/home.html +150 -0
- package/template/src/views/public/login.html +102 -0
- package/template/tests/unit/component.test.ts +87 -0
- package/template/tests/unit/computed.test.ts +79 -0
- package/template/tests/unit/form.test.ts +68 -0
- package/template/tests/unit/formatters.test.ts +49 -0
- package/template/tests/unit/lazy-components.test.ts +59 -0
- package/template/tests/unit/markdown.test.ts +62 -0
- package/template/tests/unit/router.test.ts +112 -0
- package/template/tests/unit/signals.test.ts +54 -0
- package/template/tests/unit/validation.test.ts +50 -0
- package/template/tsconfig.build.json +21 -0
- package/template/tsconfig.json +51 -0
- package/template/vitest.config.ts +36 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication Middleware
|
|
3
|
+
* Enforces access control for protected routes in the single-shell SPA
|
|
4
|
+
*/
|
|
5
|
+
import auth from '../services/auth.service.js';
|
|
6
|
+
import router from '../core/router.js';
|
|
7
|
+
import { protectedRoutes } from '../routes/routes.js';
|
|
8
|
+
import type { RouteMatch } from '../core/router.js';
|
|
9
|
+
|
|
10
|
+
export async function authMiddleware(route: RouteMatch): Promise<boolean> {
|
|
11
|
+
const isProtected = protectedRoutes.some(path => route.path.startsWith(path));
|
|
12
|
+
const isAuthenticated = auth.isAuthenticated();
|
|
13
|
+
|
|
14
|
+
// Protected route accessed without authentication
|
|
15
|
+
// Redirect to login page
|
|
16
|
+
if (isProtected && !isAuthenticated) {
|
|
17
|
+
router.replace('/login');
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Note: Shell switching is now handled by the router before middleware runs
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route Configuration
|
|
3
|
+
*/
|
|
4
|
+
import { bustCache } from '../utils/cacheBuster.js';
|
|
5
|
+
import type { ControllerFunction } from '../core/router.js';
|
|
6
|
+
|
|
7
|
+
function lazyController(controllerName: string, controllerPath: string): ControllerFunction {
|
|
8
|
+
return async (...args: any[]) => {
|
|
9
|
+
const module = await import(bustCache(controllerPath));
|
|
10
|
+
return module[controllerName](...args);
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function registerRoutes(router: any): void {
|
|
15
|
+
router
|
|
16
|
+
// Static marketing pages — cache for 5 min, serve stale instantly while refreshing in background
|
|
17
|
+
.register('/', 'src/views/public/home.html', lazyController('homeController', '../controllers/home.controller.js'))
|
|
18
|
+
.cache({ ttl: 300, revalidate: true }) .cache({ ttl: 600, revalidate: true })
|
|
19
|
+
.register('/docs', 'src/views/public/docs.html', lazyController('docsController', '../controllers/docs.controller.js'))
|
|
20
|
+
.cache({ ttl: 300 })
|
|
21
|
+
|
|
22
|
+
// Auth page — never cache (always fresh)
|
|
23
|
+
.register('/login', 'src/views/public/login.html', lazyController('loginController', '../controllers/login.controller.js'))
|
|
24
|
+
|
|
25
|
+
// Component library — cache for 2 min, block on stale (content changes rarely but must be accurate)
|
|
26
|
+
.register('/components', 'src/views/public/components.html', lazyController('componentsController', '../controllers/components.controller.js'))
|
|
27
|
+
.cache({ ttl: 120 })
|
|
28
|
+
|
|
29
|
+
// Protected pages — short cache, revalidate in background
|
|
30
|
+
.register('/dashboard', 'src/views/protected/dashboard.html', lazyController('dashboardController', '../controllers/dashboard.controller.js'))
|
|
31
|
+
.cache({ ttl: 30, revalidate: true })
|
|
32
|
+
.register('/under-construction', 'src/views/protected/under-construction.html')
|
|
33
|
+
.register('/user/:id', 'src/views/protected/user-detail.html', lazyController('userDetailController', '../controllers/user-detail.controller.js'))
|
|
34
|
+
.cache({ ttl: 60, revalidate: true })
|
|
35
|
+
|
|
36
|
+
.register('/testpage', 'src/views/public/testpage.html', lazyController('testpageController', '../controllers/testpage.controller.js'))}
|
|
37
|
+
|
|
38
|
+
export const protectedRoutes = ['/dashboard', '/user', '/under-construction'];
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Service
|
|
3
|
+
* Reusable service for making backend API calls
|
|
4
|
+
*/
|
|
5
|
+
import auth from './auth.service.js';
|
|
6
|
+
|
|
7
|
+
type QueryPrimitive = string | number | boolean | null;
|
|
8
|
+
type QueryKeyValue = QueryPrimitive | QueryKeyObject | QueryKeyValue[];
|
|
9
|
+
type QueryKeyObject = { [key: string]: QueryKeyValue | undefined };
|
|
10
|
+
|
|
11
|
+
export type ApiQueryKey = readonly QueryKeyValue[];
|
|
12
|
+
|
|
13
|
+
export interface ApiCacheOptions {
|
|
14
|
+
ttl: number;
|
|
15
|
+
revalidate?: boolean;
|
|
16
|
+
params?: Record<string, string | number | boolean | null | undefined>;
|
|
17
|
+
forceRefresh?: boolean;
|
|
18
|
+
cacheKey?: string;
|
|
19
|
+
queryKey?: ApiQueryKey;
|
|
20
|
+
tags?: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface CacheEntry<T = unknown> {
|
|
24
|
+
data: T;
|
|
25
|
+
cachedAt: number;
|
|
26
|
+
ttl: number;
|
|
27
|
+
queryKey?: ApiQueryKey;
|
|
28
|
+
tags: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class ApiService {
|
|
32
|
+
private baseURL = this.resolveBaseURL();
|
|
33
|
+
private defaultHeaders: Record<string, string> = {
|
|
34
|
+
'Content-Type': 'application/json',
|
|
35
|
+
};
|
|
36
|
+
private responseCache = new Map<string, CacheEntry>();
|
|
37
|
+
private inFlightRequests = new Map<string, Promise<unknown>>();
|
|
38
|
+
private queryKeyIndex = new Map<string, Set<string>>();
|
|
39
|
+
private tagIndex = new Map<string, Set<string>>();
|
|
40
|
+
|
|
41
|
+
constructor() {
|
|
42
|
+
auth.subscribe(event => {
|
|
43
|
+
if (event === 'logout') {
|
|
44
|
+
this.clearCache();
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private resolveBaseURL(): string {
|
|
50
|
+
if (typeof window === 'undefined') {
|
|
51
|
+
return '/api';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const hostname = window.location.hostname;
|
|
55
|
+
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname.startsWith('192.168.') || hostname.endsWith('.local')) {
|
|
56
|
+
return '/api';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return 'https://api.nativecorejs.com';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private isAbsoluteURL(endpoint: string): boolean {
|
|
63
|
+
return /^https?:\/\//i.test(endpoint);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Set base URL
|
|
68
|
+
*/
|
|
69
|
+
setBaseURL(url: string): void {
|
|
70
|
+
this.baseURL = url;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
clearCache(): void {
|
|
74
|
+
this.responseCache.clear();
|
|
75
|
+
this.inFlightRequests.clear();
|
|
76
|
+
this.queryKeyIndex.clear();
|
|
77
|
+
this.tagIndex.clear();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
invalidateCache(match: string | RegExp): void {
|
|
81
|
+
for (const key of Array.from(this.responseCache.keys())) {
|
|
82
|
+
const shouldDelete = typeof match === 'string'
|
|
83
|
+
? key.includes(match)
|
|
84
|
+
: match.test(key);
|
|
85
|
+
|
|
86
|
+
if (shouldDelete) {
|
|
87
|
+
this.deleteCacheEntry(key);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
invalidateQuery(queryKey: ApiQueryKey, options: { exact?: boolean } = {}): void {
|
|
93
|
+
const target = this.serializeQueryKey(queryKey);
|
|
94
|
+
|
|
95
|
+
for (const [serializedQueryKey, cacheKeys] of this.queryKeyIndex.entries()) {
|
|
96
|
+
const matches = options.exact
|
|
97
|
+
? serializedQueryKey === target
|
|
98
|
+
: serializedQueryKey === target || serializedQueryKey.startsWith(`${target}|`);
|
|
99
|
+
|
|
100
|
+
if (!matches) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for (const cacheKey of Array.from(cacheKeys)) {
|
|
105
|
+
this.deleteCacheEntry(cacheKey);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
invalidateTags(tags: string | string[]): void {
|
|
111
|
+
const tagList = Array.isArray(tags) ? tags : [tags];
|
|
112
|
+
|
|
113
|
+
for (const tag of tagList) {
|
|
114
|
+
const cacheKeys = this.tagIndex.get(tag);
|
|
115
|
+
if (!cacheKeys) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const cacheKey of Array.from(cacheKeys)) {
|
|
120
|
+
this.deleteCacheEntry(cacheKey);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Make HTTP request
|
|
127
|
+
*/
|
|
128
|
+
async request<T = any>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
|
129
|
+
const url = this.isAbsoluteURL(endpoint) ? endpoint : `${this.baseURL}${endpoint}`;
|
|
130
|
+
|
|
131
|
+
const config: RequestInit = {
|
|
132
|
+
...options,
|
|
133
|
+
headers: {
|
|
134
|
+
...this.defaultHeaders,
|
|
135
|
+
...auth.getAuthHeader(),
|
|
136
|
+
...options.headers,
|
|
137
|
+
} as HeadersInit,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const response = await fetch(url, config);
|
|
142
|
+
|
|
143
|
+
const data = await this.parseResponse(response);
|
|
144
|
+
|
|
145
|
+
if (!response.ok) {
|
|
146
|
+
// Don't logout on 401 for login endpoint - it's just invalid credentials
|
|
147
|
+
if (response.status === 401 && !endpoint.includes('/auth/login')) {
|
|
148
|
+
auth.logout();
|
|
149
|
+
window.dispatchEvent(new CustomEvent('unauthorized'));
|
|
150
|
+
throw new Error('Unauthorized - please login again');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Try to get error message from response (supports both 'error' and 'message' fields)
|
|
154
|
+
const errorMessage = (data as any).error || (data as any).message || `HTTP ${response.status}`;
|
|
155
|
+
throw new Error(errorMessage);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return data as T;
|
|
159
|
+
} catch (error) {
|
|
160
|
+
console.error('API Error:', error);
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Parse response
|
|
167
|
+
*/
|
|
168
|
+
private async parseResponse(response: Response): Promise<any> {
|
|
169
|
+
const contentType = response.headers.get('content-type');
|
|
170
|
+
|
|
171
|
+
if (contentType && contentType.includes('application/json')) {
|
|
172
|
+
return await response.json();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return await response.text();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* GET request
|
|
180
|
+
*/
|
|
181
|
+
async get<T = any>(endpoint: string, params: Record<string, any> = {}): Promise<T> {
|
|
182
|
+
const queryString = new URLSearchParams(params).toString();
|
|
183
|
+
const url = queryString ? `${endpoint}?${queryString}` : endpoint;
|
|
184
|
+
|
|
185
|
+
return this.request<T>(url, { method: 'GET' });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async getCached<T = any>(endpoint: string, options: ApiCacheOptions): Promise<T> {
|
|
189
|
+
const cacheKey = this.buildCacheKey(endpoint, options);
|
|
190
|
+
const cached = this.responseCache.get(cacheKey) as CacheEntry<T> | undefined;
|
|
191
|
+
const now = Date.now();
|
|
192
|
+
const ttlMs = Math.max(options.ttl, 0) * 1000;
|
|
193
|
+
const isFresh = !!cached && ttlMs > 0 && (now - cached.cachedAt) < ttlMs;
|
|
194
|
+
const isStale = !!cached && ttlMs > 0 && !isFresh;
|
|
195
|
+
|
|
196
|
+
if (!options.forceRefresh) {
|
|
197
|
+
if (isFresh && cached) {
|
|
198
|
+
return cached.data;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (isStale && cached && options.revalidate) {
|
|
202
|
+
this.refreshCachedRequest<T>(cacheKey, endpoint, options);
|
|
203
|
+
return cached.data;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!options.forceRefresh) {
|
|
208
|
+
const inFlight = this.inFlightRequests.get(cacheKey) as Promise<T> | undefined;
|
|
209
|
+
if (inFlight) {
|
|
210
|
+
return inFlight;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const requestPromise = this.get<T>(endpoint, options.params ?? {})
|
|
215
|
+
.then(data => {
|
|
216
|
+
this.setCacheEntry(cacheKey, {
|
|
217
|
+
data,
|
|
218
|
+
cachedAt: Date.now(),
|
|
219
|
+
ttl: options.ttl,
|
|
220
|
+
queryKey: options.queryKey,
|
|
221
|
+
tags: options.tags ?? [],
|
|
222
|
+
});
|
|
223
|
+
this.inFlightRequests.delete(cacheKey);
|
|
224
|
+
return data;
|
|
225
|
+
})
|
|
226
|
+
.catch(error => {
|
|
227
|
+
this.inFlightRequests.delete(cacheKey);
|
|
228
|
+
throw error;
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
this.inFlightRequests.set(cacheKey, requestPromise as Promise<unknown>);
|
|
232
|
+
return requestPromise;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* POST request
|
|
237
|
+
*/
|
|
238
|
+
async post<T = any>(endpoint: string, body: any = {}): Promise<T> {
|
|
239
|
+
return this.request<T>(endpoint, {
|
|
240
|
+
method: 'POST',
|
|
241
|
+
body: JSON.stringify(body),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* PUT request
|
|
247
|
+
*/
|
|
248
|
+
async put<T = any>(endpoint: string, body: any = {}): Promise<T> {
|
|
249
|
+
return this.request<T>(endpoint, {
|
|
250
|
+
method: 'PUT',
|
|
251
|
+
body: JSON.stringify(body),
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* PATCH request
|
|
257
|
+
*/
|
|
258
|
+
async patch<T = any>(endpoint: string, body: any = {}): Promise<T> {
|
|
259
|
+
return this.request<T>(endpoint, {
|
|
260
|
+
method: 'PATCH',
|
|
261
|
+
body: JSON.stringify(body),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* DELETE request
|
|
267
|
+
*/
|
|
268
|
+
async delete<T = any>(endpoint: string): Promise<T> {
|
|
269
|
+
return this.request<T>(endpoint, { method: 'DELETE' });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private buildCacheKey(endpoint: string, options: ApiCacheOptions): string {
|
|
273
|
+
if (options.queryKey) {
|
|
274
|
+
return `q:${this.serializeQueryKey(options.queryKey)}`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (options.cacheKey) {
|
|
278
|
+
return options.cacheKey;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const queryString = new URLSearchParams(
|
|
282
|
+
Object.entries(options.params ?? {}).reduce<Record<string, string>>((accumulator, [key, value]) => {
|
|
283
|
+
if (value !== undefined && value !== null) {
|
|
284
|
+
accumulator[key] = String(value);
|
|
285
|
+
}
|
|
286
|
+
return accumulator;
|
|
287
|
+
}, {})
|
|
288
|
+
).toString();
|
|
289
|
+
|
|
290
|
+
return queryString ? `${endpoint}?${queryString}` : endpoint;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private refreshCachedRequest<T>(cacheKey: string, endpoint: string, options: ApiCacheOptions): void {
|
|
294
|
+
if (this.inFlightRequests.has(cacheKey)) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const refreshPromise = this.get<T>(endpoint, options.params ?? {})
|
|
299
|
+
.then(data => {
|
|
300
|
+
this.setCacheEntry(cacheKey, {
|
|
301
|
+
data,
|
|
302
|
+
cachedAt: Date.now(),
|
|
303
|
+
ttl: options.ttl,
|
|
304
|
+
queryKey: options.queryKey,
|
|
305
|
+
tags: options.tags ?? [],
|
|
306
|
+
});
|
|
307
|
+
this.inFlightRequests.delete(cacheKey);
|
|
308
|
+
return data;
|
|
309
|
+
})
|
|
310
|
+
.catch(() => {
|
|
311
|
+
this.inFlightRequests.delete(cacheKey);
|
|
312
|
+
return undefined;
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
this.inFlightRequests.set(cacheKey, refreshPromise as Promise<unknown>);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private setCacheEntry<T>(cacheKey: string, entry: CacheEntry<T>): void {
|
|
319
|
+
this.deleteCacheEntry(cacheKey);
|
|
320
|
+
this.responseCache.set(cacheKey, entry);
|
|
321
|
+
|
|
322
|
+
if (entry.queryKey) {
|
|
323
|
+
const serializedQueryKey = this.serializeQueryKey(entry.queryKey);
|
|
324
|
+
const existingKeys = this.queryKeyIndex.get(serializedQueryKey) ?? new Set<string>();
|
|
325
|
+
existingKeys.add(cacheKey);
|
|
326
|
+
this.queryKeyIndex.set(serializedQueryKey, existingKeys);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
for (const tag of entry.tags) {
|
|
330
|
+
const existingKeys = this.tagIndex.get(tag) ?? new Set<string>();
|
|
331
|
+
existingKeys.add(cacheKey);
|
|
332
|
+
this.tagIndex.set(tag, existingKeys);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private deleteCacheEntry(cacheKey: string): void {
|
|
337
|
+
const existingEntry = this.responseCache.get(cacheKey);
|
|
338
|
+
|
|
339
|
+
this.responseCache.delete(cacheKey);
|
|
340
|
+
this.inFlightRequests.delete(cacheKey);
|
|
341
|
+
|
|
342
|
+
if (!existingEntry) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (existingEntry.queryKey) {
|
|
347
|
+
const serializedQueryKey = this.serializeQueryKey(existingEntry.queryKey);
|
|
348
|
+
const existingKeys = this.queryKeyIndex.get(serializedQueryKey);
|
|
349
|
+
if (existingKeys) {
|
|
350
|
+
existingKeys.delete(cacheKey);
|
|
351
|
+
if (existingKeys.size === 0) {
|
|
352
|
+
this.queryKeyIndex.delete(serializedQueryKey);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
for (const tag of existingEntry.tags) {
|
|
358
|
+
const existingKeys = this.tagIndex.get(tag);
|
|
359
|
+
if (existingKeys) {
|
|
360
|
+
existingKeys.delete(cacheKey);
|
|
361
|
+
if (existingKeys.size === 0) {
|
|
362
|
+
this.tagIndex.delete(tag);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private serializeQueryKey(queryKey: ApiQueryKey): string {
|
|
369
|
+
return queryKey.map(part => this.stableSerialize(part)).join('|');
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private stableSerialize(value: QueryKeyValue | undefined): string {
|
|
373
|
+
if (value === undefined) {
|
|
374
|
+
return 'undefined';
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (value === null) {
|
|
378
|
+
return 'null';
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (Array.isArray(value)) {
|
|
382
|
+
return `[${value.map(item => this.stableSerialize(item)).join(',')}]`;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (typeof value === 'object') {
|
|
386
|
+
const sortedKeys = Object.keys(value).sort();
|
|
387
|
+
return `{${sortedKeys.map(key => `${key}:${this.stableSerialize(value[key])}`).join(',')}}`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return JSON.stringify(value);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export default new ApiService();
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication Service
|
|
3
|
+
* Handles JWT token management and user authentication
|
|
4
|
+
*/
|
|
5
|
+
import storage from './storage.service.js';
|
|
6
|
+
|
|
7
|
+
// Types
|
|
8
|
+
export interface User {
|
|
9
|
+
id: string | number;
|
|
10
|
+
email: string;
|
|
11
|
+
name?: string;
|
|
12
|
+
[key: string]: any;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface TokenPayload {
|
|
16
|
+
exp?: number;
|
|
17
|
+
[key: string]: any;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type AuthEvent = 'login' | 'logout';
|
|
21
|
+
type AuthCallback = (event: AuthEvent) => void;
|
|
22
|
+
|
|
23
|
+
class AuthService {
|
|
24
|
+
private readonly TOKEN_KEY = 'access_token';
|
|
25
|
+
private readonly REFRESH_TOKEN_KEY = 'refresh_token';
|
|
26
|
+
private readonly USER_KEY = 'user_data';
|
|
27
|
+
private listeners: AuthCallback[] = [];
|
|
28
|
+
|
|
29
|
+
constructor() {
|
|
30
|
+
storage.setStrategy('session');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Set authentication tokens
|
|
35
|
+
*/
|
|
36
|
+
setTokens(accessToken: string, refreshToken: string | null = null): void {
|
|
37
|
+
if (!accessToken || accessToken === 'undefined') {
|
|
38
|
+
console.error('Cannot set undefined token');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
storage.set(this.TOKEN_KEY, accessToken);
|
|
42
|
+
if (refreshToken && refreshToken !== 'undefined') {
|
|
43
|
+
storage.set(this.REFRESH_TOKEN_KEY, refreshToken);
|
|
44
|
+
}
|
|
45
|
+
this.notifyListeners('login');
|
|
46
|
+
window.dispatchEvent(new CustomEvent('auth-change'));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get access token
|
|
51
|
+
*/
|
|
52
|
+
getToken(): string | null {
|
|
53
|
+
const token = storage.get(this.TOKEN_KEY);
|
|
54
|
+
if (!token || token === 'undefined' || token === 'null') {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return token;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get refresh token
|
|
62
|
+
*/
|
|
63
|
+
getRefreshToken(): string | null {
|
|
64
|
+
return storage.get(this.REFRESH_TOKEN_KEY);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Set user data
|
|
69
|
+
*/
|
|
70
|
+
setUser(user: User): void {
|
|
71
|
+
storage.set(this.USER_KEY, JSON.stringify(user));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get user data
|
|
76
|
+
*/
|
|
77
|
+
getUser(): User | null {
|
|
78
|
+
const userData = storage.get(this.USER_KEY);
|
|
79
|
+
if (!userData || userData === 'undefined' || userData === 'null') {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(userData);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
console.error('Error parsing user data:', error);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Check if user is authenticated
|
|
92
|
+
*/
|
|
93
|
+
isAuthenticated(): boolean {
|
|
94
|
+
const token = this.getToken();
|
|
95
|
+
if (!token) return false;
|
|
96
|
+
|
|
97
|
+
if (this.isTokenExpired(token)) {
|
|
98
|
+
this.logout();
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Check if JWT token is expired
|
|
107
|
+
*/
|
|
108
|
+
isTokenExpired(token: string): boolean {
|
|
109
|
+
try {
|
|
110
|
+
const payload = this.decodeToken(token);
|
|
111
|
+
if (!payload.exp) return false;
|
|
112
|
+
|
|
113
|
+
const currentTime = Date.now() / 1000;
|
|
114
|
+
return payload.exp < currentTime;
|
|
115
|
+
} catch {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Decode JWT token
|
|
122
|
+
*/
|
|
123
|
+
decodeToken(token: string): TokenPayload {
|
|
124
|
+
try {
|
|
125
|
+
const base64Url = token.split('.')[1];
|
|
126
|
+
if (!base64Url) {
|
|
127
|
+
throw new Error('Invalid token');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
|
131
|
+
const paddedBase64 = base64.padEnd(base64.length + ((4 - base64.length % 4) % 4), '=');
|
|
132
|
+
const jsonPayload = atob(paddedBase64);
|
|
133
|
+
return JSON.parse(jsonPayload);
|
|
134
|
+
} catch {
|
|
135
|
+
throw new Error('Invalid token');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Logout user
|
|
141
|
+
*/
|
|
142
|
+
logout(): void {
|
|
143
|
+
storage.remove(this.TOKEN_KEY);
|
|
144
|
+
storage.remove(this.REFRESH_TOKEN_KEY);
|
|
145
|
+
storage.remove(this.USER_KEY);
|
|
146
|
+
this.notifyListeners('logout');
|
|
147
|
+
window.dispatchEvent(new CustomEvent('auth-change'));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Subscribe to auth state changes
|
|
152
|
+
*/
|
|
153
|
+
subscribe(callback: AuthCallback): () => void {
|
|
154
|
+
this.listeners.push(callback);
|
|
155
|
+
return () => {
|
|
156
|
+
this.listeners = this.listeners.filter(cb => cb !== callback);
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Notify listeners
|
|
162
|
+
*/
|
|
163
|
+
private notifyListeners(event: AuthEvent): void {
|
|
164
|
+
this.listeners.forEach(callback => callback(event));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Get authorization header
|
|
169
|
+
*/
|
|
170
|
+
getAuthHeader(): Record<string, string> {
|
|
171
|
+
const token = this.getToken();
|
|
172
|
+
return token ? { 'Authorization': `Bearer ${token}` } : {};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export default new AuthService();
|