@flexireact/core 2.0.1 → 2.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/cli/index.ts +50 -4
- package/core/actions/index.ts +364 -0
- package/core/build/index.ts +70 -2
- package/core/client/Link.tsx +345 -0
- package/core/client/index.ts +5 -1
- package/core/helpers.ts +494 -0
- package/core/index.ts +41 -2
- package/core/render/index.ts +175 -1
- package/core/router/index.ts +15 -7
- package/core/server/index.ts +96 -1
- package/core/tsconfig.json +3 -1
- package/package.json +1 -1
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
|
|
@@ -75,6 +91,164 @@ export async function renderPage(options) {
|
|
|
75
91
|
}
|
|
76
92
|
}
|
|
77
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Streaming SSR with React 18
|
|
96
|
+
* Renders the page progressively, sending HTML chunks as they become ready
|
|
97
|
+
*/
|
|
98
|
+
export async function renderPageStream(options: {
|
|
99
|
+
Component: React.ComponentType<any>;
|
|
100
|
+
props?: Record<string, any>;
|
|
101
|
+
layouts?: Array<{ Component: React.ComponentType<any>; props?: Record<string, any> }>;
|
|
102
|
+
loading?: React.ComponentType | null;
|
|
103
|
+
error?: React.ComponentType<{ error: Error }> | null;
|
|
104
|
+
title?: string;
|
|
105
|
+
meta?: Record<string, string>;
|
|
106
|
+
scripts?: Array<string | { src?: string; content?: string; type?: string }>;
|
|
107
|
+
styles?: Array<string | { content: string }>;
|
|
108
|
+
favicon?: string | null;
|
|
109
|
+
route?: string;
|
|
110
|
+
onShellReady?: () => void;
|
|
111
|
+
onAllReady?: () => void;
|
|
112
|
+
onError?: (error: Error) => void;
|
|
113
|
+
}): Promise<{ stream: NodeJS.ReadableStream; shellReady: Promise<void> }> {
|
|
114
|
+
const {
|
|
115
|
+
Component,
|
|
116
|
+
props = {},
|
|
117
|
+
layouts = [],
|
|
118
|
+
loading = null,
|
|
119
|
+
error = null,
|
|
120
|
+
title = 'FlexiReact App',
|
|
121
|
+
meta = {},
|
|
122
|
+
scripts = [],
|
|
123
|
+
styles = [],
|
|
124
|
+
favicon = null,
|
|
125
|
+
route = '/',
|
|
126
|
+
onShellReady,
|
|
127
|
+
onAllReady,
|
|
128
|
+
onError
|
|
129
|
+
} = options;
|
|
130
|
+
|
|
131
|
+
const renderStart = Date.now();
|
|
132
|
+
|
|
133
|
+
// Build the component tree
|
|
134
|
+
let element: any = React.createElement(Component, props);
|
|
135
|
+
|
|
136
|
+
// Wrap with error boundary if error component exists
|
|
137
|
+
if (error) {
|
|
138
|
+
element = React.createElement(ErrorBoundaryWrapper as any, {
|
|
139
|
+
fallback: error,
|
|
140
|
+
children: element
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Wrap with Suspense if loading component exists
|
|
145
|
+
if (loading) {
|
|
146
|
+
element = React.createElement(React.Suspense as any, {
|
|
147
|
+
fallback: React.createElement(loading),
|
|
148
|
+
children: element
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Wrap with layouts
|
|
153
|
+
for (const layout of [...layouts].reverse()) {
|
|
154
|
+
if (layout.Component) {
|
|
155
|
+
element = React.createElement(layout.Component, layout.props, element);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Create the full document wrapper
|
|
160
|
+
const DocumentWrapper = ({ children }: { children: React.ReactNode }) => {
|
|
161
|
+
return React.createElement('html', { lang: 'en', className: 'dark' },
|
|
162
|
+
React.createElement('head', null,
|
|
163
|
+
React.createElement('meta', { charSet: 'UTF-8' }),
|
|
164
|
+
React.createElement('meta', { name: 'viewport', content: 'width=device-width, initial-scale=1.0' }),
|
|
165
|
+
React.createElement('title', null, title),
|
|
166
|
+
favicon && React.createElement('link', { rel: 'icon', href: favicon }),
|
|
167
|
+
...Object.entries(meta).map(([name, content]) =>
|
|
168
|
+
React.createElement('meta', { key: name, name, content })
|
|
169
|
+
),
|
|
170
|
+
...styles.map((style, i) =>
|
|
171
|
+
typeof style === 'string'
|
|
172
|
+
? React.createElement('link', { key: i, rel: 'stylesheet', href: style })
|
|
173
|
+
: React.createElement('style', { key: i, dangerouslySetInnerHTML: { __html: style.content } })
|
|
174
|
+
)
|
|
175
|
+
),
|
|
176
|
+
React.createElement('body', null,
|
|
177
|
+
React.createElement('div', { id: 'root' }, children),
|
|
178
|
+
...scripts.map((script, i) =>
|
|
179
|
+
typeof script === 'string'
|
|
180
|
+
? React.createElement('script', { key: i, src: script })
|
|
181
|
+
: script.src
|
|
182
|
+
? React.createElement('script', { key: i, src: script.src, type: script.type })
|
|
183
|
+
: React.createElement('script', { key: i, type: script.type, dangerouslySetInnerHTML: { __html: script.content } })
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const fullElement = React.createElement(DocumentWrapper, null, element);
|
|
190
|
+
|
|
191
|
+
// Create streaming render
|
|
192
|
+
let shellReadyResolve: () => void;
|
|
193
|
+
const shellReady = new Promise<void>((resolve) => {
|
|
194
|
+
shellReadyResolve = resolve;
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const { pipe, abort } = renderToPipeableStream(fullElement, {
|
|
198
|
+
onShellReady() {
|
|
199
|
+
const renderTime = Date.now() - renderStart;
|
|
200
|
+
console.log(`⚡ Shell ready in ${renderTime}ms`);
|
|
201
|
+
shellReadyResolve();
|
|
202
|
+
onShellReady?.();
|
|
203
|
+
},
|
|
204
|
+
onAllReady() {
|
|
205
|
+
const renderTime = Date.now() - renderStart;
|
|
206
|
+
console.log(`✨ All content ready in ${renderTime}ms`);
|
|
207
|
+
onAllReady?.();
|
|
208
|
+
},
|
|
209
|
+
onError(err: Error) {
|
|
210
|
+
console.error('Streaming SSR Error:', err);
|
|
211
|
+
onError?.(err);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Create a passthrough stream
|
|
216
|
+
const { PassThrough } = await import('stream');
|
|
217
|
+
const passThrough = new PassThrough();
|
|
218
|
+
|
|
219
|
+
// Pipe the render stream to our passthrough
|
|
220
|
+
pipe(passThrough);
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
stream: passThrough,
|
|
224
|
+
shellReady
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Render to stream for HTTP response
|
|
230
|
+
* Use this in the server to stream HTML to the client
|
|
231
|
+
*/
|
|
232
|
+
export function streamToResponse(
|
|
233
|
+
res: { write: (chunk: string) => void; end: () => void },
|
|
234
|
+
stream: NodeJS.ReadableStream,
|
|
235
|
+
options: { onFinish?: () => void } = {}
|
|
236
|
+
): void {
|
|
237
|
+
stream.on('data', (chunk) => {
|
|
238
|
+
res.write(chunk.toString());
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
stream.on('end', () => {
|
|
242
|
+
res.end();
|
|
243
|
+
options.onFinish?.();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
stream.on('error', (err) => {
|
|
247
|
+
console.error('Stream error:', err);
|
|
248
|
+
res.end();
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
78
252
|
/**
|
|
79
253
|
* Error Boundary Wrapper for SSR
|
|
80
254
|
*/
|
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,8 @@ 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';
|
|
19
|
+
import { executeAction, deserializeArgs } from '../actions/index.js';
|
|
18
20
|
import React from 'react';
|
|
19
21
|
|
|
20
22
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -124,6 +126,11 @@ export async function createServer(options: CreateServerOptions = {}) {
|
|
|
124
126
|
return await serveClientComponent(res, config.pagesDir, componentName);
|
|
125
127
|
}
|
|
126
128
|
|
|
129
|
+
// Handle server actions
|
|
130
|
+
if (effectivePath === '/_flexi/action' && req.method === 'POST') {
|
|
131
|
+
return await handleServerAction(req, res);
|
|
132
|
+
}
|
|
133
|
+
|
|
127
134
|
// Rebuild routes in dev mode for hot reload
|
|
128
135
|
if (isDev) {
|
|
129
136
|
routes = buildRouteTree(config.pagesDir, config.layoutsDir);
|
|
@@ -157,7 +164,21 @@ export async function createServer(options: CreateServerOptions = {}) {
|
|
|
157
164
|
res.writeHead(404, { 'Content-Type': 'text/html' });
|
|
158
165
|
res.end(renderError(404, 'Page not found'));
|
|
159
166
|
|
|
160
|
-
} catch (error) {
|
|
167
|
+
} catch (error: any) {
|
|
168
|
+
// Handle redirect() calls
|
|
169
|
+
if (error instanceof RedirectError) {
|
|
170
|
+
res.writeHead(error.statusCode, { 'Location': error.url });
|
|
171
|
+
res.end();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Handle notFound() calls
|
|
176
|
+
if (error instanceof NotFoundError) {
|
|
177
|
+
res.writeHead(404, { 'Content-Type': 'text/html' });
|
|
178
|
+
res.end(renderError(404, error.message));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
161
182
|
console.error('Server Error:', error);
|
|
162
183
|
|
|
163
184
|
if (!res.headersSent) {
|
|
@@ -302,6 +323,49 @@ async function handleApiRoute(req, res, route, loadModule) {
|
|
|
302
323
|
}
|
|
303
324
|
}
|
|
304
325
|
|
|
326
|
+
/**
|
|
327
|
+
* Handles server action requests
|
|
328
|
+
*/
|
|
329
|
+
async function handleServerAction(req, res) {
|
|
330
|
+
try {
|
|
331
|
+
// Parse request body
|
|
332
|
+
const body: any = await parseBody(req);
|
|
333
|
+
const { actionId, args } = body;
|
|
334
|
+
|
|
335
|
+
if (!actionId) {
|
|
336
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
337
|
+
res.end(JSON.stringify({ success: false, error: 'Missing actionId' }));
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Deserialize arguments
|
|
342
|
+
const deserializedArgs = deserializeArgs(args || []);
|
|
343
|
+
|
|
344
|
+
// Execute the action
|
|
345
|
+
const result = await executeAction(actionId, deserializedArgs, {
|
|
346
|
+
request: new Request(`http://${req.headers.host}${req.url}`, {
|
|
347
|
+
method: req.method,
|
|
348
|
+
headers: req.headers as any
|
|
349
|
+
})
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// Send response
|
|
353
|
+
res.writeHead(200, {
|
|
354
|
+
'Content-Type': 'application/json',
|
|
355
|
+
'X-Flexi-Action': actionId
|
|
356
|
+
});
|
|
357
|
+
res.end(JSON.stringify(result));
|
|
358
|
+
|
|
359
|
+
} catch (error: any) {
|
|
360
|
+
console.error('Server Action Error:', error);
|
|
361
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
362
|
+
res.end(JSON.stringify({
|
|
363
|
+
success: false,
|
|
364
|
+
error: error.message || 'Action execution failed'
|
|
365
|
+
}));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
305
369
|
/**
|
|
306
370
|
* Creates an enhanced API response object
|
|
307
371
|
*/
|
|
@@ -360,6 +424,37 @@ function createApiResponse(res) {
|
|
|
360
424
|
*/
|
|
361
425
|
async function handlePageRoute(req, res, route, routes, config, loadModule, url) {
|
|
362
426
|
try {
|
|
427
|
+
// Run route-specific middleware if exists
|
|
428
|
+
if (route.middleware) {
|
|
429
|
+
try {
|
|
430
|
+
const middlewareModule = await loadModule(route.middleware);
|
|
431
|
+
const middlewareFn = middlewareModule.default || middlewareModule.middleware;
|
|
432
|
+
|
|
433
|
+
if (typeof middlewareFn === 'function') {
|
|
434
|
+
const result = await middlewareFn(req, res, { route, params: route.params });
|
|
435
|
+
|
|
436
|
+
// If middleware returns a response, use it
|
|
437
|
+
if (result?.redirect) {
|
|
438
|
+
res.writeHead(result.statusCode || 307, { 'Location': result.redirect });
|
|
439
|
+
res.end();
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (result?.rewrite) {
|
|
444
|
+
// Rewrite to different path
|
|
445
|
+
req.url = result.rewrite;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (result === false || result?.stop) {
|
|
449
|
+
// Middleware stopped the request
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
} catch (middlewareError: any) {
|
|
454
|
+
console.error('Route middleware error:', middlewareError.message);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
363
458
|
// Load page module
|
|
364
459
|
const pageModule = await loadModule(route.filePath);
|
|
365
460
|
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
|
}
|