@voltx/server 0.4.6 → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  <p align="center">
2
2
  <strong>@voltx/server</strong><br/>
3
- <em>Hono-based HTTP server with file-based routing, SSR, and SSE streaming</em>
3
+ <em>Hono-based HTTP server with file-based routing, React SSR, and Vite plugins</em>
4
4
  </p>
5
5
 
6
6
  <p align="center">
@@ -11,7 +11,7 @@
11
11
 
12
12
  ---
13
13
 
14
- The HTTP layer of the [VoltX](https://github.com/codewithshail/voltx) framework. Built on [Hono](https://hono.dev) with file-based routing, React SSR (streaming), CORS, logging, error handling, and static file serving.
14
+ The HTTP layer of the [VoltX](https://github.com/codewithshail/voltx) framework. Built on [Hono](https://hono.dev) with file-based page routing, file-based API routing, React SSR (streaming), CORS, logging, error handling, and static file serving.
15
15
 
16
16
  ## Installation
17
17
 
@@ -22,24 +22,125 @@ npm install @voltx/server
22
22
  ## Quick Start
23
23
 
24
24
  ```ts
25
+ // server.ts
25
26
  import { Hono } from "hono";
26
27
  import { registerSSR } from "@voltx/server";
28
+ import { registerRoutes } from "voltx/api";
27
29
 
28
30
  const app = new Hono();
29
31
 
30
- // API routes
31
- app.get("/api", (c) => c.json({ status: "ok" }));
32
+ // Auto-discover and mount all API routes from api/ directory
33
+ registerRoutes(app);
32
34
 
33
35
  // SSR — renders React on the server, hydrates on the client
34
36
  registerSSR(app, null, {
35
37
  title: "My App",
36
38
  entryServer: "src/entry-server.tsx",
37
39
  entryClient: "src/entry-client.tsx",
40
+ css: "src/globals.css",
38
41
  });
39
42
 
40
43
  export default app;
41
44
  ```
42
45
 
46
+ ```ts
47
+ // vite.config.ts
48
+ import { defineConfig } from "vite";
49
+ import devServer from "@hono/vite-dev-server";
50
+ import react from "@vitejs/plugin-react";
51
+ import tailwindcss from "@tailwindcss/vite";
52
+ import { voltxRouter, voltxAPI } from "@voltx/server";
53
+
54
+ export default defineConfig({
55
+ plugins: [
56
+ react(),
57
+ tailwindcss(),
58
+ voltxRouter(), // File-based page routing → voltx/router
59
+ voltxAPI(), // File-based API routing → voltx/api
60
+ devServer({ entry: "server.ts" }),
61
+ ],
62
+ });
63
+ ```
64
+
65
+ ## Vite Plugins
66
+
67
+ ### `voltxRouter()` — File-Based Page Routing
68
+
69
+ Auto-discovers React components in `src/pages/` and provides the `voltx/router` virtual module.
70
+
71
+ ```
72
+ src/pages/index.tsx → /
73
+ src/pages/about.tsx → /about
74
+ src/pages/blog/index.tsx → /blog
75
+ src/pages/blog/[slug].tsx → /blog/:slug (dynamic route)
76
+ ```
77
+
78
+ ```tsx
79
+ // Use in entry-client.tsx and entry-server.tsx
80
+ import { VoltxRoutes, Link, useNavigate, useParams } from "voltx/router";
81
+
82
+ // VoltxRoutes renders the matched page component
83
+ // Link, NavLink, useNavigate, useParams, useLocation, useSearchParams
84
+ // are re-exported from react-router — no separate install needed
85
+ ```
86
+
87
+ #### Options
88
+
89
+ ```ts
90
+ voltxRouter({
91
+ pagesDir: "src/pages", // default: "src/pages"
92
+ })
93
+ ```
94
+
95
+ ### `voltxAPI()` — File-Based API Routing
96
+
97
+ Auto-discovers API route files in `api/` and provides the `voltx/api` virtual module.
98
+
99
+ ```
100
+ api/index.ts → /api
101
+ api/users.ts → /api/users
102
+ api/users/[id].ts → /api/users/:id
103
+ api/[...slug].ts → /api/* (catch-all)
104
+ ```
105
+
106
+ Each file exports named HTTP method handlers:
107
+
108
+ ```ts
109
+ // api/users.ts
110
+ import type { Context } from "@voltx/server";
111
+
112
+ export function GET(c: Context) {
113
+ return c.json([{ id: 1, name: "Alice" }]);
114
+ }
115
+
116
+ export async function POST(c: Context) {
117
+ const body = await c.req.json();
118
+ return c.json({ created: true }, 201);
119
+ }
120
+ ```
121
+
122
+ Supported methods: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`.
123
+
124
+ Routes are sorted automatically: static first, dynamic (`:param`) second, catch-all (`*`) last. HMR support means new files are picked up instantly in dev mode.
125
+
126
+ #### Options
127
+
128
+ ```ts
129
+ voltxAPI({
130
+ apiDir: "api", // default: "api"
131
+ })
132
+ ```
133
+
134
+ #### Usage in server.ts
135
+
136
+ ```ts
137
+ import { registerRoutes } from "voltx/api";
138
+
139
+ // Mounts all discovered API handlers on the Hono app
140
+ const registered = registerRoutes(app);
141
+ // → [{ method: "GET", path: "/api" }, { method: "POST", path: "/api/users" }, ...]
142
+ ```
143
+
43
144
  ## Server-Side Rendering
44
145
 
45
146
  `registerSSR()` provides streaming React SSR with zero config:
@@ -47,6 +148,7 @@ export default app;
47
148
  - **Dev mode** — works with `@hono/vite-dev-server` for HMR
48
149
  - **Production** — reads the Vite client manifest for hashed asset paths, serves pre-built SSR bundle
49
150
  - **Streaming** — uses `renderToReadableStream` for fast TTFB
151
+ - **CSS injection** — injects your global CSS in dev mode to prevent FOUC
50
152
  - **Public env** — injects `window.__VOLTX_ENV__` for `VITE_*` variables
51
153
 
52
154
  ```ts
@@ -56,6 +158,7 @@ registerSSR(app, viteInstance, {
56
158
  title: "My App",
57
159
  entryServer: "src/entry-server.tsx",
58
160
  entryClient: "src/entry-client.tsx",
161
+ css: "src/globals.css",
59
162
  });
60
163
  ```
61
164
 
@@ -64,38 +167,16 @@ registerSSR(app, viteInstance, {
64
167
  | `title` | `string` | HTML `<title>` |
65
168
  | `entryServer` | `string` | Path to SSR entry (exports `render()`) |
66
169
  | `entryClient` | `string` | Path to client hydration entry |
67
-
68
- ## File-Based Routing
69
-
70
- Drop files in `api/` and they become API endpoints:
71
-
72
- ```
73
- api/index.ts → GET /api
74
- api/chat.ts → POST /api/chat
75
- api/users/[id].ts → GET /api/users/:id
76
- api/rag/query.ts → POST /api/rag/query
77
- ```
78
-
79
- Each file exports HTTP method handlers:
80
-
81
- ```ts
82
- import type { Context } from "@voltx/server";
83
-
84
- export async function POST(c: Context) {
85
- const body = await c.req.json();
86
- return c.json({ message: "Hello!" });
87
- }
88
-
89
- export function GET(c: Context) {
90
- return c.json({ status: "ok" });
91
- }
92
- ```
170
+ | `css` | `string` | Path to global CSS file (prevents FOUC in dev) |
93
171
 
94
172
  ## Features
95
173
 
174
+ - **Vite plugins** — `voltxRouter()` and `voltxAPI()` for Next.js-style file-based routing
175
+ - **Virtual modules** — `voltx/router` and `voltx/api` for clean imports
96
176
  - **React SSR** — streaming server-side rendering with `registerSSR()`
97
- - **File-based routing** — Next.js-style `api/` directory
98
- - **Dynamic routes** — `[param]` and `[...slug]` catch-all
177
+ - **Navigation** — `Link`, `NavLink`, `useNavigate`, `useParams` from `voltx/router`
178
+ - **Dynamic routes** — `[param]` and `[...slug]` catch-all for both pages and API
179
+ - **HMR** — new pages and API files are picked up instantly in dev mode
99
180
  - **Built-in middleware** — CORS, request logging, error handling
100
181
  - **Static file serving** — `public/` directory (favicon, robots.txt, manifest)
101
182
  - **Full Hono access** — use any Hono middleware or plugin
package/dist/index.cjs CHANGED
@@ -334,63 +334,302 @@ function voltxRouter(options = {}) {
334
334
  },
335
335
  load(id) {
336
336
  if (id === RESOLVED_ID) {
337
- return `
338
- import { createElement } from "react";
339
- import { Routes, Route } from "react-router";
337
+ return generateRouterModule(pagesDir);
338
+ }
339
+ },
340
+ handleHotUpdate({ file, server }) {
341
+ if (file.includes(pagesDir.replace(/\//g, "/"))) {
342
+ const mod = server.moduleGraph.getModuleById(RESOLVED_ID);
343
+ if (mod) {
344
+ server.moduleGraph.invalidateModule(mod);
345
+ server.ws.send({ type: "full-reload" });
346
+ }
347
+ }
348
+ }
349
+ };
350
+ }
351
+ function generateRouterModule(pagesDir) {
352
+ return `
353
+ import { createElement, Component, Suspense } from "react";
354
+ import { Routes, Route, Outlet } from "react-router";
355
+
356
+ // \u2500\u2500\u2500 Glob all files in pages directory \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
357
+ const allFiles = import.meta.glob("/${pagesDir}/**/*.tsx", { eager: true });
358
+
359
+ // \u2500\u2500\u2500 Classify files by type \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
360
+ // Special files: layout.tsx, loading.tsx, error.tsx, not-found.tsx
361
+ // Page files: everything else (index.tsx, about.tsx, [slug].tsx, etc.)
362
+
363
+ const SPECIAL_FILES = new Set(["layout", "loading", "error", "not-found"]);
340
364
 
341
- const pages = import.meta.glob("/${pagesDir}/**/*.tsx", { eager: true });
365
+ function classifyFiles() {
366
+ const pages = {}; // dir \u2192 [{ routePath, Component }]
367
+ const layouts = {}; // dir \u2192 Component
368
+ const loadings = {}; // dir \u2192 Component
369
+ const errors = {}; // dir \u2192 Component
370
+ let notFound = null; // global not-found component
342
371
 
343
- function buildRoutes() {
344
- const routes = [];
345
- for (const [filePath, mod] of Object.entries(pages)) {
372
+ for (const [filePath, mod] of Object.entries(allFiles)) {
346
373
  const Component = mod.default;
347
374
  if (!Component) continue;
348
375
 
349
- let routePath = filePath
350
- .replace("/${pagesDir}", "")
351
- .replace(/\\.tsx$/, "")
352
- .replace(/\\/index$/, "/")
353
- .replace(/\\[([^\\]]+)\\]/g, ":$1");
376
+ // Get relative path from pages dir: "/src/pages/blog/index.tsx" \u2192 "blog/index.tsx"
377
+ const rel = filePath.replace("/${pagesDir}/", "");
378
+ const parts = rel.replace(/\\.tsx$/, "").split("/");
379
+ const fileName = parts[parts.length - 1];
380
+ const dir = parts.length > 1 ? parts.slice(0, -1).join("/") : "";
381
+
382
+ if (fileName === "layout") {
383
+ layouts[dir] = Component;
384
+ } else if (fileName === "loading") {
385
+ loadings[dir] = Component;
386
+ } else if (fileName === "error") {
387
+ errors[dir] = Component;
388
+ } else if (fileName === "not-found") {
389
+ if (dir === "") notFound = Component;
390
+ } else {
391
+ // It's a page file \u2014 compute route path
392
+ let routePath = rel
393
+ .replace(/\\.tsx$/, "")
394
+ .replace(/(^|\\/)index$/, "$1")
395
+ .replace(/\\[([^\\]]+)\\]/g, ":$1")
396
+ .replace(/\\/$/, "");
397
+
398
+ if (!routePath) routePath = "";
399
+
400
+ if (!pages[dir]) pages[dir] = [];
401
+ pages[dir].push({ routePath: "/" + routePath, Component });
402
+ }
403
+ }
404
+
405
+ return { pages, layouts, loadings, errors, notFound };
406
+ }
407
+
408
+ const { pages, layouts, loadings, errors, notFound } = classifyFiles();
354
409
 
355
- if (!routePath.startsWith("/")) routePath = "/" + routePath;
356
- if (routePath !== "/" && routePath.endsWith("/")) {
357
- routePath = routePath.slice(0, -1);
410
+ // \u2500\u2500\u2500 Error Boundary Component \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
411
+ class VoltxErrorBoundary extends Component {
412
+ constructor(props) {
413
+ super(props);
414
+ this.state = { hasError: false, error: null };
415
+ }
416
+
417
+ static getDerivedStateFromError(error) {
418
+ return { hasError: true, error };
419
+ }
420
+
421
+ render() {
422
+ if (this.state.hasError) {
423
+ const reset = () => this.setState({ hasError: false, error: null });
424
+ if (this.props.fallback) {
425
+ return createElement(this.props.fallback, {
426
+ error: this.state.error,
427
+ reset,
428
+ });
429
+ }
430
+ return createElement("div", { style: { padding: "2rem" } },
431
+ createElement("h2", null, "Something went wrong"),
432
+ createElement("button", { onClick: reset }, "Try again")
433
+ );
358
434
  }
435
+ return this.props.children;
436
+ }
437
+ }
438
+
439
+ // \u2500\u2500\u2500 Build nested route tree \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
440
+ // Directories are sorted so parents come before children.
441
+ // Each directory with a layout.tsx becomes a layout route.
442
+ // Pages within that directory become its children.
443
+
444
+ function getAllDirs() {
445
+ const dirs = new Set([""]);
446
+ for (const dir of Object.keys(pages)) dirs.add(dir);
447
+ for (const dir of Object.keys(layouts)) dirs.add(dir);
448
+ for (const dir of Object.keys(loadings)) dirs.add(dir);
449
+ for (const dir of Object.keys(errors)) dirs.add(dir);
450
+ return Array.from(dirs).sort((a, b) => {
451
+ if (a === "") return -1;
452
+ if (b === "") return 1;
453
+ return a.localeCompare(b);
454
+ });
455
+ }
359
456
 
360
- routes.push({ path: routePath, Component });
457
+ function getParentDir(dir) {
458
+ if (dir === "") return null;
459
+ const idx = dir.lastIndexOf("/");
460
+ return idx === -1 ? "" : dir.substring(0, idx);
461
+ }
462
+
463
+ // Find the nearest ancestor directory that has a layout
464
+ function findLayoutAncestor(dir) {
465
+ let current = getParentDir(dir);
466
+ while (current !== null) {
467
+ if (layouts[current]) return current;
468
+ current = getParentDir(current);
469
+ }
470
+ return null;
471
+ }
472
+
473
+ // Wrap element with loading (Suspense) and error boundary if available for a dir
474
+ function wrapElement(element, dir) {
475
+ let wrapped = element;
476
+ if (errors[dir]) {
477
+ wrapped = createElement(VoltxErrorBoundary, { fallback: errors[dir] }, wrapped);
361
478
  }
362
- return routes;
479
+ if (loadings[dir]) {
480
+ wrapped = createElement(Suspense, { fallback: createElement(loadings[dir]) }, wrapped);
481
+ }
482
+ return wrapped;
363
483
  }
364
484
 
365
- const routes = buildRoutes();
485
+ // \u2500\u2500\u2500 Layout wrapper component \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
486
+ // A layout component that renders the layout with <Outlet /> for children
487
+ function createLayoutElement(LayoutComp, dir) {
488
+ // The layout component receives <Outlet /> as its children rendering point
489
+ // We create a wrapper that renders Layout with Outlet inside
490
+ function LayoutWrapper() {
491
+ return createElement(LayoutComp, null, createElement(Outlet));
492
+ }
493
+ LayoutWrapper.displayName = "Layout(" + (dir || "root") + ")";
494
+ return LayoutWrapper;
495
+ }
496
+
497
+ // \u2500\u2500\u2500 Generate the <Routes> tree \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
498
+ function buildRouteElements() {
499
+ const allDirs = getAllDirs();
500
+
501
+ // Build a tree structure: each dir can have child routes and child layout dirs
502
+ // dirChildren[dir] = array of Route elements (pages + nested layout routes)
503
+ const dirChildren = {};
504
+ for (const dir of allDirs) {
505
+ dirChildren[dir] = [];
506
+ }
507
+
508
+ // First, add page routes to their directory
509
+ for (const dir of allDirs) {
510
+ const dirPages = pages[dir] || [];
511
+ for (const { routePath, Component: PageComp } of dirPages) {
512
+ // Compute the path relative to the directory's base
513
+ const dirBase = dir ? "/" + dir : "";
514
+ let relativePath = routePath;
515
+ if (dirBase && relativePath.startsWith(dirBase)) {
516
+ relativePath = relativePath.substring(dirBase.length);
517
+ }
518
+ if (relativePath.startsWith("/")) relativePath = relativePath.substring(1);
519
+
520
+ const isIndex = relativePath === "";
521
+ const routeProps = isIndex
522
+ ? { index: true, element: createElement(PageComp), key: routePath }
523
+ : { path: relativePath, element: createElement(PageComp), key: routePath };
524
+
525
+ dirChildren[dir].push(createElement(Route, routeProps));
526
+ }
527
+ }
528
+
529
+ // Now, nest directories bottom-up: child dirs become Route children of parent dirs
530
+ // Process in reverse order (deepest first)
531
+ const reversedDirs = allDirs.slice().reverse();
532
+
533
+ for (const dir of reversedDirs) {
534
+ if (dir === "") continue; // root handled last
535
+
536
+ const parentDir = getParentDir(dir);
537
+ if (parentDir === null) continue;
538
+
539
+ const hasLayout = !!layouts[dir];
540
+ const children = dirChildren[dir];
541
+
542
+ // Compute path segment for this directory relative to parent
543
+ const parentBase = parentDir ? parentDir + "/" : "";
544
+ const segment = dir.startsWith(parentBase) ? dir.substring(parentBase.length) : dir;
545
+ // Convert [param] to :param in segment
546
+ const routeSegment = segment.replace(/\\[([^\\]]+)\\]/g, ":$1");
547
+
548
+ if (hasLayout) {
549
+ // This dir has a layout \u2014 create a layout route with children
550
+ const LayoutWrapper = createLayoutElement(layouts[dir], dir);
551
+ let layoutElement = createElement(LayoutWrapper);
552
+ layoutElement = wrapElement(layoutElement, dir);
553
+
554
+ const layoutRoute = createElement(
555
+ Route,
556
+ { path: routeSegment, element: layoutElement, key: "layout:" + dir },
557
+ ...children
558
+ );
559
+ dirChildren[parentDir].push(layoutRoute);
560
+ } else if (children.length > 0) {
561
+ // No layout \u2014 just a path prefix grouping
562
+ if (routeSegment) {
563
+ const prefixRoute = createElement(
564
+ Route,
565
+ { path: routeSegment, key: "prefix:" + dir },
566
+ ...children
567
+ );
568
+ dirChildren[parentDir].push(prefixRoute);
569
+ } else {
570
+ // Empty segment \u2014 push children directly
571
+ dirChildren[parentDir].push(...children);
572
+ }
573
+ }
574
+ }
575
+
576
+ // Build root routes
577
+ const rootChildren = dirChildren[""];
578
+
579
+ // Add not-found catch-all at the end
580
+ if (notFound) {
581
+ rootChildren.push(
582
+ createElement(Route, { path: "*", element: createElement(notFound), key: "not-found" })
583
+ );
584
+ }
585
+
586
+ // If root has a layout, wrap everything in it
587
+ if (layouts[""]) {
588
+ const RootLayout = createLayoutElement(layouts[""], "");
589
+ let rootElement = createElement(RootLayout);
590
+ rootElement = wrapElement(rootElement, "");
591
+
592
+ return createElement(
593
+ Routes,
594
+ null,
595
+ createElement(Route, { path: "/", element: rootElement }, ...rootChildren)
596
+ );
597
+ }
598
+
599
+ // No root layout \u2014 wrap with loading/error if present
600
+ let routesElement = createElement(Routes, null, ...rootChildren);
601
+ if (errors[""]) {
602
+ routesElement = createElement(VoltxErrorBoundary, { fallback: errors[""] }, routesElement);
603
+ }
604
+ if (loadings[""]) {
605
+ routesElement = createElement(Suspense, { fallback: createElement(loadings[""]) }, routesElement);
606
+ }
607
+
608
+ return routesElement;
609
+ }
610
+
611
+ // \u2500\u2500\u2500 Collect flat routes list (for backward compat) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
612
+ function collectRoutes() {
613
+ const result = [];
614
+ for (const dirPages of Object.values(pages)) {
615
+ for (const { routePath, Component } of dirPages) {
616
+ result.push({ path: routePath, Component });
617
+ }
618
+ }
619
+ return result;
620
+ }
621
+
622
+ const routes = collectRoutes();
366
623
 
367
624
  export function VoltxRoutes() {
368
- return createElement(
369
- Routes,
370
- null,
371
- routes.map(({ path, Component }) =>
372
- createElement(Route, { key: path, path, element: createElement(Component) })
373
- )
374
- );
625
+ return buildRouteElements();
375
626
  }
376
627
 
377
628
  export { routes };
378
629
 
379
630
  // Navigation primitives \u2014 single import source
380
- export { Link, NavLink, useNavigate, useParams, useLocation, useSearchParams } from "react-router";
631
+ export { Link, NavLink, Outlet, useNavigate, useParams, useLocation, useSearchParams, useOutletContext } from "react-router";
381
632
  `;
382
- }
383
- },
384
- handleHotUpdate({ file, server }) {
385
- if (file.includes(pagesDir.replace(/\//g, "/"))) {
386
- const mod = server.moduleGraph.getModuleById(RESOLVED_ID);
387
- if (mod) {
388
- server.moduleGraph.invalidateModule(mod);
389
- server.ws.send({ type: "full-reload" });
390
- }
391
- }
392
- }
393
- };
394
633
  }
