@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/LICENSE +21 -0
- package/README.md +1049 -0
- package/bin/velojs.js +15 -0
- package/package.json +120 -0
- package/src/cli.ts +83 -0
- package/src/client.tsx +79 -0
- package/src/components.tsx +155 -0
- package/src/config.ts +29 -0
- package/src/cookie.ts +7 -0
- package/src/factory.ts +1 -0
- package/src/hooks.tsx +266 -0
- package/src/index.ts +19 -0
- package/src/init.ts +177 -0
- package/src/server.tsx +347 -0
- package/src/types.ts +39 -0
- package/src/vite.ts +937 -0
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.
|