@flexireact/core 2.1.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +203 -15
- package/core/actions/index.ts +364 -0
- package/core/font/index.ts +306 -0
- package/core/image/index.ts +413 -0
- package/core/index.ts +59 -2
- package/core/metadata/index.ts +622 -0
- package/core/render/index.ts +158 -0
- package/core/server/index.ts +61 -0
- package/package.json +1 -1
package/core/render/index.ts
CHANGED
|
@@ -91,6 +91,164 @@ export async function renderPage(options) {
|
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
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
|
+
|
|
94
252
|
/**
|
|
95
253
|
* Error Boundary Wrapper for SSR
|
|
96
254
|
*/
|
package/core/server/index.ts
CHANGED
|
@@ -16,6 +16,9 @@ import { getRegisteredIslands, generateAdvancedHydrationScript } from '../island
|
|
|
16
16
|
import { createRequestContext, RequestContext, RouteContext } from '../context.js';
|
|
17
17
|
import { logger } from '../logger.js';
|
|
18
18
|
import { RedirectError, NotFoundError } from '../helpers.js';
|
|
19
|
+
import { executeAction, deserializeArgs } from '../actions/index.js';
|
|
20
|
+
import { handleImageOptimization } from '../image/index.js';
|
|
21
|
+
import { handleFontRequest } from '../font/index.js';
|
|
19
22
|
import React from 'react';
|
|
20
23
|
|
|
21
24
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -125,6 +128,21 @@ export async function createServer(options: CreateServerOptions = {}) {
|
|
|
125
128
|
return await serveClientComponent(res, config.pagesDir, componentName);
|
|
126
129
|
}
|
|
127
130
|
|
|
131
|
+
// Handle server actions
|
|
132
|
+
if (effectivePath === '/_flexi/action' && req.method === 'POST') {
|
|
133
|
+
return await handleServerAction(req, res);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Handle image optimization
|
|
137
|
+
if (effectivePath.startsWith('/_flexi/image')) {
|
|
138
|
+
return await handleImageOptimization(req, res, config.images || {});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Handle font requests
|
|
142
|
+
if (effectivePath.startsWith('/_flexi/font')) {
|
|
143
|
+
return await handleFontRequest(req, res);
|
|
144
|
+
}
|
|
145
|
+
|
|
128
146
|
// Rebuild routes in dev mode for hot reload
|
|
129
147
|
if (isDev) {
|
|
130
148
|
routes = buildRouteTree(config.pagesDir, config.layoutsDir);
|
|
@@ -317,6 +335,49 @@ async function handleApiRoute(req, res, route, loadModule) {
|
|
|
317
335
|
}
|
|
318
336
|
}
|
|
319
337
|
|
|
338
|
+
/**
|
|
339
|
+
* Handles server action requests
|
|
340
|
+
*/
|
|
341
|
+
async function handleServerAction(req, res) {
|
|
342
|
+
try {
|
|
343
|
+
// Parse request body
|
|
344
|
+
const body: any = await parseBody(req);
|
|
345
|
+
const { actionId, args } = body;
|
|
346
|
+
|
|
347
|
+
if (!actionId) {
|
|
348
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
349
|
+
res.end(JSON.stringify({ success: false, error: 'Missing actionId' }));
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Deserialize arguments
|
|
354
|
+
const deserializedArgs = deserializeArgs(args || []);
|
|
355
|
+
|
|
356
|
+
// Execute the action
|
|
357
|
+
const result = await executeAction(actionId, deserializedArgs, {
|
|
358
|
+
request: new Request(`http://${req.headers.host}${req.url}`, {
|
|
359
|
+
method: req.method,
|
|
360
|
+
headers: req.headers as any
|
|
361
|
+
})
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// Send response
|
|
365
|
+
res.writeHead(200, {
|
|
366
|
+
'Content-Type': 'application/json',
|
|
367
|
+
'X-Flexi-Action': actionId
|
|
368
|
+
});
|
|
369
|
+
res.end(JSON.stringify(result));
|
|
370
|
+
|
|
371
|
+
} catch (error: any) {
|
|
372
|
+
console.error('Server Action Error:', error);
|
|
373
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
374
|
+
res.end(JSON.stringify({
|
|
375
|
+
success: false,
|
|
376
|
+
error: error.message || 'Action execution failed'
|
|
377
|
+
}));
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
320
381
|
/**
|
|
321
382
|
* Creates an enhanced API response object
|
|
322
383
|
*/
|