@flexireact/core 2.0.0 → 2.1.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/cli/index.ts +50 -4
- package/core/build/index.ts +70 -2
- package/core/client/Link.tsx +345 -0
- package/core/client/index.ts +5 -1
- package/core/config.ts +7 -4
- package/core/helpers.ts +494 -0
- package/core/index.ts +29 -1
- package/core/render/index.ts +17 -1
- package/core/router/index.ts +15 -7
- package/core/server/index.ts +47 -1
- package/core/tsconfig.json +3 -1
- package/package.json +2 -2
package/cli/index.ts
CHANGED
|
@@ -856,10 +856,13 @@ ${pc.cyan(' ╰─────────────────────
|
|
|
856
856
|
// Build Command
|
|
857
857
|
// ============================================================================
|
|
858
858
|
|
|
859
|
-
async function runBuild(): Promise<void> {
|
|
859
|
+
async function runBuild(options: { analyze?: boolean } = {}): Promise<void> {
|
|
860
860
|
console.log(MINI_LOGO);
|
|
861
861
|
log.blank();
|
|
862
862
|
log.info('Building for production...');
|
|
863
|
+
if (options.analyze) {
|
|
864
|
+
log.info('Bundle analysis enabled');
|
|
865
|
+
}
|
|
863
866
|
log.blank();
|
|
864
867
|
|
|
865
868
|
const spinner = ora({ text: 'Compiling...', color: 'cyan' }).start();
|
|
@@ -876,16 +879,50 @@ async function runBuild(): Promise<void> {
|
|
|
876
879
|
const rawConfig = await configModule.loadConfig(projectRoot);
|
|
877
880
|
const config = configModule.resolvePaths(rawConfig, projectRoot);
|
|
878
881
|
|
|
879
|
-
await buildModule.build({
|
|
882
|
+
const result = await buildModule.build({
|
|
880
883
|
projectRoot,
|
|
881
884
|
config,
|
|
882
|
-
mode: 'production'
|
|
885
|
+
mode: 'production',
|
|
886
|
+
analyze: options.analyze
|
|
883
887
|
});
|
|
884
888
|
|
|
885
889
|
spinner.succeed('Build complete!');
|
|
886
890
|
log.blank();
|
|
887
891
|
log.success(`Output: ${pc.cyan('.flexi/')}`);
|
|
888
892
|
|
|
893
|
+
// Show bundle analysis if enabled
|
|
894
|
+
if (options.analyze && result?.analysis) {
|
|
895
|
+
log.blank();
|
|
896
|
+
log.info('📊 Bundle Analysis:');
|
|
897
|
+
log.blank();
|
|
898
|
+
|
|
899
|
+
const analysis = result.analysis;
|
|
900
|
+
|
|
901
|
+
// Sort by size
|
|
902
|
+
const sorted = Object.entries(analysis.files || {})
|
|
903
|
+
.sort((a: any, b: any) => b[1].size - a[1].size);
|
|
904
|
+
|
|
905
|
+
console.log(pc.dim(' ─────────────────────────────────────────────────'));
|
|
906
|
+
console.log(` ${pc.bold('File')}${' '.repeat(35)}${pc.bold('Size')}`);
|
|
907
|
+
console.log(pc.dim(' ─────────────────────────────────────────────────'));
|
|
908
|
+
|
|
909
|
+
for (const [file, info] of sorted.slice(0, 15) as any) {
|
|
910
|
+
const name = file.length > 35 ? '...' + file.slice(-32) : file;
|
|
911
|
+
const size = formatBytes(info.size);
|
|
912
|
+
const gzip = info.gzipSize ? pc.dim(` (${formatBytes(info.gzipSize)} gzip)`) : '';
|
|
913
|
+
console.log(` ${name.padEnd(38)} ${pc.cyan(size)}${gzip}`);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
console.log(pc.dim(' ─────────────────────────────────────────────────'));
|
|
917
|
+
console.log(` ${pc.bold('Total:')}${' '.repeat(31)} ${pc.green(formatBytes(analysis.totalSize || 0))}`);
|
|
918
|
+
|
|
919
|
+
if (analysis.totalGzipSize) {
|
|
920
|
+
console.log(` ${pc.dim('Gzipped:')}${' '.repeat(29)} ${pc.dim(formatBytes(analysis.totalGzipSize))}`);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
log.blank();
|
|
924
|
+
}
|
|
925
|
+
|
|
889
926
|
} catch (error: any) {
|
|
890
927
|
spinner.fail('Build failed');
|
|
891
928
|
log.error(error.message);
|
|
@@ -893,6 +930,14 @@ async function runBuild(): Promise<void> {
|
|
|
893
930
|
}
|
|
894
931
|
}
|
|
895
932
|
|
|
933
|
+
function formatBytes(bytes: number): string {
|
|
934
|
+
if (bytes === 0) return '0 B';
|
|
935
|
+
const k = 1024;
|
|
936
|
+
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
937
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
938
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
939
|
+
}
|
|
940
|
+
|
|
896
941
|
// ============================================================================
|
|
897
942
|
// Start Command
|
|
898
943
|
// ============================================================================
|
|
@@ -1078,7 +1123,8 @@ async function main(): Promise<void> {
|
|
|
1078
1123
|
break;
|
|
1079
1124
|
|
|
1080
1125
|
case 'build':
|
|
1081
|
-
|
|
1126
|
+
const analyzeFlag = args.includes('--analyze') || args.includes('-a');
|
|
1127
|
+
await runBuild({ analyze: analyzeFlag });
|
|
1082
1128
|
break;
|
|
1083
1129
|
|
|
1084
1130
|
case 'start':
|
package/core/build/index.ts
CHANGED
|
@@ -28,7 +28,8 @@ export async function build(options) {
|
|
|
28
28
|
const {
|
|
29
29
|
projectRoot,
|
|
30
30
|
config,
|
|
31
|
-
mode = BuildMode.PRODUCTION
|
|
31
|
+
mode = BuildMode.PRODUCTION,
|
|
32
|
+
analyze = false
|
|
32
33
|
} = options;
|
|
33
34
|
|
|
34
35
|
const startTime = Date.now();
|
|
@@ -95,12 +96,79 @@ export async function build(options) {
|
|
|
95
96
|
console.log(` Server modules: ${serverResult.outputs.length}`);
|
|
96
97
|
console.log('');
|
|
97
98
|
|
|
99
|
+
// Generate bundle analysis if requested
|
|
100
|
+
let analysis = null;
|
|
101
|
+
if (analyze) {
|
|
102
|
+
analysis = generateBundleAnalysis(clientResult, serverResult, outDir);
|
|
103
|
+
}
|
|
104
|
+
|
|
98
105
|
return {
|
|
99
106
|
success: true,
|
|
100
107
|
duration,
|
|
101
108
|
manifest,
|
|
102
109
|
clientResult,
|
|
103
|
-
serverResult
|
|
110
|
+
serverResult,
|
|
111
|
+
analysis
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Generates bundle analysis data
|
|
117
|
+
*/
|
|
118
|
+
function generateBundleAnalysis(clientResult, serverResult, outDir) {
|
|
119
|
+
const files: Record<string, { size: number; gzipSize?: number }> = {};
|
|
120
|
+
let totalSize = 0;
|
|
121
|
+
let totalGzipSize = 0;
|
|
122
|
+
|
|
123
|
+
// Analyze client outputs
|
|
124
|
+
for (const output of clientResult.outputs || []) {
|
|
125
|
+
if (output.path && fs.existsSync(output.path)) {
|
|
126
|
+
const stat = fs.statSync(output.path);
|
|
127
|
+
const relativePath = path.relative(outDir, output.path);
|
|
128
|
+
|
|
129
|
+
// Estimate gzip size (roughly 30% of original for JS)
|
|
130
|
+
const gzipSize = Math.round(stat.size * 0.3);
|
|
131
|
+
|
|
132
|
+
files[relativePath] = {
|
|
133
|
+
size: stat.size,
|
|
134
|
+
gzipSize
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
totalSize += stat.size;
|
|
138
|
+
totalGzipSize += gzipSize;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Analyze server outputs
|
|
143
|
+
for (const output of serverResult.outputs || []) {
|
|
144
|
+
if (output.path && fs.existsSync(output.path)) {
|
|
145
|
+
const stat = fs.statSync(output.path);
|
|
146
|
+
const relativePath = path.relative(outDir, output.path);
|
|
147
|
+
|
|
148
|
+
files[relativePath] = {
|
|
149
|
+
size: stat.size
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
totalSize += stat.size;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
files,
|
|
158
|
+
totalSize,
|
|
159
|
+
totalGzipSize,
|
|
160
|
+
clientSize: clientResult.outputs?.reduce((sum, o) => {
|
|
161
|
+
if (o.path && fs.existsSync(o.path)) {
|
|
162
|
+
return sum + fs.statSync(o.path).size;
|
|
163
|
+
}
|
|
164
|
+
return sum;
|
|
165
|
+
}, 0) || 0,
|
|
166
|
+
serverSize: serverResult.outputs?.reduce((sum, o) => {
|
|
167
|
+
if (o.path && fs.existsSync(o.path)) {
|
|
168
|
+
return sum + fs.statSync(o.path).size;
|
|
169
|
+
}
|
|
170
|
+
return sum;
|
|
171
|
+
}, 0) || 0
|
|
104
172
|
};
|
|
105
173
|
}
|
|
106
174
|
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* FlexiReact Link Component
|
|
5
|
+
* Enhanced link with prefetching, client-side navigation, and loading states
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
9
|
+
|
|
10
|
+
export interface LinkProps extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> {
|
|
11
|
+
/** The URL to navigate to */
|
|
12
|
+
href: string;
|
|
13
|
+
/** Prefetch the page on hover/visibility */
|
|
14
|
+
prefetch?: boolean | 'hover' | 'viewport';
|
|
15
|
+
/** Replace the current history entry instead of pushing */
|
|
16
|
+
replace?: boolean;
|
|
17
|
+
/** Scroll to top after navigation */
|
|
18
|
+
scroll?: boolean;
|
|
19
|
+
/** Show loading indicator while navigating */
|
|
20
|
+
showLoading?: boolean;
|
|
21
|
+
/** Custom loading component */
|
|
22
|
+
loadingComponent?: React.ReactNode;
|
|
23
|
+
/** Callback when navigation starts */
|
|
24
|
+
onNavigationStart?: () => void;
|
|
25
|
+
/** Callback when navigation ends */
|
|
26
|
+
onNavigationEnd?: () => void;
|
|
27
|
+
/** Children */
|
|
28
|
+
children: React.ReactNode;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Prefetch cache to avoid duplicate requests
|
|
32
|
+
const prefetchCache = new Set<string>();
|
|
33
|
+
|
|
34
|
+
// Prefetch a URL
|
|
35
|
+
async function prefetchUrl(url: string): Promise<void> {
|
|
36
|
+
if (prefetchCache.has(url)) return;
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
// Mark as prefetched immediately to prevent duplicate requests
|
|
40
|
+
prefetchCache.add(url);
|
|
41
|
+
|
|
42
|
+
// Use link preload for better browser optimization
|
|
43
|
+
const link = document.createElement('link');
|
|
44
|
+
link.rel = 'prefetch';
|
|
45
|
+
link.href = url;
|
|
46
|
+
link.as = 'document';
|
|
47
|
+
document.head.appendChild(link);
|
|
48
|
+
|
|
49
|
+
// Also fetch the page to warm the cache
|
|
50
|
+
const controller = new AbortController();
|
|
51
|
+
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
52
|
+
|
|
53
|
+
await fetch(url, {
|
|
54
|
+
method: 'GET',
|
|
55
|
+
credentials: 'same-origin',
|
|
56
|
+
signal: controller.signal,
|
|
57
|
+
headers: {
|
|
58
|
+
'X-Flexi-Prefetch': '1',
|
|
59
|
+
'Accept': 'text/html'
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
clearTimeout(timeoutId);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
// Remove from cache on error so it can be retried
|
|
66
|
+
prefetchCache.delete(url);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Check if URL is internal
|
|
71
|
+
function isInternalUrl(url: string): boolean {
|
|
72
|
+
if (url.startsWith('/')) return true;
|
|
73
|
+
if (url.startsWith('#')) return true;
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const parsed = new URL(url, window.location.origin);
|
|
77
|
+
return parsed.origin === window.location.origin;
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Navigate to a URL
|
|
84
|
+
function navigate(url: string, options: { replace?: boolean; scroll?: boolean } = {}): void {
|
|
85
|
+
const { replace = false, scroll = true } = options;
|
|
86
|
+
|
|
87
|
+
if (replace) {
|
|
88
|
+
window.history.replaceState({}, '', url);
|
|
89
|
+
} else {
|
|
90
|
+
window.history.pushState({}, '', url);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Dispatch popstate event to trigger any listeners
|
|
94
|
+
window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
|
|
95
|
+
|
|
96
|
+
// Scroll to top if requested
|
|
97
|
+
if (scroll) {
|
|
98
|
+
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Link component with prefetching and client-side navigation
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```tsx
|
|
107
|
+
* import { Link } from '@flexireact/core/client';
|
|
108
|
+
*
|
|
109
|
+
* // Basic usage
|
|
110
|
+
* <Link href="/about">About</Link>
|
|
111
|
+
*
|
|
112
|
+
* // With prefetch on hover
|
|
113
|
+
* <Link href="/products" prefetch="hover">Products</Link>
|
|
114
|
+
*
|
|
115
|
+
* // With prefetch on viewport visibility
|
|
116
|
+
* <Link href="/contact" prefetch="viewport">Contact</Link>
|
|
117
|
+
*
|
|
118
|
+
* // Replace history instead of push
|
|
119
|
+
* <Link href="/login" replace>Login</Link>
|
|
120
|
+
*
|
|
121
|
+
* // Disable scroll to top
|
|
122
|
+
* <Link href="/section#anchor" scroll={false}>Go to section</Link>
|
|
123
|
+
* ```
|
|
124
|
+
*/
|
|
125
|
+
export function Link({
|
|
126
|
+
href,
|
|
127
|
+
prefetch = true,
|
|
128
|
+
replace = false,
|
|
129
|
+
scroll = true,
|
|
130
|
+
showLoading = false,
|
|
131
|
+
loadingComponent,
|
|
132
|
+
onNavigationStart,
|
|
133
|
+
onNavigationEnd,
|
|
134
|
+
children,
|
|
135
|
+
className,
|
|
136
|
+
onClick,
|
|
137
|
+
onMouseEnter,
|
|
138
|
+
onFocus,
|
|
139
|
+
...props
|
|
140
|
+
}: LinkProps) {
|
|
141
|
+
const [isNavigating, setIsNavigating] = useState(false);
|
|
142
|
+
const linkRef = useRef<HTMLAnchorElement>(null);
|
|
143
|
+
const hasPrefetched = useRef(false);
|
|
144
|
+
|
|
145
|
+
// Prefetch on viewport visibility
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
if (prefetch !== 'viewport' && prefetch !== true) return;
|
|
148
|
+
if (!isInternalUrl(href)) return;
|
|
149
|
+
if (hasPrefetched.current) return;
|
|
150
|
+
|
|
151
|
+
const observer = new IntersectionObserver(
|
|
152
|
+
(entries) => {
|
|
153
|
+
entries.forEach((entry) => {
|
|
154
|
+
if (entry.isIntersecting) {
|
|
155
|
+
prefetchUrl(href);
|
|
156
|
+
hasPrefetched.current = true;
|
|
157
|
+
observer.disconnect();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
},
|
|
161
|
+
{ rootMargin: '200px' }
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
if (linkRef.current) {
|
|
165
|
+
observer.observe(linkRef.current);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return () => observer.disconnect();
|
|
169
|
+
}, [href, prefetch]);
|
|
170
|
+
|
|
171
|
+
// Handle hover prefetch
|
|
172
|
+
const handleMouseEnter = useCallback(
|
|
173
|
+
(e: React.MouseEvent<HTMLAnchorElement>) => {
|
|
174
|
+
onMouseEnter?.(e);
|
|
175
|
+
|
|
176
|
+
if ((prefetch === 'hover' || prefetch === true) && isInternalUrl(href)) {
|
|
177
|
+
prefetchUrl(href);
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
[href, prefetch, onMouseEnter]
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
// Handle focus prefetch (for keyboard navigation)
|
|
184
|
+
const handleFocus = useCallback(
|
|
185
|
+
(e: React.FocusEvent<HTMLAnchorElement>) => {
|
|
186
|
+
onFocus?.(e);
|
|
187
|
+
|
|
188
|
+
if ((prefetch === 'hover' || prefetch === true) && isInternalUrl(href)) {
|
|
189
|
+
prefetchUrl(href);
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
[href, prefetch, onFocus]
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
// Handle click for client-side navigation
|
|
196
|
+
const handleClick = useCallback(
|
|
197
|
+
async (e: React.MouseEvent<HTMLAnchorElement>) => {
|
|
198
|
+
onClick?.(e);
|
|
199
|
+
|
|
200
|
+
// Don't handle if default was prevented
|
|
201
|
+
if (e.defaultPrevented) return;
|
|
202
|
+
|
|
203
|
+
// Don't handle if modifier keys are pressed (open in new tab, etc.)
|
|
204
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
205
|
+
|
|
206
|
+
// Don't handle external URLs
|
|
207
|
+
if (!isInternalUrl(href)) return;
|
|
208
|
+
|
|
209
|
+
// Don't handle if target is set
|
|
210
|
+
if (props.target && props.target !== '_self') return;
|
|
211
|
+
|
|
212
|
+
// Prevent default navigation
|
|
213
|
+
e.preventDefault();
|
|
214
|
+
|
|
215
|
+
// Start navigation
|
|
216
|
+
setIsNavigating(true);
|
|
217
|
+
onNavigationStart?.();
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
// Fetch the new page
|
|
221
|
+
const response = await fetch(href, {
|
|
222
|
+
method: 'GET',
|
|
223
|
+
credentials: 'same-origin',
|
|
224
|
+
headers: {
|
|
225
|
+
'X-Flexi-Navigation': '1',
|
|
226
|
+
'Accept': 'text/html'
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
if (response.ok) {
|
|
231
|
+
const html = await response.text();
|
|
232
|
+
|
|
233
|
+
// Parse and update the page
|
|
234
|
+
const parser = new DOMParser();
|
|
235
|
+
const doc = parser.parseFromString(html, 'text/html');
|
|
236
|
+
|
|
237
|
+
// Update title
|
|
238
|
+
const newTitle = doc.querySelector('title')?.textContent;
|
|
239
|
+
if (newTitle) {
|
|
240
|
+
document.title = newTitle;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Update body content (or specific container)
|
|
244
|
+
const newContent = doc.querySelector('#root') || doc.body;
|
|
245
|
+
const currentContent = document.querySelector('#root') || document.body;
|
|
246
|
+
|
|
247
|
+
if (newContent && currentContent) {
|
|
248
|
+
currentContent.innerHTML = newContent.innerHTML;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Update URL
|
|
252
|
+
navigate(href, { replace, scroll });
|
|
253
|
+
} else {
|
|
254
|
+
// Fallback to regular navigation on error
|
|
255
|
+
window.location.href = href;
|
|
256
|
+
}
|
|
257
|
+
} catch (error) {
|
|
258
|
+
// Fallback to regular navigation on error
|
|
259
|
+
window.location.href = href;
|
|
260
|
+
} finally {
|
|
261
|
+
setIsNavigating(false);
|
|
262
|
+
onNavigationEnd?.();
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
[href, replace, scroll, onClick, onNavigationStart, onNavigationEnd, props.target]
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
<a
|
|
270
|
+
ref={linkRef}
|
|
271
|
+
href={href}
|
|
272
|
+
className={className}
|
|
273
|
+
onClick={handleClick}
|
|
274
|
+
onMouseEnter={handleMouseEnter}
|
|
275
|
+
onFocus={handleFocus}
|
|
276
|
+
data-prefetch={prefetch}
|
|
277
|
+
data-navigating={isNavigating || undefined}
|
|
278
|
+
{...props}
|
|
279
|
+
>
|
|
280
|
+
{showLoading && isNavigating ? (
|
|
281
|
+
loadingComponent || (
|
|
282
|
+
<span className="flexi-link-loading">
|
|
283
|
+
<span className="flexi-link-spinner" />
|
|
284
|
+
{children}
|
|
285
|
+
</span>
|
|
286
|
+
)
|
|
287
|
+
) : (
|
|
288
|
+
children
|
|
289
|
+
)}
|
|
290
|
+
</a>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Programmatic navigation function
|
|
296
|
+
*
|
|
297
|
+
* @example
|
|
298
|
+
* ```tsx
|
|
299
|
+
* import { useRouter } from '@flexireact/core/client';
|
|
300
|
+
*
|
|
301
|
+
* function MyComponent() {
|
|
302
|
+
* const router = useRouter();
|
|
303
|
+
*
|
|
304
|
+
* const handleClick = () => {
|
|
305
|
+
* router.push('/dashboard');
|
|
306
|
+
* };
|
|
307
|
+
*
|
|
308
|
+
* return <button onClick={handleClick}>Go to Dashboard</button>;
|
|
309
|
+
* }
|
|
310
|
+
* ```
|
|
311
|
+
*/
|
|
312
|
+
export function useRouter() {
|
|
313
|
+
return {
|
|
314
|
+
push(url: string, options?: { scroll?: boolean }) {
|
|
315
|
+
navigate(url, { replace: false, scroll: options?.scroll ?? true });
|
|
316
|
+
// Trigger page reload for now (full SPA navigation requires more work)
|
|
317
|
+
window.location.href = url;
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
replace(url: string, options?: { scroll?: boolean }) {
|
|
321
|
+
navigate(url, { replace: true, scroll: options?.scroll ?? true });
|
|
322
|
+
window.location.href = url;
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
back() {
|
|
326
|
+
window.history.back();
|
|
327
|
+
},
|
|
328
|
+
|
|
329
|
+
forward() {
|
|
330
|
+
window.history.forward();
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
prefetch(url: string) {
|
|
334
|
+
if (isInternalUrl(url)) {
|
|
335
|
+
prefetchUrl(url);
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
|
|
339
|
+
refresh() {
|
|
340
|
+
window.location.reload();
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export default Link;
|
package/core/client/index.ts
CHANGED
|
@@ -4,5 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
export { hydrateIsland, hydrateApp } from './hydration.js';
|
|
7
|
-
export { navigate, prefetch, Link } from './navigation.js';
|
|
7
|
+
export { navigate, prefetch, Link as NavLink } from './navigation.js';
|
|
8
8
|
export { useIsland, IslandBoundary } from './islands.js';
|
|
9
|
+
|
|
10
|
+
// Enhanced Link component with prefetching
|
|
11
|
+
export { Link, useRouter } from './Link.js';
|
|
12
|
+
export type { LinkProps } from './Link.js';
|
package/core/config.ts
CHANGED
|
@@ -63,8 +63,11 @@ export const defaultConfig = {
|
|
|
63
63
|
* @param {string} projectRoot - Path to project root
|
|
64
64
|
* @returns {Object} Merged configuration
|
|
65
65
|
*/
|
|
66
|
-
export async function loadConfig(projectRoot) {
|
|
67
|
-
|
|
66
|
+
export async function loadConfig(projectRoot: string) {
|
|
67
|
+
// Try .ts first, then .js
|
|
68
|
+
const configPathTs = path.join(projectRoot, 'flexireact.config.ts');
|
|
69
|
+
const configPathJs = path.join(projectRoot, 'flexireact.config.js');
|
|
70
|
+
const configPath = fs.existsSync(configPathTs) ? configPathTs : configPathJs;
|
|
68
71
|
|
|
69
72
|
let userConfig = {};
|
|
70
73
|
|
|
@@ -73,8 +76,8 @@ export async function loadConfig(projectRoot) {
|
|
|
73
76
|
const configUrl = pathToFileURL(configPath).href;
|
|
74
77
|
const module = await import(`${configUrl}?t=${Date.now()}`);
|
|
75
78
|
userConfig = module.default || module;
|
|
76
|
-
} catch (error) {
|
|
77
|
-
console.warn('Warning: Failed to load flexireact
|
|
79
|
+
} catch (error: any) {
|
|
80
|
+
console.warn('Warning: Failed to load flexireact config:', error.message);
|
|
78
81
|
}
|
|
79
82
|
}
|
|
80
83
|
|
package/core/helpers.ts
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FlexiReact Server Helpers
|
|
3
|
+
* Utility functions for server-side operations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// Response Helpers
|
|
8
|
+
// ============================================================================
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Custom error classes for control flow
|
|
12
|
+
*/
|
|
13
|
+
export class RedirectError extends Error {
|
|
14
|
+
public readonly url: string;
|
|
15
|
+
public readonly statusCode: number;
|
|
16
|
+
public readonly type = 'redirect' as const;
|
|
17
|
+
|
|
18
|
+
constructor(url: string, statusCode: number = 307) {
|
|
19
|
+
super(`Redirect to ${url}`);
|
|
20
|
+
this.name = 'RedirectError';
|
|
21
|
+
this.url = url;
|
|
22
|
+
this.statusCode = statusCode;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class NotFoundError extends Error {
|
|
27
|
+
public readonly type = 'notFound' as const;
|
|
28
|
+
|
|
29
|
+
constructor(message: string = 'Page not found') {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = 'NotFoundError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Redirect to a different URL
|
|
37
|
+
* @param url - The URL to redirect to
|
|
38
|
+
* @param type - 'replace' (307) or 'push' (308) for permanent
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```tsx
|
|
42
|
+
* import { redirect } from '@flexireact/core';
|
|
43
|
+
*
|
|
44
|
+
* export default function ProtectedPage() {
|
|
45
|
+
* const user = getUser();
|
|
46
|
+
* if (!user) {
|
|
47
|
+
* redirect('/login');
|
|
48
|
+
* }
|
|
49
|
+
* return <Dashboard user={user} />;
|
|
50
|
+
* }
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export function redirect(url: string, type: 'replace' | 'permanent' = 'replace'): never {
|
|
54
|
+
const statusCode = type === 'permanent' ? 308 : 307;
|
|
55
|
+
throw new RedirectError(url, statusCode);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Trigger a 404 Not Found response
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```tsx
|
|
63
|
+
* import { notFound } from '@flexireact/core';
|
|
64
|
+
*
|
|
65
|
+
* export default function ProductPage({ params }) {
|
|
66
|
+
* const product = getProduct(params.id);
|
|
67
|
+
* if (!product) {
|
|
68
|
+
* notFound();
|
|
69
|
+
* }
|
|
70
|
+
* return <Product data={product} />;
|
|
71
|
+
* }
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export function notFound(message?: string): never {
|
|
75
|
+
throw new NotFoundError(message);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Create a JSON response
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* ```ts
|
|
83
|
+
* // In API route
|
|
84
|
+
* export function GET() {
|
|
85
|
+
* return json({ message: 'Hello' });
|
|
86
|
+
* }
|
|
87
|
+
*
|
|
88
|
+
* export function POST() {
|
|
89
|
+
* return json({ error: 'Bad request' }, { status: 400 });
|
|
90
|
+
* }
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
export function json<T>(
|
|
94
|
+
data: T,
|
|
95
|
+
options: {
|
|
96
|
+
status?: number;
|
|
97
|
+
headers?: Record<string, string>;
|
|
98
|
+
} = {}
|
|
99
|
+
): Response {
|
|
100
|
+
const { status = 200, headers = {} } = options;
|
|
101
|
+
|
|
102
|
+
return new Response(JSON.stringify(data), {
|
|
103
|
+
status,
|
|
104
|
+
headers: {
|
|
105
|
+
'Content-Type': 'application/json',
|
|
106
|
+
...headers
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Create an HTML response
|
|
113
|
+
*/
|
|
114
|
+
export function html(
|
|
115
|
+
content: string,
|
|
116
|
+
options: {
|
|
117
|
+
status?: number;
|
|
118
|
+
headers?: Record<string, string>;
|
|
119
|
+
} = {}
|
|
120
|
+
): Response {
|
|
121
|
+
const { status = 200, headers = {} } = options;
|
|
122
|
+
|
|
123
|
+
return new Response(content, {
|
|
124
|
+
status,
|
|
125
|
+
headers: {
|
|
126
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
127
|
+
...headers
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Create a text response
|
|
134
|
+
*/
|
|
135
|
+
export function text(
|
|
136
|
+
content: string,
|
|
137
|
+
options: {
|
|
138
|
+
status?: number;
|
|
139
|
+
headers?: Record<string, string>;
|
|
140
|
+
} = {}
|
|
141
|
+
): Response {
|
|
142
|
+
const { status = 200, headers = {} } = options;
|
|
143
|
+
|
|
144
|
+
return new Response(content, {
|
|
145
|
+
status,
|
|
146
|
+
headers: {
|
|
147
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
148
|
+
...headers
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ============================================================================
|
|
154
|
+
// Cookies API
|
|
155
|
+
// ============================================================================
|
|
156
|
+
|
|
157
|
+
export interface CookieOptions {
|
|
158
|
+
/** Max age in seconds */
|
|
159
|
+
maxAge?: number;
|
|
160
|
+
/** Expiration date */
|
|
161
|
+
expires?: Date;
|
|
162
|
+
/** Cookie path */
|
|
163
|
+
path?: string;
|
|
164
|
+
/** Cookie domain */
|
|
165
|
+
domain?: string;
|
|
166
|
+
/** Secure flag (HTTPS only) */
|
|
167
|
+
secure?: boolean;
|
|
168
|
+
/** HttpOnly flag */
|
|
169
|
+
httpOnly?: boolean;
|
|
170
|
+
/** SameSite policy */
|
|
171
|
+
sameSite?: 'strict' | 'lax' | 'none';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Cookie utilities for server-side operations
|
|
176
|
+
*/
|
|
177
|
+
export const cookies = {
|
|
178
|
+
/**
|
|
179
|
+
* Parse cookies from a cookie header string
|
|
180
|
+
*/
|
|
181
|
+
parse(cookieHeader: string): Record<string, string> {
|
|
182
|
+
const cookies: Record<string, string> = {};
|
|
183
|
+
if (!cookieHeader) return cookies;
|
|
184
|
+
|
|
185
|
+
cookieHeader.split(';').forEach(cookie => {
|
|
186
|
+
const [name, ...rest] = cookie.split('=');
|
|
187
|
+
if (name) {
|
|
188
|
+
const value = rest.join('=');
|
|
189
|
+
cookies[name.trim()] = decodeURIComponent(value.trim());
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
return cookies;
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Get a cookie value from request headers
|
|
198
|
+
*/
|
|
199
|
+
get(request: Request, name: string): string | undefined {
|
|
200
|
+
const cookieHeader = request.headers.get('cookie') || '';
|
|
201
|
+
const parsed = this.parse(cookieHeader);
|
|
202
|
+
return parsed[name];
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Get all cookies from request
|
|
207
|
+
*/
|
|
208
|
+
getAll(request: Request): Record<string, string> {
|
|
209
|
+
const cookieHeader = request.headers.get('cookie') || '';
|
|
210
|
+
return this.parse(cookieHeader);
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Serialize a cookie for Set-Cookie header
|
|
215
|
+
*/
|
|
216
|
+
serialize(name: string, value: string, options: CookieOptions = {}): string {
|
|
217
|
+
let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
|
|
218
|
+
|
|
219
|
+
if (options.maxAge !== undefined) {
|
|
220
|
+
cookie += `; Max-Age=${options.maxAge}`;
|
|
221
|
+
}
|
|
222
|
+
if (options.expires) {
|
|
223
|
+
cookie += `; Expires=${options.expires.toUTCString()}`;
|
|
224
|
+
}
|
|
225
|
+
if (options.path) {
|
|
226
|
+
cookie += `; Path=${options.path}`;
|
|
227
|
+
}
|
|
228
|
+
if (options.domain) {
|
|
229
|
+
cookie += `; Domain=${options.domain}`;
|
|
230
|
+
}
|
|
231
|
+
if (options.secure) {
|
|
232
|
+
cookie += '; Secure';
|
|
233
|
+
}
|
|
234
|
+
if (options.httpOnly) {
|
|
235
|
+
cookie += '; HttpOnly';
|
|
236
|
+
}
|
|
237
|
+
if (options.sameSite) {
|
|
238
|
+
cookie += `; SameSite=${options.sameSite.charAt(0).toUpperCase() + options.sameSite.slice(1)}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return cookie;
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Create a Set-Cookie header value
|
|
246
|
+
*/
|
|
247
|
+
set(name: string, value: string, options: CookieOptions = {}): string {
|
|
248
|
+
return this.serialize(name, value, {
|
|
249
|
+
path: '/',
|
|
250
|
+
httpOnly: true,
|
|
251
|
+
secure: process.env.NODE_ENV === 'production',
|
|
252
|
+
sameSite: 'lax',
|
|
253
|
+
...options
|
|
254
|
+
});
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Create a cookie deletion header
|
|
259
|
+
*/
|
|
260
|
+
delete(name: string, options: Omit<CookieOptions, 'maxAge' | 'expires'> = {}): string {
|
|
261
|
+
return this.serialize(name, '', {
|
|
262
|
+
...options,
|
|
263
|
+
path: '/',
|
|
264
|
+
maxAge: 0
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// ============================================================================
|
|
270
|
+
// Headers API
|
|
271
|
+
// ============================================================================
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Headers utilities for server-side operations
|
|
275
|
+
*/
|
|
276
|
+
export const headers = {
|
|
277
|
+
/**
|
|
278
|
+
* Create a new Headers object with common defaults
|
|
279
|
+
*/
|
|
280
|
+
create(init?: HeadersInit): Headers {
|
|
281
|
+
return new Headers(init);
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Get a header value from request
|
|
286
|
+
*/
|
|
287
|
+
get(request: Request, name: string): string | null {
|
|
288
|
+
return request.headers.get(name);
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Get all headers as an object
|
|
293
|
+
*/
|
|
294
|
+
getAll(request: Request): Record<string, string> {
|
|
295
|
+
const result: Record<string, string> = {};
|
|
296
|
+
request.headers.forEach((value, key) => {
|
|
297
|
+
result[key] = value;
|
|
298
|
+
});
|
|
299
|
+
return result;
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Check if request has a specific header
|
|
304
|
+
*/
|
|
305
|
+
has(request: Request, name: string): boolean {
|
|
306
|
+
return request.headers.has(name);
|
|
307
|
+
},
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Get content type from request
|
|
311
|
+
*/
|
|
312
|
+
contentType(request: Request): string | null {
|
|
313
|
+
return request.headers.get('content-type');
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Check if request accepts JSON
|
|
318
|
+
*/
|
|
319
|
+
acceptsJson(request: Request): boolean {
|
|
320
|
+
const accept = request.headers.get('accept') || '';
|
|
321
|
+
return accept.includes('application/json') || accept.includes('*/*');
|
|
322
|
+
},
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Check if request is AJAX/fetch
|
|
326
|
+
*/
|
|
327
|
+
isAjax(request: Request): boolean {
|
|
328
|
+
return request.headers.get('x-requested-with') === 'XMLHttpRequest' ||
|
|
329
|
+
this.acceptsJson(request);
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Get authorization header
|
|
334
|
+
*/
|
|
335
|
+
authorization(request: Request): { type: string; credentials: string } | null {
|
|
336
|
+
const auth = request.headers.get('authorization');
|
|
337
|
+
if (!auth) return null;
|
|
338
|
+
|
|
339
|
+
const [type, ...rest] = auth.split(' ');
|
|
340
|
+
return {
|
|
341
|
+
type: type.toLowerCase(),
|
|
342
|
+
credentials: rest.join(' ')
|
|
343
|
+
};
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Get bearer token from authorization header
|
|
348
|
+
*/
|
|
349
|
+
bearerToken(request: Request): string | null {
|
|
350
|
+
const auth = this.authorization(request);
|
|
351
|
+
if (auth?.type === 'bearer') {
|
|
352
|
+
return auth.credentials;
|
|
353
|
+
}
|
|
354
|
+
return null;
|
|
355
|
+
},
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Common security headers
|
|
359
|
+
*/
|
|
360
|
+
security(): Record<string, string> {
|
|
361
|
+
return {
|
|
362
|
+
'X-Content-Type-Options': 'nosniff',
|
|
363
|
+
'X-Frame-Options': 'DENY',
|
|
364
|
+
'X-XSS-Protection': '1; mode=block',
|
|
365
|
+
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
|
366
|
+
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()'
|
|
367
|
+
};
|
|
368
|
+
},
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* CORS headers
|
|
372
|
+
*/
|
|
373
|
+
cors(options: {
|
|
374
|
+
origin?: string;
|
|
375
|
+
methods?: string[];
|
|
376
|
+
headers?: string[];
|
|
377
|
+
credentials?: boolean;
|
|
378
|
+
maxAge?: number;
|
|
379
|
+
} = {}): Record<string, string> {
|
|
380
|
+
const {
|
|
381
|
+
origin = '*',
|
|
382
|
+
methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
|
383
|
+
headers: allowHeaders = ['Content-Type', 'Authorization'],
|
|
384
|
+
credentials = false,
|
|
385
|
+
maxAge = 86400
|
|
386
|
+
} = options;
|
|
387
|
+
|
|
388
|
+
const corsHeaders: Record<string, string> = {
|
|
389
|
+
'Access-Control-Allow-Origin': origin,
|
|
390
|
+
'Access-Control-Allow-Methods': methods.join(', '),
|
|
391
|
+
'Access-Control-Allow-Headers': allowHeaders.join(', '),
|
|
392
|
+
'Access-Control-Max-Age': String(maxAge)
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
if (credentials) {
|
|
396
|
+
corsHeaders['Access-Control-Allow-Credentials'] = 'true';
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return corsHeaders;
|
|
400
|
+
},
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Cache control headers
|
|
404
|
+
*/
|
|
405
|
+
cache(options: {
|
|
406
|
+
maxAge?: number;
|
|
407
|
+
sMaxAge?: number;
|
|
408
|
+
staleWhileRevalidate?: number;
|
|
409
|
+
private?: boolean;
|
|
410
|
+
noStore?: boolean;
|
|
411
|
+
} = {}): Record<string, string> {
|
|
412
|
+
if (options.noStore) {
|
|
413
|
+
return { 'Cache-Control': 'no-store, no-cache, must-revalidate' };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const directives: string[] = [];
|
|
417
|
+
|
|
418
|
+
if (options.private) {
|
|
419
|
+
directives.push('private');
|
|
420
|
+
} else {
|
|
421
|
+
directives.push('public');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (options.maxAge !== undefined) {
|
|
425
|
+
directives.push(`max-age=${options.maxAge}`);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (options.sMaxAge !== undefined) {
|
|
429
|
+
directives.push(`s-maxage=${options.sMaxAge}`);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (options.staleWhileRevalidate !== undefined) {
|
|
433
|
+
directives.push(`stale-while-revalidate=${options.staleWhileRevalidate}`);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return { 'Cache-Control': directives.join(', ') };
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
// ============================================================================
|
|
441
|
+
// Request Helpers
|
|
442
|
+
// ============================================================================
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Parse JSON body from request
|
|
446
|
+
*/
|
|
447
|
+
export async function parseJson<T = unknown>(request: Request): Promise<T> {
|
|
448
|
+
try {
|
|
449
|
+
return await request.json();
|
|
450
|
+
} catch {
|
|
451
|
+
throw new Error('Invalid JSON body');
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Parse form data from request
|
|
457
|
+
*/
|
|
458
|
+
export async function parseFormData(request: Request): Promise<FormData> {
|
|
459
|
+
return await request.formData();
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Parse URL search params
|
|
464
|
+
*/
|
|
465
|
+
export function parseSearchParams(request: Request): URLSearchParams {
|
|
466
|
+
const url = new URL(request.url);
|
|
467
|
+
return url.searchParams;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Get request method
|
|
472
|
+
*/
|
|
473
|
+
export function getMethod(request: Request): string {
|
|
474
|
+
return request.method.toUpperCase();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Get request pathname
|
|
479
|
+
*/
|
|
480
|
+
export function getPathname(request: Request): string {
|
|
481
|
+
const url = new URL(request.url);
|
|
482
|
+
return url.pathname;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Check if request method matches
|
|
487
|
+
*/
|
|
488
|
+
export function isMethod(request: Request, method: string | string[]): boolean {
|
|
489
|
+
const reqMethod = getMethod(request);
|
|
490
|
+
if (Array.isArray(method)) {
|
|
491
|
+
return method.map(m => m.toUpperCase()).includes(reqMethod);
|
|
492
|
+
}
|
|
493
|
+
return reqMethod === method.toUpperCase();
|
|
494
|
+
}
|
package/core/index.ts
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
* A modern React framework with RSC, SSG, Islands, and more
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
// Types
|
|
7
|
+
export type { FlexiConfig, Route, RouteType as RouteTypeEnum, PageProps, LayoutProps } from './types.js';
|
|
8
|
+
|
|
6
9
|
// Core exports
|
|
7
10
|
export { loadConfig, defaultConfig, resolvePaths } from './config.js';
|
|
8
11
|
export { createRequestContext, useRequest, useParams, useQuery, usePathname } from './context.js';
|
|
@@ -67,8 +70,33 @@ export {
|
|
|
67
70
|
builtinPlugins
|
|
68
71
|
} from './plugins/index.js';
|
|
69
72
|
|
|
73
|
+
// Server Helpers
|
|
74
|
+
export {
|
|
75
|
+
// Response helpers
|
|
76
|
+
redirect,
|
|
77
|
+
notFound,
|
|
78
|
+
json,
|
|
79
|
+
html,
|
|
80
|
+
text,
|
|
81
|
+
// Error classes
|
|
82
|
+
RedirectError,
|
|
83
|
+
NotFoundError,
|
|
84
|
+
// Cookies API
|
|
85
|
+
cookies,
|
|
86
|
+
// Headers API
|
|
87
|
+
headers,
|
|
88
|
+
// Request helpers
|
|
89
|
+
parseJson,
|
|
90
|
+
parseFormData,
|
|
91
|
+
parseSearchParams,
|
|
92
|
+
getMethod,
|
|
93
|
+
getPathname,
|
|
94
|
+
isMethod
|
|
95
|
+
} from './helpers.js';
|
|
96
|
+
export type { CookieOptions } from './helpers.js';
|
|
97
|
+
|
|
70
98
|
// Version
|
|
71
|
-
export const VERSION = '2.
|
|
99
|
+
export const VERSION = '2.1.0';
|
|
72
100
|
|
|
73
101
|
// Default export
|
|
74
102
|
export default {
|
package/core/render/index.ts
CHANGED
|
@@ -32,7 +32,23 @@ export async function renderPage(options) {
|
|
|
32
32
|
|
|
33
33
|
try {
|
|
34
34
|
// Build the component tree - start with the page component
|
|
35
|
-
let element = React.createElement(Component, props);
|
|
35
|
+
let element: any = React.createElement(Component, props);
|
|
36
|
+
|
|
37
|
+
// Wrap with error boundary if error component exists
|
|
38
|
+
if (error) {
|
|
39
|
+
element = React.createElement(ErrorBoundaryWrapper as any, {
|
|
40
|
+
fallback: error,
|
|
41
|
+
children: element
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Wrap with Suspense if loading component exists (for streaming/async)
|
|
46
|
+
if (loading) {
|
|
47
|
+
element = React.createElement(React.Suspense as any, {
|
|
48
|
+
fallback: React.createElement(loading),
|
|
49
|
+
children: element
|
|
50
|
+
});
|
|
51
|
+
}
|
|
36
52
|
|
|
37
53
|
// Wrap with layouts (innermost to outermost)
|
|
38
54
|
// Each layout receives children as a prop
|
package/core/router/index.ts
CHANGED
|
@@ -96,13 +96,14 @@ export function buildRouteTree(pagesDir, layoutsDir, appDir = null, routesDir =
|
|
|
96
96
|
* - api/hello.ts → /api/hello (API route)
|
|
97
97
|
* - dashboard/layout.tsx → layout for /dashboard/*
|
|
98
98
|
*/
|
|
99
|
-
function scanRoutesDirectory(baseDir, currentDir, routes, parentSegments = [], parentLayout = null) {
|
|
99
|
+
function scanRoutesDirectory(baseDir, currentDir, routes, parentSegments = [], parentLayout = null, parentMiddleware = null) {
|
|
100
100
|
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
101
101
|
|
|
102
102
|
// Find special files in current directory
|
|
103
103
|
let layoutFile = null;
|
|
104
104
|
let loadingFile = null;
|
|
105
105
|
let errorFile = null;
|
|
106
|
+
let middlewareFile = null;
|
|
106
107
|
|
|
107
108
|
for (const entry of entries) {
|
|
108
109
|
if (entry.isFile()) {
|
|
@@ -114,9 +115,10 @@ function scanRoutesDirectory(baseDir, currentDir, routes, parentSegments = [], p
|
|
|
114
115
|
if (name === 'layout') layoutFile = fullPath;
|
|
115
116
|
if (name === 'loading') loadingFile = fullPath;
|
|
116
117
|
if (name === 'error') errorFile = fullPath;
|
|
118
|
+
if (name === '_middleware' || name === 'middleware') middlewareFile = fullPath;
|
|
117
119
|
|
|
118
120
|
// Skip special files and non-route files
|
|
119
|
-
if (['layout', 'loading', 'error', 'not-found'].includes(name)) continue;
|
|
121
|
+
if (['layout', 'loading', 'error', 'not-found', '_middleware', 'middleware'].includes(name)) continue;
|
|
120
122
|
if (!['.tsx', '.jsx', '.ts', '.js'].includes(ext)) continue;
|
|
121
123
|
|
|
122
124
|
// API routes (in api/ folder or .ts/.js files in api/)
|
|
@@ -171,6 +173,7 @@ function scanRoutesDirectory(baseDir, currentDir, routes, parentSegments = [], p
|
|
|
171
173
|
layout: layoutFile || parentLayout,
|
|
172
174
|
loading: loadingFile,
|
|
173
175
|
error: errorFile,
|
|
176
|
+
middleware: middlewareFile || parentMiddleware,
|
|
174
177
|
isFlexiRouter: true,
|
|
175
178
|
isServerComponent: isServerComponent(fullPath),
|
|
176
179
|
isClientComponent: isClientComponent(fullPath),
|
|
@@ -205,8 +208,9 @@ function scanRoutesDirectory(baseDir, currentDir, routes, parentSegments = [], p
|
|
|
205
208
|
|
|
206
209
|
const newSegments = isGroup ? parentSegments : [...parentSegments, segmentName];
|
|
207
210
|
const newLayout = layoutFile || parentLayout;
|
|
211
|
+
const newMiddleware = middlewareFile || parentMiddleware;
|
|
208
212
|
|
|
209
|
-
scanRoutesDirectory(baseDir, fullPath, routes, newSegments, newLayout);
|
|
213
|
+
scanRoutesDirectory(baseDir, fullPath, routes, newSegments, newLayout, newMiddleware);
|
|
210
214
|
}
|
|
211
215
|
}
|
|
212
216
|
}
|
|
@@ -215,17 +219,18 @@ function scanRoutesDirectory(baseDir, currentDir, routes, parentSegments = [], p
|
|
|
215
219
|
* Scans app directory for Next.js style routing
|
|
216
220
|
* Supports: page.tsx, layout.tsx, loading.tsx, error.tsx, not-found.tsx
|
|
217
221
|
*/
|
|
218
|
-
function scanAppDirectory(baseDir, currentDir, routes, parentSegments = [], parentLayout = null) {
|
|
222
|
+
function scanAppDirectory(baseDir, currentDir, routes, parentSegments = [], parentLayout = null, parentMiddleware = null) {
|
|
219
223
|
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
220
224
|
|
|
221
225
|
// Find special files in current directory
|
|
222
|
-
const specialFiles = {
|
|
226
|
+
const specialFiles: Record<string, string | null> = {
|
|
223
227
|
page: null,
|
|
224
228
|
layout: null,
|
|
225
229
|
loading: null,
|
|
226
230
|
error: null,
|
|
227
231
|
notFound: null,
|
|
228
|
-
template: null
|
|
232
|
+
template: null,
|
|
233
|
+
middleware: null
|
|
229
234
|
};
|
|
230
235
|
|
|
231
236
|
for (const entry of entries) {
|
|
@@ -239,6 +244,7 @@ function scanAppDirectory(baseDir, currentDir, routes, parentSegments = [], pare
|
|
|
239
244
|
if (name === 'error') specialFiles.error = fullPath;
|
|
240
245
|
if (name === 'not-found') specialFiles.notFound = fullPath;
|
|
241
246
|
if (name === 'template') specialFiles.template = fullPath;
|
|
247
|
+
if (name === 'middleware' || name === '_middleware') specialFiles.middleware = fullPath;
|
|
242
248
|
}
|
|
243
249
|
}
|
|
244
250
|
|
|
@@ -257,6 +263,7 @@ function scanAppDirectory(baseDir, currentDir, routes, parentSegments = [], pare
|
|
|
257
263
|
error: specialFiles.error,
|
|
258
264
|
notFound: specialFiles.notFound,
|
|
259
265
|
template: specialFiles.template,
|
|
266
|
+
middleware: specialFiles.middleware || parentMiddleware,
|
|
260
267
|
isAppRouter: true,
|
|
261
268
|
isServerComponent: isServerComponent(specialFiles.page),
|
|
262
269
|
isClientComponent: isClientComponent(specialFiles.page),
|
|
@@ -289,8 +296,9 @@ function scanAppDirectory(baseDir, currentDir, routes, parentSegments = [], pare
|
|
|
289
296
|
|
|
290
297
|
const newSegments = isGroup ? parentSegments : [...parentSegments, segmentName];
|
|
291
298
|
const newLayout = specialFiles.layout || parentLayout;
|
|
299
|
+
const newMiddleware = specialFiles.middleware || parentMiddleware;
|
|
292
300
|
|
|
293
|
-
scanAppDirectory(baseDir, fullPath, routes, newSegments, newLayout);
|
|
301
|
+
scanAppDirectory(baseDir, fullPath, routes, newSegments, newLayout, newMiddleware);
|
|
294
302
|
}
|
|
295
303
|
}
|
|
296
304
|
}
|
package/core/server/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { loadPlugins, pluginManager, PluginHooks } from '../plugins/index.js';
|
|
|
15
15
|
import { getRegisteredIslands, generateAdvancedHydrationScript } from '../islands/index.js';
|
|
16
16
|
import { createRequestContext, RequestContext, RouteContext } from '../context.js';
|
|
17
17
|
import { logger } from '../logger.js';
|
|
18
|
+
import { RedirectError, NotFoundError } from '../helpers.js';
|
|
18
19
|
import React from 'react';
|
|
19
20
|
|
|
20
21
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -157,7 +158,21 @@ export async function createServer(options: CreateServerOptions = {}) {
|
|
|
157
158
|
res.writeHead(404, { 'Content-Type': 'text/html' });
|
|
158
159
|
res.end(renderError(404, 'Page not found'));
|
|
159
160
|
|
|
160
|
-
} catch (error) {
|
|
161
|
+
} catch (error: any) {
|
|
162
|
+
// Handle redirect() calls
|
|
163
|
+
if (error instanceof RedirectError) {
|
|
164
|
+
res.writeHead(error.statusCode, { 'Location': error.url });
|
|
165
|
+
res.end();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Handle notFound() calls
|
|
170
|
+
if (error instanceof NotFoundError) {
|
|
171
|
+
res.writeHead(404, { 'Content-Type': 'text/html' });
|
|
172
|
+
res.end(renderError(404, error.message));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
161
176
|
console.error('Server Error:', error);
|
|
162
177
|
|
|
163
178
|
if (!res.headersSent) {
|
|
@@ -360,6 +375,37 @@ function createApiResponse(res) {
|
|
|
360
375
|
*/
|
|
361
376
|
async function handlePageRoute(req, res, route, routes, config, loadModule, url) {
|
|
362
377
|
try {
|
|
378
|
+
// Run route-specific middleware if exists
|
|
379
|
+
if (route.middleware) {
|
|
380
|
+
try {
|
|
381
|
+
const middlewareModule = await loadModule(route.middleware);
|
|
382
|
+
const middlewareFn = middlewareModule.default || middlewareModule.middleware;
|
|
383
|
+
|
|
384
|
+
if (typeof middlewareFn === 'function') {
|
|
385
|
+
const result = await middlewareFn(req, res, { route, params: route.params });
|
|
386
|
+
|
|
387
|
+
// If middleware returns a response, use it
|
|
388
|
+
if (result?.redirect) {
|
|
389
|
+
res.writeHead(result.statusCode || 307, { 'Location': result.redirect });
|
|
390
|
+
res.end();
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (result?.rewrite) {
|
|
395
|
+
// Rewrite to different path
|
|
396
|
+
req.url = result.rewrite;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (result === false || result?.stop) {
|
|
400
|
+
// Middleware stopped the request
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
} catch (middlewareError: any) {
|
|
405
|
+
console.error('Route middleware error:', middlewareError.message);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
363
409
|
// Load page module
|
|
364
410
|
const pageModule = await loadModule(route.filePath);
|
|
365
411
|
const Component = pageModule.default;
|
package/core/tsconfig.json
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
"module": "NodeNext",
|
|
5
5
|
"moduleResolution": "NodeNext",
|
|
6
6
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
7
|
+
"jsx": "react-jsx",
|
|
7
8
|
"outDir": "./dist",
|
|
8
9
|
"rootDir": ".",
|
|
9
10
|
"strict": false,
|
|
@@ -22,7 +23,8 @@
|
|
|
22
23
|
},
|
|
23
24
|
"include": [
|
|
24
25
|
"*.ts",
|
|
25
|
-
"**/*.ts"
|
|
26
|
+
"**/*.ts",
|
|
27
|
+
"**/*.tsx"
|
|
26
28
|
],
|
|
27
29
|
"exclude": ["node_modules", "dist"]
|
|
28
30
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flexireact/core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "The Modern React Framework v2 - SSR, SSG, Islands, App Router, TypeScript, Tailwind",
|
|
5
5
|
"main": "core/index.ts",
|
|
6
6
|
"types": "core/types.ts",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
},
|
|
55
55
|
"homepage": "https://github.com/flexireact/flexireact#readme",
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"esbuild": "^0.
|
|
57
|
+
"esbuild": "^0.25.0",
|
|
58
58
|
"ora": "^8.1.1",
|
|
59
59
|
"picocolors": "^1.1.1",
|
|
60
60
|
"prompts": "^2.4.2"
|