@flexireact/core 2.0.1 → 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 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
- await runBuild();
1126
+ const analyzeFlag = args.includes('--analyze') || args.includes('-a');
1127
+ await runBuild({ analyze: analyzeFlag });
1082
1128
  break;
1083
1129
 
1084
1130
  case 'start':
@@ -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;
@@ -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';
@@ -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
@@ -70,8 +70,33 @@ export {
70
70
  builtinPlugins
71
71
  } from './plugins/index.js';
72
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
+
73
98
  // Version
74
- export const VERSION = '2.0.1';
99
+ export const VERSION = '2.1.0';
75
100
 
76
101
  // Default export
77
102
  export default {
@@ -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
@@ -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
  }
@@ -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;
@@ -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.0.1",
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",