@mauroandre/velojs 0.0.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/README.md ADDED
@@ -0,0 +1,1049 @@
1
+ # VeloJS
2
+
3
+ Fullstack web framework with SSR, hydration, and file-based conventions.
4
+
5
+ - **Server**: Hono (web framework) + Preact SSR
6
+ - **Client**: Preact + @preact/signals + wouter-preact
7
+ - **Build**: Vite with custom plugin (Babel AST transforms)
8
+
9
+ ---
10
+
11
+ ## Getting Started
12
+
13
+ ### Create a new project
14
+
15
+ ```bash
16
+ mkdir my-app && cd my-app
17
+ npm init -y
18
+ npm install velojs
19
+ npm install -D typescript
20
+ ```
21
+
22
+ ### Project structure
23
+
24
+ ```
25
+ my-app/
26
+ ├── app/
27
+ │ ├── routes.tsx # Route definitions (export default)
28
+ │ ├── server.tsx # Server init (DB connections, custom routes, etc)
29
+ │ ├── client.tsx # Client init (global CSS, etc)
30
+ │ ├── client-root.tsx # Root component (<html>, <head>, <body>)
31
+ │ └── pages/ # Pages, layouts, modules
32
+ ├── vite.config.ts
33
+ ├── tsconfig.json
34
+ └── package.json
35
+ ```
36
+
37
+ ### vite.config.ts
38
+
39
+ ```typescript
40
+ import { defineConfig } from "vite";
41
+ import { veloPlugin } from "velojs/vite";
42
+
43
+ export default defineConfig({
44
+ plugins: [veloPlugin()],
45
+ });
46
+ ```
47
+
48
+ ### package.json scripts
49
+
50
+ ```json
51
+ {
52
+ "scripts": {
53
+ "dev": "velojs dev",
54
+ "build": "velojs build",
55
+ "start": "NODE_ENV=production velojs start"
56
+ }
57
+ }
58
+ ```
59
+
60
+ ### app/client-root.tsx — Root component
61
+
62
+ The root component renders the HTML shell. It must accept `children` and include `<Scripts />`.
63
+
64
+ ```tsx
65
+ import type { ComponentChildren } from "preact";
66
+ import { Scripts } from "velojs";
67
+
68
+ export const Component = ({ children }: { children?: ComponentChildren }) => (
69
+ <html lang="en">
70
+ <head>
71
+ <meta charset="UTF-8" />
72
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
73
+ <title>My App</title>
74
+ <Scripts />
75
+ </head>
76
+ <body>{children}</body>
77
+ </html>
78
+ );
79
+ ```
80
+
81
+ ### app/client.tsx — Client entry
82
+
83
+ Runs on the client only. Use it to import global CSS, initialize client-side libraries, or set up global components like toasts.
84
+
85
+ ```typescript
86
+ // Import global styles
87
+ import "./styles/global.css";
88
+
89
+ // Optional: set up global client-side features
90
+ // import { initAnalytics } from "./modules/analytics.js";
91
+ // initAnalytics();
92
+ ```
93
+
94
+ ### app/server.tsx — Server entry
95
+
96
+ Runs on the server only. Use it to connect to databases, create indexes, register custom API routes, start background jobs, and set up WebSocket handlers.
97
+
98
+ ```typescript
99
+ import type { Hono } from "hono";
100
+ import { addRoutes, onServer } from "velojs/server";
101
+
102
+ // Connect to database
103
+ import { connectDB } from "../db/engine.js";
104
+ await connectDB();
105
+
106
+ // Create indexes
107
+ import { getDB } from "../db/engine.js";
108
+ const db = getDB();
109
+ await db.collection("users").createIndex({ email: 1 }, { unique: true });
110
+
111
+ // Register custom API routes
112
+ addRoutes((app: Hono) => {
113
+ app.get("/api/health", (c) => c.json({ ok: true }));
114
+ });
115
+
116
+ // Start background jobs
117
+ const { runCleanup } = await import("./modules/cleanup.js");
118
+ setInterval(() => runCleanup().catch(console.error), 60_000);
119
+ ```
120
+
121
+ ### app/routes.tsx — Route definitions
122
+
123
+ ```typescript
124
+ import type { AppRoutes } from "velojs";
125
+ import * as Root from "./client-root.js";
126
+ import * as Home from "./pages/Home.js";
127
+
128
+ export default [
129
+ {
130
+ module: Root,
131
+ isRoot: true,
132
+ children: [
133
+ { path: "/", module: Home },
134
+ ],
135
+ },
136
+ ] satisfies AppRoutes;
137
+ ```
138
+
139
+ ### app/pages/Home.tsx — First page
140
+
141
+ ```typescript
142
+ import type { LoaderArgs } from "velojs";
143
+ import { useLoader } from "velojs/hooks";
144
+
145
+ export const loader = async ({ c }: LoaderArgs) => {
146
+ return { message: "Hello, VeloJS!" };
147
+ };
148
+
149
+ export const Component = () => {
150
+ const { data } = useLoader<{ message: string }>();
151
+ return <h1>{data.value?.message}</h1>;
152
+ };
153
+ ```
154
+
155
+ ### Run
156
+
157
+ ```bash
158
+ npm run dev # http://localhost:3000
159
+ ```
160
+
161
+ ### Configuration
162
+
163
+ ```typescript
164
+ veloPlugin({
165
+ appDirectory: "./app", // default
166
+ routesFile: "routes.tsx", // default
167
+ serverInit: "server.tsx", // default
168
+ clientInit: "client.tsx", // default
169
+ });
170
+ ```
171
+
172
+ ---
173
+
174
+ ## Routes
175
+
176
+ Routes are defined in `app/routes.tsx` as a tree structure. Each node can have a `module` (component + loader + actions), `children` (nested routes), and `middlewares`.
177
+
178
+ ```typescript
179
+ // app/routes.tsx
180
+ import type { AppRoutes } from "velojs";
181
+ import * as Root from "./client-root.js";
182
+ import * as AuthLayout from "./auth/Layout.js";
183
+ import * as Login from "./auth/Login.js";
184
+ import * as AdminLayout from "./admin/Layout.js";
185
+ import * as Dashboard from "./admin/Dashboard.js";
186
+ import * as Users from "./admin/Users.js";
187
+ import * as UserDetail from "./admin/UserDetail.js";
188
+ import { authMiddleware } from "./modules/auth/auth.middleware.js";
189
+
190
+ export default [
191
+ {
192
+ module: Root,
193
+ isRoot: true,
194
+ children: [
195
+ // Public routes
196
+ {
197
+ module: AuthLayout,
198
+ children: [
199
+ { path: "/login", module: Login },
200
+ ],
201
+ },
202
+ // Authenticated routes
203
+ {
204
+ module: AdminLayout,
205
+ middlewares: [authMiddleware],
206
+ children: [
207
+ { path: "/", module: Dashboard },
208
+ { path: "/users", module: Users },
209
+ { path: "/users/:id", module: UserDetail },
210
+ ],
211
+ },
212
+ ],
213
+ },
214
+ ] satisfies AppRoutes;
215
+ ```
216
+
217
+ ### Component nesting
218
+
219
+ Routes with `children` act as **layouts**. Their `Component` receives `children` and wraps nested routes. VeloJS renders the full hierarchy from root to leaf:
220
+
221
+ ```
222
+ GET /users/123 renders:
223
+
224
+ Root (isRoot — <html>, <head>, <body>)
225
+ └─ AdminLayout (sidebar, nav)
226
+ └─ UserDetail (page content)
227
+ ```
228
+
229
+ ```typescript
230
+ // app/client-root.tsx — Root component
231
+ import { Scripts } from "velojs";
232
+
233
+ export const Component = ({ children }: { children: any }) => (
234
+ <html>
235
+ <head><Scripts /></head>
236
+ <body>{children}</body>
237
+ </html>
238
+ );
239
+
240
+ // app/admin/Layout.tsx — Layout component
241
+ export const Component = ({ children }: { children: any }) => (
242
+ <div class={css.layout}>
243
+ <nav class={css.sidebar}>...</nav>
244
+ <main class={css.content}>{children}</main>
245
+ </div>
246
+ );
247
+
248
+ // app/admin/UserDetail.tsx — Page component (leaf, no children)
249
+ export const Component = () => {
250
+ const { data } = useLoader<User>();
251
+ return <div>{data.value?.name}</div>;
252
+ };
253
+ ```
254
+
255
+ Every layout and page can have its own `loader`. On a request, **all loaders in the hierarchy run in parallel** — Root loader + AdminLayout loader + UserDetail loader all execute at the same time.
256
+
257
+ ### Route Node Properties
258
+
259
+ | Property | Type | Description |
260
+ |----------|------|-------------|
261
+ | `path` | `string` | URL path segment. Supports `:params` (e.g., `/users/:id`). |
262
+ | `module` | `RouteModule` | Module with `Component`, `loader`, `action_*` |
263
+ | `children` | `RouteNode[]` | Nested routes (module acts as layout) |
264
+ | `middlewares` | `MiddlewareHandler[]` | Hono middlewares (server-only, inherited by children) |
265
+ | `isRoot` | `boolean` | Marks the root node (renders `<html>`, `<head>`, `<body>`) |
266
+
267
+ ### Path resolution
268
+
269
+ Paths are **relative segments** that concatenate with parent paths:
270
+
271
+ ```
272
+ Root (no path)
273
+ └─ AdminLayout (no path)
274
+ ├─ Dashboard → path: "/" → fullPath: "/"
275
+ ├─ Users → path: "/users" → fullPath: "/users"
276
+ └─ UserDetail → path: "/users/:id" → fullPath: "/users/:id"
277
+ ```
278
+
279
+ Nodes without `path` don't add a segment — they're pure layout wrappers. The Vite plugin parses `routes.tsx` at build-time and calculates both `fullPath` (absolute) and `path` (relative segment), injecting them into each module's `metadata` export.
280
+
281
+ ### Shared layouts, different paths
282
+
283
+ You can reuse the same layout for different route groups:
284
+
285
+ ```typescript
286
+ export default [
287
+ {
288
+ module: Root,
289
+ isRoot: true,
290
+ children: [
291
+ // Public pages — same layout, no auth
292
+ {
293
+ module: PublicLayout,
294
+ children: [
295
+ { path: "/", module: Home },
296
+ { path: "/about", module: About },
297
+ ],
298
+ },
299
+ // Dashboard — same root, different layout + auth
300
+ {
301
+ path: "/dashboard",
302
+ module: DashboardLayout,
303
+ middlewares: [authMiddleware],
304
+ children: [
305
+ { path: "/", module: Overview },
306
+ { path: "/settings", module: Settings },
307
+ ],
308
+ },
309
+ ],
310
+ },
311
+ ] satisfies AppRoutes;
312
+ ```
313
+
314
+ ---
315
+
316
+ ## Components
317
+
318
+ ### Conventions
319
+
320
+ | Export | Purpose |
321
+ |--------|---------|
322
+ | `export const Component` | Preact component (required) |
323
+ | `export const loader` | Server-side data loader |
324
+ | `export const action_*` | Server-side actions (RPC) |
325
+
326
+ ### Example Page
327
+
328
+ ```typescript
329
+ // app/admin/Users.tsx
330
+ import type { LoaderArgs, ActionArgs } from "velojs";
331
+ import { useLoader } from "velojs/hooks";
332
+
333
+ interface User { id: string; name: string; }
334
+
335
+ export const loader = async ({ params, query, c }: LoaderArgs) => {
336
+ const { getUsers } = await import("./user.service.js");
337
+ return getUsers();
338
+ };
339
+
340
+ export const action_delete = async ({
341
+ body,
342
+ c,
343
+ }: ActionArgs<{ id: string }>) => {
344
+ const { deleteUser } = await import("./user.service.js");
345
+ await deleteUser(body.id);
346
+ return { ok: true };
347
+ };
348
+
349
+ export const Component = () => {
350
+ const { data, loading, refetch } = useLoader<User[]>();
351
+
352
+ if (loading.value) return <div>Loading...</div>;
353
+
354
+ return (
355
+ <ul>
356
+ {data.value?.map((u) => (
357
+ <li key={u.id}>
358
+ {u.name}
359
+ <button onClick={async () => {
360
+ await action_delete({ body: { id: u.id } });
361
+ refetch();
362
+ }}>Delete</button>
363
+ </li>
364
+ ))}
365
+ </ul>
366
+ );
367
+ };
368
+ ```
369
+
370
+ ### Server-only imports
371
+
372
+ Loaders and actions run on the server, but the **file itself** is also bundled for the client (the Vite plugin strips the loader body and transforms actions into fetch stubs). This means **top-level imports are included in the client bundle**.
373
+
374
+ Always use `await import()` inside loaders and actions for server-only code (database access, file system, secrets, etc.):
375
+
376
+ ```typescript
377
+ // BAD — leaks server code into client bundle
378
+ import { getUsers } from "./user.service.js";
379
+ import { db } from "../db/engine.js";
380
+
381
+ export const loader = async () => {
382
+ return db.collection("users").find().toArray();
383
+ };
384
+
385
+ // GOOD — dynamic import, only runs on server
386
+ export const loader = async () => {
387
+ const { getUsers } = await import("./user.service.js");
388
+ return getUsers();
389
+ };
390
+ ```
391
+
392
+ This is the most important convention in VeloJS. If you top-level import a module that uses Node.js APIs (fs, crypto, database drivers), the client build will fail or include unnecessary code.
393
+
394
+ ---
395
+
396
+ ## Loaders
397
+
398
+ Two patterns for consuming loader data:
399
+
400
+ ### `useLoader<T>()` — Component-level (SSR + SPA)
401
+
402
+ Use for page-specific data. Supports SSR hydration and SPA navigation (auto-fetches on navigation).
403
+
404
+ ```typescript
405
+ export const Component = () => {
406
+ const { data, loading, refetch } = useLoader<MyType>();
407
+ // data: Signal<T | null>
408
+ // loading: Signal<boolean>
409
+ // refetch: () => void — manually re-fetch data
410
+ };
411
+ ```
412
+
413
+ With dependencies (re-fetch when deps change):
414
+
415
+ ```typescript
416
+ const params = useParams<{ id: string }>();
417
+ const { data } = useLoader<User>([params.id]);
418
+ ```
419
+
420
+ ### `Loader<T>()` — Module-level (SSR only)
421
+
422
+ Use for global/shared data loaded in a Layout and exported to child modules. Runs once on import — does **not** re-fetch on SPA navigation.
423
+
424
+ ```typescript
425
+ // app/admin/Layout.tsx
426
+ import { Loader } from "velojs/hooks";
427
+
428
+ export const { data: globalData } = Loader<GlobalType>();
429
+
430
+ export const Component = ({ children }) => (
431
+ <div>
432
+ <header>Hello, {globalData.value?.user.name}</header>
433
+ {children}
434
+ </div>
435
+ );
436
+
437
+ // app/admin/Home.tsx — import from Layout
438
+ import { globalData } from "./Layout.js";
439
+
440
+ export const Component = () => (
441
+ <div>Permissions: {globalData.value?.permissions.join(", ")}</div>
442
+ );
443
+ ```
444
+
445
+ ### Data Flow
446
+
447
+ ```
448
+ SSR:
449
+ loader() → server runs all loaders in parallel
450
+ → injects window.__PAGE_DATA__ = { moduleId: data, ... }
451
+ → Loader()/useLoader() hydrate from __PAGE_DATA__
452
+
453
+ SPA navigation:
454
+ useLoader() → fetch(currentPath?_data=1) → JSON { moduleId: data }
455
+ Loader() → returns null (no re-fetch)
456
+ ```
457
+
458
+ ---
459
+
460
+ ## Actions
461
+
462
+ Server-side functions callable from the client via RPC.
463
+
464
+ ### Definition
465
+
466
+ ```typescript
467
+ export const action_login = async ({
468
+ body,
469
+ c,
470
+ }: ActionArgs<{ email: string; password: string }>) => {
471
+ const { authenticate } = await import("./auth.service.js");
472
+ const token = await authenticate(body.email, body.password);
473
+
474
+ const { setCookie } = await import("velojs/cookie");
475
+ setCookie(c!, "session", token, { path: "/" });
476
+
477
+ return { ok: true };
478
+ };
479
+ ```
480
+
481
+ ### Client-Side Behavior
482
+
483
+ The Vite plugin transforms action bodies into fetch stubs at build time:
484
+
485
+ ```typescript
486
+ // Original (server)
487
+ export const action_login = async ({ body, c }: ActionArgs<LoginBody>) => {
488
+ // ... server logic
489
+ };
490
+
491
+ // Transformed (client)
492
+ export const action_login = async ({ body }: { body: LoginBody }) => {
493
+ return fetch("/_action/auth/Login/login", {
494
+ method: "POST",
495
+ headers: { "Content-Type": "application/json" },
496
+ body: JSON.stringify(body),
497
+ }).then(r => r.json());
498
+ };
499
+ ```
500
+
501
+ **Error handling**: Actions do NOT throw on server errors. They resolve with `{ error: "message" }`. Always check `result.error` explicitly.
502
+
503
+ ---
504
+
505
+ ## Hooks
506
+
507
+ All hooks work in both SSR and client (via AsyncLocalStorage on server, wouter/DOM on client).
508
+
509
+ | Hook | Description |
510
+ |------|-------------|
511
+ | `useLoader<T>(deps?)` | Loader data with SSR + SPA support. Returns `{ data, loading, refetch }` |
512
+ | `Loader<T>()` | Module-level SSR-only loader. Returns `{ data, loading }` |
513
+ | `useParams<T>()` | Route parameters (e.g., `:id`) |
514
+ | `useQuery<T>()` | Query string parameters |
515
+ | `useNavigate()` | Programmatic navigation. Returns `navigate(path)` function |
516
+ | `usePathname()` | Absolute pathname (unlike wouter's `useLocation` which is relative to nest context) |
517
+ | `touch(signal)` | Force signal notification after nested property mutation |
518
+
519
+ ### touch
520
+
521
+ ```typescript
522
+ const items = useSignal<Item[]>([]);
523
+
524
+ // Mutating nested properties doesn't trigger signal updates
525
+ items.value[0].checked = true;
526
+
527
+ // touch() forces the update
528
+ touch(items);
529
+ ```
530
+
531
+ ---
532
+
533
+ ## Link Component
534
+
535
+ Navigation with type-safe module references or string paths.
536
+
537
+ ```typescript
538
+ import { Link } from "velojs";
539
+ import * as UserPage from "./users/UserDetail.js";
540
+ import * as LoginPage from "./auth/Login.js";
541
+
542
+ // With route module (relative — uses metadata.path, works with wouter nest context)
543
+ <Link to={UserPage} params={{ id: "123" }}>View</Link>
544
+
545
+ // With route module (absolute — uses metadata.fullPath)
546
+ <Link to={LoginPage} absolute>Login</Link>
547
+
548
+ // With query string
549
+ <Link to={UserPage} params={{ id: "123" }} search={{ tab: "settings" }}>
550
+ Settings
551
+ </Link>
552
+
553
+ // String path (relative to current nest context)
554
+ <Link to="/users">Users</Link>
555
+
556
+ // String path with ~/ prefix (absolute — escapes nest context)
557
+ <Link to="~/stacks">Stacks</Link>
558
+ <Link to={`~/stacks/apps/${appId}/edit`}>Edit App</Link>
559
+ ```
560
+
561
+ ### The `~/` prefix
562
+
563
+ VeloJS uses wouter-preact for routing. When routes are nested (layouts wrapping children), wouter creates a **nest context** — relative paths resolve within the current layout's scope.
564
+
565
+ The `~/` prefix escapes the nest context and navigates from the root. Use it when navigating between sections:
566
+
567
+ ```typescript
568
+ // Inside /master/workers layout, these behave differently:
569
+ <Link to="/details"> → resolves to /master/workers/details (relative)
570
+ <Link to="~/stacks"> → resolves to /stacks (absolute from root)
571
+ ```
572
+
573
+ **When to use `~/`**: anytime you navigate to a route outside the current layout's scope. In practice, most cross-section links use `~/`.
574
+
575
+ ### Props
576
+
577
+ | Prop | Type | Description |
578
+ |------|------|-------------|
579
+ | `to` | `string \| RouteModule` | Destination path or module. String paths support `~/` prefix for absolute navigation |
580
+ | `params` | `Record<string, string>` | URL parameter substitution (`:id` → value) |
581
+ | `search` | `Record<string, string>` | Query string parameters |
582
+ | `absolute` | `boolean` | When using module reference: use `fullPath` instead of `path` (default: `false`) |
583
+
584
+ ---
585
+
586
+ ## Scripts Component
587
+
588
+ Injects necessary scripts and styles in `<head>`.
589
+
590
+ ```tsx
591
+ import { Scripts } from "velojs";
592
+
593
+ export const Component = ({ children }) => (
594
+ <html>
595
+ <head>
596
+ <Scripts />
597
+ </head>
598
+ <body>{children}</body>
599
+ </html>
600
+ );
601
+ ```
602
+
603
+ ### Props
604
+
605
+ | Prop | Type | Default | Description |
606
+ |------|------|---------|-------------|
607
+ | `basePath` | `string` | `process.env.STATIC_BASE_URL \|\| ""` | Base path for static assets |
608
+ | `favicon` | `string \| false` | `"/favicon.ico"` | Favicon path, or `false` to disable |
609
+
610
+ ### Output
611
+
612
+ **Development:**
613
+ ```html
614
+ <link rel="icon" href="/favicon.ico" type="image/x-icon" />
615
+ <script type="module" src="/@vite/client"></script>
616
+ <script type="module" src="/__velo_client.js"></script>
617
+ ```
618
+
619
+ **Production:**
620
+ ```html
621
+ <link rel="icon" href="/favicon.ico" type="image/x-icon" />
622
+ <link rel="stylesheet" href="/client.css" />
623
+ <script type="module" src="/client.js"></script>
624
+ ```
625
+
626
+ ---
627
+
628
+ ## Middlewares
629
+
630
+ Server-side only. Removed from client bundle at build time.
631
+
632
+ ### Creating a middleware
633
+
634
+ Use `createMiddleware` from `velojs/factory` (wraps Hono's middleware):
635
+
636
+ ```typescript
637
+ // app/modules/auth/auth.middleware.ts
638
+ import { createMiddleware } from "velojs/factory";
639
+ import { getCookie } from "velojs/cookie";
640
+
641
+ export const authMiddleware = createMiddleware(async (c, next) => {
642
+ const token = getCookie(c, "session");
643
+
644
+ if (!token) {
645
+ if (c.req.method === "GET") return c.redirect("/login");
646
+ return c.json({ error: "unauthorized" }, 401);
647
+ }
648
+
649
+ // Set data on context — accessible in loaders and actions via c.get()
650
+ const user = await verifyToken(token);
651
+ c.set("user", user);
652
+
653
+ await next();
654
+ });
655
+ ```
656
+
657
+ ### Using in routes
658
+
659
+ Add `middlewares` to any route node. All children inherit the middleware:
660
+
661
+ ```typescript
662
+ // app/routes.tsx
663
+ import { authMiddleware } from "./modules/auth/auth.middleware.js";
664
+ import { masterMiddleware } from "./modules/auth/master.middleware.js";
665
+
666
+ export default [
667
+ {
668
+ module: Root,
669
+ isRoot: true,
670
+ children: [
671
+ // Public routes — no middleware
672
+ { path: "/login", module: AuthLayout, children: [{ module: Login }] },
673
+
674
+ // Authenticated routes
675
+ {
676
+ module: AdminLayout,
677
+ middlewares: [authMiddleware],
678
+ children: [
679
+ { path: "/", module: Dashboard }, // authMiddleware applies
680
+ { path: "/stacks", module: Stacks }, // authMiddleware applies
681
+
682
+ // Admin-only routes — both middlewares apply
683
+ {
684
+ path: "/master",
685
+ module: MasterLayout,
686
+ middlewares: [masterMiddleware],
687
+ children: [
688
+ { path: "/workers", module: Workers }, // auth + master
689
+ { path: "/settings", module: Settings },// auth + master
690
+ ],
691
+ },
692
+ ],
693
+ },
694
+ ],
695
+ },
696
+ ] satisfies AppRoutes;
697
+ ```
698
+
699
+ ### Inheritance
700
+
701
+ Middlewares accumulate from parent to child. In the example above, `/master/workers` runs `authMiddleware` first, then `masterMiddleware`. This applies to both page loads (loaders) and action calls.
702
+
703
+ ### Accessing middleware data in loaders and actions
704
+
705
+ Use Hono's `c.get()` / `c.set()`:
706
+
707
+ ```typescript
708
+ // Middleware sets data
709
+ c.set("user", { id: "123", name: "Mauro", role: "master" });
710
+
711
+ // Loader reads it
712
+ export const loader = async ({ c }: LoaderArgs) => {
713
+ const user = c.get("user");
714
+ return { greeting: `Hello, ${user.name}` };
715
+ };
716
+
717
+ // Action reads it
718
+ export const action_save = async ({ body, c }: ActionArgs<{ name: string }>) => {
719
+ const user = c!.get("user");
720
+ // ...
721
+ };
722
+ ```
723
+
724
+ ---
725
+
726
+ ## Server API
727
+
728
+ ### `addRoutes(fn)`
729
+
730
+ Register custom Hono routes before page/action routes. Call in `app/server.tsx`. Use this for REST APIs, SSE streams, file uploads, webhooks, and any custom HTTP endpoints.
731
+
732
+ ```typescript
733
+ // app/server.tsx
734
+ import { addRoutes } from "velojs/server";
735
+ import type { Hono } from "hono";
736
+
737
+ addRoutes((app: Hono) => {
738
+ // REST API
739
+ app.get("/api/health", (c) => c.json({ ok: true }));
740
+
741
+ app.post("/api/upload", async (c) => {
742
+ const body = await c.req.parseBody();
743
+ const file = body.file;
744
+ // ...
745
+ return c.json({ ok: true });
746
+ });
747
+
748
+ // Middleware for a group of routes
749
+ app.use("/api/admin/*", async (c, next) => {
750
+ const token = c.req.header("Authorization");
751
+ if (!token) return c.json({ error: "Unauthorized" }, 401);
752
+ await next();
753
+ });
754
+ });
755
+ ```
756
+
757
+ ### Server-Sent Events (SSE)
758
+
759
+ Use Hono's `streamSSE` for real-time server-to-client communication.
760
+
761
+ ```typescript
762
+ import { addRoutes } from "velojs/server";
763
+
764
+ addRoutes((app) => {
765
+ app.get("/api/events", async (c) => {
766
+ const { streamSSE } = await import("hono/streaming");
767
+
768
+ return streamSSE(c, async (stream) => {
769
+ // Send snapshot on connect
770
+ await stream.writeSSE({ event: "snapshot", data: JSON.stringify({ count: 0 }) });
771
+
772
+ // Subscribe to updates
773
+ const unsubscribe = subscribe((data) => {
774
+ stream.writeSSE({ event: "update", data: JSON.stringify(data) });
775
+ });
776
+
777
+ // Cleanup on disconnect
778
+ stream.onAbort(() => { unsubscribe(); });
779
+
780
+ // Keep stream open
781
+ await new Promise<void>(() => {});
782
+ });
783
+ });
784
+ });
785
+ ```
786
+
787
+ Client-side consumption with `EventSource`:
788
+
789
+ ```typescript
790
+ useEffect(() => {
791
+ const es = new EventSource("/api/events");
792
+
793
+ es.addEventListener("snapshot", (e) => {
794
+ state.value = JSON.parse(e.data);
795
+ });
796
+
797
+ es.addEventListener("update", (e) => {
798
+ state.value = JSON.parse(e.data);
799
+ });
800
+
801
+ return () => es.close();
802
+ }, []);
803
+ ```
804
+
805
+ ### SSE with polling (live metrics)
806
+
807
+ ```typescript
808
+ addRoutes((app) => {
809
+ app.get("/api/metrics/live", async (c) => {
810
+ const { streamSSE } = await import("hono/streaming");
811
+
812
+ return streamSSE(c, async (stream) => {
813
+ let running = true;
814
+ stream.onAbort(() => { running = false; });
815
+
816
+ while (running) {
817
+ const metrics = await collectMetrics();
818
+ await stream.writeSSE({ data: JSON.stringify(metrics) });
819
+ await new Promise((r) => setTimeout(r, 3000));
820
+ }
821
+ });
822
+ });
823
+ });
824
+ ```
825
+
826
+ ### `onServer(fn)`
827
+
828
+ Access the underlying Node.js HTTP server. Useful for WebSocket handlers.
829
+
830
+ ```typescript
831
+ import { onServer } from "velojs/server";
832
+
833
+ onServer((httpServer) => {
834
+ const { WebSocketServer } = await import("ws");
835
+ const wss = new WebSocketServer({ noServer: true });
836
+
837
+ httpServer.on("upgrade", (req, socket, head) => {
838
+ const url = new URL(req.url!, `http://${req.headers.host}`);
839
+
840
+ if (url.pathname === "/ws") {
841
+ wss.handleUpgrade(req, socket, head, (ws) => {
842
+ ws.on("message", (raw) => {
843
+ const msg = JSON.parse(raw.toString());
844
+ // Handle message
845
+ });
846
+
847
+ ws.on("close", () => {
848
+ // Cleanup
849
+ });
850
+ });
851
+ }
852
+ });
853
+ });
854
+ ```
855
+
856
+ Callbacks queue until the server starts. If called after startup, executes immediately.
857
+
858
+ ### Environment Variables
859
+
860
+ | Variable | Default | Description |
861
+ |----------|---------|-------------|
862
+ | `SERVER_PORT` | `3000` | Server port |
863
+ | `NODE_ENV` | — | `production` enables static file serving |
864
+ | `STATIC_BASE_URL` | `""` | CDN/bucket prefix for static assets |
865
+
866
+ ---
867
+
868
+ ## Vite Plugin Architecture
869
+
870
+ `veloPlugin()` returns 6 plugins:
871
+
872
+ | Plugin | Purpose |
873
+ |--------|---------|
874
+ | `velo:config` | Build config (client/server modes, aliases, defines) |
875
+ | `velo:transform` | AST transforms (metadata injection, action stubs, loader removal) |
876
+ | `velo:static-url` | Rewrites CSS `url(/path)` to `url(STATIC_BASE_URL/path)` at build time |
877
+ | `@preact/preset-vite` | Preact JSX support |
878
+ | `@hono/vite-dev-server` | Dev server with SSR |
879
+ | `velo:ws-bridge` | Exposes Vite's HTTP server for WebSocket handlers in dev mode |
880
+
881
+ ### AST Transformations
882
+
883
+ Applied during Vite's `transform` hook to files in `appDirectory`:
884
+
885
+ | # | Transform | When | What it does |
886
+ |---|-----------|------|-------------|
887
+ | 1 | `injectMetadata` | Server + Client | Adds `export const metadata = { moduleId, fullPath, path }` |
888
+ | 2 | `transformLoaderFunctions` | Server + Client | Injects moduleId: `useLoader()` → `useLoader("moduleId")` |
889
+ | 3 | `transformActionsForClient` | Client only | Replaces action body with `fetch()` stub |
890
+ | 4 | `removeLoaders` | Client only | Removes `export const loader` entirely |
891
+ | 5 | `removeMiddlewares` | Client only | Removes `middlewares: [...]` and related imports |
892
+
893
+ ### Build Process
894
+
895
+ ```bash
896
+ velojs build
897
+ # 1. vite build → dist/client/ (client.js, client.css, manifest.json)
898
+ # 2. vite build --mode server → dist/server.js (SSR entry)
899
+ ```
900
+
901
+ ### Virtual Modules
902
+
903
+ | Module | Purpose |
904
+ |--------|---------|
905
+ | `virtual:velo/server-entry` | Server entry — imports `server.tsx` + routes, calls `startServer()` |
906
+ | `virtual:velo/client-entry` | Client entry — imports `client.tsx` + routes, calls `startClient()` |
907
+ | `/__velo_client.js` | Alias for client entry (used in dev) |
908
+
909
+ ### Hot Reload
910
+
911
+ When `routes.tsx` changes, the plugin rebuilds the fullPath map and triggers a full page reload (not partial HMR).
912
+
913
+ ---
914
+
915
+ ## Request Isolation
916
+
917
+ VeloJS uses Node's `AsyncLocalStorage` to isolate data per request. Each SSR render runs in its own storage context, preventing data leaks between concurrent requests.
918
+
919
+ Hooks (`useParams`, `useQuery`, `usePathname`, `Loader`, `useLoader`) access this storage on the server via `globalThis.__veloServerData`.
920
+
921
+ ---
922
+
923
+ ## Subpath Exports
924
+
925
+ | Import | Contents |
926
+ |--------|----------|
927
+ | `velojs` | Types (`AppRoutes`, `ActionArgs`, `LoaderArgs`, `Metadata`), `Scripts`, `Link`, `defineConfig` |
928
+ | `velojs/server` | `startServer`, `createApp`, `addRoutes`, `onServer`, `serverDataStorage` |
929
+ | `velojs/client` | `startClient` |
930
+ | `velojs/hooks` | `Loader`, `useLoader`, `useParams`, `useQuery`, `useNavigate`, `usePathname`, `touch` |
931
+ | `velojs/cookie` | `getCookie`, `setCookie`, `deleteCookie`, `getSignedCookie`, `setSignedCookie` |
932
+ | `velojs/factory` | `createMiddleware`, `createFactory` |
933
+ | `velojs/vite` | `veloPlugin` |
934
+ | `velojs/config` | `defineConfig`, `VeloConfig` |
935
+
936
+ ---
937
+
938
+ ## Type Reference
939
+
940
+ ```typescript
941
+ interface LoaderArgs {
942
+ params: Record<string, string>;
943
+ query: Record<string, string>;
944
+ c: Context; // Hono Context
945
+ }
946
+
947
+ interface ActionArgs<TBody = unknown> {
948
+ body: TBody;
949
+ params?: Record<string, string>;
950
+ query?: Record<string, string>;
951
+ c?: Context;
952
+ }
953
+
954
+ interface Metadata {
955
+ moduleId: string;
956
+ fullPath?: string;
957
+ path?: string;
958
+ }
959
+
960
+ interface RouteModule {
961
+ Component: ComponentType<any>;
962
+ loader?: (args: LoaderArgs) => Promise<any>;
963
+ metadata?: Metadata;
964
+ [key: `action_${string}`]: (args: ActionArgs) => Promise<any>;
965
+ }
966
+
967
+ interface RouteNode {
968
+ path?: string;
969
+ module: RouteModule;
970
+ children?: RouteNode[];
971
+ middlewares?: MiddlewareHandler[];
972
+ isRoot?: boolean;
973
+ }
974
+
975
+ type AppRoutes = RouteNode[];
976
+
977
+ interface VeloConfig {
978
+ appDirectory?: string; // default: "./app"
979
+ routesFile?: string; // default: "routes.tsx"
980
+ serverInit?: string; // default: "server.tsx"
981
+ clientInit?: string; // default: "client.tsx"
982
+ }
983
+ ```
984
+
985
+ ---
986
+
987
+ ## Docker / Production Deploy
988
+
989
+ ### Dockerfile
990
+
991
+ ```dockerfile
992
+ FROM node:22-alpine AS builder
993
+ WORKDIR /app
994
+ COPY package.json package-lock.json ./
995
+ RUN npm ci
996
+ COPY app ./app
997
+ COPY tsconfig.json vite.config.ts ./
998
+ RUN npm run build
999
+
1000
+ FROM node:22-alpine
1001
+ WORKDIR /app
1002
+ COPY package.json package-lock.json ./
1003
+ RUN npm ci --omit=dev
1004
+ COPY --from=builder /app/dist ./dist
1005
+
1006
+ ENV NODE_ENV=production
1007
+ ENV SERVER_PORT=3000
1008
+ EXPOSE 3000
1009
+ CMD ["node", "dist/server.js"]
1010
+ ```
1011
+
1012
+ ### Build output
1013
+
1014
+ ```bash
1015
+ velojs build
1016
+ # dist/
1017
+ # client/ # Static assets (JS, CSS, images)
1018
+ # client.js
1019
+ # client.css
1020
+ # server.js # SSR server entry (single file)
1021
+ ```
1022
+
1023
+ In production, VeloJS serves static files from `dist/client/` automatically when `NODE_ENV=production`.
1024
+
1025
+ ### Static assets on CDN
1026
+
1027
+ Set `STATIC_BASE_URL` to serve static assets from a CDN or S3 bucket:
1028
+
1029
+ ```bash
1030
+ STATIC_BASE_URL=https://cdn.example.com/assets node dist/server.js
1031
+ ```
1032
+
1033
+ The `<Scripts />` component and CSS `url()` references will use this prefix automatically.
1034
+
1035
+ ---
1036
+
1037
+ ## Included Dependencies
1038
+
1039
+ VeloJS includes everything you need. A single `npm install velojs` brings:
1040
+
1041
+ - **Hono** — HTTP server and routing
1042
+ - **Preact** — UI rendering (SSR + client)
1043
+ - **@preact/signals** — Reactive state management
1044
+ - **wouter-preact** — Client-side routing
1045
+ - **Vite** — Build tool and dev server
1046
+ - **@preact/preset-vite** — Preact JSX support
1047
+ - **@hono/vite-dev-server** — SSR dev server
1048
+
1049
+ No need to install these separately.