@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.
- package/core/actions/index.ts +364 -0
- package/core/index.ts +16 -2
- package/core/render/index.ts +158 -0
- package/core/server/index.ts +49 -0
- package/package.json +1 -1
|
@@ -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.
|
|
113
|
+
export const VERSION = '2.2.0';
|
|
100
114
|
|
|
101
115
|
// Default export
|
|
102
116
|
export default {
|
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,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
|
*/
|