@mauroandre/velojs 0.0.37 → 0.0.39
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 +19 -9
- package/agent/AGENTS.md +143 -0
- package/agent/skills/velojs-actions/SKILL.md +192 -0
- package/agent/skills/velojs-components/SKILL.md +99 -0
- package/agent/skills/velojs-endpoints/SKILL.md +187 -0
- package/agent/skills/velojs-event-streams/SKILL.md +705 -0
- package/agent/skills/velojs-getting-started/SKILL.md +191 -0
- package/agent/skills/velojs-hooks/SKILL.md +103 -0
- package/agent/skills/velojs-link-component/SKILL.md +67 -0
- package/agent/skills/velojs-loaders/SKILL.md +134 -0
- package/agent/skills/velojs-middlewares/SKILL.md +143 -0
- package/agent/skills/velojs-production-deploy/SKILL.md +96 -0
- package/agent/skills/velojs-project-graph/SKILL.md +97 -0
- package/agent/skills/velojs-routes/SKILL.md +209 -0
- package/agent/skills/velojs-scripts-component/SKILL.md +62 -0
- package/agent/skills/velojs-server-api/SKILL.md +155 -0
- package/agent/skills/velojs-sockets/SKILL.md +303 -0
- package/agent/skills/velojs-static-generation/SKILL.md +148 -0
- package/agent/skills/velojs-testing/SKILL.md +472 -0
- package/agent/skills/velojs-type-reference/SKILL.md +142 -0
- package/agent/skills/velojs-vite-plugin/SKILL.md +71 -0
- package/dist/cli.js +42 -31
- package/dist/graph.d.ts +37 -0
- package/dist/graph.js +202 -0
- package/dist/init.js +16 -7
- package/dist/vite.js +279 -248
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -746,18 +746,22 @@ poll({ intervalMs: 3000, tick: async (emit) => emit(await collect()) })
|
|
|
746
746
|
Use `perChannelSource` for resources that scale per channel (SSH connections, DB cursors, pub/sub topics). Invoked once per channel on first subscriber, aborted when the last subscriber of that channel leaves.
|
|
747
747
|
|
|
748
748
|
```typescript
|
|
749
|
+
// The client sends the composite key: useEventStream(stream_logs, { channel: `${worker}:${container}` })
|
|
749
750
|
export const stream_logs = createEventStream<string>({
|
|
750
|
-
channel: (c) =>
|
|
751
|
+
channel: (c) => c.req.query("channel") ?? "",
|
|
751
752
|
bufferSize: 500,
|
|
752
753
|
perChannelSource: async (key, emit, { abortSignal }) => {
|
|
753
|
-
const
|
|
754
|
-
const
|
|
754
|
+
const [worker, container] = key.split(":");
|
|
755
|
+
const conn = await ssh.connect(worker);
|
|
756
|
+
const stream = conn.exec(`podman logs -f ${container}`);
|
|
755
757
|
stream.on("data", (d) => emit(d.toString(), { snapshot: true }));
|
|
756
758
|
abortSignal.addEventListener("abort", () => { stream.close(); conn.end(); });
|
|
757
759
|
},
|
|
758
760
|
});
|
|
759
761
|
```
|
|
760
762
|
|
|
763
|
+
> `c.req.param()` does **not** work in a `stream_*` resolver — convention streams mount at a static path (`/_event/{moduleId}/{name}`), so the page's `:params` are not in scope and it always returns empty. The channel travels as `?channel=`. (Params do work in a standalone stream given an explicit `path` containing `:segments`.)
|
|
764
|
+
|
|
761
765
|
Mutually exclusive with `source`.
|
|
762
766
|
|
|
763
767
|
### Async channel resolver (auth + ownership)
|
|
@@ -1272,17 +1276,23 @@ Applied during Vite's `transform` hook to files in `appDirectory`:
|
|
|
1272
1276
|
|
|
1273
1277
|
| # | Transform | When | What it does |
|
|
1274
1278
|
|---|-----------|------|-------------|
|
|
1275
|
-
| 1 | `
|
|
1276
|
-
| 2 | `
|
|
1277
|
-
| 3 | `
|
|
1278
|
-
| 4 | `
|
|
1279
|
-
| 5 | `
|
|
1279
|
+
| 1 | `removeMiddlewares` | Client only | Removes every `middlewares` property and the imports only it referenced |
|
|
1280
|
+
| 2 | `removeEndpointRoutes` | Client only | Strips `handler:` endpoint nodes from `routes.tsx` |
|
|
1281
|
+
| 3 | `injectMetadata` | Server + Client | Adds `export const metadata = { moduleId, fullPath, path }` |
|
|
1282
|
+
| 4 | `transformLoaderFunctions` | Server + Client | Injects moduleId: `useLoader()` → `useLoader("moduleId")` |
|
|
1283
|
+
| 5 | `transformActionsForClient` | Client only | Replaces action body with `fetch()` stub |
|
|
1284
|
+
| 6 | `transformStreamsForClient` | Client only | `stream_*` → `{ __isVeloEventStream, __path }` stub |
|
|
1285
|
+
| 7 | `transformSocketsForClient` | Client only | `socket_*` → `{ __isVeloSocket, __path }` stub |
|
|
1286
|
+
| 8 | `removeLoaders` | Client only | Removes `export const loader` entirely |
|
|
1287
|
+
| 9 | `pruneClientOnlyImports` | Client only | Drops imports left unreferenced by the strips above |
|
|
1288
|
+
|
|
1289
|
+
Only `.ts`/`.tsx` files inside `appDirectory` are transformed — a `.jsx`/`.js` page, or one outside it, is not touched at all.
|
|
1280
1290
|
|
|
1281
1291
|
### Build Process
|
|
1282
1292
|
|
|
1283
1293
|
```bash
|
|
1284
1294
|
velojs build
|
|
1285
|
-
# 1. vite build → dist/client/ (client.js, client.css, manifest.json)
|
|
1295
|
+
# 1. vite build → dist/client/ (client.[hash].js, client.[hash].css, .vite/manifest.json)
|
|
1286
1296
|
# 2. vite build --mode server → dist/server.js (SSR entry)
|
|
1287
1297
|
```
|
|
1288
1298
|
|
package/agent/AGENTS.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# VeloJS — Agent Rules
|
|
2
|
+
|
|
3
|
+
You are building an application **with** VeloJS. This file is the constitution and the
|
|
4
|
+
map. It is not documentation: load the matching skill (below) before writing anything
|
|
5
|
+
non-trivial.
|
|
6
|
+
|
|
7
|
+
## The stack is a premise, not a choice
|
|
8
|
+
|
|
9
|
+
Server: **Hono**. UI: **Preact** + `@preact/signals`. Router: **wouter-preact**.
|
|
10
|
+
Build: **Vite** + the VeloJS Babel plugin.
|
|
11
|
+
|
|
12
|
+
Never introduce, suggest, or import: Express, Fastify, Next, Remix, React Router,
|
|
13
|
+
`react`/`react-dom` (both are aliased to `preact/compat` — import from `preact`),
|
|
14
|
+
Redux/Zustand/Jotai (component state is `@preact/signals`), or any router other than
|
|
15
|
+
the route tree. If a task seems to need one, you have misread the task — re-read the
|
|
16
|
+
relevant skill first.
|
|
17
|
+
|
|
18
|
+
## Rules that fail silently
|
|
19
|
+
|
|
20
|
+
These do not fail typecheck. They do not throw at build. They produce a broken app that
|
|
21
|
+
looks fine. Every row below was verified against the compiler transforms — treat them as
|
|
22
|
+
hard constraints, not style.
|
|
23
|
+
|
|
24
|
+
| Never write | Always write | What silently happens |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| `export default routes` (a variable) | the array literal inline in the `export default` — `satisfies AppRoutes` or `as AppRoutes` both work | the path map is built by reading the array **literal**; an identifier yields an empty map → no module gets `fullPath` → **every route 404s** |
|
|
27
|
+
| `import Home from "./pages/Home.js"`<br>`import { Component } from "./pages/Home.js"` | `import * as Home from "./pages/Home.js"` | only `import * as` is registered → **that route silently disappears** |
|
|
28
|
+
| `import * as Home from "../shared/Home.js"`<br>or a tsconfig alias, in `routes.tsx` | `./`-relative paths inside `appDirectory` | the map key won't match the module id → **route dropped** |
|
|
29
|
+
| `export async function action_x(...)` | `export const action_x = async ({ body }) => {}` | no client stub is generated → **the action body and its server imports ship to the browser and execute there** |
|
|
30
|
+
| `export const action_x = async (args) => {}` | `export const action_x = async ({ body }) => {}` | the generated stub references an undeclared `body` → **ReferenceError in the browser** when called |
|
|
31
|
+
| `export const action_x = ({ body }) => {}` (not `async`) | `async ({ body }) => {}` | not recognized → no stub → **server code runs in the browser** |
|
|
32
|
+
| `export async function loader(...)` | `export const loader = async ({ params }) => {}` | only `const` loaders are stripped → **the whole loader ships to the client bundle** |
|
|
33
|
+
| `export const action_a = ..., action_b = ...` | one `export const` per declaration | only the **first** declarator is read → **the second is silently ignored** (same for `loader`, `stream_*`, `socket_*`, `metadata`) |
|
|
34
|
+
| `useParams()` / `useQuery()` / `usePathname()` inside a `loader` | the loader's own args: `async ({ params, query, c }) => {}` | the async context wraps only rendering; loaders run **before** it → returns `{}` / `"/"` |
|
|
35
|
+
| `c.req.param(...)` or `params.x` inside `action_*`, `stream_*`, `socket_*` | actions: read it from `body`. streams/sockets: send `?channel=` from the client and read `query.channel` | these register at **static** paths (`/_action/{moduleId}/{name}`, `/_event/…`, `/_socket/…`) — the page's `:params` are not in scope → always `{}` / `undefined` |
|
|
36
|
+
| a page in `.jsx` / `.js`, or any page outside `appDirectory` | `.tsx` inside `app/` | **zero transforms**: no metadata, no stubs, loader ships to the client |
|
|
37
|
+
| a root layout that doesn't render a literal `<head>` | `isRoot` component renders `<html><head>…</head><body>{children}</body></html>` | `__PAGE_DATA__` is injected by replacing `</head>` → **every loader hydrates `null`** and refetches |
|
|
38
|
+
|
|
39
|
+
Never pass a module id to `useLoader()` / `Loader()` yourself — the plugin injects it.
|
|
40
|
+
|
|
41
|
+
## Sharing a layout's data with its children
|
|
42
|
+
|
|
43
|
+
`Loader()` is the importable handle for a module's loader data; `useLoader()` is the
|
|
44
|
+
component-local form. Both read the same entry, so a child can read — and optimistically
|
|
45
|
+
mutate — its layout's data:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
// app/admin/CompaniesLayout.tsx
|
|
49
|
+
export const { data: companiesData, refetch } = Loader<LoaderData>();
|
|
50
|
+
|
|
51
|
+
// app/admin/companies/CompanyInfo.tsx — rendered inside it
|
|
52
|
+
import { companiesData } from "../CompaniesLayout.js";
|
|
53
|
+
companiesData.value!.companies[i] = saved;
|
|
54
|
+
touch(companiesData); // you already know the result — no round-trip
|
|
55
|
+
refetch(); // or make the server recompute
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Never mirror loader data into a module-level `export let`: on the server a module binding
|
|
59
|
+
is per-process, so a value stored there belongs to whichever request wrote it last.
|
|
60
|
+
`Loader()`'s server value is an AsyncLocalStorage getter — per-request by construction.
|
|
61
|
+
|
|
62
|
+
An entry refreshes on navigation when the params **its own route declares** change
|
|
63
|
+
(`/x/:id`). A query string is not declared in `routes.tsx`, so nothing infers it — use
|
|
64
|
+
`useLoader([query.tab])` when data depends on one.
|
|
65
|
+
|
|
66
|
+
## A stream/socket `channel` is untrusted client input
|
|
67
|
+
|
|
68
|
+
`useEventStream(s, { channel })` and `useSocket(s, { channel })` put the channel on the
|
|
69
|
+
query string. Anyone can send any value. A resolver that echoes it back subscribes the
|
|
70
|
+
caller to **someone else's data**:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
channel: (c) => c.req.query("channel") ?? "" // IDOR unless the route is already role-gated
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Resolve it through an ownership check instead. Returning `null` denies with 403:
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
export const ownAppChannel = async (c: Context): Promise<string | null> => {
|
|
80
|
+
const appId = c.req.query("channel");
|
|
81
|
+
if (!appId) return null;
|
|
82
|
+
const user = c.get("user"); // set by the route's middleware
|
|
83
|
+
if (!user?.id) return null;
|
|
84
|
+
const { getApp } = await import("./app.service.js"); // keep the service off the client graph
|
|
85
|
+
const app = await getApp({ id: appId });
|
|
86
|
+
if (!app) return null;
|
|
87
|
+
if (user.role !== "master" && app.ownerId !== user.id) return null;
|
|
88
|
+
return appId;
|
|
89
|
+
};
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Echoing the raw query value is only acceptable when the route's middleware already
|
|
93
|
+
restricts every subscriber (e.g. a master-only page). Deriving the channel from the
|
|
94
|
+
session instead — `channel: (c) => c.get("user").id` — needs no check, since the client
|
|
95
|
+
cannot influence it.
|
|
96
|
+
|
|
97
|
+
## The map
|
|
98
|
+
|
|
99
|
+
| File | Role |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `app/routes.tsx` | The route tree. `export default [...] satisfies AppRoutes`, `import * as` for every page/layout |
|
|
102
|
+
| `app/<domain>/Page.tsx` | One module per route: `Component`, plus optional `loader`, `action_*`, `stream_*`, `socket_*`, `metadata`. Group by domain (`app/auth/`, `app/admin/`), not in a flat `pages/` folder |
|
|
103
|
+
| `app/layouts/*.tsx` | Shared layouts. Long-lived `stream_*` declarations usually live on the layout that spans their pages |
|
|
104
|
+
| `app/modules/<domain>/` | Server-side logic imported by pages: `*.service.ts`, `*.middleware.ts`, `*.stream.ts`. Not routed |
|
|
105
|
+
| `app/components/*.tsx` | Shared UI with no route of its own |
|
|
106
|
+
| `app/client-root.tsx` | The `isRoot` HTML shell — must render `<head>` with `<Scripts />` |
|
|
107
|
+
| `app/server.tsx` | Server-only init (`addRoutes`, `onServer`, connections) |
|
|
108
|
+
| `app/client.tsx` | Client-only init (global CSS) |
|
|
109
|
+
| `vite.config.ts` | `veloPlugin()` |
|
|
110
|
+
|
|
111
|
+
Import subpaths (the package root exports only types, `defineConfig`, `Scripts`, `Link`,
|
|
112
|
+
`createEventStream`, `poll`): `@mauroandre/velojs/hooks`, `/server`, `/client`,
|
|
113
|
+
`/events`, `/sockets`, `/testing`, `/vite`, `/config`, `/cookie`.
|
|
114
|
+
|
|
115
|
+
Conventions that carry meaning: a route node is `{ path, module, children, middlewares,
|
|
116
|
+
isRoot, statusCode }`; `{ path: "*" }` is the catch-all (served via Hono's `notFound`,
|
|
117
|
+
defaults to 404); a node with `{ method, handler }` and no `module` is an HTTP endpoint.
|
|
118
|
+
|
|
119
|
+
## Load the skill first
|
|
120
|
+
|
|
121
|
+
Before writing anything non-trivial, load the skill for the subject. Do not infer the
|
|
122
|
+
API from this file — it is deliberately incomplete.
|
|
123
|
+
|
|
124
|
+
| Subject | Skill |
|
|
125
|
+
|---|---|
|
|
126
|
+
| New project, first page, project layout | `getting-started` |
|
|
127
|
+
| Route tree, nesting, layouts, params, catch-all, status codes | `routes` |
|
|
128
|
+
| Page modules, `Component`, `metadata` | `components` |
|
|
129
|
+
| Server data for a page, SSR + SPA fetch | `loaders` |
|
|
130
|
+
| Calling server code from the client, forms, mutations | `actions` |
|
|
131
|
+
| REST/JSON endpoints, webhooks, non-page HTTP | `endpoints` |
|
|
132
|
+
| Auth, guards, per-route server logic | `middlewares` |
|
|
133
|
+
| `useLoader`, `useParams`, `useQuery`, `usePathname`, `useNavigate` | `hooks` |
|
|
134
|
+
| Navigation, `<Link>` | `link-component` |
|
|
135
|
+
| `<Scripts />`, assets, favicon | `scripts-component` |
|
|
136
|
+
| SSE, live/progress/streaming data | `event-streams` |
|
|
137
|
+
| WebSockets, bidirectional realtime | `sockets` |
|
|
138
|
+
| `addRoutes`, `onServer`, ports, server lifecycle | `server-api` |
|
|
139
|
+
| The build, transforms, `veloPlugin` options | `vite-plugin` |
|
|
140
|
+
| SSG, prerendering, `staticPaths` | `static-generation` |
|
|
141
|
+
| Deploy, env vars, Docker, production | `production-deploy` |
|
|
142
|
+
| Writing tests, `createTestApp` | `testing` |
|
|
143
|
+
| Exact type signatures | `type-reference` |
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: velojs-actions
|
|
3
|
+
description: "Server actions: call server code from the client as a normal async function. Use when building login, form submit, a mutation, save/delete buttons, or any server-side operation triggered by user interaction."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Actions
|
|
7
|
+
|
|
8
|
+
Actions are server-side functions that you can call from the client as if they were regular functions. VeloJS transforms them into HTTP calls automatically — you write server code, the framework handles the rest.
|
|
9
|
+
|
|
10
|
+
## How actions work
|
|
11
|
+
|
|
12
|
+
An exported **`const` holding an `async` arrow function** whose name starts with `action_` is treated as a server action:
|
|
13
|
+
|
|
14
|
+
```typescript
|
|
15
|
+
export const action_login = async ({
|
|
16
|
+
body,
|
|
17
|
+
c,
|
|
18
|
+
}: ActionArgs<{ email: string; password: string }>) => {
|
|
19
|
+
const { authenticate } = await import("./auth.service.js");
|
|
20
|
+
const token = await authenticate(body.email, body.password);
|
|
21
|
+
|
|
22
|
+
const { setCookie } = await import("@mauroandre/velojs/cookie");
|
|
23
|
+
setCookie(c!, "session", token, { path: "/" });
|
|
24
|
+
|
|
25
|
+
return { ok: true };
|
|
26
|
+
};
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
On the **server**, this function runs normally — it has access to the Hono context (`c`), can read cookies, access the database, and do anything a server function can do.
|
|
30
|
+
|
|
31
|
+
On the **client**, the Vite plugin replaces the function body with a `fetch()` call. The client version looks like this:
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
// What the client actually executes (generated automatically):
|
|
35
|
+
export const action_login = async ({ body }: { body: { email: string; password: string } }) => {
|
|
36
|
+
return fetch("/_action/auth/Login/login", {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: { "Content-Type": "application/json" },
|
|
39
|
+
body: JSON.stringify(body),
|
|
40
|
+
}).then(r => r.json());
|
|
41
|
+
};
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
You don't write this — the Vite plugin generates it at build time by analyzing the AST of your code.
|
|
45
|
+
|
|
46
|
+
## The shape is load-bearing
|
|
47
|
+
|
|
48
|
+
The plugin recognizes actions by their exact shape. Write one differently and it is silently **not** transformed: the server still registers it, so it looks like it works — but the real body and its server-only imports ship to the browser and run there.
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
// ✅ the only recognized form
|
|
52
|
+
export const action_save = async ({ body }: ActionArgs<Body>) => { ... };
|
|
53
|
+
|
|
54
|
+
// ❌ a function declaration — no stub is generated; your DB code goes to the browser
|
|
55
|
+
export async function action_save({ body }: ActionArgs<Body>) { ... }
|
|
56
|
+
|
|
57
|
+
// ❌ not async — same
|
|
58
|
+
export const action_save = ({ body }: ActionArgs<Body>) => { ... };
|
|
59
|
+
|
|
60
|
+
// ❌ the param must be destructured — the generated stub sends `body`, so a
|
|
61
|
+
// non-destructured param leaves it undeclared → ReferenceError when called
|
|
62
|
+
export const action_save = async (args: ActionArgs<Body>) => { ... };
|
|
63
|
+
|
|
64
|
+
// ❌ one action per `export const` — only the first declarator is read,
|
|
65
|
+
// so `action_b` is silently ignored
|
|
66
|
+
export const action_a = async ({ body }) => { ... },
|
|
67
|
+
action_b = async ({ body }) => { ... };
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## The ActionArgs type
|
|
71
|
+
|
|
72
|
+
Every action receives an object with these properties:
|
|
73
|
+
|
|
74
|
+
| Property | Type | Available on | Description |
|
|
75
|
+
|----------|------|-------------|-------------|
|
|
76
|
+
| `body` | `TBody` | Server + Client | The data sent from the client |
|
|
77
|
+
| `c` | `Context` | Server only | Hono request context (cookies, headers, etc) |
|
|
78
|
+
| `params` | `Record<string, string>` | Server only | **Always empty.** See below |
|
|
79
|
+
| `query` | `Record<string, string>` | Server only | Empty unless you hand-craft the request (e.g. `app.action(fn, { query })` in a test) |
|
|
80
|
+
|
|
81
|
+
On the client, only `body` is available (the rest are stripped by the Vite plugin).
|
|
82
|
+
|
|
83
|
+
### `params` is always empty in an action
|
|
84
|
+
|
|
85
|
+
An action is mounted at its own static path — `/_action/{moduleId}/{name}` — not at the URL of the page it lives on. That path has no `:segments`, so `params` never has anything in it, even when the page is at `/users/:id`.
|
|
86
|
+
|
|
87
|
+
Put the id in the body:
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
// ❌ params.id is undefined — the action isn't mounted on /users/:id
|
|
91
|
+
export const action_remove = async ({ params }: ActionArgs) => remove(params.id);
|
|
92
|
+
|
|
93
|
+
// ✅
|
|
94
|
+
export const action_remove = async ({ body }: ActionArgs<{ id: string }>) => remove(body.id);
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
```tsx
|
|
98
|
+
const params = useParams<{ id: string }>();
|
|
99
|
+
await action_remove({ body: { id: params.id } });
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Error handling
|
|
103
|
+
|
|
104
|
+
Actions **do not throw** on server errors. Instead, they resolve with `{ error: "message" }`. Always check for errors explicitly:
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
const result = await action_login({ body: { email, password } });
|
|
108
|
+
|
|
109
|
+
if (result.error) {
|
|
110
|
+
showToast(result.error, "danger");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Success
|
|
115
|
+
window.location.href = "/admin";
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Remember: use dynamic imports
|
|
119
|
+
|
|
120
|
+
Just like loaders, actions should use `await import()` for server-only code:
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
export const action_delete = async ({ body }: ActionArgs<{ id: string }>) => {
|
|
124
|
+
// GOOD — dynamic import
|
|
125
|
+
const { deleteUser } = await import("./user.service.js");
|
|
126
|
+
await deleteUser(body.id);
|
|
127
|
+
return { ok: true };
|
|
128
|
+
};
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Sharing actions across pages
|
|
132
|
+
|
|
133
|
+
Every action lives at a fixed URL: `/_action/{moduleId}/{actionName}`. The `moduleId` comes from the file where the action is **declared** — never from the file where it's imported. This means you can declare an action once in a layout and import it from any child page.
|
|
134
|
+
|
|
135
|
+
### When to use this
|
|
136
|
+
|
|
137
|
+
Common cases:
|
|
138
|
+
|
|
139
|
+
- **Logout** — every page under an auth layout needs the same action
|
|
140
|
+
- **Global forms** — like a "contact us" that appears in multiple pages
|
|
141
|
+
- **Shared mutations** — like toggling a theme or closing a notification
|
|
142
|
+
|
|
143
|
+
### Example: logout action in a layout
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
// app/admin/Layout.tsx
|
|
147
|
+
import type { ActionArgs } from "@mauroandre/velojs";
|
|
148
|
+
|
|
149
|
+
export const action_logout = async ({ c }: ActionArgs) => {
|
|
150
|
+
const { deleteCookie } = await import("@mauroandre/velojs/cookie");
|
|
151
|
+
deleteCookie(c!, "session");
|
|
152
|
+
return { ok: true };
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export const Component = ({ children }) => (
|
|
156
|
+
<div>
|
|
157
|
+
<nav>/* ... */</nav>
|
|
158
|
+
{children}
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Now any page under the layout can import and call it:
|
|
164
|
+
|
|
165
|
+
```typescript
|
|
166
|
+
// app/admin/Dashboard.tsx
|
|
167
|
+
import { action_logout } from "./Layout.js";
|
|
168
|
+
|
|
169
|
+
export const Component = () => (
|
|
170
|
+
<button onClick={async () => {
|
|
171
|
+
await action_logout({});
|
|
172
|
+
window.location.href = "/login";
|
|
173
|
+
}}>
|
|
174
|
+
Logout
|
|
175
|
+
</button>
|
|
176
|
+
);
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
// app/admin/Settings.tsx — same action, different page
|
|
181
|
+
import { action_logout } from "./Layout.js";
|
|
182
|
+
|
|
183
|
+
export const Component = () => (
|
|
184
|
+
<a onClick={() => action_logout({})}>Leave</a>
|
|
185
|
+
);
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Both imports compile to the **same URL**: `POST /_action/admin/Layout/logout`. Middlewares on the layout (like `authMiddleware`) apply automatically.
|
|
189
|
+
|
|
190
|
+
### Why this works
|
|
191
|
+
|
|
192
|
+
The Vite plugin replaces the action body with a `fetch()` call to its registered URL **during compile time**, using the `moduleId` of the file where the export lives. Imports in other files just reuse that compiled stub — they never change the URL.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: velojs-components
|
|
3
|
+
description: "Page and layout modules: the `Component` export, `metadata`/title, `children`, and keeping server-only code out of the client bundle. Use when creating a page or layout, setting a page title, or asked why a server import leaked to the browser."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Components
|
|
7
|
+
|
|
8
|
+
Every page and layout in VeloJS is a module that can export up to three things. Understanding these conventions is key to building with VeloJS.
|
|
9
|
+
|
|
10
|
+
## The three exports
|
|
11
|
+
|
|
12
|
+
| Export | Purpose |
|
|
13
|
+
|--------|---------|
|
|
14
|
+
| `export const Component` | The Preact component that renders the UI (required) |
|
|
15
|
+
| `export const loader` | A server-side function that fetches data for the page |
|
|
16
|
+
| `export const action_*` | Server-side functions callable from the client (RPC) |
|
|
17
|
+
|
|
18
|
+
## Example page
|
|
19
|
+
|
|
20
|
+
Here's a complete page that uses all three:
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
// app/admin/Users.tsx
|
|
24
|
+
import type { LoaderArgs, ActionArgs } from "@mauroandre/velojs";
|
|
25
|
+
import { useLoader } from "@mauroandre/velojs/hooks";
|
|
26
|
+
|
|
27
|
+
interface User { id: string; name: string; }
|
|
28
|
+
|
|
29
|
+
// 1. Loader — runs on the server, fetches data
|
|
30
|
+
export const loader = async ({ params, query, c }: LoaderArgs) => {
|
|
31
|
+
const { getUsers } = await import("./user.service.js");
|
|
32
|
+
return getUsers();
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// 2. Action — runs on the server, called from the client
|
|
36
|
+
export const action_delete = async ({
|
|
37
|
+
body,
|
|
38
|
+
c,
|
|
39
|
+
}: ActionArgs<{ id: string }>) => {
|
|
40
|
+
const { deleteUser } = await import("./user.service.js");
|
|
41
|
+
await deleteUser(body.id);
|
|
42
|
+
return { ok: true };
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// 3. Component — renders on both server (SSR) and client
|
|
46
|
+
export const Component = () => {
|
|
47
|
+
const { data, loading, refetch } = useLoader<User[]>();
|
|
48
|
+
|
|
49
|
+
if (loading.value) return <div>Loading...</div>;
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<ul>
|
|
53
|
+
{data.value?.map((u) => (
|
|
54
|
+
<li key={u.id}>
|
|
55
|
+
{u.name}
|
|
56
|
+
<button onClick={async () => {
|
|
57
|
+
await action_delete({ body: { id: u.id } });
|
|
58
|
+
refetch();
|
|
59
|
+
}}>Delete</button>
|
|
60
|
+
</li>
|
|
61
|
+
))}
|
|
62
|
+
</ul>
|
|
63
|
+
);
|
|
64
|
+
};
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Server-only imports (critical)
|
|
68
|
+
|
|
69
|
+
This is the **most important convention** in VeloJS. Read this carefully.
|
|
70
|
+
|
|
71
|
+
Your page file is bundled for **both** server and client. For the client, the Vite plugin removes the `loader` export, stubs `action_*` into `fetch()` calls, and then prunes any top-level import left **unreferenced** by that strip.
|
|
72
|
+
|
|
73
|
+
So an import used only inside a loader is dropped for you. What survives the strip keeps its imports — and that's where server code leaks:
|
|
74
|
+
|
|
75
|
+
**The rule**: always use `await import()` inside loaders and actions for server-only code. Never reference it from anything that survives into the client bundle.
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
// BAD — `conn` is module-scope, so it survives the strip, so the import survives
|
|
79
|
+
// with it, and the database driver lands in the browser.
|
|
80
|
+
import { db } from "../db/engine.js";
|
|
81
|
+
|
|
82
|
+
const conn = db.connect();
|
|
83
|
+
|
|
84
|
+
export const loader = async () => conn.query("select * from users");
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
// GOOD — dynamic import only runs on the server
|
|
89
|
+
export const loader = async () => {
|
|
90
|
+
const { getUsers } = await import("./user.service.js");
|
|
91
|
+
return getUsers();
|
|
92
|
+
};
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Why does this matter? Because `user.service.js` might import a database driver, which imports Node.js native modules like `fs` or `crypto`. If that code ends up in the client bundle, Vite will throw errors or include unnecessary code.
|
|
96
|
+
|
|
97
|
+
The same applies anywhere the strip leaves a reference behind — a value used in the `Component`, a helper called at module scope, or a type kept in an action's signature. When in doubt, move the import inside the function that needs it.
|
|
98
|
+
|
|
99
|
+
**Think of it this way**: any `import` at the top of the file is shared between server and client. Any `await import()` inside a function is server-only (because loaders and actions only run on the server).
|