@mauroandre/velojs 0.0.36 → 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.
Files changed (32) hide show
  1. package/README.md +20 -10
  2. package/agent/AGENTS.md +143 -0
  3. package/agent/skills/velojs-actions/SKILL.md +192 -0
  4. package/agent/skills/velojs-components/SKILL.md +99 -0
  5. package/agent/skills/velojs-endpoints/SKILL.md +187 -0
  6. package/agent/skills/velojs-event-streams/SKILL.md +705 -0
  7. package/agent/skills/velojs-getting-started/SKILL.md +191 -0
  8. package/agent/skills/velojs-hooks/SKILL.md +103 -0
  9. package/agent/skills/velojs-link-component/SKILL.md +67 -0
  10. package/agent/skills/velojs-loaders/SKILL.md +134 -0
  11. package/agent/skills/velojs-middlewares/SKILL.md +143 -0
  12. package/agent/skills/velojs-production-deploy/SKILL.md +96 -0
  13. package/agent/skills/velojs-routes/SKILL.md +209 -0
  14. package/agent/skills/velojs-scripts-component/SKILL.md +62 -0
  15. package/agent/skills/velojs-server-api/SKILL.md +155 -0
  16. package/agent/skills/velojs-sockets/SKILL.md +303 -0
  17. package/agent/skills/velojs-static-generation/SKILL.md +148 -0
  18. package/agent/skills/velojs-testing/SKILL.md +472 -0
  19. package/agent/skills/velojs-type-reference/SKILL.md +142 -0
  20. package/agent/skills/velojs-vite-plugin/SKILL.md +71 -0
  21. package/dist/client.js +58 -47
  22. package/dist/components.js +18 -16
  23. package/dist/hooks.d.ts +3 -2
  24. package/dist/hooks.js +107 -110
  25. package/dist/loader-store.d.ts +49 -0
  26. package/dist/loader-store.js +103 -0
  27. package/dist/server.js +11 -11
  28. package/dist/testing/createTestApp.d.ts +1 -1
  29. package/dist/testing/index.d.ts +1 -1
  30. package/dist/testing/types.d.ts +7 -3
  31. package/dist/vite.js +276 -277
  32. package/package.json +6 -2
