@flexireact/core 2.1.0 → 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.
@@ -0,0 +1,364 @@
1
+ /**
2
+ * FlexiReact Server Actions
3
+ *
4
+ * Server Actions allow you to define server-side functions that can be called
5
+ * directly from client components. They are automatically serialized and executed
6
+ * on the server.
7
+ *
8
+ * Usage:
9
+ * ```tsx
10
+ * // In a server file (actions.ts)
11
+ * 'use server';
12
+ *
13
+ * export async function createUser(formData: FormData) {
14
+ * const name = formData.get('name');
15
+ * // Save to database...
16
+ * return { success: true, id: 123 };
17
+ * }
18
+ *
19
+ * // In a client component
20
+ * 'use client';
21
+ * import { createUser } from './actions';
22
+ *
23
+ * function Form() {
24
+ * return (
25
+ * <form action={createUser}>
26
+ * <input name="name" />
27
+ * <button type="submit">Create</button>
28
+ * </form>
29
+ * );
30
+ * }
31
+ * ```
32
+ */
33
+
34
+ import { cookies, headers, redirect, notFound, RedirectError, NotFoundError } from '../helpers.js';
35
+
36
+ // Global action registry
37
+ declare global {
38
+ var __FLEXI_ACTIONS__: Record<string, ServerActionFunction>;
39
+ var __FLEXI_ACTION_CONTEXT__: ActionContext | null;
40
+ }
41
+
42
+ globalThis.__FLEXI_ACTIONS__ = globalThis.__FLEXI_ACTIONS__ || {};
43
+ globalThis.__FLEXI_ACTION_CONTEXT__ = null;
44
+
45
+ export interface ActionContext {
46
+ request: Request;
47
+ cookies: typeof cookies;
48
+ headers: typeof headers;
49
+ redirect: typeof redirect;
50
+ notFound: typeof notFound;
51
+ }
52
+
53
+ export type ServerActionFunction = (...args: any[]) => Promise<any>;
54
+
55
+ export interface ActionResult<T = any> {
56
+ success: boolean;
57
+ data?: T;
58
+ error?: string;
59
+ redirect?: string;
60
+ }
61
+
62
+ /**
63
+ * Decorator to mark a function as a server action
64
+ */
65
+ export function serverAction<T extends ServerActionFunction>(
66
+ fn: T,
67
+ actionId?: string
68
+ ): T {
69
+ const id = actionId || `action_${fn.name}_${generateActionId()}`;
70
+
71
+ // Register the action
72
+ globalThis.__FLEXI_ACTIONS__[id] = fn;
73
+
74
+ // Create a proxy that will be serialized for the client
75
+ const proxy = (async (...args: any[]) => {
76
+ // If we're on the server, execute directly
77
+ if (typeof window === 'undefined') {
78
+ return await executeAction(id, args);
79
+ }
80
+
81
+ // If we're on the client, make a fetch request
82
+ return await callServerAction(id, args);
83
+ }) as T;
84
+
85
+ // Mark as server action
86
+ (proxy as any).$$typeof = Symbol.for('react.server.action');
87
+ (proxy as any).$$id = id;
88
+ (proxy as any).$$bound = null;
89
+
90
+ return proxy;
91
+ }
92
+
93
+ /**
94
+ * Register a server action
95
+ */
96
+ export function registerAction(id: string, fn: ServerActionFunction): void {
97
+ globalThis.__FLEXI_ACTIONS__[id] = fn;
98
+ }
99
+
100
+ /**
101
+ * Get a registered action
102
+ */
103
+ export function getAction(id: string): ServerActionFunction | undefined {
104
+ return globalThis.__FLEXI_ACTIONS__[id];
105
+ }
106
+
107
+ /**
108
+ * Execute a server action on the server
109
+ */
110
+ export async function executeAction(
111
+ actionId: string,
112
+ args: any[],
113
+ context?: Partial<ActionContext>
114
+ ): Promise<ActionResult> {
115
+ const action = globalThis.__FLEXI_ACTIONS__[actionId];
116
+
117
+ if (!action) {
118
+ return {
119
+ success: false,
120
+ error: `Server action not found: ${actionId}`
121
+ };
122
+ }
123
+
124
+ // Set up action context
125
+ const actionContext: ActionContext = {
126
+ request: context?.request || new Request('http://localhost'),
127
+ cookies,
128
+ headers,
129
+ redirect,
130
+ notFound
131
+ };
132
+
133
+ globalThis.__FLEXI_ACTION_CONTEXT__ = actionContext;
134
+
135
+ try {
136
+ const result = await action(...args);
137
+
138
+ return {
139
+ success: true,
140
+ data: result
141
+ };
142
+ } catch (error: any) {
143
+ // Handle redirect
144
+ if (error instanceof RedirectError) {
145
+ return {
146
+ success: true,
147
+ redirect: error.url
148
+ };
149
+ }
150
+
151
+ // Handle not found
152
+ if (error instanceof NotFoundError) {
153
+ return {
154
+ success: false,
155
+ error: 'Not found'
156
+ };
157
+ }
158
+
159
+ return {
160
+ success: false,
161
+ error: error.message || 'Action failed'
162
+ };
163
+ } finally {
164
+ globalThis.__FLEXI_ACTION_CONTEXT__ = null;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Call a server action from the client
170
+ */
171
+ export async function callServerAction(
172
+ actionId: string,
173
+ args: any[]
174
+ ): Promise<ActionResult> {
175
+ try {
176
+ const response = await fetch('/_flexi/action', {
177
+ method: 'POST',
178
+ headers: {
179
+ 'Content-Type': 'application/json',
180
+ 'X-Flexi-Action': actionId
181
+ },
182
+ body: JSON.stringify({
183
+ actionId,
184
+ args: serializeArgs(args)
185
+ }),
186
+ credentials: 'same-origin'
187
+ });
188
+
189
+ if (!response.ok) {
190
+ throw new Error(`Action failed: ${response.statusText}`);
191
+ }
192
+
193
+ const result = await response.json();
194
+
195
+ // Handle redirect
196
+ if (result.redirect) {
197
+ window.location.href = result.redirect;
198
+ return result;
199
+ }
200
+
201
+ return result;
202
+ } catch (error: any) {
203
+ return {
204
+ success: false,
205
+ error: error.message || 'Network error'
206
+ };
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Serialize action arguments for transmission
212
+ */
213
+ function serializeArgs(args: any[]): any[] {
214
+ return args.map(arg => {
215
+ // Handle FormData
216
+ if (arg instanceof FormData) {
217
+ const obj: Record<string, any> = {};
218
+ arg.forEach((value, key) => {
219
+ if (obj[key]) {
220
+ // Handle multiple values
221
+ if (Array.isArray(obj[key])) {
222
+ obj[key].push(value);
223
+ } else {
224
+ obj[key] = [obj[key], value];
225
+ }
226
+ } else {
227
+ obj[key] = value;
228
+ }
229
+ });
230
+ return { $$type: 'FormData', data: obj };
231
+ }
232
+
233
+ // Handle File
234
+ if (typeof File !== 'undefined' && arg instanceof File) {
235
+ return { $$type: 'File', name: arg.name, type: arg.type, size: arg.size };
236
+ }
237
+
238
+ // Handle Date
239
+ if (arg instanceof Date) {
240
+ return { $$type: 'Date', value: arg.toISOString() };
241
+ }
242
+
243
+ // Handle regular objects
244
+ if (typeof arg === 'object' && arg !== null) {
245
+ return JSON.parse(JSON.stringify(arg));
246
+ }
247
+
248
+ return arg;
249
+ });
250
+ }
251
+
252
+ /**
253
+ * Deserialize action arguments on the server
254
+ */
255
+ export function deserializeArgs(args: any[]): any[] {
256
+ return args.map(arg => {
257
+ if (arg && typeof arg === 'object') {
258
+ // Handle FormData
259
+ if (arg.$$type === 'FormData') {
260
+ const formData = new FormData();
261
+ for (const [key, value] of Object.entries(arg.data)) {
262
+ if (Array.isArray(value)) {
263
+ value.forEach(v => formData.append(key, v as string));
264
+ } else {
265
+ formData.append(key, value as string);
266
+ }
267
+ }
268
+ return formData;
269
+ }
270
+
271
+ // Handle Date
272
+ if (arg.$$type === 'Date') {
273
+ return new Date(arg.value);
274
+ }
275
+ }
276
+
277
+ return arg;
278
+ });
279
+ }
280
+
281
+ /**
282
+ * Generate a unique action ID
283
+ */
284
+ function generateActionId(): string {
285
+ return Math.random().toString(36).substring(2, 10);
286
+ }
287
+
288
+ /**
289
+ * Hook to get the current action context
290
+ */
291
+ export function useActionContext(): ActionContext | null {
292
+ return globalThis.__FLEXI_ACTION_CONTEXT__;
293
+ }
294
+
295
+ /**
296
+ * Create a form action handler
297
+ * Wraps a server action for use with HTML forms
298
+ */
299
+ export function formAction<T>(
300
+ action: (formData: FormData) => Promise<T>
301
+ ): (formData: FormData) => Promise<ActionResult<T>> {
302
+ return async (formData: FormData) => {
303
+ try {
304
+ const result = await action(formData);
305
+ return { success: true, data: result };
306
+ } catch (error: any) {
307
+ if (error instanceof RedirectError) {
308
+ return { success: true, redirect: error.url };
309
+ }
310
+ return { success: false, error: error.message };
311
+ }
312
+ };
313
+ }
314
+
315
+ /**
316
+ * useFormState hook for progressive enhancement
317
+ * Works with server actions and provides loading/error states
318
+ */
319
+ export function createFormState<T>(
320
+ action: (formData: FormData) => Promise<ActionResult<T>>,
321
+ initialState: T | null = null
322
+ ) {
323
+ return {
324
+ action,
325
+ initialState,
326
+ // This will be enhanced on the client
327
+ pending: false,
328
+ error: null as string | null,
329
+ data: initialState
330
+ };
331
+ }
332
+
333
+ /**
334
+ * Bind arguments to a server action
335
+ * Creates a new action with pre-filled arguments
336
+ */
337
+ export function bindArgs<T extends ServerActionFunction>(
338
+ action: T,
339
+ ...boundArgs: any[]
340
+ ): T {
341
+ const boundAction = (async (...args: any[]) => {
342
+ return await (action as any)(...boundArgs, ...args);
343
+ }) as T;
344
+
345
+ // Copy action metadata
346
+ (boundAction as any).$$typeof = (action as any).$$typeof;
347
+ (boundAction as any).$$id = (action as any).$$id;
348
+ (boundAction as any).$$bound = boundArgs;
349
+
350
+ return boundAction;
351
+ }
352
+
353
+ export default {
354
+ serverAction,
355
+ registerAction,
356
+ getAction,
357
+ executeAction,
358
+ callServerAction,
359
+ deserializeArgs,
360
+ useActionContext,
361
+ formAction,
362
+ createFormState,
363
+ bindArgs
364
+ };
package/core/index.ts CHANGED
@@ -15,7 +15,7 @@ export * from './utils.js';
15
15
  export { buildRouteTree, matchRoute, findRouteLayouts, RouteType } from './router/index.js';
16
16
 
17
17
  // Render
18
- export { renderPage, renderError, renderLoading } from './render/index.js';
18
+ export { renderPage, renderPageStream, streamToResponse, renderError, renderLoading } from './render/index.js';
19
19
 
20
20
  // Server
21
21
  import { createServer } from './server/index.js';
@@ -70,6 +70,20 @@ export {
70
70
  builtinPlugins
71
71
  } from './plugins/index.js';
72
72
 
73
+ // Server Actions
74
+ export {
75
+ serverAction,
76
+ registerAction,
77
+ getAction,
78
+ executeAction,
79
+ callServerAction,
80
+ formAction,
81
+ createFormState,
82
+ bindArgs,
83
+ useActionContext
84
+ } from './actions/index.js';
85
+ export type { ActionContext, ActionResult, ServerActionFunction } from './actions/index.js';
86
+
73
87
  // Server Helpers
74
88
  export {
75
89
  // Response helpers
@@ -96,7 +110,7 @@ export {
96
110
  export type { CookieOptions } from './helpers.js';
97
111
 
98
112
  // Version
99
- export const VERSION = '2.1.0';
113
+ export const VERSION = '2.2.0';
100
114
 
101
115
  // Default export
102
116
  export default {
@@ -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
  */
@@ -16,6 +16,7 @@ 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';
19
20
  import React from 'react';
20
21
 
21
22
  const __filename = fileURLToPath(import.meta.url);
@@ -125,6 +126,11 @@ export async function createServer(options: CreateServerOptions = {}) {
125
126
  return await serveClientComponent(res, config.pagesDir, componentName);
126
127
  }
127
128
 
129
+ // Handle server actions
130
+ if (effectivePath === '/_flexi/action' && req.method === 'POST') {
131
+ return await handleServerAction(req, res);
132
+ }
133
+
128
134
  // Rebuild routes in dev mode for hot reload
129
135
  if (isDev) {
130
136
  routes = buildRouteTree(config.pagesDir, config.layoutsDir);
@@ -317,6 +323,49 @@ async function handleApiRoute(req, res, route, loadModule) {
317
323
  }
318
324
  }
319
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
+
320
369
  /**
321
370
  * Creates an enhanced API response object
322
371
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flexireact/core",
3
- "version": "2.1.0",
3
+ "version": "2.2.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",