@cloudwerk/vite-plugin 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Squirrelsoft Dev Tools
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,182 @@
1
+ import { Plugin } from 'vite';
2
+ import { CloudwerkConfig, RouteManifest, ScanResult } from '@cloudwerk/core/build';
3
+
4
+ /**
5
+ * @cloudwerk/vite-plugin - Types
6
+ *
7
+ * Configuration and internal types for the Cloudwerk Vite plugin.
8
+ */
9
+
10
+ /**
11
+ * Options for the Cloudwerk Vite plugin.
12
+ */
13
+ interface CloudwerkVitePluginOptions {
14
+ /**
15
+ * Directory containing route files.
16
+ * @default 'app'
17
+ */
18
+ appDir?: string;
19
+ /**
20
+ * Subdirectory within appDir for routes.
21
+ * @default 'routes'
22
+ */
23
+ routesDir?: string;
24
+ /**
25
+ * Override Cloudwerk configuration.
26
+ * If not provided, loads from cloudwerk.config.ts
27
+ */
28
+ config?: Partial<CloudwerkConfig>;
29
+ /**
30
+ * Custom server entry file path.
31
+ * If provided, disables virtual:cloudwerk/server-entry generation.
32
+ * Plugin also auto-detects if user provides their own app/server.ts
33
+ */
34
+ serverEntry?: string;
35
+ /**
36
+ * Custom client entry file path.
37
+ * If provided, disables virtual:cloudwerk/client-entry generation.
38
+ */
39
+ clientEntry?: string;
40
+ /**
41
+ * Enable verbose logging for debugging.
42
+ * @default false
43
+ */
44
+ verbose?: boolean;
45
+ /**
46
+ * Hydration endpoint path for client bundles.
47
+ * @default '/__cloudwerk'
48
+ */
49
+ hydrationEndpoint?: string;
50
+ /**
51
+ * UI renderer to use.
52
+ * @default 'hono-jsx'
53
+ */
54
+ renderer?: 'hono-jsx' | 'react';
55
+ }
56
+ /**
57
+ * Resolved plugin options after applying defaults and loading config.
58
+ */
59
+ interface ResolvedCloudwerkOptions {
60
+ /** Directory containing app files (relative to root) */
61
+ appDir: string;
62
+ /** Subdirectory within appDir for routes */
63
+ routesDir: string;
64
+ /** Loaded Cloudwerk configuration */
65
+ config: CloudwerkConfig;
66
+ /** Path to user-provided server entry, or null for generated */
67
+ serverEntry: string | null;
68
+ /** Path to user-provided client entry, or null for generated */
69
+ clientEntry: string | null;
70
+ /** Whether verbose logging is enabled */
71
+ verbose: boolean;
72
+ /** Hydration endpoint path */
73
+ hydrationEndpoint: string;
74
+ /** UI renderer name */
75
+ renderer: 'hono-jsx' | 'react';
76
+ /** Vite root directory (absolute path) */
77
+ root: string;
78
+ }
79
+ /**
80
+ * Information about a detected client component.
81
+ */
82
+ interface ClientComponentInfo {
83
+ /** Component identifier for hydration */
84
+ componentId: string;
85
+ /** Path to the component's client bundle */
86
+ bundlePath: string;
87
+ /** Absolute file path */
88
+ absolutePath: string;
89
+ }
90
+ /**
91
+ * Virtual module IDs used by the plugin.
92
+ */
93
+ declare const VIRTUAL_MODULE_IDS: {
94
+ readonly SERVER_ENTRY: "virtual:cloudwerk/server-entry";
95
+ readonly CLIENT_ENTRY: "virtual:cloudwerk/client-entry";
96
+ readonly MANIFEST: "virtual:cloudwerk/manifest";
97
+ };
98
+ /**
99
+ * Resolved (internal) virtual module IDs with \0 prefix.
100
+ */
101
+ declare const RESOLVED_VIRTUAL_IDS: {
102
+ readonly SERVER_ENTRY: "\0virtual:cloudwerk/server-entry";
103
+ readonly CLIENT_ENTRY: "\0virtual:cloudwerk/client-entry";
104
+ readonly MANIFEST: "\0virtual:cloudwerk/manifest";
105
+ };
106
+
107
+ /**
108
+ * @cloudwerk/vite-plugin - Core Plugin
109
+ *
110
+ * Vite plugin that provides file-based routing for Cloudwerk with
111
+ * virtual module generation for server and client entry points.
112
+ */
113
+
114
+ /**
115
+ * Create the Cloudwerk Vite plugin.
116
+ *
117
+ * @param options - Plugin configuration options
118
+ * @returns Vite plugin
119
+ *
120
+ * @example
121
+ * ```typescript
122
+ * // vite.config.ts
123
+ * import { defineConfig } from 'vite'
124
+ * import devServer from '@hono/vite-dev-server'
125
+ * import cloudwerk from '@cloudwerk/vite-plugin'
126
+ *
127
+ * export default defineConfig({
128
+ * plugins: [
129
+ * cloudwerk(),
130
+ * devServer({ entry: 'virtual:cloudwerk/server-entry' }),
131
+ * ],
132
+ * })
133
+ * ```
134
+ */
135
+ declare function cloudwerkPlugin(options?: CloudwerkVitePluginOptions): Plugin;
136
+
137
+ /**
138
+ * Server Entry Virtual Module Generator
139
+ *
140
+ * Generates the virtual:cloudwerk/server-entry module that creates
141
+ * a Hono app with all routes registered from the file-based routing manifest.
142
+ */
143
+
144
+ /**
145
+ * Generate the server entry module code.
146
+ *
147
+ * This creates a complete Hono application with:
148
+ * - All page and API routes registered
149
+ * - Layouts applied to pages in correct order
150
+ * - Middleware chains applied
151
+ * - Route config support
152
+ * - Error and 404 handling
153
+ *
154
+ * @param manifest - Route manifest from @cloudwerk/core
155
+ * @param scanResult - Scan result with file information
156
+ * @param options - Resolved plugin options
157
+ * @returns Generated TypeScript/JavaScript code
158
+ */
159
+ declare function generateServerEntry(manifest: RouteManifest, scanResult: ScanResult, options: ResolvedCloudwerkOptions): string;
160
+
161
+ /**
162
+ * Client Entry Virtual Module Generator
163
+ *
164
+ * Generates the virtual:cloudwerk/client-entry module that handles
165
+ * client-side hydration of server-rendered components.
166
+ */
167
+
168
+ /**
169
+ * Generate the client entry module code.
170
+ *
171
+ * This creates a hydration bootstrap that:
172
+ * - Finds all elements with data-hydrate-id attributes
173
+ * - Dynamically imports the corresponding component bundles
174
+ * - Hydrates each component with its serialized props
175
+ *
176
+ * @param clientComponents - Map of detected client components
177
+ * @param options - Resolved plugin options
178
+ * @returns Generated JavaScript code
179
+ */
180
+ declare function generateClientEntry(clientComponents: Map<string, ClientComponentInfo>, options: ResolvedCloudwerkOptions): string;
181
+
182
+ export { type ClientComponentInfo, type CloudwerkVitePluginOptions, RESOLVED_VIRTUAL_IDS, type ResolvedCloudwerkOptions, VIRTUAL_MODULE_IDS, cloudwerkPlugin, cloudwerkPlugin as default, generateClientEntry, generateServerEntry };
package/dist/index.js ADDED
@@ -0,0 +1,903 @@
1
+ // src/plugin.ts
2
+ import * as path from "path";
3
+ import * as fs from "fs";
4
+ import {
5
+ scanRoutes,
6
+ buildRouteManifest,
7
+ resolveLayouts,
8
+ resolveMiddleware,
9
+ loadConfig,
10
+ resolveRoutesPath,
11
+ hasUseClientDirective,
12
+ generateComponentId,
13
+ ROUTE_FILE_NAMES
14
+ } from "@cloudwerk/core/build";
15
+
16
+ // src/types.ts
17
+ var VIRTUAL_MODULE_IDS = {
18
+ SERVER_ENTRY: "virtual:cloudwerk/server-entry",
19
+ CLIENT_ENTRY: "virtual:cloudwerk/client-entry",
20
+ MANIFEST: "virtual:cloudwerk/manifest"
21
+ };
22
+ var RESOLVED_VIRTUAL_IDS = {
23
+ SERVER_ENTRY: "\0virtual:cloudwerk/server-entry",
24
+ CLIENT_ENTRY: "\0virtual:cloudwerk/client-entry",
25
+ MANIFEST: "\0virtual:cloudwerk/manifest"
26
+ };
27
+
28
+ // src/virtual-modules/server-entry.ts
29
+ function generateServerEntry(manifest, scanResult, options) {
30
+ const imports = [];
31
+ const pageRegistrations = [];
32
+ const routeRegistrations = [];
33
+ const layoutImports = [];
34
+ const middlewareImports = [];
35
+ const importedModules = /* @__PURE__ */ new Set();
36
+ const layoutModules = /* @__PURE__ */ new Map();
37
+ const middlewareModules = /* @__PURE__ */ new Map();
38
+ let pageIndex = 0;
39
+ let routeIndex = 0;
40
+ let layoutIndex = 0;
41
+ let middlewareIndex = 0;
42
+ for (const route of manifest.routes) {
43
+ for (const middlewarePath of route.middleware) {
44
+ if (!importedModules.has(middlewarePath)) {
45
+ const varName = `middleware_${middlewareIndex++}`;
46
+ middlewareImports.push(`import ${varName} from '${middlewarePath}'`);
47
+ middlewareModules.set(middlewarePath, varName);
48
+ importedModules.add(middlewarePath);
49
+ }
50
+ }
51
+ if (route.fileType === "page") {
52
+ for (const layoutPath of route.layouts) {
53
+ if (!importedModules.has(layoutPath)) {
54
+ const varName = `layout_${layoutIndex++}`;
55
+ layoutImports.push(`import * as ${varName} from '${layoutPath}'`);
56
+ layoutModules.set(layoutPath, varName);
57
+ importedModules.add(layoutPath);
58
+ }
59
+ }
60
+ }
61
+ if (route.fileType === "page") {
62
+ const varName = `page_${pageIndex++}`;
63
+ imports.push(`import * as ${varName} from '${route.absolutePath}'`);
64
+ const layoutChain = route.layouts.map((p) => layoutModules.get(p)).join(", ");
65
+ const middlewareChain = route.middleware.map((p) => middlewareModules.get(p)).join(", ");
66
+ pageRegistrations.push(
67
+ ` registerPage(app, '${route.urlPattern}', ${varName}, [${layoutChain}], [${middlewareChain}])`
68
+ );
69
+ } else if (route.fileType === "route") {
70
+ const varName = `route_${routeIndex++}`;
71
+ imports.push(`import * as ${varName} from '${route.absolutePath}'`);
72
+ const middlewareChain = route.middleware.map((p) => middlewareModules.get(p)).join(", ");
73
+ routeRegistrations.push(
74
+ ` registerRoute(app, '${route.urlPattern}', ${varName}, [${middlewareChain}])`
75
+ );
76
+ }
77
+ }
78
+ const rendererName = options.renderer;
79
+ return `/**
80
+ * Generated Cloudwerk Server Entry
81
+ * This file is auto-generated by @cloudwerk/vite-plugin - do not edit
82
+ */
83
+
84
+ import { Hono } from 'hono'
85
+ import { contextMiddleware, createHandlerAdapter, setRouteConfig } from '@cloudwerk/core/runtime'
86
+ import { setActiveRenderer } from '@cloudwerk/ui'
87
+
88
+ // Page and Route Imports
89
+ ${imports.join("\n")}
90
+
91
+ // Layout Imports
92
+ ${layoutImports.join("\n")}
93
+
94
+ // Middleware Imports
95
+ ${middlewareImports.join("\n")}
96
+
97
+ // ============================================================================
98
+ // Route Registration Helpers
99
+ // ============================================================================
100
+
101
+ const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD']
102
+
103
+ function registerPage(app, pattern, pageModule, layoutModules, middlewareModules) {
104
+ // Apply middleware
105
+ for (const mw of middlewareModules) {
106
+ app.use(pattern, mw)
107
+ }
108
+
109
+ // Apply config middleware if present
110
+ if (pageModule.config) {
111
+ app.use(pattern, async (_c, next) => {
112
+ setRouteConfig(pageModule.config)
113
+ await next()
114
+ })
115
+ }
116
+
117
+ // Register GET handler for page
118
+ app.get(pattern, async (c) => {
119
+ const params = c.req.param()
120
+ const request = c.req.raw
121
+ const url = new URL(request.url)
122
+ const searchParams = Object.fromEntries(url.searchParams.entries())
123
+
124
+ // Execute layout loaders
125
+ const layoutLoaderData = []
126
+ const loaderArgs = { params, request, context: c }
127
+
128
+ for (const layoutModule of layoutModules) {
129
+ if (layoutModule.loader) {
130
+ const data = await Promise.resolve(layoutModule.loader(loaderArgs))
131
+ layoutLoaderData.push(data ?? {})
132
+ } else {
133
+ layoutLoaderData.push({})
134
+ }
135
+ }
136
+
137
+ // Execute page loader
138
+ let pageLoaderData = {}
139
+ if (pageModule.loader) {
140
+ pageLoaderData = (await Promise.resolve(pageModule.loader(loaderArgs))) ?? {}
141
+ }
142
+
143
+ // Build page props
144
+ const pageProps = { params, searchParams, ...pageLoaderData }
145
+
146
+ // Render page
147
+ let element = await Promise.resolve(pageModule.default(pageProps))
148
+
149
+ // Wrap with layouts (inside-out)
150
+ for (let i = layoutModules.length - 1; i >= 0; i--) {
151
+ const Layout = layoutModules[i].default
152
+ const layoutProps = {
153
+ children: element,
154
+ params,
155
+ ...layoutLoaderData[i],
156
+ }
157
+ element = await Promise.resolve(Layout(layoutProps))
158
+ }
159
+
160
+ // Render the page with hydration script injection
161
+ return renderWithHydration(element)
162
+ })
163
+ }
164
+
165
+ /**
166
+ * Render element to a Response, injecting hydration script before </body>.
167
+ */
168
+ function renderWithHydration(element) {
169
+ // Hono JSX elements have toString() for synchronous rendering
170
+ const html = '<!DOCTYPE html>' + String(element)
171
+
172
+ // Inject hydration script before </body> if it exists (case-insensitive for HTML compat)
173
+ const hydrationScript = '<script type="module" src="/@id/__x00__virtual:cloudwerk/client-entry"></script>'
174
+ const bodyCloseRegex = /<\\/body>/i
175
+ const injectedHtml = bodyCloseRegex.test(html)
176
+ ? html.replace(bodyCloseRegex, hydrationScript + '</body>')
177
+ : html + hydrationScript
178
+
179
+ return new Response(injectedHtml, {
180
+ headers: {
181
+ 'Content-Type': 'text/html; charset=utf-8',
182
+ },
183
+ })
184
+ }
185
+
186
+ function registerRoute(app, pattern, routeModule, middlewareModules) {
187
+ // Apply middleware
188
+ for (const mw of middlewareModules) {
189
+ app.use(pattern, mw)
190
+ }
191
+
192
+ // Apply config middleware if present
193
+ if (routeModule.config) {
194
+ app.use(pattern, async (_c, next) => {
195
+ setRouteConfig(routeModule.config)
196
+ await next()
197
+ })
198
+ }
199
+
200
+ // Register each HTTP method handler
201
+ for (const method of HTTP_METHODS) {
202
+ const handler = routeModule[method]
203
+ if (handler && typeof handler === 'function') {
204
+ const h = handler.length === 2 ? createHandlerAdapter(handler) : handler
205
+ switch (method) {
206
+ case 'GET': app.get(pattern, h); break
207
+ case 'POST': app.post(pattern, h); break
208
+ case 'PUT': app.put(pattern, h); break
209
+ case 'PATCH': app.patch(pattern, h); break
210
+ case 'DELETE': app.delete(pattern, h); break
211
+ case 'OPTIONS': app.options(pattern, h); break
212
+ case 'HEAD': app.on('HEAD', [pattern], h); break
213
+ }
214
+ }
215
+ }
216
+ }
217
+
218
+ // ============================================================================
219
+ // App Initialization
220
+ // ============================================================================
221
+
222
+ // Initialize renderer
223
+ setActiveRenderer('${rendererName}')
224
+
225
+ // Create Hono app
226
+ const app = new Hono({ strict: false })
227
+
228
+ // Add context middleware
229
+ app.use('*', contextMiddleware())
230
+
231
+ // Register all routes
232
+ ${pageRegistrations.join("\n")}
233
+ ${routeRegistrations.join("\n")}
234
+
235
+ // 404 handler
236
+ app.notFound((c) => {
237
+ return c.json({ error: 'Not Found', path: c.req.path }, 404)
238
+ })
239
+
240
+ // Error handler
241
+ app.onError((err, c) => {
242
+ console.error('Request error:', err.message)
243
+ return c.json({ error: 'Internal Server Error', message: err.message }, 500)
244
+ })
245
+
246
+ // ============================================================================
247
+ // Export
248
+ // ============================================================================
249
+
250
+ export default app
251
+ `;
252
+ }
253
+
254
+ // src/virtual-modules/client-entry.ts
255
+ function generateClientEntry(clientComponents, options) {
256
+ const { renderer, hydrationEndpoint } = options;
257
+ if (renderer === "react") {
258
+ return generateReactClientEntry(clientComponents, hydrationEndpoint);
259
+ }
260
+ return generateHonoClientEntry(clientComponents, hydrationEndpoint);
261
+ }
262
+ function generateHonoClientEntry(clientComponents, _hydrationEndpoint) {
263
+ const bundleMap = Object.fromEntries(
264
+ Array.from(clientComponents.values()).map((info) => [info.componentId, `/@fs${info.absolutePath}`])
265
+ );
266
+ return `/**
267
+ * Generated Cloudwerk Client Entry (Hono JSX)
268
+ * This file is auto-generated by @cloudwerk/vite-plugin - do not edit
269
+ */
270
+
271
+ import { render } from 'hono/jsx/dom'
272
+ import { jsx } from 'hono/jsx/jsx-runtime'
273
+
274
+ // Bundle map for component lookups
275
+ const bundles = ${JSON.stringify(bundleMap, null, 2)}
276
+
277
+ // Module cache to avoid re-importing
278
+ const moduleCache = new Map()
279
+
280
+ /**
281
+ * Load a component module, using cache for repeated imports.
282
+ */
283
+ async function loadComponent(bundlePath) {
284
+ if (moduleCache.has(bundlePath)) {
285
+ return moduleCache.get(bundlePath)
286
+ }
287
+ const module = await import(/* @vite-ignore */ bundlePath)
288
+ moduleCache.set(bundlePath, module)
289
+ return module
290
+ }
291
+
292
+ /**
293
+ * Hydrate all marked elements on the page.
294
+ */
295
+ async function hydrate() {
296
+ const elements = document.querySelectorAll('[data-hydrate-id]')
297
+ if (elements.length === 0) {
298
+ console.debug('[Cloudwerk] No client components to hydrate')
299
+ return
300
+ }
301
+
302
+ console.debug('[Cloudwerk] Hydrating', elements.length, 'client components')
303
+
304
+ for (const el of elements) {
305
+ const componentId = el.getAttribute('data-hydrate-id')
306
+ const propsJson = el.getAttribute('data-hydrate-props')
307
+
308
+ if (!componentId) {
309
+ console.warn('[Cloudwerk] Element missing data-hydrate-id')
310
+ continue
311
+ }
312
+
313
+ const bundlePath = bundles[componentId]
314
+ if (!bundlePath) {
315
+ console.warn('[Cloudwerk] Unknown client component:', componentId)
316
+ continue
317
+ }
318
+
319
+ try {
320
+ const props = propsJson ? JSON.parse(propsJson) : {}
321
+ const module = await loadComponent(bundlePath)
322
+ const Component = module.default
323
+
324
+ if (!Component) {
325
+ console.error('[Cloudwerk] No default export in component:', componentId)
326
+ continue
327
+ }
328
+
329
+ // Hydrate the component using jsx() to create a proper element
330
+ // This allows Hono's reactive system to manage re-renders
331
+ render(jsx(Component, props), el)
332
+
333
+ // Clean up hydration attributes
334
+ el.removeAttribute('data-hydrate-id')
335
+ el.removeAttribute('data-hydrate-props')
336
+
337
+ console.debug('[Cloudwerk] Hydrated:', componentId)
338
+ } catch (error) {
339
+ console.error('[Cloudwerk] Failed to hydrate component:', componentId, error)
340
+ }
341
+ }
342
+ }
343
+
344
+ // Run hydration when DOM is ready
345
+ if (document.readyState === 'loading') {
346
+ document.addEventListener('DOMContentLoaded', hydrate)
347
+ } else {
348
+ hydrate()
349
+ }
350
+
351
+ // Export for manual hydration if needed
352
+ export { hydrate }
353
+ `;
354
+ }
355
+ function generateReactClientEntry(clientComponents, _hydrationEndpoint) {
356
+ const bundleMap = Object.fromEntries(
357
+ Array.from(clientComponents.values()).map((info) => [info.componentId, `/@fs${info.absolutePath}`])
358
+ );
359
+ return `/**
360
+ * Generated Cloudwerk Client Entry (React)
361
+ * This file is auto-generated by @cloudwerk/vite-plugin - do not edit
362
+ */
363
+
364
+ import { hydrateRoot, createRoot } from 'react-dom/client'
365
+ import { createElement } from 'react'
366
+
367
+ // Bundle map for component lookups
368
+ const bundles = ${JSON.stringify(bundleMap, null, 2)}
369
+
370
+ // Module cache to avoid re-importing
371
+ const moduleCache = new Map()
372
+
373
+ // Root cache for React 18 concurrent mode
374
+ const rootCache = new Map()
375
+
376
+ /**
377
+ * Load a component module, using cache for repeated imports.
378
+ */
379
+ async function loadComponent(bundlePath) {
380
+ if (moduleCache.has(bundlePath)) {
381
+ return moduleCache.get(bundlePath)
382
+ }
383
+ const module = await import(/* @vite-ignore */ bundlePath)
384
+ moduleCache.set(bundlePath, module)
385
+ return module
386
+ }
387
+
388
+ /**
389
+ * Hydrate all marked elements on the page.
390
+ */
391
+ async function hydrate() {
392
+ const elements = document.querySelectorAll('[data-hydrate-id]')
393
+ if (elements.length === 0) {
394
+ console.debug('[Cloudwerk] No client components to hydrate')
395
+ return
396
+ }
397
+
398
+ console.debug('[Cloudwerk] Hydrating', elements.length, 'client components')
399
+
400
+ for (const el of elements) {
401
+ const componentId = el.getAttribute('data-hydrate-id')
402
+ const propsJson = el.getAttribute('data-hydrate-props')
403
+
404
+ if (!componentId) {
405
+ console.warn('[Cloudwerk] Element missing data-hydrate-id')
406
+ continue
407
+ }
408
+
409
+ const bundlePath = bundles[componentId]
410
+ if (!bundlePath) {
411
+ console.warn('[Cloudwerk] Unknown client component:', componentId)
412
+ continue
413
+ }
414
+
415
+ try {
416
+ const props = propsJson ? JSON.parse(propsJson) : {}
417
+ const module = await loadComponent(bundlePath)
418
+ const Component = module.default
419
+
420
+ if (!Component) {
421
+ console.error('[Cloudwerk] No default export in component:', componentId)
422
+ continue
423
+ }
424
+
425
+ // Hydrate the component using React 18 hydrateRoot
426
+ const root = hydrateRoot(el, createElement(Component, props))
427
+ rootCache.set(el, root)
428
+
429
+ // Clean up hydration attributes
430
+ el.removeAttribute('data-hydrate-id')
431
+ el.removeAttribute('data-hydrate-props')
432
+
433
+ console.debug('[Cloudwerk] Hydrated:', componentId)
434
+ } catch (error) {
435
+ console.error('[Cloudwerk] Failed to hydrate component:', componentId, error)
436
+ }
437
+ }
438
+ }
439
+
440
+ // Run hydration when DOM is ready
441
+ if (document.readyState === 'loading') {
442
+ document.addEventListener('DOMContentLoaded', hydrate)
443
+ } else {
444
+ hydrate()
445
+ }
446
+
447
+ // Export for manual hydration if needed
448
+ export { hydrate }
449
+ `;
450
+ }
451
+
452
+ // src/transform-client-component.ts
453
+ import { parseSync } from "@swc/core";
454
+ function findDefaultExport(ast) {
455
+ for (let i = 0; i < ast.body.length; i++) {
456
+ const node = ast.body[i];
457
+ if (node.type === "ExportDefaultDeclaration") {
458
+ const decl = node;
459
+ if (decl.decl.type === "FunctionExpression") {
460
+ return {
461
+ type: "function",
462
+ name: decl.decl.identifier?.value ?? null,
463
+ index: i,
464
+ isAsync: decl.decl.async
465
+ };
466
+ }
467
+ if (decl.decl.type === "ClassExpression") {
468
+ return {
469
+ type: "class",
470
+ name: decl.decl.identifier?.value ?? null,
471
+ index: i
472
+ };
473
+ }
474
+ }
475
+ if (node.type === "ExportDefaultExpression") {
476
+ const expr = node;
477
+ if (expr.expression.type === "Identifier") {
478
+ return {
479
+ type: "identifier",
480
+ name: expr.expression.value,
481
+ index: i
482
+ };
483
+ }
484
+ if (expr.expression.type === "ArrowFunctionExpression") {
485
+ return {
486
+ type: "arrow",
487
+ name: null,
488
+ index: i,
489
+ isAsync: expr.expression.async
490
+ };
491
+ }
492
+ if (expr.expression.type === "FunctionExpression") {
493
+ return {
494
+ type: "function",
495
+ name: expr.expression.identifier?.value ?? null,
496
+ index: i,
497
+ isAsync: expr.expression.async
498
+ };
499
+ }
500
+ if (expr.expression.type === "ClassExpression") {
501
+ return {
502
+ type: "class",
503
+ name: expr.expression.identifier?.value ?? null,
504
+ index: i
505
+ };
506
+ }
507
+ }
508
+ if (node.type === "ExportNamedDeclaration") {
509
+ const specifiers = node.specifiers;
510
+ for (const spec of specifiers) {
511
+ if (spec.type === "ExportSpecifier") {
512
+ const exported = spec.exported;
513
+ if (exported && exported.type === "Identifier" && exported.value === "default") {
514
+ if (spec.orig.type === "Identifier") {
515
+ return {
516
+ type: "named-export",
517
+ name: spec.orig.value,
518
+ index: i
519
+ };
520
+ }
521
+ }
522
+ }
523
+ }
524
+ }
525
+ }
526
+ return { type: null, name: null, index: -1 };
527
+ }
528
+ function transformClientComponent(code, options) {
529
+ const { componentId, bundlePath } = options;
530
+ try {
531
+ const ast = parseSync(code, {
532
+ syntax: "typescript",
533
+ tsx: true,
534
+ comments: true
535
+ });
536
+ const exportInfo = findDefaultExport(ast);
537
+ if (exportInfo.type === null) {
538
+ return {
539
+ code,
540
+ success: false,
541
+ error: `Could not find default export in client component: ${componentId}`
542
+ };
543
+ }
544
+ let transformed = code.replace(/['"]use client['"]\s*;?\s*\n?/g, "");
545
+ const wrapperImport = `import { createClientComponentWrapper as __createWrapper } from '@cloudwerk/ui/client'
546
+ `;
547
+ const metaObj = JSON.stringify({ componentId, bundlePath });
548
+ switch (exportInfo.type) {
549
+ case "function": {
550
+ if (exportInfo.name) {
551
+ const asyncPrefix = exportInfo.isAsync ? "async " : "";
552
+ transformed = transformed.replace(
553
+ new RegExp(`export\\s+default\\s+${asyncPrefix}function\\s+${exportInfo.name}`),
554
+ `${asyncPrefix}function ${exportInfo.name}`
555
+ );
556
+ transformed = wrapperImport + transformed;
557
+ transformed += `
558
+ const __WrappedComponent = __createWrapper(${exportInfo.name}, ${metaObj})
559
+ export default __WrappedComponent
560
+ `;
561
+ } else {
562
+ const asyncPrefix = exportInfo.isAsync ? "async " : "";
563
+ transformed = transformed.replace(
564
+ new RegExp(`export\\s+default\\s+${asyncPrefix}function\\s*\\(`),
565
+ `const __OriginalComponent = ${asyncPrefix}function(`
566
+ );
567
+ transformed = wrapperImport + transformed;
568
+ transformed += `
569
+ const __WrappedComponent = __createWrapper(__OriginalComponent, ${metaObj})
570
+ export default __WrappedComponent
571
+ `;
572
+ }
573
+ break;
574
+ }
575
+ case "arrow": {
576
+ transformed = transformed.replace(
577
+ /export\s+default/,
578
+ "const __OriginalComponent ="
579
+ );
580
+ transformed = wrapperImport + transformed;
581
+ transformed += `
582
+ const __WrappedComponent = __createWrapper(__OriginalComponent, ${metaObj})
583
+ export default __WrappedComponent
584
+ `;
585
+ break;
586
+ }
587
+ case "class": {
588
+ if (exportInfo.name) {
589
+ transformed = transformed.replace(
590
+ new RegExp(`export\\s+default\\s+class\\s+${exportInfo.name}`),
591
+ `class ${exportInfo.name}`
592
+ );
593
+ transformed = wrapperImport + transformed;
594
+ transformed += `
595
+ const __WrappedComponent = __createWrapper(${exportInfo.name}, ${metaObj})
596
+ export default __WrappedComponent
597
+ `;
598
+ } else {
599
+ transformed = transformed.replace(
600
+ /export\s+default\s+class\s*\{/,
601
+ "const __OriginalComponent = class {"
602
+ );
603
+ transformed = wrapperImport + transformed;
604
+ transformed += `
605
+ const __WrappedComponent = __createWrapper(__OriginalComponent, ${metaObj})
606
+ export default __WrappedComponent
607
+ `;
608
+ }
609
+ break;
610
+ }
611
+ case "identifier": {
612
+ transformed = transformed.replace(
613
+ /export\s+default\s+\w+\s*;?\s*$/m,
614
+ ""
615
+ );
616
+ transformed = wrapperImport + transformed;
617
+ transformed += `
618
+ const __WrappedComponent = __createWrapper(${exportInfo.name}, ${metaObj})
619
+ export default __WrappedComponent
620
+ `;
621
+ break;
622
+ }
623
+ case "named-export": {
624
+ transformed = transformed.replace(
625
+ /export\s*\{\s*\w+\s+as\s+default\s*\}\s*;?/,
626
+ ""
627
+ );
628
+ transformed = wrapperImport + transformed;
629
+ transformed += `
630
+ const __WrappedComponent = __createWrapper(${exportInfo.name}, ${metaObj})
631
+ export default __WrappedComponent
632
+ `;
633
+ break;
634
+ }
635
+ }
636
+ return {
637
+ code: transformed,
638
+ success: true
639
+ };
640
+ } catch (error) {
641
+ const message = error instanceof Error ? error.message : String(error);
642
+ return {
643
+ code,
644
+ success: false,
645
+ error: `Failed to parse client component ${componentId}: ${message}`
646
+ };
647
+ }
648
+ }
649
+
650
+ // src/plugin.ts
651
+ function cloudwerkPlugin(options = {}) {
652
+ let state = null;
653
+ let server = null;
654
+ async function buildManifest(root) {
655
+ if (!state) {
656
+ throw new Error("Plugin state not initialized");
657
+ }
658
+ const routesPath = resolveRoutesPath(
659
+ state.options.routesDir,
660
+ state.options.appDir,
661
+ root
662
+ );
663
+ state.scanResult = await scanRoutes(routesPath, {
664
+ extensions: state.options.config.extensions
665
+ });
666
+ state.manifest = buildRouteManifest(
667
+ state.scanResult,
668
+ routesPath,
669
+ resolveLayouts,
670
+ resolveMiddleware
671
+ );
672
+ state.serverEntryCache = null;
673
+ state.clientEntryCache = null;
674
+ if (state.options.verbose) {
675
+ console.log(`[cloudwerk] Found ${state.manifest.routes.length} routes`);
676
+ }
677
+ }
678
+ function isRouteFile(filePath) {
679
+ if (!state) return false;
680
+ const appDir = path.resolve(state.options.root, state.options.appDir);
681
+ if (!filePath.startsWith(appDir)) return false;
682
+ const basename2 = path.basename(filePath);
683
+ const nameWithoutExt = basename2.replace(/\.(ts|tsx|js|jsx)$/, "");
684
+ return ROUTE_FILE_NAMES.includes(nameWithoutExt);
685
+ }
686
+ function invalidateVirtualModules() {
687
+ if (!server) return;
688
+ const idsToInvalidate = [
689
+ RESOLVED_VIRTUAL_IDS.SERVER_ENTRY,
690
+ RESOLVED_VIRTUAL_IDS.CLIENT_ENTRY,
691
+ RESOLVED_VIRTUAL_IDS.MANIFEST
692
+ ];
693
+ for (const id of idsToInvalidate) {
694
+ const mod = server.moduleGraph.getModuleById(id);
695
+ if (mod) {
696
+ server.moduleGraph.invalidateModule(mod);
697
+ }
698
+ }
699
+ server.ws.send({ type: "full-reload", path: "*" });
700
+ }
701
+ return {
702
+ name: "cloudwerk",
703
+ /**
704
+ * Resolve configuration and build initial manifest.
705
+ */
706
+ async configResolved(config) {
707
+ const root = config.root;
708
+ let detectedServerEntry = options.serverEntry ? path.resolve(root, options.serverEntry) : null;
709
+ if (!detectedServerEntry) {
710
+ const conventionalPaths = [
711
+ path.resolve(root, "app/server.ts"),
712
+ path.resolve(root, "app/server.tsx"),
713
+ path.resolve(root, "src/server.ts"),
714
+ path.resolve(root, "src/server.tsx")
715
+ ];
716
+ for (const p of conventionalPaths) {
717
+ if (fs.existsSync(p)) {
718
+ detectedServerEntry = p;
719
+ if (options.verbose) {
720
+ console.log(`[cloudwerk] Detected custom server entry: ${p}`);
721
+ }
722
+ break;
723
+ }
724
+ }
725
+ }
726
+ const cloudwerkConfig = await loadConfig(root);
727
+ const resolvedOptions = {
728
+ appDir: options.appDir ?? cloudwerkConfig.appDir,
729
+ routesDir: options.routesDir ?? cloudwerkConfig.routesDir ?? "routes",
730
+ config: { ...cloudwerkConfig, ...options.config },
731
+ serverEntry: detectedServerEntry,
732
+ clientEntry: options.clientEntry ?? null,
733
+ verbose: options.verbose ?? false,
734
+ hydrationEndpoint: options.hydrationEndpoint ?? "/__cloudwerk",
735
+ renderer: options.renderer ?? cloudwerkConfig.ui?.renderer ?? "hono-jsx",
736
+ root
737
+ };
738
+ state = {
739
+ options: resolvedOptions,
740
+ manifest: {
741
+ routes: [],
742
+ layouts: /* @__PURE__ */ new Map(),
743
+ middleware: /* @__PURE__ */ new Map(),
744
+ errors: [],
745
+ warnings: [],
746
+ generatedAt: /* @__PURE__ */ new Date(),
747
+ rootDir: ""
748
+ },
749
+ scanResult: {
750
+ routes: [],
751
+ layouts: [],
752
+ middleware: [],
753
+ loading: [],
754
+ errors: [],
755
+ notFound: []
756
+ },
757
+ clientComponents: /* @__PURE__ */ new Map(),
758
+ serverEntryCache: null,
759
+ clientEntryCache: null
760
+ };
761
+ await buildManifest(root);
762
+ },
763
+ /**
764
+ * Configure the dev server with file watching.
765
+ */
766
+ configureServer(devServer) {
767
+ server = devServer;
768
+ if (!state) return;
769
+ const appDir = path.resolve(state.options.root, state.options.appDir);
770
+ devServer.watcher.on("add", async (filePath) => {
771
+ if (isRouteFile(filePath)) {
772
+ if (state?.options.verbose) {
773
+ console.log(`[cloudwerk] Route added: ${path.relative(appDir, filePath)}`);
774
+ }
775
+ await buildManifest(state.options.root);
776
+ invalidateVirtualModules();
777
+ }
778
+ });
779
+ devServer.watcher.on("unlink", async (filePath) => {
780
+ if (isRouteFile(filePath)) {
781
+ if (state?.options.verbose) {
782
+ console.log(`[cloudwerk] Route removed: ${path.relative(appDir, filePath)}`);
783
+ }
784
+ await buildManifest(state.options.root);
785
+ invalidateVirtualModules();
786
+ }
787
+ });
788
+ devServer.watcher.on("change", async (filePath) => {
789
+ if (isRouteFile(filePath)) {
790
+ if (state?.options.verbose) {
791
+ console.log(`[cloudwerk] Route changed: ${path.relative(appDir, filePath)}`);
792
+ }
793
+ await buildManifest(state.options.root);
794
+ invalidateVirtualModules();
795
+ }
796
+ });
797
+ },
798
+ /**
799
+ * Resolve virtual module IDs.
800
+ */
801
+ resolveId(id) {
802
+ if (id === VIRTUAL_MODULE_IDS.SERVER_ENTRY) {
803
+ return RESOLVED_VIRTUAL_IDS.SERVER_ENTRY;
804
+ }
805
+ if (id === VIRTUAL_MODULE_IDS.CLIENT_ENTRY) {
806
+ return RESOLVED_VIRTUAL_IDS.CLIENT_ENTRY;
807
+ }
808
+ if (id === VIRTUAL_MODULE_IDS.MANIFEST) {
809
+ return RESOLVED_VIRTUAL_IDS.MANIFEST;
810
+ }
811
+ return null;
812
+ },
813
+ /**
814
+ * Load virtual module content.
815
+ */
816
+ load(id) {
817
+ if (!state) return null;
818
+ if (id === RESOLVED_VIRTUAL_IDS.SERVER_ENTRY) {
819
+ if (state.options.serverEntry) {
820
+ return `export { default } from '${state.options.serverEntry}'`;
821
+ }
822
+ if (!state.serverEntryCache) {
823
+ state.serverEntryCache = generateServerEntry(
824
+ state.manifest,
825
+ state.scanResult,
826
+ state.options
827
+ );
828
+ }
829
+ return state.serverEntryCache;
830
+ }
831
+ if (id === RESOLVED_VIRTUAL_IDS.CLIENT_ENTRY) {
832
+ if (state.options.clientEntry) {
833
+ return `export * from '${state.options.clientEntry}'`;
834
+ }
835
+ if (!state.clientEntryCache) {
836
+ state.clientEntryCache = generateClientEntry(
837
+ state.clientComponents,
838
+ state.options
839
+ );
840
+ }
841
+ return state.clientEntryCache;
842
+ }
843
+ if (id === RESOLVED_VIRTUAL_IDS.MANIFEST) {
844
+ return `export default ${JSON.stringify(
845
+ {
846
+ routes: state.manifest.routes.map((r) => ({
847
+ urlPattern: r.urlPattern,
848
+ filePath: r.filePath,
849
+ fileType: r.fileType
850
+ })),
851
+ generatedAt: state.manifest.generatedAt.toISOString()
852
+ },
853
+ null,
854
+ 2
855
+ )}`;
856
+ }
857
+ return null;
858
+ },
859
+ /**
860
+ * Transform hook to detect and wrap client components.
861
+ */
862
+ transform(code, id) {
863
+ if (!state) return null;
864
+ if (id.includes("node_modules")) return null;
865
+ if (!id.endsWith(".tsx") && !id.endsWith(".ts")) return null;
866
+ if (hasUseClientDirective(code)) {
867
+ const componentId = generateComponentId(id, state.options.root);
868
+ const bundlePath = `${state.options.hydrationEndpoint}/${componentId}.js`;
869
+ const info = {
870
+ componentId,
871
+ bundlePath,
872
+ absolutePath: id
873
+ };
874
+ state.clientComponents.set(id, info);
875
+ state.clientEntryCache = null;
876
+ if (state.options.verbose) {
877
+ console.log(`[cloudwerk] Detected client component: ${componentId}`);
878
+ }
879
+ const result = transformClientComponent(code, {
880
+ componentId,
881
+ bundlePath: id
882
+ // Use file path for Vite to resolve in dev mode
883
+ });
884
+ if (!result.success && state.options.verbose) {
885
+ console.warn(`[cloudwerk] ${result.error}`);
886
+ }
887
+ return {
888
+ code: result.code,
889
+ map: null
890
+ };
891
+ }
892
+ return null;
893
+ }
894
+ };
895
+ }
896
+ export {
897
+ RESOLVED_VIRTUAL_IDS,
898
+ VIRTUAL_MODULE_IDS,
899
+ cloudwerkPlugin,
900
+ cloudwerkPlugin as default,
901
+ generateClientEntry,
902
+ generateServerEntry
903
+ };
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@cloudwerk/vite-plugin",
3
+ "version": "0.1.1",
4
+ "description": "Vite plugin for Cloudwerk file-based routing with virtual entry generation",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/squirrelsoft-dev/cloudwerk.git",
8
+ "directory": "packages/vite-plugin"
9
+ },
10
+ "type": "module",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "dependencies": {
21
+ "@swc/core": "^1.3.100",
22
+ "@cloudwerk/core": "^0.7.0",
23
+ "@cloudwerk/ui": "^0.7.0"
24
+ },
25
+ "peerDependencies": {
26
+ "vite": "^5.0.0 || ^6.0.0",
27
+ "@hono/vite-dev-server": ">=0.18.0",
28
+ "hono": "^4.0.0"
29
+ },
30
+ "peerDependenciesMeta": {
31
+ "@hono/vite-dev-server": {
32
+ "optional": true
33
+ }
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^20.0.0",
37
+ "tsup": "^8.0.0",
38
+ "typescript": "^5.0.0",
39
+ "vite": "^6.0.0",
40
+ "vitest": "^1.0.0"
41
+ },
42
+ "scripts": {
43
+ "build": "tsup src/index.ts --format esm --dts",
44
+ "dev": "tsup src/index.ts --format esm --dts --watch",
45
+ "test": "vitest --run --passWithNoTests",
46
+ "test:watch": "vitest"
47
+ }
48
+ }