@cloudwerk/ui 0.3.0 → 0.6.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/dist/index.d.ts +219 -4
- package/dist/index.js +337 -10
- package/dist/react-PVIKZSJC.js +104 -0
- package/package.json +21 -5
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { HydrationManifest } from '@cloudwerk/core';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* @cloudwerk/ui - Type Definitions
|
|
3
5
|
*
|
|
@@ -75,7 +77,7 @@ interface HtmlOptions {
|
|
|
75
77
|
headers?: Record<string, string>;
|
|
76
78
|
}
|
|
77
79
|
/**
|
|
78
|
-
* Options for streaming render.
|
|
80
|
+
* Options for streaming render (loading-swap pattern).
|
|
79
81
|
*/
|
|
80
82
|
interface StreamRenderOptions {
|
|
81
83
|
/**
|
|
@@ -89,6 +91,29 @@ interface StreamRenderOptions {
|
|
|
89
91
|
*/
|
|
90
92
|
headers?: Record<string, string>;
|
|
91
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Options for native progressive streaming render with Suspense boundaries.
|
|
96
|
+
*
|
|
97
|
+
* This is used by renderToStream() which uses Hono's renderToReadableStream
|
|
98
|
+
* for native progressive streaming with Suspense support.
|
|
99
|
+
*/
|
|
100
|
+
interface RenderToStreamOptions {
|
|
101
|
+
/**
|
|
102
|
+
* HTTP status code for the response.
|
|
103
|
+
* @default 200
|
|
104
|
+
*/
|
|
105
|
+
status?: number;
|
|
106
|
+
/**
|
|
107
|
+
* Additional response headers.
|
|
108
|
+
* These are merged with the default Content-Type header.
|
|
109
|
+
*/
|
|
110
|
+
headers?: Record<string, string>;
|
|
111
|
+
/**
|
|
112
|
+
* Whether to include the <!DOCTYPE html> declaration.
|
|
113
|
+
* @default true
|
|
114
|
+
*/
|
|
115
|
+
doctype?: boolean;
|
|
116
|
+
}
|
|
92
117
|
/**
|
|
93
118
|
* Props for components that receive children.
|
|
94
119
|
*
|
|
@@ -108,6 +133,135 @@ interface PropsWithChildren<_P = unknown> {
|
|
|
108
133
|
children?: unknown;
|
|
109
134
|
}
|
|
110
135
|
|
|
136
|
+
/**
|
|
137
|
+
* @cloudwerk/ui - Hydration Utilities
|
|
138
|
+
*
|
|
139
|
+
* Server-side helpers for wrapping client components with hydration metadata
|
|
140
|
+
* and generating the client-side hydration bootstrap script.
|
|
141
|
+
*/
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Options for wrapping a component for hydration.
|
|
145
|
+
*/
|
|
146
|
+
interface WrapForHydrationOptions {
|
|
147
|
+
/** Unique component ID */
|
|
148
|
+
componentId: string;
|
|
149
|
+
/** Props to serialize for client-side hydration */
|
|
150
|
+
props: Record<string, unknown>;
|
|
151
|
+
/** Custom wrapper element tag (default: 'div') */
|
|
152
|
+
wrapperTag?: string;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Options for generating hydration script.
|
|
156
|
+
*/
|
|
157
|
+
interface HydrationScriptOptions {
|
|
158
|
+
/** Whether to include source maps in development */
|
|
159
|
+
includeSourceMaps?: boolean;
|
|
160
|
+
/** Custom hydration endpoint path */
|
|
161
|
+
hydrationEndpoint?: string;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Wrap a server-rendered element with hydration metadata.
|
|
165
|
+
*
|
|
166
|
+
* This wraps the component's HTML output with a container element
|
|
167
|
+
* that includes data attributes for client-side hydration:
|
|
168
|
+
* - `data-hydrate-id`: The component ID for looking up the client bundle
|
|
169
|
+
* - `data-hydrate-props`: JSON-serialized props to pass during hydration
|
|
170
|
+
*
|
|
171
|
+
* @param html - Server-rendered HTML string
|
|
172
|
+
* @param options - Hydration options
|
|
173
|
+
* @returns HTML string with hydration wrapper
|
|
174
|
+
*
|
|
175
|
+
* @example
|
|
176
|
+
* ```typescript
|
|
177
|
+
* const html = wrapForHydration('<button>Count: 0</button>', {
|
|
178
|
+
* componentId: 'components_Counter',
|
|
179
|
+
* props: { initialCount: 0 },
|
|
180
|
+
* })
|
|
181
|
+
* // Returns:
|
|
182
|
+
* // <div data-hydrate-id="components_Counter" data-hydrate-props='{"initialCount":0}'>
|
|
183
|
+
* // <button>Count: 0</button>
|
|
184
|
+
* // </div>
|
|
185
|
+
* ```
|
|
186
|
+
*/
|
|
187
|
+
declare function wrapForHydration(html: string, options: WrapForHydrationOptions): string;
|
|
188
|
+
/**
|
|
189
|
+
* Generate the hydration bootstrap script to include in the HTML response.
|
|
190
|
+
*
|
|
191
|
+
* This script:
|
|
192
|
+
* 1. Finds all elements with `data-hydrate-id` attributes
|
|
193
|
+
* 2. Loads the corresponding client bundles
|
|
194
|
+
* 3. Hydrates each component with its serialized props
|
|
195
|
+
*
|
|
196
|
+
* @param manifest - Hydration manifest with component metadata
|
|
197
|
+
* @param options - Script generation options
|
|
198
|
+
* @returns Script tags for hydration
|
|
199
|
+
*
|
|
200
|
+
* @example
|
|
201
|
+
* ```typescript
|
|
202
|
+
* const script = generateHydrationScript(manifest, {
|
|
203
|
+
* hydrationEndpoint: '/__cloudwerk',
|
|
204
|
+
* })
|
|
205
|
+
* // Insert at the end of the <body> tag
|
|
206
|
+
* ```
|
|
207
|
+
*/
|
|
208
|
+
declare function generateHydrationScript(manifest: HydrationManifest, options?: HydrationScriptOptions): string;
|
|
209
|
+
/**
|
|
210
|
+
* Generate script tags for preloading client bundles.
|
|
211
|
+
*
|
|
212
|
+
* This adds modulepreload hints for better performance.
|
|
213
|
+
*
|
|
214
|
+
* @param manifest - Hydration manifest with component metadata
|
|
215
|
+
* @returns Link tags for modulepreload
|
|
216
|
+
*/
|
|
217
|
+
declare function generatePreloadHints(manifest: HydrationManifest): string;
|
|
218
|
+
/**
|
|
219
|
+
* Generate the hydration runtime module for Hono JSX.
|
|
220
|
+
*
|
|
221
|
+
* This is served at `/__cloudwerk/runtime.js` and provides the render
|
|
222
|
+
* function that uses hono/jsx/dom for client-side hydration.
|
|
223
|
+
*
|
|
224
|
+
* The render function uses hono/jsx/dom's built-in virtual DOM diffing
|
|
225
|
+
* to safely update the DOM without raw HTML insertion.
|
|
226
|
+
*
|
|
227
|
+
* @returns JavaScript module source code
|
|
228
|
+
*/
|
|
229
|
+
declare function generateHydrationRuntime(): string;
|
|
230
|
+
/**
|
|
231
|
+
* Generate the React hydration runtime module.
|
|
232
|
+
*
|
|
233
|
+
* This is served at `/__cloudwerk/react-runtime.js` and provides the
|
|
234
|
+
* hydrateRoot function from react-dom/client for client-side hydration.
|
|
235
|
+
*
|
|
236
|
+
* The runtime exports:
|
|
237
|
+
* - hydrateRoot from react-dom/client for hydration
|
|
238
|
+
* - React and all React hooks for client components
|
|
239
|
+
*
|
|
240
|
+
* @returns JavaScript module source code
|
|
241
|
+
*/
|
|
242
|
+
declare function generateReactHydrationRuntime(): string;
|
|
243
|
+
/**
|
|
244
|
+
* Generate the React hydration bootstrap script to include in the HTML response.
|
|
245
|
+
*
|
|
246
|
+
* This script:
|
|
247
|
+
* 1. Finds all elements with `data-hydrate-id` attributes
|
|
248
|
+
* 2. Loads the corresponding client bundles
|
|
249
|
+
* 3. Hydrates each component with React's hydrateRoot
|
|
250
|
+
*
|
|
251
|
+
* @param manifest - Hydration manifest with component metadata
|
|
252
|
+
* @param options - Script generation options
|
|
253
|
+
* @returns Script tags for hydration
|
|
254
|
+
*
|
|
255
|
+
* @example
|
|
256
|
+
* ```typescript
|
|
257
|
+
* const script = generateReactHydrationScript(manifest, {
|
|
258
|
+
* hydrationEndpoint: '/__cloudwerk',
|
|
259
|
+
* })
|
|
260
|
+
* // Insert at the end of the <body> tag
|
|
261
|
+
* ```
|
|
262
|
+
*/
|
|
263
|
+
declare function generateReactHydrationScript(manifest: HydrationManifest, options?: HydrationScriptOptions): string;
|
|
264
|
+
|
|
111
265
|
/**
|
|
112
266
|
* @cloudwerk/ui - Renderer Selection
|
|
113
267
|
*
|
|
@@ -133,12 +287,32 @@ declare function getActiveRenderer(): Renderer;
|
|
|
133
287
|
* console.log(`Using ${getActiveRendererName()} renderer`)
|
|
134
288
|
*/
|
|
135
289
|
declare function getActiveRendererName(): string;
|
|
290
|
+
/**
|
|
291
|
+
* Initialize and register the React renderer.
|
|
292
|
+
*
|
|
293
|
+
* This must be called before using setActiveRenderer('react').
|
|
294
|
+
* Requires react and react-dom packages to be installed.
|
|
295
|
+
*
|
|
296
|
+
* @throws Error if React packages are not installed
|
|
297
|
+
*
|
|
298
|
+
* @example
|
|
299
|
+
* import { initReactRenderer, setActiveRenderer } from '@cloudwerk/ui'
|
|
300
|
+
*
|
|
301
|
+
* // Initialize React renderer (requires react and react-dom)
|
|
302
|
+
* await initReactRenderer()
|
|
303
|
+
*
|
|
304
|
+
* // Now you can use React
|
|
305
|
+
* setActiveRenderer('react')
|
|
306
|
+
*/
|
|
307
|
+
declare function initReactRenderer(): Promise<void>;
|
|
136
308
|
/**
|
|
137
309
|
* Set the active renderer by name.
|
|
138
310
|
*
|
|
139
311
|
* Called during app initialization based on the `ui.renderer` config option.
|
|
140
312
|
* The renderer must be registered (either built-in or via registerRenderer).
|
|
141
313
|
*
|
|
314
|
+
* For the React renderer, you must call initReactRenderer() first.
|
|
315
|
+
*
|
|
142
316
|
* @param name - Renderer name from config (e.g., 'hono-jsx', 'react')
|
|
143
317
|
* @throws Error if renderer is not found
|
|
144
318
|
*
|
|
@@ -174,7 +348,7 @@ declare function registerRenderer(name: string, renderer: Renderer): void;
|
|
|
174
348
|
*
|
|
175
349
|
* @example
|
|
176
350
|
* const available = getAvailableRenderers()
|
|
177
|
-
* // ['hono-jsx']
|
|
351
|
+
* // ['hono-jsx'] (or ['hono-jsx', 'react'] if initReactRenderer() was called)
|
|
178
352
|
*/
|
|
179
353
|
declare function getAvailableRenderers(): string[];
|
|
180
354
|
/**
|
|
@@ -202,7 +376,8 @@ declare function _resetRenderers(): void;
|
|
|
202
376
|
* - Automatic doctype handling
|
|
203
377
|
* - Proper Content-Type headers
|
|
204
378
|
*
|
|
205
|
-
*
|
|
379
|
+
* Native progressive streaming is available via renderToStream() using Hono's
|
|
380
|
+
* renderToReadableStream for Suspense boundary support.
|
|
206
381
|
*/
|
|
207
382
|
declare const honoJsxRenderer: Renderer;
|
|
208
383
|
/**
|
|
@@ -232,6 +407,46 @@ declare const honoJsxRenderer: Renderer;
|
|
|
232
407
|
* return renderStream(loadingElement, contentPromise)
|
|
233
408
|
*/
|
|
234
409
|
declare function renderStream(loadingElement: unknown, contentPromise: Promise<unknown>, options?: StreamRenderOptions): Response;
|
|
410
|
+
/**
|
|
411
|
+
* Render a JSX element to a streaming Response using native progressive streaming.
|
|
412
|
+
*
|
|
413
|
+
* This uses Hono's renderToReadableStream for native support of React-style
|
|
414
|
+
* Suspense boundaries. Content inside Suspense components will be progressively
|
|
415
|
+
* streamed as their async content resolves.
|
|
416
|
+
*
|
|
417
|
+
* Unlike renderStream() which uses a loading-swap pattern, this function
|
|
418
|
+
* provides true progressive streaming where:
|
|
419
|
+
* - The initial shell is sent immediately
|
|
420
|
+
* - Suspense fallbacks are shown while async content loads
|
|
421
|
+
* - Async content is streamed in-place as it resolves
|
|
422
|
+
* - No JavaScript is required for the initial render
|
|
423
|
+
*
|
|
424
|
+
* @param element - Hono JSX element to render (may contain Suspense boundaries)
|
|
425
|
+
* @param options - Render options
|
|
426
|
+
* @returns Promise resolving to Response with streaming HTML content
|
|
427
|
+
*
|
|
428
|
+
* @example
|
|
429
|
+
* // In a route handler
|
|
430
|
+
* import { Suspense } from 'hono/jsx/streaming'
|
|
431
|
+
*
|
|
432
|
+
* function Page() {
|
|
433
|
+
* return (
|
|
434
|
+
* <html>
|
|
435
|
+
* <body>
|
|
436
|
+
* <h1>Dashboard</h1>
|
|
437
|
+
* <Suspense fallback={<p>Loading stats...</p>}>
|
|
438
|
+
* <AsyncStats />
|
|
439
|
+
* </Suspense>
|
|
440
|
+
* </body>
|
|
441
|
+
* </html>
|
|
442
|
+
* )
|
|
443
|
+
* }
|
|
444
|
+
*
|
|
445
|
+
* export function GET() {
|
|
446
|
+
* return renderToStream(<Page />)
|
|
447
|
+
* }
|
|
448
|
+
*/
|
|
449
|
+
declare function renderToStream(element: unknown, options?: RenderToStreamOptions): Promise<Response>;
|
|
235
450
|
|
|
236
451
|
/**
|
|
237
452
|
* @cloudwerk/ui
|
|
@@ -315,4 +530,4 @@ declare function html(content: string, options?: HtmlOptions): Response;
|
|
|
315
530
|
*/
|
|
316
531
|
declare function hydrate(element: unknown, root: Element): void;
|
|
317
532
|
|
|
318
|
-
export { type HtmlOptions, type PropsWithChildren, type RenderOptions, type Renderer, type StreamRenderOptions, _resetRenderers, getActiveRenderer, getActiveRendererName, getAvailableRenderers, honoJsxRenderer, html, hydrate, registerRenderer, render, renderStream, setActiveRenderer };
|
|
533
|
+
export { type HtmlOptions, type HydrationScriptOptions, type PropsWithChildren, type RenderOptions, type RenderToStreamOptions, type Renderer, type StreamRenderOptions, type WrapForHydrationOptions, _resetRenderers, generateHydrationRuntime, generateHydrationScript, generatePreloadHints, generateReactHydrationRuntime, generateReactHydrationScript, getActiveRenderer, getActiveRendererName, getAvailableRenderers, honoJsxRenderer, html, hydrate, initReactRenderer, registerRenderer, render, renderStream, renderToStream, setActiveRenderer, wrapForHydration };
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// src/renderers/hono-jsx.ts
|
|
2
|
+
import { renderToReadableStream } from "hono/jsx/streaming";
|
|
2
3
|
var honoJsxRenderer = {
|
|
3
4
|
/**
|
|
4
5
|
* Render a JSX element to an HTML Response.
|
|
@@ -45,17 +46,28 @@ var honoJsxRenderer = {
|
|
|
45
46
|
/**
|
|
46
47
|
* Hydrate a JSX element on the client.
|
|
47
48
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
49
|
+
* Uses hono/jsx/dom render function to attach event handlers and state
|
|
50
|
+
* to server-rendered HTML. This is called by the client-side hydration
|
|
51
|
+
* bootstrap script for each Client Component.
|
|
50
52
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
53
|
+
* Note: This method is primarily used by the client-side hydration runtime.
|
|
54
|
+
* In server-side code, it will throw an error since the DOM is not available.
|
|
55
|
+
*
|
|
56
|
+
* @param element - JSX element to hydrate
|
|
57
|
+
* @param root - DOM element to hydrate into
|
|
58
|
+
* @throws Error if called in a non-browser environment
|
|
54
59
|
*/
|
|
55
|
-
hydrate(
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
60
|
+
hydrate(element, root) {
|
|
61
|
+
if (typeof window === "undefined" || typeof document === "undefined") {
|
|
62
|
+
throw new Error(
|
|
63
|
+
"hydrate() can only be called in a browser environment. For server-side rendering, use render() instead."
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
import("hono/jsx/dom").then(({ render: render2 }) => {
|
|
67
|
+
render2(element, root);
|
|
68
|
+
}).catch((error) => {
|
|
69
|
+
console.error("[Cloudwerk] Failed to hydrate component:", error);
|
|
70
|
+
});
|
|
59
71
|
}
|
|
60
72
|
};
|
|
61
73
|
function renderStream(loadingElement, contentPromise, options = {}) {
|
|
@@ -102,6 +114,34 @@ function renderStream(loadingElement, contentPromise, options = {}) {
|
|
|
102
114
|
}
|
|
103
115
|
});
|
|
104
116
|
}
|
|
117
|
+
function prependDoctype(stream) {
|
|
118
|
+
const encoder = new TextEncoder();
|
|
119
|
+
const doctypeBytes = encoder.encode("<!DOCTYPE html>");
|
|
120
|
+
let doctypeSent = false;
|
|
121
|
+
return stream.pipeThrough(
|
|
122
|
+
new TransformStream({
|
|
123
|
+
transform(chunk, controller) {
|
|
124
|
+
if (!doctypeSent) {
|
|
125
|
+
controller.enqueue(doctypeBytes);
|
|
126
|
+
doctypeSent = true;
|
|
127
|
+
}
|
|
128
|
+
controller.enqueue(chunk);
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
async function renderToStream(element, options = {}) {
|
|
134
|
+
const { status = 200, headers = {}, doctype = true } = options;
|
|
135
|
+
const contentStream = renderToReadableStream(element);
|
|
136
|
+
const stream = doctype ? prependDoctype(contentStream) : contentStream;
|
|
137
|
+
return new Response(stream, {
|
|
138
|
+
status,
|
|
139
|
+
headers: {
|
|
140
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
141
|
+
...headers
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
105
145
|
|
|
106
146
|
// src/renderer.ts
|
|
107
147
|
var renderers = {
|
|
@@ -115,10 +155,29 @@ function getActiveRenderer() {
|
|
|
115
155
|
function getActiveRendererName() {
|
|
116
156
|
return activeRendererName;
|
|
117
157
|
}
|
|
158
|
+
async function initReactRenderer() {
|
|
159
|
+
if (renderers["react"]) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
const { reactRenderer } = await import("./react-PVIKZSJC.js");
|
|
164
|
+
renderers["react"] = reactRenderer;
|
|
165
|
+
} catch (error) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`Failed to initialize React renderer. Make sure react and react-dom are installed: npm install react react-dom
|
|
168
|
+
Original error: ${error instanceof Error ? error.message : String(error)}`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
118
172
|
function setActiveRenderer(name) {
|
|
119
173
|
const renderer = renderers[name];
|
|
120
174
|
if (!renderer) {
|
|
121
175
|
const available = Object.keys(renderers).join(", ");
|
|
176
|
+
if (name === "react") {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`React renderer is not initialized. Call initReactRenderer() first, or install react and react-dom packages.`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
122
181
|
throw new Error(`Unknown renderer "${name}". Available renderers: ${available}`);
|
|
123
182
|
}
|
|
124
183
|
activeRenderer = renderer;
|
|
@@ -145,6 +204,266 @@ function _resetRenderers() {
|
|
|
145
204
|
activeRendererName = "hono-jsx";
|
|
146
205
|
}
|
|
147
206
|
|
|
207
|
+
// src/hydration.ts
|
|
208
|
+
import { serializeProps } from "@cloudwerk/core";
|
|
209
|
+
function wrapForHydration(html2, options) {
|
|
210
|
+
const { componentId, props, wrapperTag = "div" } = options;
|
|
211
|
+
const serializedProps = serializeProps(props);
|
|
212
|
+
const escapedProps = escapeHtmlAttribute(serializedProps);
|
|
213
|
+
return `<${wrapperTag} data-hydrate-id="${componentId}" data-hydrate-props="${escapedProps}">${html2}</${wrapperTag}>`;
|
|
214
|
+
}
|
|
215
|
+
function escapeHtmlAttribute(str) {
|
|
216
|
+
return str.replace(/&/g, "&").replace(/"/g, """).replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">");
|
|
217
|
+
}
|
|
218
|
+
function generateHydrationScript(manifest, options = {}) {
|
|
219
|
+
const { hydrationEndpoint = "/__cloudwerk" } = options;
|
|
220
|
+
if (manifest.components.size === 0) {
|
|
221
|
+
return "";
|
|
222
|
+
}
|
|
223
|
+
const bundleMap = {};
|
|
224
|
+
for (const [id, meta] of manifest.components) {
|
|
225
|
+
bundleMap[id] = meta.bundlePath;
|
|
226
|
+
}
|
|
227
|
+
const script = `
|
|
228
|
+
<script type="module">
|
|
229
|
+
(async function() {
|
|
230
|
+
// Bundle map for component lookups
|
|
231
|
+
const bundles = ${JSON.stringify(bundleMap)};
|
|
232
|
+
|
|
233
|
+
// Find all elements that need hydration
|
|
234
|
+
const elements = document.querySelectorAll('[data-hydrate-id]');
|
|
235
|
+
if (elements.length === 0) return;
|
|
236
|
+
|
|
237
|
+
// Cache for loaded modules
|
|
238
|
+
const moduleCache = new Map();
|
|
239
|
+
|
|
240
|
+
// Load a component module
|
|
241
|
+
async function loadComponent(bundlePath) {
|
|
242
|
+
if (moduleCache.has(bundlePath)) {
|
|
243
|
+
return moduleCache.get(bundlePath);
|
|
244
|
+
}
|
|
245
|
+
const module = await import(bundlePath);
|
|
246
|
+
moduleCache.set(bundlePath, module);
|
|
247
|
+
return module;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Hydrate each element
|
|
251
|
+
for (const el of elements) {
|
|
252
|
+
const componentId = el.getAttribute('data-hydrate-id');
|
|
253
|
+
const propsJson = el.getAttribute('data-hydrate-props');
|
|
254
|
+
|
|
255
|
+
if (!componentId || !bundles[componentId]) {
|
|
256
|
+
console.warn('[Cloudwerk] Unknown client component:', componentId);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
// Parse props
|
|
262
|
+
const props = propsJson ? JSON.parse(propsJson) : {};
|
|
263
|
+
|
|
264
|
+
// Load the component module
|
|
265
|
+
const bundlePath = bundles[componentId];
|
|
266
|
+
const module = await loadComponent(bundlePath);
|
|
267
|
+
const Component = module.default;
|
|
268
|
+
|
|
269
|
+
if (!Component) {
|
|
270
|
+
console.error('[Cloudwerk] No default export in component:', componentId);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Import hono/jsx/dom for hydration
|
|
275
|
+
const { render } = await import('${hydrationEndpoint}/runtime.js');
|
|
276
|
+
|
|
277
|
+
// Hydrate the component using hono/jsx/dom render
|
|
278
|
+
// This safely replaces content using virtual DOM diffing
|
|
279
|
+
render(Component(props), el);
|
|
280
|
+
|
|
281
|
+
// Remove hydration attributes after successful hydration
|
|
282
|
+
el.removeAttribute('data-hydrate-id');
|
|
283
|
+
el.removeAttribute('data-hydrate-props');
|
|
284
|
+
} catch (error) {
|
|
285
|
+
console.error('[Cloudwerk] Failed to hydrate component:', componentId, error);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
})();
|
|
289
|
+
</script>
|
|
290
|
+
`.trim();
|
|
291
|
+
return script;
|
|
292
|
+
}
|
|
293
|
+
function generatePreloadHints(manifest) {
|
|
294
|
+
if (manifest.components.size === 0) {
|
|
295
|
+
return "";
|
|
296
|
+
}
|
|
297
|
+
const hints = [];
|
|
298
|
+
for (const meta of manifest.components.values()) {
|
|
299
|
+
hints.push(`<link rel="modulepreload" href="${meta.bundlePath}">`);
|
|
300
|
+
}
|
|
301
|
+
return hints.join("\n");
|
|
302
|
+
}
|
|
303
|
+
function generateHydrationRuntime() {
|
|
304
|
+
return `
|
|
305
|
+
// Cloudwerk Hydration Runtime
|
|
306
|
+
// Uses hono/jsx/dom for client-side rendering with virtual DOM diffing
|
|
307
|
+
import { render as honoRender } from 'hono/jsx/dom';
|
|
308
|
+
|
|
309
|
+
export function render(element, container) {
|
|
310
|
+
// Use hono/jsx/dom render which safely updates DOM via virtual DOM diffing
|
|
311
|
+
// This replaces the server-rendered content with the interactive version
|
|
312
|
+
honoRender(element, container);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Re-export hooks for client components
|
|
316
|
+
export {
|
|
317
|
+
useState,
|
|
318
|
+
useEffect,
|
|
319
|
+
useRef,
|
|
320
|
+
useCallback,
|
|
321
|
+
useMemo,
|
|
322
|
+
useReducer,
|
|
323
|
+
useSyncExternalStore,
|
|
324
|
+
useTransition,
|
|
325
|
+
useDeferredValue,
|
|
326
|
+
useId,
|
|
327
|
+
} from 'hono/jsx/dom';
|
|
328
|
+
`.trim();
|
|
329
|
+
}
|
|
330
|
+
function generateReactHydrationRuntime() {
|
|
331
|
+
return `
|
|
332
|
+
// Cloudwerk React Hydration Runtime
|
|
333
|
+
// Uses react-dom/client for client-side hydration
|
|
334
|
+
import React from 'react';
|
|
335
|
+
import { hydrateRoot } from 'react-dom/client';
|
|
336
|
+
import {
|
|
337
|
+
useState,
|
|
338
|
+
useEffect,
|
|
339
|
+
useRef,
|
|
340
|
+
useCallback,
|
|
341
|
+
useMemo,
|
|
342
|
+
useReducer,
|
|
343
|
+
useContext,
|
|
344
|
+
useLayoutEffect,
|
|
345
|
+
useImperativeHandle,
|
|
346
|
+
useDebugValue,
|
|
347
|
+
useSyncExternalStore,
|
|
348
|
+
useTransition,
|
|
349
|
+
useDeferredValue,
|
|
350
|
+
useId,
|
|
351
|
+
useInsertionEffect,
|
|
352
|
+
useOptimistic,
|
|
353
|
+
useActionState,
|
|
354
|
+
use,
|
|
355
|
+
} from 'react';
|
|
356
|
+
|
|
357
|
+
// Re-export React for component rendering
|
|
358
|
+
export { React };
|
|
359
|
+
|
|
360
|
+
// Re-export hydrateRoot for hydration
|
|
361
|
+
export { hydrateRoot };
|
|
362
|
+
|
|
363
|
+
// Hydrate function that wraps hydrateRoot for Cloudwerk usage
|
|
364
|
+
export function hydrate(Component, props, container) {
|
|
365
|
+
return hydrateRoot(container, React.createElement(Component, props));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Re-export all hooks for client components
|
|
369
|
+
export {
|
|
370
|
+
useState,
|
|
371
|
+
useEffect,
|
|
372
|
+
useRef,
|
|
373
|
+
useCallback,
|
|
374
|
+
useMemo,
|
|
375
|
+
useReducer,
|
|
376
|
+
useContext,
|
|
377
|
+
useLayoutEffect,
|
|
378
|
+
useImperativeHandle,
|
|
379
|
+
useDebugValue,
|
|
380
|
+
useSyncExternalStore,
|
|
381
|
+
useTransition,
|
|
382
|
+
useDeferredValue,
|
|
383
|
+
useId,
|
|
384
|
+
useInsertionEffect,
|
|
385
|
+
useOptimistic,
|
|
386
|
+
useActionState,
|
|
387
|
+
use,
|
|
388
|
+
};
|
|
389
|
+
`.trim();
|
|
390
|
+
}
|
|
391
|
+
function generateReactHydrationScript(manifest, options = {}) {
|
|
392
|
+
const { hydrationEndpoint = "/__cloudwerk" } = options;
|
|
393
|
+
if (manifest.components.size === 0) {
|
|
394
|
+
return "";
|
|
395
|
+
}
|
|
396
|
+
const bundleMap = {};
|
|
397
|
+
for (const [id, meta] of manifest.components) {
|
|
398
|
+
bundleMap[id] = meta.bundlePath;
|
|
399
|
+
}
|
|
400
|
+
const script = `
|
|
401
|
+
<script type="module">
|
|
402
|
+
(async function() {
|
|
403
|
+
// Bundle map for component lookups
|
|
404
|
+
const bundles = ${JSON.stringify(bundleMap)};
|
|
405
|
+
|
|
406
|
+
// Find all elements that need hydration
|
|
407
|
+
const elements = document.querySelectorAll('[data-hydrate-id]');
|
|
408
|
+
if (elements.length === 0) return;
|
|
409
|
+
|
|
410
|
+
// Cache for loaded modules
|
|
411
|
+
const moduleCache = new Map();
|
|
412
|
+
|
|
413
|
+
// Load a component module
|
|
414
|
+
async function loadComponent(bundlePath) {
|
|
415
|
+
if (moduleCache.has(bundlePath)) {
|
|
416
|
+
return moduleCache.get(bundlePath);
|
|
417
|
+
}
|
|
418
|
+
const module = await import(bundlePath);
|
|
419
|
+
moduleCache.set(bundlePath, module);
|
|
420
|
+
return module;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Import React and hydrateRoot from the runtime
|
|
424
|
+
const { React, hydrateRoot } = await import('${hydrationEndpoint}/react-runtime.js');
|
|
425
|
+
|
|
426
|
+
// Hydrate each element
|
|
427
|
+
for (const el of elements) {
|
|
428
|
+
const componentId = el.getAttribute('data-hydrate-id');
|
|
429
|
+
const propsJson = el.getAttribute('data-hydrate-props');
|
|
430
|
+
|
|
431
|
+
if (!componentId || !bundles[componentId]) {
|
|
432
|
+
console.warn('[Cloudwerk] Unknown client component:', componentId);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
try {
|
|
437
|
+
// Parse props
|
|
438
|
+
const props = propsJson ? JSON.parse(propsJson) : {};
|
|
439
|
+
|
|
440
|
+
// Load the component module
|
|
441
|
+
const bundlePath = bundles[componentId];
|
|
442
|
+
const module = await loadComponent(bundlePath);
|
|
443
|
+
const Component = module.default;
|
|
444
|
+
|
|
445
|
+
if (!Component) {
|
|
446
|
+
console.error('[Cloudwerk] No default export in component:', componentId);
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Hydrate the component using React's hydrateRoot
|
|
451
|
+
// This attaches event handlers to the server-rendered HTML
|
|
452
|
+
hydrateRoot(el, React.createElement(Component, props));
|
|
453
|
+
|
|
454
|
+
// Remove hydration attributes after successful hydration
|
|
455
|
+
el.removeAttribute('data-hydrate-id');
|
|
456
|
+
el.removeAttribute('data-hydrate-props');
|
|
457
|
+
} catch (error) {
|
|
458
|
+
console.error('[Cloudwerk] Failed to hydrate component:', componentId, error);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
})();
|
|
462
|
+
</script>
|
|
463
|
+
`.trim();
|
|
464
|
+
return script;
|
|
465
|
+
}
|
|
466
|
+
|
|
148
467
|
// src/index.ts
|
|
149
468
|
function render(element, options) {
|
|
150
469
|
return getActiveRenderer().render(element, options);
|
|
@@ -157,14 +476,22 @@ function hydrate(element, root) {
|
|
|
157
476
|
}
|
|
158
477
|
export {
|
|
159
478
|
_resetRenderers,
|
|
479
|
+
generateHydrationRuntime,
|
|
480
|
+
generateHydrationScript,
|
|
481
|
+
generatePreloadHints,
|
|
482
|
+
generateReactHydrationRuntime,
|
|
483
|
+
generateReactHydrationScript,
|
|
160
484
|
getActiveRenderer,
|
|
161
485
|
getActiveRendererName,
|
|
162
486
|
getAvailableRenderers,
|
|
163
487
|
honoJsxRenderer,
|
|
164
488
|
html,
|
|
165
489
|
hydrate,
|
|
490
|
+
initReactRenderer,
|
|
166
491
|
registerRenderer,
|
|
167
492
|
render,
|
|
168
493
|
renderStream,
|
|
169
|
-
|
|
494
|
+
renderToStream,
|
|
495
|
+
setActiveRenderer,
|
|
496
|
+
wrapForHydration
|
|
170
497
|
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// src/renderers/react.ts
|
|
2
|
+
import { renderToString, renderToReadableStream } from "react-dom/server";
|
|
3
|
+
var reactRenderer = {
|
|
4
|
+
/**
|
|
5
|
+
* Render a React element to an HTML Response.
|
|
6
|
+
*
|
|
7
|
+
* Uses React's renderToString() for synchronous server-side rendering.
|
|
8
|
+
* This method wraps the output in a proper Response object with headers.
|
|
9
|
+
*
|
|
10
|
+
* @param element - React element (React.ReactElement)
|
|
11
|
+
* @param options - Render options
|
|
12
|
+
* @returns Response object with HTML content
|
|
13
|
+
*/
|
|
14
|
+
render(element, options = {}) {
|
|
15
|
+
const { status = 200, headers = {}, doctype = true } = options;
|
|
16
|
+
const htmlString = renderToString(element);
|
|
17
|
+
const body = doctype ? `<!DOCTYPE html>${htmlString}` : htmlString;
|
|
18
|
+
return new Response(body, {
|
|
19
|
+
status,
|
|
20
|
+
headers: {
|
|
21
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
22
|
+
...headers
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
},
|
|
26
|
+
/**
|
|
27
|
+
* Create an HTML Response from a raw string.
|
|
28
|
+
*
|
|
29
|
+
* Useful for static HTML, templates, or pre-rendered content
|
|
30
|
+
* that doesn't need to go through JSX rendering.
|
|
31
|
+
*
|
|
32
|
+
* @param content - Raw HTML string
|
|
33
|
+
* @param options - HTML response options
|
|
34
|
+
* @returns Response object with HTML content
|
|
35
|
+
*/
|
|
36
|
+
html(content, options = {}) {
|
|
37
|
+
const { status = 200, headers = {} } = options;
|
|
38
|
+
return new Response(content, {
|
|
39
|
+
status,
|
|
40
|
+
headers: {
|
|
41
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
42
|
+
...headers
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
/**
|
|
47
|
+
* Hydrate a React element on the client.
|
|
48
|
+
*
|
|
49
|
+
* Uses react-dom/client's hydrateRoot to attach event handlers and state
|
|
50
|
+
* to server-rendered HTML. This is called by the client-side hydration
|
|
51
|
+
* runtime for each Client Component.
|
|
52
|
+
*
|
|
53
|
+
* Note: This method is primarily used by the client-side hydration runtime.
|
|
54
|
+
* In server-side code, it will throw an error since the DOM is not available.
|
|
55
|
+
*
|
|
56
|
+
* @param element - React element to hydrate
|
|
57
|
+
* @param root - DOM element to hydrate into
|
|
58
|
+
* @throws Error if called in a non-browser environment
|
|
59
|
+
*/
|
|
60
|
+
hydrate(element, root) {
|
|
61
|
+
if (typeof window === "undefined" || typeof document === "undefined") {
|
|
62
|
+
throw new Error(
|
|
63
|
+
"hydrate() can only be called in a browser environment. For server-side rendering, use render() instead."
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
import("react-dom/client").then(({ hydrateRoot }) => {
|
|
67
|
+
hydrateRoot(root, element);
|
|
68
|
+
}).catch((error) => {
|
|
69
|
+
console.error("[Cloudwerk] Failed to hydrate React component:", error);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
function prependDoctype(stream) {
|
|
74
|
+
const encoder = new TextEncoder();
|
|
75
|
+
const doctypeBytes = encoder.encode("<!DOCTYPE html>");
|
|
76
|
+
let doctypeSent = false;
|
|
77
|
+
return stream.pipeThrough(
|
|
78
|
+
new TransformStream({
|
|
79
|
+
transform(chunk, controller) {
|
|
80
|
+
if (!doctypeSent) {
|
|
81
|
+
controller.enqueue(doctypeBytes);
|
|
82
|
+
doctypeSent = true;
|
|
83
|
+
}
|
|
84
|
+
controller.enqueue(chunk);
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
async function reactRenderToStream(element, options = {}) {
|
|
90
|
+
const { status = 200, headers = {}, doctype = true } = options;
|
|
91
|
+
const contentStream = await renderToReadableStream(element);
|
|
92
|
+
const stream = doctype ? prependDoctype(contentStream) : contentStream;
|
|
93
|
+
return new Response(stream, {
|
|
94
|
+
status,
|
|
95
|
+
headers: {
|
|
96
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
97
|
+
...headers
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
export {
|
|
102
|
+
reactRenderToStream,
|
|
103
|
+
reactRenderer
|
|
104
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudwerk/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "UI rendering abstraction for Cloudwerk",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -17,20 +17,36 @@
|
|
|
17
17
|
"files": [
|
|
18
18
|
"dist"
|
|
19
19
|
],
|
|
20
|
-
"dependencies": {
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@cloudwerk/core": "0.6.0"
|
|
22
|
+
},
|
|
21
23
|
"peerDependencies": {
|
|
22
|
-
"hono": "^4.0.0"
|
|
24
|
+
"hono": "^4.0.0",
|
|
25
|
+
"react": "^19.0.0",
|
|
26
|
+
"react-dom": "^19.0.0"
|
|
27
|
+
},
|
|
28
|
+
"peerDependenciesMeta": {
|
|
29
|
+
"react": {
|
|
30
|
+
"optional": true
|
|
31
|
+
},
|
|
32
|
+
"react-dom": {
|
|
33
|
+
"optional": true
|
|
34
|
+
}
|
|
23
35
|
},
|
|
24
36
|
"devDependencies": {
|
|
37
|
+
"@types/react": "^19.0.0",
|
|
38
|
+
"@types/react-dom": "^19.0.0",
|
|
25
39
|
"@vitest/coverage-v8": "^1.0.0",
|
|
26
40
|
"hono": "^4.7.4",
|
|
41
|
+
"react": "^19.0.0",
|
|
42
|
+
"react-dom": "^19.0.0",
|
|
27
43
|
"tsup": "^8.0.0",
|
|
28
44
|
"typescript": "^5.0.0",
|
|
29
45
|
"vitest": "^1.0.0"
|
|
30
46
|
},
|
|
31
47
|
"scripts": {
|
|
32
|
-
"build": "tsup
|
|
33
|
-
"dev": "tsup
|
|
48
|
+
"build": "tsup",
|
|
49
|
+
"dev": "tsup --watch",
|
|
34
50
|
"test": "vitest --run",
|
|
35
51
|
"test:watch": "vitest",
|
|
36
52
|
"test:coverage": "vitest --run --coverage",
|