@mauroandre/velojs 0.0.37 → 0.0.38

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.
@@ -0,0 +1,472 @@
1
+ ---
2
+ name: velojs-testing
3
+ description: "Writing tests with `createTestApp` from `@mauroandre/velojs/testing`: HTTP requests, actions, loaders, auth, SSE subscriptions, sockets. Use when writing or fixing a test for a VeloJS app."
4
+ ---
5
+
6
+ # Testing
7
+
8
+ VeloJS ships a backend testing toolkit at `@mauroandre/velojs/testing`. It spins up your app in memory, fires HTTP requests against the registered handlers, subscribes to event streams, and asserts on the result. No socket. No browser. No fragile mocks of the framework internals.
9
+
10
+ You test the whole stack — middleware, auth, routing, conventions, serialization — through the API your real users hit.
11
+
12
+ ## Why a built-in toolkit?
13
+
14
+ Writing integration tests for a VeloJS app without this toolkit means one of:
15
+ - Spinning up a real HTTP server, opening sockets, racing on ports
16
+ - Calling `loader`/`action_*`/`stream_*` functions directly and mocking the Hono `Context` by hand
17
+ - Reaching into the database to assert state, bypassing services
18
+
19
+ All three pull tests away from the API your code actually exposes. The toolkit closes that gap.
20
+
21
+ ## Prerequisite — Vitest must load the VeloJS Vite plugin
22
+
23
+ The toolkit relies on metadata that the VeloJS Vite plugin injects at build time (`metadata.moduleId`, `metadata.fullPath`, stream `__path`). For tests to see this metadata, your `vitest.config.ts` must include `veloPlugin()`:
24
+
25
+ ```typescript
26
+ // vitest.config.ts
27
+ import { defineConfig } from "vitest/config";
28
+ import { veloPlugin } from "@mauroandre/velojs/vite";
29
+
30
+ export default defineConfig({
31
+ plugins: [veloPlugin()],
32
+ test: {
33
+ // your test config
34
+ },
35
+ });
36
+ ```
37
+
38
+ If you have a `vite.config.ts` with the plugin already, you can extend it with `mergeConfig`. Without this step, `.action(fn)`, `.loader(fn)`, and `.subscribe(stream)` won't be able to resolve URLs.
39
+
40
+ ## Quick start
41
+
42
+ ```typescript
43
+ import { createTestApp } from "@mauroandre/velojs/testing";
44
+ import routes from "../app/routes.js";
45
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
46
+
47
+ describe("API", () => {
48
+ let app: Awaited<ReturnType<typeof createTestApp>>;
49
+
50
+ beforeAll(async () => {
51
+ app = await createTestApp({
52
+ routes,
53
+ bootstrap: async () => {
54
+ await connect(process.env.MONGO_URI!);
55
+ },
56
+ getSessionCookie: async ({ user }) => ({
57
+ session: await createToken(user),
58
+ }),
59
+ });
60
+ });
61
+
62
+ afterAll(async () => await app.close());
63
+
64
+ it("returns 200 for health check", async () => {
65
+ const res = await app.get("/api/health");
66
+ expect(res.status).toBe(200);
67
+ });
68
+ });
69
+ ```
70
+
71
+ That's the whole shape. Below is each piece in detail.
72
+
73
+ ## createTestApp
74
+
75
+ ```typescript
76
+ const app = await createTestApp({
77
+ routes,
78
+ bootstrap?: async () => { ... },
79
+ getSessionCookie?: async ({ user }) => ({ ... }),
80
+ });
81
+ ```
82
+
83
+ | Option | Purpose |
84
+ |--------|---------|
85
+ | `routes` | Required. Your app's `routes` array — typically `import routes from "./app/routes.js"` (it is the **default** export of `routes.tsx`) |
86
+ | `bootstrap` | Runs once before the app is created. Use for DB connections, index creation, anything `app/server.tsx` does at startup. `addRoutes()` and `onServer()` calls here are **scoped to this app only** |
87
+ | `getSessionCookie` | Maps a user object to cookies the test client will attach. Required for `app.as(user)` and `app.sessionCookies(user)` |
88
+
89
+ ### app.close()
90
+
91
+ Tears down everything: heartbeats, retention timers, listeners, callbacks. After `close()`, Vitest must report **zero open handles**. Always call it in `afterAll`.
92
+
93
+ ### app.reset()
94
+
95
+ Zeros transient state without re-importing routes or re-running `bootstrap`:
96
+ - All event stream buffers (`{ snapshot: true }` accumulator)
97
+ - All listeners (residual subscribers)
98
+ - All closed-channel sets
99
+ - All `perChannelSource` AbortControllers
100
+
101
+ Call it in `beforeEach` to keep tests isolated:
102
+
103
+ ```typescript
104
+ beforeEach(async () => {
105
+ await app.reset();
106
+ await cleanTestDb(); // your responsibility
107
+ });
108
+ ```
109
+
110
+ ## HTTP client
111
+
112
+ ```typescript
113
+ const res = await app.get("/api/repos", {
114
+ cookies: { session: "jwt" },
115
+ headers: { "X-Trace": "abc" },
116
+ query: { filter: "active" },
117
+ });
118
+
119
+ await app.post("/api/payments/webhook", {
120
+ body: { txid: "abc" }, // auto JSON-serialized + Content-Type set
121
+ });
122
+
123
+ await app.post("/api/upload", {
124
+ body: formData, // FormData passed as-is
125
+ });
126
+ ```
127
+
128
+ Methods: `.get`, `.post`, `.put`, `.patch`, `.delete`.
129
+
130
+ These methods hit everything registered under your `routes` — **pages, actions, streams, and endpoints**. A webhook declared as `{ path: "/api/github/webhook", method: "POST", handler: githubWebhook }` in `routes.tsx` is exercised exactly like any other POST route:
131
+
132
+ ```typescript
133
+ const res = await app.post("/api/github/webhook", {
134
+ headers: { "x-hub-signature-256": sig },
135
+ body: { action: "push" },
136
+ });
137
+ ```
138
+
139
+ See [06-endpoints.md](/docs/endpoints) for declaring endpoints.
140
+
141
+ `TestResponse` mirrors the Fetch `Response`:
142
+
143
+ | Field | Description |
144
+ |-------|-------------|
145
+ | `status` | HTTP status |
146
+ | `headers` | Plain object. **Keys are lowercased** (Fetch `Headers` normalization) — use `res.headers["x-trace"]`, not `res.headers["X-Trace"]` |
147
+ | `cookies` | Set-Cookie headers parsed into a record |
148
+ | `json<T>()` | Parse body as JSON |
149
+ | `text()` | Body as text |
150
+ | `blob()` | Body as Blob |
151
+ | `raw` | The underlying Response |
152
+
153
+ ### Cookies and query strings
154
+
155
+ Pass `cookies: { name: value }` and the toolkit serializes them into the `Cookie` header. Query strings accept arrays:
156
+
157
+ ```typescript
158
+ await app.get("/api/posts", { query: { tag: ["preact", "ssr"] } });
159
+ // → /api/posts?tag=preact&tag=ssr
160
+ ```
161
+
162
+ ## Auth — getSessionCookie and .as
163
+
164
+ Most authenticated tests look like:
165
+
166
+ ```typescript
167
+ const cookies = await app.sessionCookies({ user: alice });
168
+ const res = await app.get("/api/me", { cookies });
169
+ ```
170
+
171
+ But that gets repetitive. Use `app.as({ user })` to scope a sub-client:
172
+
173
+ ```typescript
174
+ const asAlice = app.as({ user: alice });
175
+ await asAlice.get("/api/me");
176
+ await asAlice.action(action_save, { body: {...} });
177
+ await asAlice.subscribe(stream_progress, { channel: appId });
178
+ ```
179
+
180
+ Every method of the sub-client carries Alice's cookies automatically.
181
+
182
+ If `getSessionCookie` was not provided, `.as()` throws a clear error. The framework doesn't know your app's session format — you provide it.
183
+
184
+ ## Conventions — .action(fn) and .loader(fn)
185
+
186
+ For VeloJS conventions, you don't need to know the URL. Pass the imported function:
187
+
188
+ ```typescript
189
+ import { action_setBackup } from "../app/stacks/Backup.js";
190
+
191
+ const res = await app.action(action_setBackup, {
192
+ body: { appId, schedule: "daily" },
193
+ cookies: { session: jwt },
194
+ });
195
+ expect(res.status).toBe(200);
196
+ ```
197
+
198
+ ```typescript
199
+ import { loader as homeLoader } from "../app/Home.js";
200
+
201
+ const data = await app.loader(homeLoader, {
202
+ cookies: { session: jwt },
203
+ params: { id: "abc" },
204
+ });
205
+ // `data` is the loader's return value, unwrapped from the HTTP response
206
+ expect(data.user.name).toBe("Alice");
207
+ ```
208
+
209
+ ### .loader unwrapping rules
210
+
211
+ | Loader behavior | Returned by `.loader()` |
212
+ |-----------------|------------------------|
213
+ | Returned a value | The value directly (unwrapped) |
214
+ | Redirected (`c.redirect(...)`) | `TestResponse` for status inspection |
215
+ | Set a non-2xx status (`c.status(403)`) | `TestResponse` for status inspection |
216
+ | Threw an exception | `TestResponse` with status 500 — **not** re-thrown |
217
+
218
+ A loader runs inside a Hono handler, and Hono catches a throw and turns it into a 500 response — so `.loader()` resolves either way. Assert on `res.status`; `await expect(app.loader(fn)).rejects.toThrow()` can never pass.
219
+
220
+ If the function isn't found in the route tree, `.action`/`.loader` throws a clear error pointing at the most likely cause (Vitest config missing the VeloJS plugin).
221
+
222
+ ### Fallback — string URL
223
+
224
+ If you need to bypass resolution, pass a URL string:
225
+
226
+ ```typescript
227
+ await app.action("/_action/Login/login", { body: {...} });
228
+ ```
229
+
230
+ ## Streams — .subscribe(stream)
231
+
232
+ Subscribing to a stream returns a `TestSubscription`:
233
+
234
+ ```typescript
235
+ const sub = await app.as({ user: alice }).subscribe(stream_progress, {
236
+ channel: deployId,
237
+ });
238
+ ```
239
+
240
+ | Field/method | Description |
241
+ |--------------|-------------|
242
+ | `sub.status` | HTTP status of the initial response (200, 403, etc) |
243
+ | `sub.snapshot` | Snapshot from the server (buffer or callback). `null` if none |
244
+ | `sub.events` | All events received since connect, in order |
245
+ | `sub.closed` | `true` after server signals deliberate close |
246
+ | `sub.next({ timeoutMs })` | Wait for next event. **Rejects on timeout** (no silent undefined) |
247
+ | `sub.nextN(n, { timeoutMs })` | Wait for N events. Total timeout is shared across them |
248
+ | `sub.close()` | Force disconnect — triggers `perChannelSource` `abortSignal` server-side |
249
+
250
+ ### Asserting cross-user isolation
251
+
252
+ ```typescript
253
+ it("user B cannot subscribe to user A's app", async () => {
254
+ const sub = await app.as({ user: userB }).subscribe(stream_appLogs, {
255
+ channel: userAsAppId,
256
+ });
257
+ expect(sub.status).toBe(403);
258
+ expect(sub.events).toEqual([]);
259
+ });
260
+ ```
261
+
262
+ ### Asserting snapshot replay on reconnect
263
+
264
+ ```typescript
265
+ it("F5 mid-deploy shows current state immediately", async () => {
266
+ const subA = await app.subscribe(stream_progress, { channel: appId });
267
+ await app.action(action_startDeploy, { body: { appId } });
268
+ await subA.next({ timeoutMs: 2000 });
269
+ await subA.close();
270
+
271
+ // Reconnect — buffer/snapshot still available within retainMs
272
+ const subB = await app.subscribe(stream_progress, { channel: appId });
273
+ expect(subB.snapshot?.[0]).toMatchObject({ status: "running" });
274
+ });
275
+ ```
276
+
277
+ ### Asserting perChannelSource lifecycle
278
+
279
+ ```typescript
280
+ it("perChannelSource aborts when the last subscriber leaves", async () => {
281
+ const a = await app.subscribe(stream_logs, { channel: "session" });
282
+ const b = await app.subscribe(stream_logs, { channel: "session" });
283
+
284
+ await a.close();
285
+ // Source still running — b is still subscribed
286
+ expect(activeSshConnections.get("session")).toBeDefined();
287
+
288
+ await b.close();
289
+ // Source aborted
290
+ expect(activeSshConnections.get("session")).toBeUndefined();
291
+ });
292
+ ```
293
+
294
+ ## Sockets — `.socket(handler)`
295
+
296
+ `app.socket(handlerOrStubOrPath, opts)` opens an **in-memory** WebSocket session against a `socket_*` handler — no real TCP, no upgrade handshake. The handler runs directly with a mock Context.
297
+
298
+ ```typescript
299
+ import { socket_terminal } from "../app/workers/WorkerTerminal.js";
300
+
301
+ const ws = await app.socket(socket_terminal, {
302
+ user: { id: "alice" },
303
+ params: { workerId: "w42" },
304
+ channel: "w42",
305
+ });
306
+
307
+ // Send frames client → server
308
+ ws.send({ type: "data", data: "ls\n" });
309
+
310
+ // Receive frames server → client
311
+ const reply = await ws.next({ timeoutMs: 500 });
312
+ expect(JSON.parse(reply as string)).toMatchObject({ type: "data" });
313
+
314
+ // Consume a batch
315
+ const batch = await ws.nextN(3, { timeoutMs: 1000 });
316
+
317
+ // Close from the client — fires abortSignal inside the handler
318
+ await ws.close();
319
+ ```
320
+
321
+ | Field / method | Description |
322
+ |---|---|
323
+ | `ws.send(msg)` | Send a frame. `string` / `Uint8Array` pass through; objects → `JSON.stringify`. |
324
+ | `ws.next({ timeoutMs })` | Wait for the next server-sent frame (cursor advances). |
325
+ | `ws.nextN(n, { timeoutMs })` | Collect `n` consecutive frames (total timeout, not per-frame). |
326
+ | `ws.messages` | All frames received so far. |
327
+ | `ws.closed` | `true` when the handler finished or `close()` was called. |
328
+ | `ws.done` | Promise that resolves when the handler finishes. |
329
+ | `ws.close(code?, reason?)` | Client-initiated close. Aborts `abortSignal` inside the handler. |
330
+
331
+ ### What `app.socket()` does NOT do
332
+
333
+ - **Middleware does not run.** If your socket handler reads `c.get("user")`, pass `user` via the option:
334
+ ```typescript
335
+ const ws = await app.socket(handler, { user: await buildUserForTest(...) });
336
+ ```
337
+ Test middleware correctness separately through a regular endpoint or page that exercises the same middleware.
338
+ - **No real HTTP upgrade.** Binary frames (`Uint8Array`) pass through the queue, but the adapter is a buffered channel, not a socket.
339
+
340
+ ### Accepting multiple input shapes
341
+
342
+ - **Function**: the server-imported `socket_*` handler.
343
+ `app.socket(socket_terminal, ...)`
344
+ - **Path string**: resolve by route path.
345
+ `app.socket("/_socket/workers/WorkerTerminal/terminal", ...)`
346
+ - **Stub**: the client stub `{ __path }` produced by the Vite plugin.
347
+ `app.socket(terminalStub, ...)`
348
+
349
+ `app.close()` aborts every open socket session on the app — no leaked handlers between tests.
350
+
351
+ ## mockContext — escape hatch
352
+
353
+ Last resort. Build a partial Hono `Context` for direct invocation of background jobs or internal helpers that aren't routes:
354
+
355
+ ```typescript
356
+ const c = app.mockContext({
357
+ user: { id, email, role },
358
+ params: { id: "abc" },
359
+ query: { filter: "active" },
360
+ body: { foo: "bar" },
361
+ });
362
+
363
+ await someJob({ c });
364
+ ```
365
+
366
+ Middlewares **don't run** with `mockContext`. That's the trade-off — direct invocation skips the stack. Use `.action`/`.loader`/`.subscribe` whenever possible; reach for `mockContext` only for code that isn't behind a route.
367
+
368
+ ## Putting it together — full example
369
+
370
+ ```typescript
371
+ import { createTestApp } from "@mauroandre/velojs/testing";
372
+ import routes from "../app/routes.js";
373
+ import { stream_backup } from "../app/layouts/AdminLayout.js";
374
+ import { action_setBackup, action_backupNow } from "../app/stacks/apps/AppEdit.js";
375
+ import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
376
+
377
+ describe("Backup — stream + auth + ownership", () => {
378
+ let app: Awaited<ReturnType<typeof createTestApp>>;
379
+ let userA: any, userB: any, appId: string;
380
+
381
+ beforeAll(async () => {
382
+ app = await createTestApp({
383
+ routes,
384
+ bootstrap: async () => {
385
+ const { connect } = await import("@mauroandre/zodmongo");
386
+ await connect(process.env.MONGO_URI!, "podcubo_test");
387
+ },
388
+ getSessionCookie: async ({ user }) => {
389
+ const { createToken } = await import("../app/modules/user/user.service.js");
390
+ return { session: await createToken({ user }) };
391
+ },
392
+ });
393
+ });
394
+
395
+ afterAll(async () => await app.close());
396
+
397
+ beforeEach(async () => {
398
+ await app.reset();
399
+ await cleanTestDb();
400
+ userA = await factory.user({ email: "a@x.com" });
401
+ userB = await factory.user({ email: "b@x.com" });
402
+ appId = await factory.dbApp(userA, "postgresql");
403
+ });
404
+
405
+ it("backup events reach the app owner", async () => {
406
+ const sub = await app.as({ user: userA }).subscribe(stream_backup, {
407
+ channel: appId,
408
+ });
409
+ expect(sub.status).toBe(200);
410
+
411
+ await app.as({ user: userA }).action(action_setBackup, {
412
+ body: { appId, schedule: "daily" },
413
+ });
414
+ await app.as({ user: userA }).action(action_backupNow, { body: { appId } });
415
+
416
+ const event = await sub.next({ timeoutMs: 2000 });
417
+ expect(event).toMatchObject({ kind: "backup", status: "running" });
418
+ await sub.close();
419
+ });
420
+
421
+ it("user B cannot subscribe to user A's app stream", async () => {
422
+ const sub = await app.as({ user: userB }).subscribe(stream_backup, {
423
+ channel: appId,
424
+ });
425
+ expect(sub.status).toBe(403);
426
+ expect(sub.events).toEqual([]);
427
+ });
428
+ });
429
+ ```
430
+
431
+ A single test exercises: channel resolver with auth, middleware chain, cookie parsing, action invocation, service → stream emit, snapshot persistence, reconnection. **No mocks of framework internals.** Real DB. Stack complete.
432
+
433
+ ## Patterns that are NOT in scope
434
+
435
+ The toolkit covers backend testing (HTTP, streams, server-side functions). It **does not** cover:
436
+
437
+ - **Driver / service mocking** — use `vi.mock(path, factory)` from Vitest. The toolkit doesn't try to abstract this since Vitest already does it well.
438
+ - **Frontend testing** — use Preact Testing Library or Playwright. These are well-served by external tools; VeloJS doesn't duplicate them.
439
+ - **End-to-end with browser** — use Playwright with `velojs start`.
440
+
441
+ ## FAQ
442
+
443
+ ### Can I run tests in parallel?
444
+
445
+ Yes. Each test file runs in its own Vitest worker, and the toolkit isolates state per `createTestApp` instance. Within a file, `it.concurrent` works as long as each test has its own app or you avoid shared streams across concurrent tests.
446
+
447
+ ### Does `app.close()` really clean up everything?
448
+
449
+ Yes — that's an invariant, not a feature. Heartbeats, retention timers, source AbortControllers, listeners — all go away. If Vitest reports open handles after `close()`, that's a bug in the toolkit.
450
+
451
+ ### What about `addRoutes` and `onServer` called at top-level import?
452
+
453
+ If your `app/server.tsx` calls `addRoutes(...)` at the top level, those calls happen at import time, before any `createTestApp`. The toolkit captures a snapshot of the default context when each `createTestApp` is created, so top-level registrations are included.
454
+
455
+ If your test needs **additional** route registration scoped to that test, do it inside `bootstrap`:
456
+
457
+ ```typescript
458
+ const app = await createTestApp({
459
+ routes,
460
+ bootstrap: async () => {
461
+ addRoutes((a) => a.get("/test-only", (c) => c.json({ ok: true })));
462
+ },
463
+ });
464
+ ```
465
+
466
+ ### How do I test a stream without a real perChannelSource resource?
467
+
468
+ Mock the resource (e.g., the SSH client) with `vi.mock`, then subscribe normally. The framework's lifecycle (subscribe → start → unsubscribe → abort) runs as in production; only the underlying resource is mocked.
469
+
470
+ ### Why no top-level await on createTestApp?
471
+
472
+ Top-level await works in Vitest, but the explicit `beforeAll` is clearer and lets you call `app.close()` symmetrically in `afterAll`.
@@ -0,0 +1,142 @@
1
+ ---
2
+ name: velojs-type-reference
3
+ description: "Exact TypeScript signatures for route and config types: `AppRoutes`, `RouteNode`, `RouteModule`, `LoaderArgs`, `ActionArgs`, `Metadata`, `EndpointHandler`, `VeloConfig`, plus the package subpath exports. Use when you need a precise type or aren't sure which subpath exports a symbol."
4
+ ---
5
+
6
+ # Type Reference
7
+
8
+ This page lists all the TypeScript types and interfaces exported by VeloJS, organized by import path.
9
+
10
+ ## Subpath exports
11
+
12
+ | Import | What you get |
13
+ |--------|-------------|
14
+ | `@mauroandre/velojs` | Types (`AppRoutes`, `RouteNode`, `RouteModule`, `ActionArgs`, `LoaderArgs`, `Metadata`, `HTTPMethod`, `EndpointHandler`, `EndpointHandlerArgs`, `VeloConfig`), `Scripts`, `Link`, `defineConfig`, `createEventStream`, `poll` |
15
+ | `@mauroandre/velojs/server` | `startServer`, `createApp`, `addRoutes`, `onServer`, `serverDataStorage` |
16
+ | `@mauroandre/velojs/client` | `startClient` |
17
+ | `@mauroandre/velojs/hooks` | `Loader`, `useLoader`, `useParams`, `useQuery`, `useNavigate`, `usePathname`, `touch`, `useEventStream`, `useSocket` |
18
+ | `@mauroandre/velojs/events` | `createEventStream`, `poll` |
19
+ | `@mauroandre/velojs/sockets` | `parseJson` |
20
+ | `@mauroandre/velojs/testing` | `createTestApp`, `TestApp`, `TestResponse`, `TestSubscription`, `TestSocketSession` |
21
+ | `@mauroandre/velojs/cookie` | `getCookie`, `setCookie`, `deleteCookie`, `getSignedCookie`, `setSignedCookie` |
22
+ | `@mauroandre/velojs/factory` | `createMiddleware`, `createFactory` |
23
+ | `@mauroandre/velojs/vite` | `veloPlugin` |
24
+ | `@mauroandre/velojs/config` | `defineConfig`, `VeloConfig` |
25
+
26
+ ## Interfaces
27
+
28
+ ### LoaderArgs
29
+
30
+ Passed to every `loader` function. Contains the URL parameters, query string, and Hono context:
31
+
32
+ ```typescript
33
+ interface LoaderArgs {
34
+ params: Record<string, string>; // URL params like :id
35
+ query: Record<string, string>; // Query string like ?page=2
36
+ c: Context; // Hono request context
37
+ }
38
+ ```
39
+
40
+ ### ActionArgs
41
+
42
+ Passed to every `action_*` function. The generic type `TBody` defines the shape of the data sent from the client:
43
+
44
+ ```typescript
45
+ interface ActionArgs<TBody = unknown> {
46
+ body: TBody; // Data from the client
47
+ params?: Record<string, string>; // URL params (server only)
48
+ query?: Record<string, string>; // Query string (server only)
49
+ c?: Context; // Hono context (server only)
50
+ }
51
+ ```
52
+
53
+ ### Metadata
54
+
55
+ Automatically injected into each module by the Vite plugin. Contains the module identifier and resolved paths:
56
+
57
+ ```typescript
58
+ interface Metadata {
59
+ moduleId: string; // e.g., "admin/Users"
60
+ fullPath?: string; // e.g., "/admin/users"
61
+ path?: string; // e.g., "/users"
62
+ [key: string]: unknown; // your own metadata (title, etc)
63
+ }
64
+ ```
65
+
66
+ ### RouteModule
67
+
68
+ The shape of a route module (what `import * as Module` gives you):
69
+
70
+ ```typescript
71
+ interface RouteModule {
72
+ Component: ComponentType<any>;
73
+ loader?: (args: LoaderArgs) => Promise<any>;
74
+ /** Dynamic routes to prerender with `velojs build --static`. */
75
+ staticPaths?: () => Promise<Record<string, string>[]>;
76
+ metadata?: Metadata;
77
+ [key: `action_${string}`]: (args: ActionArgs<any>) => Promise<any>;
78
+ }
79
+ ```
80
+
81
+ ### RouteNode
82
+
83
+ A single node in the route tree defined in `routes.tsx`:
84
+
85
+ ```typescript
86
+ interface RouteNode {
87
+ path?: string;
88
+ /** Optional: endpoint-only and grouping nodes have no module. */
89
+ module?: RouteModule;
90
+ /** Endpoint nodes — see the Endpoints doc. */
91
+ method?: HTTPMethod;
92
+ handler?: EndpointHandler;
93
+ children?: RouteNode[];
94
+ middlewares?: MiddlewareHandler[];
95
+ isRoot?: boolean;
96
+ /** HTTP status for the page (default 200). A bare `path: "*"` catch-all
97
+ * defaults to 404 and is served via Hono's notFound hook. */
98
+ statusCode?: number;
99
+ }
100
+ ```
101
+
102
+ ### HTTPMethod
103
+
104
+ ```typescript
105
+ type HTTPMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
106
+ ```
107
+
108
+ ### EndpointHandlerArgs / EndpointHandler
109
+
110
+ Passed to an endpoint node's `handler`. Unlike a loader, it returns a `Response`:
111
+
112
+ ```typescript
113
+ interface EndpointHandlerArgs {
114
+ c: Context;
115
+ params: Record<string, string>;
116
+ query: Record<string, string>;
117
+ }
118
+
119
+ type EndpointHandler = (args: EndpointHandlerArgs) => Response | Promise<Response>;
120
+ ```
121
+
122
+ ### AppRoutes
123
+
124
+ The type of the default export from `routes.tsx`:
125
+
126
+ ```typescript
127
+ type AppRoutes = RouteNode[];
128
+ ```
129
+
130
+ ### VeloConfig
131
+
132
+ Configuration options for `veloPlugin()`:
133
+
134
+ ```typescript
135
+ interface VeloConfig {
136
+ appDirectory?: string; // default: "./app"
137
+ routesFile?: string; // default: "routes.tsx"
138
+ serverInit?: string; // default: "server.tsx"
139
+ clientInit?: string; // default: "client.tsx"
140
+ port?: number; // default: 3000 (the PORT env always wins)
141
+ }
142
+ ```
@@ -0,0 +1,71 @@
1
+ ---
2
+ name: velojs-vite-plugin
3
+ description: "The `veloPlugin` build pipeline: the 9 AST transforms that inject metadata, stub actions, strip loaders/middlewares and prune imports; plugin options and virtual entries. Use when configuring `vite.config.ts` or debugging why transformed code behaves unexpectedly."
4
+ ---
5
+
6
+ # Vite Plugin
7
+
8
+ The `veloPlugin()` is the core of VeloJS. It's a single function that returns 6 Vite plugins working together to handle routing, SSR, code transforms, and more.
9
+
10
+ ## The 6 plugins
11
+
12
+ | Plugin | What it does |
13
+ |--------|-------------|
14
+ | `velo:config` | Sets up build config for client/server modes, Preact aliases, and environment defines |
15
+ | `velo:transform` | Performs AST transforms on your code (the most important plugin) |
16
+ | `velo:static-url` | Rewrites CSS `url(/path)` to use `STATIC_BASE_URL` in production builds |
17
+ | `@preact/preset-vite` | Adds Preact JSX support |
18
+ | `@hono/vite-dev-server` | Runs the SSR dev server |
19
+ | `velo:ws-bridge` | Exposes Vite's HTTP server for WebSocket handlers during development |
20
+
21
+ ## AST Transformations
22
+
23
+ The `velo:transform` plugin uses Babel to modify your code at build time. It applies 9 transformations to `.ts`/`.tsx` files inside your `appDirectory` (a file elsewhere, or a `.js`/`.jsx` one, is not transformed at all):
24
+
25
+ | # | Transform | When | What it does |
26
+ |---|-----------|------|-------------|
27
+ | 1 | Remove middlewares | Client only | Strips the `middlewares` property and any import only it referenced |
28
+ | 2 | Remove endpoint routes | Client only | Strips `{ method, handler }` nodes from `routes.tsx`, and their imports |
29
+ | 3 | Inject metadata | Server + Client | Adds `export const metadata = { moduleId, fullPath, path }` to each module |
30
+ | 4 | Transform loader functions | Server + Client | Injects moduleId into `useLoader()` and `Loader()` calls so they know which data to read |
31
+ | 5 | Transform actions for client | Client only | Replaces `action_*` bodies with `fetch()` stubs (the RPC mechanism) |
32
+ | 6 | Transform streams for client | Client only | Replaces `stream_*` with a `{ __path }` stub |
33
+ | 7 | Transform sockets for client | Client only | Replaces `socket_*` with a `{ __path }` stub |
34
+ | 8 | Remove loaders | Client only | Strips `export const loader` entirely from the client bundle |
35
+ | 9 | Prune orphaned imports | Client only | Removes imports left unreferenced by 1–8, so server-only code doesn't reach the browser |
36
+
37
+ These transforms are why VeloJS can provide such a seamless developer experience — you write one file, and the framework generates the right code for server and client automatically.
38
+
39
+ ## Build process
40
+
41
+ When you run `velojs build`, two Vite builds happen in sequence:
42
+
43
+ ```bash
44
+ velojs build
45
+ # 1. vite build → dist/client/ (client.[hash].js, client.[hash].css + .vite/manifest.json)
46
+ # 2. vite build --mode server → dist/server.js
47
+ ```
48
+
49
+ The first build creates the client bundle (JavaScript, CSS, and assets). The second build creates the server entry point that handles SSR and API routes.
50
+
51
+ ## Virtual modules
52
+
53
+ VeloJS uses Vite virtual modules to generate the server and client entry points automatically:
54
+
55
+ | Module | Purpose |
56
+ |--------|---------|
57
+ | `virtual:velo/server-entry` | Imports your `server.tsx` + routes, calls `startServer()` |
58
+ | `virtual:velo/client-entry` | Imports your `client.tsx` + routes, calls `startClient()` |
59
+ | `/__velo_client.js` | Alias for the client entry (used in dev mode) |
60
+
61
+ You never import these directly — they're generated by the plugin.
62
+
63
+ ## Hot reload
64
+
65
+ When you edit `routes.tsx`, the plugin detects the change, rebuilds the path map, and triggers a full page reload. Other file changes use Vite's standard HMR (hot module replacement).
66
+
67
+ ## Request isolation
68
+
69
+ VeloJS uses Node's `AsyncLocalStorage` to isolate data per request. Each SSR render runs in its own storage context, so concurrent requests never leak data between each other.
70
+
71
+ This is why hooks like `useParams()`, `useQuery()`, and `Loader()` work correctly on the server — they read from the current request's storage, not from a global variable.