@voltx/server 0.4.5 → 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
@@ -41,6 +41,7 @@ __export(index_exports, {
41
41
  registerSSR: () => registerSSR,
42
42
  registerStaticFiles: () => registerStaticFiles,
43
43
  scanAndRegisterRoutes: () => scanAndRegisterRoutes,
44
+ voltxAPI: () => voltxAPI,
44
45
  voltxRouter: () => voltxRouter
45
46
  });
46
47
  module.exports = __toCommonJS(index_exports);
@@ -333,55 +334,374 @@ function voltxRouter(options = {}) {
333
334
  },
334
335
  load(id) {
335
336
  if (id === RESOLVED_ID) {
336
- return `
337
- import { createElement } from "react";
338
- 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 });
339
358
 
340
- const pages = import.meta.glob("/${pagesDir}/**/*.tsx", { eager: true });
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.)
341
362
 
342
- function buildRoutes() {
343
- const routes = [];
344
- for (const [filePath, mod] of Object.entries(pages)) {
363
+ const SPECIAL_FILES = new Set(["layout", "loading", "error", "not-found"]);
364
+
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
371
+
372
+ for (const [filePath, mod] of Object.entries(allFiles)) {
345
373
  const Component = mod.default;
346
374
  if (!Component) continue;
347
375
 
348
- let routePath = filePath
349
- .replace("/${pagesDir}", "")
350
- .replace(/\\.tsx$/, "")
351
- .replace(/\\/index$/, "/")
352
- .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 = "";
353
399
 
354
- if (!routePath.startsWith("/")) routePath = "/" + routePath;
355
- if (routePath !== "/" && routePath.endsWith("/")) {
356
- routePath = routePath.slice(0, -1);
400
+ if (!pages[dir]) pages[dir] = [];
401
+ pages[dir].push({ routePath: "/" + routePath, Component });
357
402
  }
403
+ }
404
+
405
+ return { pages, layouts, loadings, errors, notFound };
406
+ }
358
407
 
359
- routes.push({ path: routePath, Component });
408
+ const { pages, layouts, loadings, errors, notFound } = classifyFiles();
409
+
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 };
360
419
  }
361
- return routes;
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
+ );
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
+ }
456
+
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);
478
+ }
479
+ if (loadings[dir]) {
480
+ wrapped = createElement(Suspense, { fallback: createElement(loadings[dir]) }, wrapped);
481
+ }
482
+ return wrapped;
362
483
  }
363
484
 
364
- 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();
365
623
 
366
624
  export function VoltxRoutes() {
367
- return createElement(
368
- Routes,
369
- null,
370
- routes.map(({ path, Component }) =>
371
- createElement(Route, { key: path, path, element: createElement(Component) })
372
- )
373
- );
625
+ return buildRouteElements();
374
626
  }
375
627
 
376
628
  export { routes };
377
629
 
378
630
  // Navigation primitives \u2014 single import source
379
- export { Link, NavLink, useNavigate, useParams, useLocation, useSearchParams } from "react-router";
631
+ export { Link, NavLink, Outlet, useNavigate, useParams, useLocation, useSearchParams, useOutletContext } from "react-router";
632
+ `;
633
+ }
634
+
635
+ // src/vite-api-plugin.ts
636
+ function voltxAPI(options = {}) {
637
+ const apiDir = options.apiDir ?? "api";
638
+ const PUBLIC_ID = "voltx/api";
639
+ const RESOLVED_ID = "\0voltx/api";
640
+ return {
641
+ name: "voltx-api",
642
+ enforce: "pre",
643
+ resolveId(id) {
644
+ if (id === PUBLIC_ID) {
645
+ return RESOLVED_ID;
646
+ }
647
+ },
648
+ load(id) {
649
+ if (id === RESOLVED_ID) {
650
+ return `
651
+ const modules = import.meta.glob("/${apiDir}/**/*.ts", { eager: true });
652
+
653
+ const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"];
654
+
655
+ function fileToRoute(filePath) {
656
+ let route = filePath
657
+ .replace("/${apiDir}", "/${apiDir}")
658
+ .replace(/\\.ts$/, "")
659
+ .replace(/\\/index$/, "");
660
+
661
+ // Convert [param] -> :param
662
+ route = route.replace(/\\[([^\\]\\.]+)\\]/g, ":$1");
663
+ // Convert [...slug] -> *
664
+ route = route.replace(/\\[\\.\\.\\.([^\\]]+)\\]/g, "*");
665
+
666
+ if (!route || route === "/${apiDir}") route = "/${apiDir}";
667
+ return route;
668
+ }
669
+
670
+ export function registerRoutes(app) {
671
+ const registered = [];
672
+
673
+ // Sort: static routes first, dynamic (:param) second, catch-all (*) last
674
+ const entries = Object.entries(modules).sort(([a], [b]) => {
675
+ const ra = fileToRoute(a);
676
+ const rb = fileToRoute(b);
677
+ const sa = ra.includes("*") ? 2 : ra.includes(":") ? 1 : 0;
678
+ const sb = rb.includes("*") ? 2 : rb.includes(":") ? 1 : 0;
679
+ if (sa !== sb) return sa - sb;
680
+ return ra.localeCompare(rb);
681
+ });
682
+
683
+ for (const [filePath, mod] of entries) {
684
+ const route = fileToRoute(filePath);
685
+
686
+ for (const method of HTTP_METHODS) {
687
+ const handler = mod[method];
688
+ if (typeof handler === "function") {
689
+ app.on(method, route, handler);
690
+ registered.push({ method, path: route });
691
+ }
692
+ }
693
+ }
694
+
695
+ return registered;
696
+ }
697
+
698
+ export { modules as apiModules };
380
699
  `;
381
700
  }
382
701
  },
702
+ // HMR: when a file in api/ is added/removed, invalidate the virtual module
383
703
  handleHotUpdate({ file, server }) {
384
- if (file.includes(pagesDir.replace(/\//g, "/"))) {
704
+ if (file.includes(`/${apiDir}/`) || file.endsWith(`/${apiDir}`)) {
385
705
  const mod = server.moduleGraph.getModuleById(RESOLVED_ID);
386
706
  if (mod) {
387
707
  server.moduleGraph.invalidateModule(mod);
@@ -532,7 +852,7 @@ ${cssLinks}
532
852
  }
533
853
 
534
854
  // src/index.ts
535
- var VERSION = "0.4.5";
855
+ var VERSION = "0.4.7";
536
856
  // Annotate the CommonJS export names for ESM import in node:
537
857
  0 && (module.exports = {
538
858
  Hono,
@@ -546,5 +866,6 @@ var VERSION = "0.4.5";
546
866
  registerSSR,
547
867
  registerStaticFiles,
548
868
  scanAndRegisterRoutes,
869
+ voltxAPI,
549
870
  voltxRouter
550
871
  });
package/dist/index.d.cts CHANGED
@@ -185,18 +185,30 @@ 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
+ declare function voltxRouter(options?: VoltxRouterOptions): Plugin;
194
+
195
+ interface VoltxAPIOptions {
196
+ /** Directory to scan for API route files (default: "api") */
197
+ apiDir?: string;
198
+ }
199
+ /**
200
+ * VoltX file-based API routing plugin for Vite.
201
+ *
202
+ * Usage in server.ts:
203
+ * import { registerRoutes } from "voltx/api";
204
+ * registerRoutes(app);
192
205
  *
193
206
  * 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
207
+ * api/index.ts → /api
208
+ * api/users.ts → /api/users
209
+ * api/users/[id].ts → /api/users/:id
198
210
  */
199
- declare function voltxRouter(options?: VoltxRouterOptions): Plugin;
211
+ declare function voltxAPI(options?: VoltxAPIOptions): Plugin;
200
212
 
201
213
  interface SSROptions {
202
214
  /** Path to entry-server module (default: src/entry-server.tsx) */
@@ -245,6 +257,6 @@ interface ViteDevServer {
245
257
  */
246
258
  declare function registerSSR(app: Hono, vite: ViteDevServer | null, options?: SSROptions): void;
247
259
 
248
- declare const VERSION = "0.4.5";
260
+ declare const VERSION = "0.4.7";
249
261
 
250
- export { type CorsConfig, type HttpMethod, type MiddlewareHandler, type RouteEntry, type RouteHandler, type RouteModule, type SSROptions, type ServerConfig, type ServerInfo, VERSION, type ViteDevOptions, type VoltxRouterOptions, type VoltxServer, createCorsMiddleware, createErrorHandler, createLoggerMiddleware, createServer, createViteDevConfig, filePathToUrlPath, registerSSR, registerStaticFiles, scanAndRegisterRoutes, voltxRouter };
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,18 +185,30 @@ 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
+ declare function voltxRouter(options?: VoltxRouterOptions): Plugin;
194
+
195
+ interface VoltxAPIOptions {
196
+ /** Directory to scan for API route files (default: "api") */
197
+ apiDir?: string;
198
+ }
199
+ /**
200
+ * VoltX file-based API routing plugin for Vite.
201
+ *
202
+ * Usage in server.ts:
203
+ * import { registerRoutes } from "voltx/api";
204
+ * registerRoutes(app);
192
205
  *
193
206
  * 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
207
+ * api/index.ts → /api
208
+ * api/users.ts → /api/users
209
+ * api/users/[id].ts → /api/users/:id
198
210
  */
199
- declare function voltxRouter(options?: VoltxRouterOptions): Plugin;
211
+ declare function voltxAPI(options?: VoltxAPIOptions): Plugin;
200
212
 
201
213
  interface SSROptions {
202
214
  /** Path to entry-server module (default: src/entry-server.tsx) */
@@ -245,6 +257,6 @@ interface ViteDevServer {
245
257
  */
246
258
  declare function registerSSR(app: Hono, vite: ViteDevServer | null, options?: SSROptions): void;
247
259
 
248
- declare const VERSION = "0.4.5";
260
+ declare const VERSION = "0.4.7";
249
261
 
250
- export { type CorsConfig, type HttpMethod, type MiddlewareHandler, type RouteEntry, type RouteHandler, type RouteModule, type SSROptions, type ServerConfig, type ServerInfo, VERSION, type ViteDevOptions, type VoltxRouterOptions, type VoltxServer, createCorsMiddleware, createErrorHandler, createLoggerMiddleware, createServer, createViteDevConfig, filePathToUrlPath, registerSSR, registerStaticFiles, scanAndRegisterRoutes, voltxRouter };
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,55 +286,374 @@ 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 = "";
306
351
 
307
- if (!routePath.startsWith("/")) routePath = "/" + routePath;
308
- if (routePath !== "/" && routePath.endsWith("/")) {
309
- routePath = routePath.slice(0, -1);
352
+ if (!pages[dir]) pages[dir] = [];
353
+ pages[dir].push({ routePath: "/" + routePath, Component });
310
354
  }
355
+ }
356
+
357
+ return { pages, layouts, loadings, errors, notFound };
358
+ }
311
359
 
312
- routes.push({ path: routePath, Component });
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 };
313
371
  }
314
- return routes;
372
+
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
+ );
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
+ }
424
+
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);
430
+ }
431
+ if (loadings[dir]) {
432
+ wrapped = createElement(Suspense, { fallback: createElement(loadings[dir]) }, wrapped);
433
+ }
434
+ return wrapped;
315
435
  }
316
436
 
317
- const routes = buildRoutes();
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;
447
+ }
448
+
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";
584
+ `;
585
+ }
586
+
587
+ // src/vite-api-plugin.ts
588
+ function voltxAPI(options = {}) {
589
+ const apiDir = options.apiDir ?? "api";
590
+ const PUBLIC_ID = "voltx/api";
591
+ const RESOLVED_ID = "\0voltx/api";
592
+ return {
593
+ name: "voltx-api",
594
+ enforce: "pre",
595
+ resolveId(id) {
596
+ if (id === PUBLIC_ID) {
597
+ return RESOLVED_ID;
598
+ }
599
+ },
600
+ load(id) {
601
+ if (id === RESOLVED_ID) {
602
+ return `
603
+ const modules = import.meta.glob("/${apiDir}/**/*.ts", { eager: true });
604
+
605
+ const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"];
606
+
607
+ function fileToRoute(filePath) {
608
+ let route = filePath
609
+ .replace("/${apiDir}", "/${apiDir}")
610
+ .replace(/\\.ts$/, "")
611
+ .replace(/\\/index$/, "");
612
+
613
+ // Convert [param] -> :param
614
+ route = route.replace(/\\[([^\\]\\.]+)\\]/g, ":$1");
615
+ // Convert [...slug] -> *
616
+ route = route.replace(/\\[\\.\\.\\.([^\\]]+)\\]/g, "*");
617
+
618
+ if (!route || route === "/${apiDir}") route = "/${apiDir}";
619
+ return route;
620
+ }
621
+
622
+ export function registerRoutes(app) {
623
+ const registered = [];
624
+
625
+ // Sort: static routes first, dynamic (:param) second, catch-all (*) last
626
+ const entries = Object.entries(modules).sort(([a], [b]) => {
627
+ const ra = fileToRoute(a);
628
+ const rb = fileToRoute(b);
629
+ const sa = ra.includes("*") ? 2 : ra.includes(":") ? 1 : 0;
630
+ const sb = rb.includes("*") ? 2 : rb.includes(":") ? 1 : 0;
631
+ if (sa !== sb) return sa - sb;
632
+ return ra.localeCompare(rb);
633
+ });
634
+
635
+ for (const [filePath, mod] of entries) {
636
+ const route = fileToRoute(filePath);
637
+
638
+ for (const method of HTTP_METHODS) {
639
+ const handler = mod[method];
640
+ if (typeof handler === "function") {
641
+ app.on(method, route, handler);
642
+ registered.push({ method, path: route });
643
+ }
644
+ }
645
+ }
646
+
647
+ return registered;
648
+ }
649
+
650
+ export { modules as apiModules };
333
651
  `;
334
652
  }
335
653
  },
654
+ // HMR: when a file in api/ is added/removed, invalidate the virtual module
336
655
  handleHotUpdate({ file, server }) {
337
- if (file.includes(pagesDir.replace(/\//g, "/"))) {
656
+ if (file.includes(`/${apiDir}/`) || file.endsWith(`/${apiDir}`)) {
338
657
  const mod = server.moduleGraph.getModuleById(RESOLVED_ID);
339
658
  if (mod) {
340
659
  server.moduleGraph.invalidateModule(mod);
@@ -485,7 +804,7 @@ ${cssLinks}
485
804
  }
486
805
 
487
806
  // src/index.ts
488
- var VERSION = "0.4.5";
807
+ var VERSION = "0.4.7";
489
808
  export {
490
809
  Hono2 as Hono,
491
810
  VERSION,
@@ -498,5 +817,6 @@ export {
498
817
  registerSSR,
499
818
  registerStaticFiles,
500
819
  scanAndRegisterRoutes,
820
+ voltxAPI,
501
821
  voltxRouter
502
822
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltx/server",
3
- "version": "0.4.5",
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",