@@ -0,0 +1,303 @@
1
+ ---
2
+ name: velojs-sockets
3
+ description: "WebSockets via `socket_*` and `useSocket`: bidirectional realtime like terminals, chat, collaborative editing. Use when the client must both send and receive continuously; for server-to-client only, prefer event-streams."
4
+ ---
5
+
6
+ # Sockets
7
+
8
+ Sockets are **declarative WebSocket handlers** co-located with the page that uses them. Export `socket_<name>` from a page or layout module and VeloJS registers a WebSocket route at `/_socket/{moduleId}/{name}`, with the same middleware inheritance as pages, actions, and streams.
9
+
10
+ Use sockets when you need **bidirectional** server ↔ client communication: interactive terminals, collaborative editing, live cursors, anything where the client needs to send messages too. For one-way push (server → client), use [Event Streams](/docs/event-streams).
11
+
12
+ ## The shortest example
13
+
14
+ ```tsx
15
+ // app/workers/WorkerTerminal.tsx
16
+ import type { SocketHandler } from "@mauroandre/velojs";
17
+ import { parseJson } from "@mauroandre/velojs/sockets";
18
+ import { useSocket, useParams } from "@mauroandre/velojs/hooks";
19
+
20
+ type Incoming = { type: "data"; data: string } | { type: "resize"; cols: number; rows: number };
21
+ type Outgoing = { type: "data"; data: string } | { type: "exit"; code: number };
22
+
23
+ export const socket_terminal: SocketHandler = async ({
24
+ incoming, send, close, keepOpen, abortSignal, c, query,
25
+ }) => {
26
+ const user = c.get("user");
27
+ // The client sends the id as `?channel=` — see the note below on `params`.
28
+ const workerId = query.channel;
29
+ if (!workerId) { close(1008, "missing channel"); return; }
30
+
31
+ const session = await createTerminal({ user, workerId });
32
+
33
+ session.on("data", (chunk) => send({ type: "data", data: chunk.toString() }));
34
+ session.on("exit", (code) => send({ type: "exit", code }));
35
+ abortSignal.addEventListener("abort", () => session.destroy());
36
+
37
+ keepOpen();
38
+
39
+ for await (const msg of parseJson<Incoming>(incoming)) {
40
+ if (msg.type === "data") session.write(msg.data);
41
+ else if (msg.type === "resize") session.resize(msg.cols, msg.rows);
42
+ }
43
+ };
44
+
45
+ export const Component = () => {
46
+ const params = useParams<{ workerId: string }>();
47
+ const { send, status, lastMessage } = useSocket(socket_terminal, {
48
+ channel: params.workerId,
49
+ onMessage: (raw) => {
50
+ if (typeof raw !== "string") return;
51
+ const msg = JSON.parse(raw) as Outgoing;
52
+ if (msg.type === "data") appendToBuffer(msg.data);
53
+ },
54
+ });
55
+
56
+ return (
57
+ <div>
58
+ <pre>{/* terminal buffer */}</pre>
59
+ <input onKeyDown={(e) => send({ type: "data", data: e.key })} />
60
+ <span>{status.value}</span>
61
+ </div>
62
+ );
63
+ };
64
+ ```
65
+
66
+ ## The handler signature
67
+
68
+ ```ts
69
+ export type SocketHandler = (args: {
70
+ incoming: AsyncIterable<string | Uint8Array>;
71
+ send: (msg: string | Uint8Array | object) => void;
72
+ close: (code?: number, reason?: string) => void;
73
+ keepOpen: () => void;
74
+ abortSignal: AbortSignal;
75
+ /** Always empty for `socket_*` — see below. */
76
+ params: Record<string, string>;
77
+ query: Record<string, string>;
78
+ }) => void | Promise<void>;
79
+ ```
80
+
81
+ ### `params` is always empty — read the id from `query.channel`
82
+
83
+ A `socket_*` handler is mounted at its own static path — `/_socket/{moduleId}/{name}` — not at the URL of the page it lives on. That path has no `:segments`, so `params` is always `{}`, even when the page route is `/workers/:workerId` and even when a middleware on that route reads the param fine.
84
+
85
+ The client sends the value as `?channel=`, so it arrives in **`query.channel`**:
86
+
87
+ ```ts
88
+ // ❌ undefined in production
89
+ const workerId = params.workerId;
90
+
91
+ // ✅
92
+ const workerId = query.channel;
93
+ ```
94
+
95
+ > This one is easy to miss because **`app.socket()` lets you inject `params` directly**, so a test written against the wrong version passes while production breaks. Pass `channel` in tests instead, matching what `useSocket({ channel })` really transmits.
96
+
97
+ ### `incoming` — `AsyncIterable<string | Uint8Array>`
98
+
99
+ Raw WebSocket frames. Text frames arrive as `string`, binary frames as `Uint8Array`. Drive the loop with `for await`:
100
+
101
+ ```ts
102
+ for await (const frame of incoming) {
103
+ // handle frame
104
+ }
105
+ ```
106
+
107
+ For the 90% case where every frame is JSON, wrap with `parseJson`:
108
+
109
+ ```ts
110
+ import { parseJson } from "@mauroandre/velojs/sockets";
111
+
112
+ for await (const msg of parseJson<MyMsgType>(incoming)) {
113
+ // msg is typed, malformed frames are skipped with a console.warn
114
+ }
115
+ ```
116
+
117
+ ### `send(msg)` — push to the client
118
+
119
+ - `string` / `Uint8Array` → sent as-is
120
+ - `object` → `JSON.stringify`'d
121
+
122
+ ```ts
123
+ send("hello"); // text frame
124
+ send(new Uint8Array([1,2,3])); // binary frame
125
+ send({ type: "tick", n: 42 }); // JSON text frame
126
+ ```
127
+
128
+ ### `keepOpen()` — control auto-close on return
129
+
130
+ When your handler returns (or its promise resolves), VeloJS checks: did you call `keepOpen()`?
131
+
132
+ - **Yes** → socket stays open until `abortSignal` fires.
133
+ - **No** → socket closes immediately.
134
+
135
+ A `for await (const msg of incoming)` loop keeps the handler alive naturally while messages flow — `keepOpen()` is redundant in that case but harmless. When you set up listeners and return (no loop), call `keepOpen()` or everything you registered will be torn down immediately.
136
+
137
+ ### `close(code?, reason?)` — server-initiated close
138
+
139
+ Close the socket imperatively. Useful for protocol-level end-of-session:
140
+
141
+ ```ts
142
+ if (!authorize(user, query.channel)) {
143
+ close(1008, "policy violation");
144
+ return;
145
+ }
146
+ ```
147
+
148
+ ### `abortSignal` — lifecycle hook
149
+
150
+ Fires when:
151
+ - The client disconnects (`ws.close()` on the browser side, tab closes, network drop).
152
+ - The server calls `close()` (this handler).
153
+ - `app.close()` runs in tests.
154
+
155
+ Register all cleanup here:
156
+
157
+ ```ts
158
+ abortSignal.addEventListener("abort", () => {
159
+ session.destroy();
160
+ bus.unsubscribe(sub);
161
+ });
162
+ ```
163
+
164
+ ## Path derivation
165
+
166
+ `socket_<name>` exported from `app/workers/WorkerTerminal.tsx` registers at `/_socket/workers/WorkerTerminal/<name>`. Same convention as `stream_*` (`/_event/...`) and `action_*` (`/_action/...`) — the plugin preserves the file path casing.
167
+
168
+ ## Middleware inheritance
169
+
170
+ Sockets inherit `middlewares` from parent route nodes, just like pages, actions, and streams. Auth rejection happens at upgrade time (the client sees an HTTP error before the WebSocket opens):
171
+
172
+ ```tsx
173
+ // app/routes.tsx
174
+ export default [
175
+ {
176
+ module: Root,
177
+ isRoot: true,
178
+ children: [
179
+ {
180
+ path: "/workers/:workerId",
181
+ middlewares: [authMiddleware, masterMiddleware],
182
+ module: WorkerTerminal,
183
+ },
184
+ ],
185
+ },
186
+ ];
187
+ ```
188
+
189
+ `authMiddleware` runs **before** the upgrade. If it rejects, no WebSocket is opened and the client's `useSocket` hook sees `status = "closed"` with an error.
190
+
191
+ ## The client hook — `useSocket`
192
+
193
+ ```ts
194
+ const { send, status, lastMessage, error, close } = useSocket(stub, {
195
+ channel?: string,
196
+ onMessage?: (msg: string | Uint8Array) => void,
197
+ onOpen?: () => void,
198
+ onClose?: (evt: CloseEvent) => void,
199
+ onError?: (err: Event) => void,
200
+ enabled?: boolean,
201
+ });
202
+ ```
203
+
204
+ - `status: Signal<"connecting" | "open" | "closed">` — reactive, bind to UI.
205
+ - `lastMessage: Signal<string | Uint8Array | null>` — the most recent frame.
206
+ - `send(msg)` — queues messages sent while `connecting`; drops messages sent while `closed`.
207
+ - `close(code?, reason?)` — client-initiated disconnect.
208
+
209
+ **No auto-reconnect.** If the server closes the socket (network drop, server restart), `status` goes to `"closed"` and stays there. The caller decides whether/when to reconnect — typically by toggling `enabled` or re-mounting the component. This is intentional: sockets are usually stateful (pty session, collaborative cursor) and a blind reconnect would silently lose state.
210
+
211
+ ## Testing
212
+
213
+ The testing toolkit ships `app.socket()`:
214
+
215
+ ```ts
216
+ import { createTestApp } from "@mauroandre/velojs/testing";
217
+
218
+ const app = await createTestApp({ routes });
219
+
220
+ const ws = await app.socket(socket_terminal, {
221
+ user: { id: "alice" },
222
+ channel: "w42",
223
+ });
224
+
225
+ ws.send({ type: "data", data: "ls\n" });
226
+
227
+ const reply = await ws.next({ timeoutMs: 500 });
228
+ expect(JSON.parse(reply as string)).toEqual({ type: "data", data: "..." });
229
+
230
+ await ws.close();
231
+ ```
232
+
233
+ ### What `app.socket()` does
234
+
235
+ - Invokes your handler directly with a mock `Context`.
236
+ - Wires `incoming` ↔ `ws.send` and `ws.next()` ↔ `send()`.
237
+ - Tracks the `AbortController` so `app.close()` aborts open sessions.
238
+
239
+ ### What `app.socket()` does NOT do
240
+
241
+ - **Middleware does not run.** If your socket relies on `authMiddleware` to set `c.get("user")`, pass `user` via the option:
242
+ ```ts
243
+ const ws = await app.socket(handler, { user: await buildUser(...) });
244
+ ```
245
+ Test middleware behavior separately through a regular endpoint or page that uses the same middleware.
246
+ - **No real HTTP upgrade.** Binary frames pass through `Uint8Array`, but there's no true TCP socket.
247
+
248
+ See [17-testing.md](/docs/testing) for the full toolkit.
249
+
250
+ ## Common patterns
251
+
252
+ ### Terminal / interactive shell
253
+
254
+ ```ts
255
+ export const socket_terminal: SocketHandler = async ({ incoming, send, keepOpen, abortSignal, query }) => {
256
+ const pty = await spawnPty(query.channel);
257
+ pty.onData((chunk) => send({ type: "data", data: chunk }));
258
+ pty.onExit((code) => send({ type: "exit", code }));
259
+ abortSignal.addEventListener("abort", () => pty.kill());
260
+ keepOpen();
261
+ for await (const msg of parseJson<{ type: string; data?: string }>(incoming)) {
262
+ if (msg.type === "data" && msg.data) pty.write(msg.data);
263
+ }
264
+ };
265
+ ```
266
+
267
+ ### Event-driven (no `for await`)
268
+
269
+ ```ts
270
+ export const socket_notifications: SocketHandler = async ({ send, keepOpen, abortSignal, c }) => {
271
+ const user = c.get("user");
272
+ const sub = bus.subscribe(`notifications:${user.id}`, (notification) => {
273
+ send(notification);
274
+ });
275
+ abortSignal.addEventListener("abort", () => bus.unsubscribe(sub));
276
+ keepOpen(); // required — handler returns but we want the socket to stay open
277
+ };
278
+ ```
279
+
280
+ ### Auth rejection
281
+
282
+ Use middleware (preferred, rejects before upgrade):
283
+
284
+ ```tsx
285
+ { path: "/workers/:id", middlewares: [authMiddleware], module: WorkerTerminal }
286
+ ```
287
+
288
+ Or reject inside the handler after upgrade:
289
+
290
+ ```ts
291
+ export const socket_foo: SocketHandler = async ({ c, close }) => {
292
+ const user = c.get("user");
293
+ if (!user) return close(1008, "unauthorized");
294
+ // ...proceed
295
+ };
296
+ ```
297
+
298
+ ## Related
299
+
300
+ - [Event Streams](/docs/event-streams) — one-way server → client push (SSE). Use when you don't need client → server.
301
+ - [`pipe` in streams](/docs/event-streams#pipe-unified-vocabulary) — share vocabulary (`send / keepOpen / abortSignal`) with `socket_*`.
302
+ - [Middlewares](/docs/middlewares)
303
+ - [Testing](/docs/testing)
@@ -0,0 +1,148 @@
1
+ ---
2
+ name: velojs-static-generation
3
+ description: "Static site generation with `velojs build --static`: prerendered HTML + `index.json`, `staticPaths` for dynamic routes, and what does not work in static mode. Use when deploying to a CDN/bucket or building a fully static site."
4
+ ---
5
+
6
+ # Static Site Generation (SSG)
7
+
8
+ VeloJS can generate fully static HTML sites — perfect for landing pages, documentation, blogs, and any content that doesn't change per request. Static sites are fast, cheap to host, and excellent for SEO since search engines see the complete HTML.
9
+
10
+ ## How it works
11
+
12
+ When you run `velojs build --static`, VeloJS:
13
+
14
+ 1. Builds the client bundle (JS, CSS) — same as a normal build
15
+ 2. Builds the server entry — same as a normal build
16
+ 3. **Crawls every route** in your app, runs the loaders, and renders the HTML
17
+ 4. Saves two files per route:
18
+ - `index.html` — the complete pre-rendered page (what search engines see)
19
+ - `index.json` — the loader data (used for SPA-like navigation between pages)
20
+
21
+ The result is a `dist/` folder with pure HTML + assets that you can deploy to any static hosting (CDN, S3, GitHub Pages, Netlify, etc).
22
+
23
+ ## Quick start
24
+
25
+ Build your app as a static site:
26
+
27
+ ```bash
28
+ npx velojs build --static
29
+ ```
30
+
31
+ The output:
32
+
33
+ ```
34
+ dist/
35
+ ├── index.html # Home page
36
+ ├── index.json # Home page data
37
+ ├── 404.html # Catch-all page (if defined) — see Custom 404 page
38
+ ├── about/
39
+ │ ├── index.html # About page
40
+ │ └── index.json # About page data
41
+ ├── docs/
42
+ │ ├── getting-started/
43
+ │ │ ├── index.html
44
+ │ │ └── index.json
45
+ │ └── routes/
46
+ │ ├── index.html
47
+ │ └── index.json
48
+ └── client/
49
+ ├── client.C3f9Ax_1.js # Client bundle (content-hashed)
50
+ └── client.C3f9Ax_1.css # Styles (content-hashed)
51
+ ```
52
+
53
+ ## SPA-like navigation
54
+
55
+ Even though the site is static, navigation between pages feels instant — no full page reloads. Here's how:
56
+
57
+ 1. **First visit**: the browser loads the pre-rendered `index.html` (fast, SEO-friendly)
58
+ 2. **Subsequent clicks**: JavaScript fetches the `index.json` for the target page and updates the UI without reloading
59
+
60
+ This gives you the best of both worlds: static HTML for SEO and performance, with SPA-like transitions for the user experience.
61
+
62
+ ## Loaders in static mode
63
+
64
+ Loaders work normally — they run once during the build and their data is frozen into the HTML and JSON files. This is perfect for:
65
+
66
+ - Fetching content from a CMS or API at build time
67
+ - Reading markdown files from disk
68
+ - Any data that doesn't change per user or per request
69
+
70
+ ```typescript
71
+ export const loader = async () => {
72
+ // This runs once during `velojs build --static`
73
+ const { getPostsFromCMS } = await import("./cms.service.js");
74
+ return getPostsFromCMS();
75
+ };
76
+ ```
77
+
78
+ The data returned by the loader is embedded in the page's HTML (for SEO) and also saved as a JSON file (for SPA navigation).
79
+
80
+ ## Dynamic routes with staticPaths
81
+
82
+ Routes with URL parameters (like `/users/:id`) can't be pre-rendered without knowing all possible values. Export a `staticPaths` function to tell VeloJS which pages to generate:
83
+
84
+ ```typescript
85
+ // app/pages/UserDetail.tsx
86
+
87
+ export const staticPaths = async () => {
88
+ const { getUsers } = await import("./user.service.js");
89
+ const users = await getUsers();
90
+ return users.map(u => ({ id: u.id }));
91
+ };
92
+
93
+ // This generates:
94
+ // /users/1/index.html
95
+ // /users/2/index.html
96
+ // /users/3/index.html
97
+ // ... one page per user
98
+
99
+ export const loader = async ({ params }: LoaderArgs) => {
100
+ const { getUser } = await import("./user.service.js");
101
+ return getUser(params.id);
102
+ };
103
+
104
+ export const Component = () => {
105
+ const { data } = useLoader<User>();
106
+ return <h1>{data.value?.name}</h1>;
107
+ };
108
+ ```
109
+
110
+ Routes with `:params` that don't export `staticPaths` are skipped during the static build (with a warning).
111
+
112
+ ## Custom 404 page
113
+
114
+ If your app defines a catch-all route (`{ path: "*", module: NotFound }` — see [Routes](/docs/routes#the-404-page)), the static build emits it as **`dist/404.html`** at the output root, following the universal `/404.html` convention.
115
+
116
+ Most static hosts serve `404.html` automatically for unmatched paths (GitHub Pages, Netlify, Cloudflare Pages, S3/CloudFront). On a bare nginx you point `error_page 404 /404.html;` at it. The HTTP status on a direct hit is then up to the host — in static mode VeloJS only produces the page content, not the response status.
117
+
118
+ ## What doesn't work in static mode
119
+
120
+ Since there's no server running after the build, some features are not available:
121
+
122
+ | Feature | Status | Why |
123
+ |---------|--------|-----|
124
+ | `Component` | Works | Pre-rendered to HTML |
125
+ | `loader` | Works | Runs once at build time, data frozen |
126
+ | `useLoader` / `Loader` | Works | Hydrates from HTML, SPA nav via JSON |
127
+ | `Link` | Works | SPA navigation without reload |
128
+ | `useParams`, `useQuery` | Works | Read from URL at runtime |
129
+ | `action_*` | Not available | No server to handle POST requests |
130
+ | `middlewares` | Not available | No per-request server context |
131
+ | `addRoutes` / `onServer` | Not available | No server running |
132
+ | `getCookie` / `setCookie` | Not available | No request/response context |
133
+ | SSE / WebSocket | Not available | No server for real-time connections |
134
+
135
+ **Rule of thumb**: if your page only displays data (no forms, no mutations, no auth), it works perfectly as static. If it needs server interaction at runtime, use the normal SSR build (`velojs build` without `--static`).
136
+
137
+ ## Deployment
138
+
139
+ The `dist/` folder is a self-contained static site. Deploy it anywhere:
140
+
141
+ ```bash
142
+ # Any static file server
143
+ npx serve dist
144
+
145
+ # Or upload to S3, Netlify, Vercel, GitHub Pages, etc.
146
+ ```
147
+
148
+ No Node.js server needed in production.