395
634
 
396
635
  // src/vite-api-plugin.ts
@@ -613,7 +852,7 @@ ${cssLinks}
613
852
  }
614
853
 
615
854
  // src/index.ts
616
- var VERSION = "0.4.6";
855
+ var VERSION = "0.4.7";
617
856
  // Annotate the CommonJS export names for ESM import in node:
618
857
  0 && (module.exports = {
619
858
  Hono,
package/dist/index.d.cts CHANGED
@@ -185,16 +185,10 @@ interface VoltxRouterOptions {
185
185
  * VoltX file-based router plugin for Vite.
186
186
  *
187
187
  * Scans `src/pages/` and generates a virtual module that maps
188
- * file paths to routes — just like Next.js.
188
+ * file paths to routes with nested layout support — like Next.js App Router.
189
189
  *
190
190
  * Usage:
191
191
  * import { Link, VoltxRoutes, useNavigate } from "voltx/router";
192
- *
193
- * Convention:
194
- * src/pages/index.tsx → /
195
- * src/pages/about.tsx → /about
196
- * src/pages/blog/index.tsx → /blog
197
- * src/pages/blog/[slug].tsx → /blog/:slug
198
192
  */
199
193
  declare function voltxRouter(options?: VoltxRouterOptions): Plugin;
200
194
 
@@ -263,6 +257,6 @@ interface ViteDevServer {
263
257
  */
264
258
  declare function registerSSR(app: Hono, vite: ViteDevServer | null, options?: SSROptions): void;
265
259
 
266
- declare const VERSION = "0.4.6";
260
+ declare const VERSION = "0.4.7";
267
261
 
268
262
  export { type CorsConfig, type HttpMethod, type MiddlewareHandler, type RouteEntry, type RouteHandler, type RouteModule, type SSROptions, type ServerConfig, type ServerInfo, VERSION, type ViteDevOptions, type VoltxAPIOptions, type VoltxRouterOptions, type VoltxServer, createCorsMiddleware, createErrorHandler, createLoggerMiddleware, createServer, createViteDevConfig, filePathToUrlPath, registerSSR, registerStaticFiles, scanAndRegisterRoutes, voltxAPI, voltxRouter };
package/dist/index.d.ts CHANGED
@@ -185,16 +185,10 @@ interface VoltxRouterOptions {
185
185
  * VoltX file-based router plugin for Vite.
186
186
  *
187
187
  * Scans `src/pages/` and generates a virtual module that maps
188
- * file paths to routes — just like Next.js.
188
+ * file paths to routes with nested layout support — like Next.js App Router.
189
189
  *
190
190
  * Usage:
191
191
  * import { Link, VoltxRoutes, useNavigate } from "voltx/router";
192
- *
193
- * Convention:
194
- * src/pages/index.tsx → /
195
- * src/pages/about.tsx → /about
196
- * src/pages/blog/index.tsx → /blog
197
- * src/pages/blog/[slug].tsx → /blog/:slug
198
192
  */
199
193
  declare function voltxRouter(options?: VoltxRouterOptions): Plugin;
200
194
 
@@ -263,6 +257,6 @@ interface ViteDevServer {
263
257
  */
264
258
  declare function registerSSR(app: Hono, vite: ViteDevServer | null, options?: SSROptions): void;
265
259
 
266
- declare const VERSION = "0.4.6";
260
+ declare const VERSION = "0.4.7";
267
261
 
268
262
  export { type CorsConfig, type HttpMethod, type MiddlewareHandler, type RouteEntry, type RouteHandler, type RouteModule, type SSROptions, type ServerConfig, type ServerInfo, VERSION, type ViteDevOptions, type VoltxAPIOptions, type VoltxRouterOptions, type VoltxServer, createCorsMiddleware, createErrorHandler, createLoggerMiddleware, createServer, createViteDevConfig, filePathToUrlPath, registerSSR, registerStaticFiles, scanAndRegisterRoutes, voltxAPI, voltxRouter };
package/dist/index.js CHANGED
@@ -286,63 +286,302 @@ function voltxRouter(options = {}) {
286
286
  },
287
287
  load(id) {
288
288
  if (id === RESOLVED_ID) {
289
- return `
290
- import { createElement } from "react";
291
- import { Routes, Route } from "react-router";
289
+ return generateRouterModule(pagesDir);
290
+ }
291
+ },
292
+ handleHotUpdate({ file, server }) {
293
+ if (file.includes(pagesDir.replace(/\//g, "/"))) {
294
+ const mod = server.moduleGraph.getModuleById(RESOLVED_ID);
295
+ if (mod) {
296
+ server.moduleGraph.invalidateModule(mod);
297
+ server.ws.send({ type: "full-reload" });
298
+ }
299
+ }
300
+ }
301
+ };
302
+ }
303
+ function generateRouterModule(pagesDir) {
304
+ return `
305
+ import { createElement, Component, Suspense } from "react";
306
+ import { Routes, Route, Outlet } from "react-router";
307
+
308
+ // \u2500\u2500\u2500 Glob all files in pages directory \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
309
+ const allFiles = import.meta.glob("/${pagesDir}/**/*.tsx", { eager: true });
292
310
 
293
- const pages = import.meta.glob("/${pagesDir}/**/*.tsx", { eager: true });
311
+ // \u2500\u2500\u2500 Classify files by type \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
312
+ // Special files: layout.tsx, loading.tsx, error.tsx, not-found.tsx
313
+ // Page files: everything else (index.tsx, about.tsx, [slug].tsx, etc.)
294
314
 
295
- function buildRoutes() {
296
- const routes = [];
297
- for (const [filePath, mod] of Object.entries(pages)) {
315
+ const SPECIAL_FILES = new Set(["layout", "loading", "error", "not-found"]);
316
+
317
+ function classifyFiles() {
318
+ const pages = {}; // dir \u2192 [{ routePath, Component }]
319
+ const layouts = {}; // dir \u2192 Component
320
+ const loadings = {}; // dir \u2192 Component
321
+ const errors = {}; // dir \u2192 Component
322
+ let notFound = null; // global not-found component
323
+
324
+ for (const [filePath, mod] of Object.entries(allFiles)) {
298
325
  const Component = mod.default;
299
326
  if (!Component) continue;
300
327
 
301
- let routePath = filePath
302
- .replace("/${pagesDir}", "")
303
- .replace(/\\.tsx$/, "")
304
- .replace(/\\/index$/, "/")
305
- .replace(/\\[([^\\]]+)\\]/g, ":$1");
328
+ // Get relative path from pages dir: "/src/pages/blog/index.tsx" \u2192 "blog/index.tsx"
329
+ const rel = filePath.replace("/${pagesDir}/", "");
330
+ const parts = rel.replace(/\\.tsx$/, "").split("/");
331
+ const fileName = parts[parts.length - 1];
332
+ const dir = parts.length > 1 ? parts.slice(0, -1).join("/") : "";
333
+
334
+ if (fileName === "layout") {
335
+ layouts[dir] = Component;
336
+ } else if (fileName === "loading") {
337
+ loadings[dir] = Component;
338
+ } else if (fileName === "error") {
339
+ errors[dir] = Component;
340
+ } else if (fileName === "not-found") {
341
+ if (dir === "") notFound = Component;
342
+ } else {
343
+ // It's a page file \u2014 compute route path
344
+ let routePath = rel
345
+ .replace(/\\.tsx$/, "")
346
+ .replace(/(^|\\/)index$/, "$1")
347
+ .replace(/\\[([^\\]]+)\\]/g, ":$1")
348
+ .replace(/\\/$/, "");
349
+
350
+ if (!routePath) routePath = "";
351
+
352
+ if (!pages[dir]) pages[dir] = [];
353
+ pages[dir].push({ routePath: "/" + routePath, Component });
354
+ }
355
+ }
356
+
357
+ return { pages, layouts, loadings, errors, notFound };
358
+ }
359
+
360
+ const { pages, layouts, loadings, errors, notFound } = classifyFiles();
361
+
362
+ // \u2500\u2500\u2500 Error Boundary Component \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
363
+ class VoltxErrorBoundary extends Component {
364
+ constructor(props) {
365
+ super(props);
366
+ this.state = { hasError: false, error: null };
367
+ }
368
+
369
+ static getDerivedStateFromError(error) {
370
+ return { hasError: true, error };
371
+ }
306
372
 
307
- if (!routePath.startsWith("/")) routePath = "/" + routePath;
308
- if (routePath !== "/" && routePath.endsWith("/")) {
309
- routePath = routePath.slice(0, -1);
373
+ render() {
374
+ if (this.state.hasError) {
375
+ const reset = () => this.setState({ hasError: false, error: null });
376
+ if (this.props.fallback) {
377
+ return createElement(this.props.fallback, {
378
+ error: this.state.error,
379
+ reset,
380
+ });
381
+ }
382
+ return createElement("div", { style: { padding: "2rem" } },
383
+ createElement("h2", null, "Something went wrong"),
384
+ createElement("button", { onClick: reset }, "Try again")
385
+ );
310
386
  }
387
+ return this.props.children;
388
+ }
389
+ }
390
+
391
+ // \u2500\u2500\u2500 Build nested route tree \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
392
+ // Directories are sorted so parents come before children.
393
+ // Each directory with a layout.tsx becomes a layout route.
394
+ // Pages within that directory become its children.
395
+
396
+ function getAllDirs() {
397
+ const dirs = new Set([""]);
398
+ for (const dir of Object.keys(pages)) dirs.add(dir);
399
+ for (const dir of Object.keys(layouts)) dirs.add(dir);
400
+ for (const dir of Object.keys(loadings)) dirs.add(dir);
401
+ for (const dir of Object.keys(errors)) dirs.add(dir);
402
+ return Array.from(dirs).sort((a, b) => {
403
+ if (a === "") return -1;
404
+ if (b === "") return 1;
405
+ return a.localeCompare(b);
406
+ });
407
+ }
408
+
409
+ function getParentDir(dir) {
410
+ if (dir === "") return null;
411
+ const idx = dir.lastIndexOf("/");
412
+ return idx === -1 ? "" : dir.substring(0, idx);
413
+ }
414
+
415
+ // Find the nearest ancestor directory that has a layout
416
+ function findLayoutAncestor(dir) {
417
+ let current = getParentDir(dir);
418
+ while (current !== null) {
419
+ if (layouts[current]) return current;
420
+ current = getParentDir(current);
421
+ }
422
+ return null;
423
+ }
311
424
 
312
- routes.push({ path: routePath, Component });
425
+ // Wrap element with loading (Suspense) and error boundary if available for a dir
426
+ function wrapElement(element, dir) {
427
+ let wrapped = element;
428
+ if (errors[dir]) {
429
+ wrapped = createElement(VoltxErrorBoundary, { fallback: errors[dir] }, wrapped);
313
430
  }
314
- return routes;
431
+ if (loadings[dir]) {
432
+ wrapped = createElement(Suspense, { fallback: createElement(loadings[dir]) }, wrapped);
433
+ }
434
+ return wrapped;
435
+ }
436
+
437
+ // \u2500\u2500\u2500 Layout wrapper component \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
438
+ // A layout component that renders the layout with <Outlet /> for children
439
+ function createLayoutElement(LayoutComp, dir) {
440
+ // The layout component receives <Outlet /> as its children rendering point
441
+ // We create a wrapper that renders Layout with Outlet inside
442
+ function LayoutWrapper() {
443
+ return createElement(LayoutComp, null, createElement(Outlet));
444
+ }
445
+ LayoutWrapper.displayName = "Layout(" + (dir || "root") + ")";
446
+ return LayoutWrapper;
315
447
  }
316
448
 
317
- const routes = buildRoutes();
449
+ // \u2500\u2500\u2500 Generate the <Routes> tree \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
450
+ function buildRouteElements() {
451
+ const allDirs = getAllDirs();
452
+
453
+ // Build a tree structure: each dir can have child routes and child layout dirs
454
+ // dirChildren[dir] = array of Route elements (pages + nested layout routes)
455
+ const dirChildren = {};
456
+ for (const dir of allDirs) {
457
+ dirChildren[dir] = [];
458
+ }
459
+
460
+ // First, add page routes to their directory
461
+ for (const dir of allDirs) {
462
+ const dirPages = pages[dir] || [];
463
+ for (const { routePath, Component: PageComp } of dirPages) {
464
+ // Compute the path relative to the directory's base
465
+ const dirBase = dir ? "/" + dir : "";
466
+ let relativePath = routePath;
467
+ if (dirBase && relativePath.startsWith(dirBase)) {
468
+ relativePath = relativePath.substring(dirBase.length);
469
+ }
470
+ if (relativePath.startsWith("/")) relativePath = relativePath.substring(1);
471
+
472
+ const isIndex = relativePath === "";
473
+ const routeProps = isIndex
474
+ ? { index: true, element: createElement(PageComp), key: routePath }
475
+ : { path: relativePath, element: createElement(PageComp), key: routePath };
476
+
477
+ dirChildren[dir].push(createElement(Route, routeProps));
478
+ }
479
+ }
480
+
481
+ // Now, nest directories bottom-up: child dirs become Route children of parent dirs
482
+ // Process in reverse order (deepest first)
483
+ const reversedDirs = allDirs.slice().reverse();
484
+
485
+ for (const dir of reversedDirs) {
486
+ if (dir === "") continue; // root handled last
487
+
488
+ const parentDir = getParentDir(dir);
489
+ if (parentDir === null) continue;
490
+
491
+ const hasLayout = !!layouts[dir];
492
+ const children = dirChildren[dir];
493
+
494
+ // Compute path segment for this directory relative to parent
495
+ const parentBase = parentDir ? parentDir + "/" : "";
496
+ const segment = dir.startsWith(parentBase) ? dir.substring(parentBase.length) : dir;
497
+ // Convert [param] to :param in segment
498
+ const routeSegment = segment.replace(/\\[([^\\]]+)\\]/g, ":$1");
499
+
500
+ if (hasLayout) {
501
+ // This dir has a layout \u2014 create a layout route with children
502
+ const LayoutWrapper = createLayoutElement(layouts[dir], dir);
503
+ let layoutElement = createElement(LayoutWrapper);
504
+ layoutElement = wrapElement(layoutElement, dir);
505
+
506
+ const layoutRoute = createElement(
507
+ Route,
508
+ { path: routeSegment, element: layoutElement, key: "layout:" + dir },
509
+ ...children
510
+ );
511
+ dirChildren[parentDir].push(layoutRoute);
512
+ } else if (children.length > 0) {
513
+ // No layout \u2014 just a path prefix grouping
514
+ if (routeSegment) {
515
+ const prefixRoute = createElement(
516
+ Route,
517
+ { path: routeSegment, key: "prefix:" + dir },
518
+ ...children
519
+ );
520
+ dirChildren[parentDir].push(prefixRoute);
521
+ } else {
522
+ // Empty segment \u2014 push children directly
523
+ dirChildren[parentDir].push(...children);
524
+ }
525
+ }
526
+ }
527
+
528
+ // Build root routes
529
+ const rootChildren = dirChildren[""];
530
+
531
+ // Add not-found catch-all at the end
532
+ if (notFound) {
533
+ rootChildren.push(
534
+ createElement(Route, { path: "*", element: createElement(notFound), key: "not-found" })
535
+ );
536
+ }
537
+
538
+ // If root has a layout, wrap everything in it
539
+ if (layouts[""]) {
540
+ const RootLayout = createLayoutElement(layouts[""], "");
541
+ let rootElement = createElement(RootLayout);
542
+ rootElement = wrapElement(rootElement, "");
543
+
544
+ return createElement(
545
+ Routes,
546
+ null,
547
+ createElement(Route, { path: "/", element: rootElement }, ...rootChildren)
548
+ );
549
+ }
550
+
551
+ // No root layout \u2014 wrap with loading/error if present
552
+ let routesElement = createElement(Routes, null, ...rootChildren);
553
+ if (errors[""]) {
554
+ routesElement = createElement(VoltxErrorBoundary, { fallback: errors[""] }, routesElement);
555
+ }
556
+ if (loadings[""]) {
557
+ routesElement = createElement(Suspense, { fallback: createElement(loadings[""]) }, routesElement);
558
+ }
559
+
560
+ return routesElement;
561
+ }
562
+
563
+ // \u2500\u2500\u2500 Collect flat routes list (for backward compat) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
564
+ function collectRoutes() {
565
+ const result = [];
566
+ for (const dirPages of Object.values(pages)) {
567
+ for (const { routePath, Component } of dirPages) {
568
+ result.push({ path: routePath, Component });
569
+ }
570
+ }
571
+ return result;
572
+ }
573
+
574
+ const routes = collectRoutes();
318
575
 
319
576
  export function VoltxRoutes() {
320
- return createElement(
321
- Routes,
322
- null,
323
- routes.map(({ path, Component }) =>
324
- createElement(Route, { key: path, path, element: createElement(Component) })
325
- )
326
- );
577
+ return buildRouteElements();
327
578
  }
328
579
 
329
580
  export { routes };
330
581
 
331
582
  // Navigation primitives \u2014 single import source
332
- export { Link, NavLink, useNavigate, useParams, useLocation, useSearchParams } from "react-router";
583
+ export { Link, NavLink, Outlet, useNavigate, useParams, useLocation, useSearchParams, useOutletContext } from "react-router";
333
584
  `;
334
- }
335
- },
336
- handleHotUpdate({ file, server }) {
337
- if (file.includes(pagesDir.replace(/\//g, "/"))) {
338
- const mod = server.moduleGraph.getModuleById(RESOLVED_ID);
339
- if (mod) {
340
- server.moduleGraph.invalidateModule(mod);
341
- server.ws.send({ type: "full-reload" });
342
- }
343
- }
344
- }
345
- };
346
585
  }
347
586
 
348
587
  // src/vite-api-plugin.ts
@@ -565,7 +804,7 @@ ${cssLinks}
565
804
  }
566
805
 
567
806
  // src/index.ts
568
- var VERSION = "0.4.6";
807
+ var VERSION = "0.4.7";
569
808
  export {
570
809
  Hono2 as Hono,
571
810
  VERSION,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltx/server",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
4
4
  "description": "VoltX Server — Hono-based HTTP server with file-based routing, SSE streaming, and static file serving",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",