@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.
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) => `${c.req.param("worker")}:${c.req.param("container")}`,
751
+ channel: (c) => c.req.query("channel") ?? "",
751
752
  bufferSize: 500,
752
753
  perChannelSource: async (key, emit, { abortSignal }) => {
753
- const conn = await ssh.connect(...);
754
- const stream = conn.exec(`podman logs -f ${key.split(":")[1]}`);
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 | `injectMetadata` | Server + Client | Adds `export const metadata = { moduleId, fullPath, path }` |
1276
- | 2 | `transformLoaderFunctions` | Server + Client | Injects moduleId: `useLoader()` → `useLoader("moduleId")` |
1277
- | 3 | `transformActionsForClient` | Client only | Replaces action body with `fetch()` stub |
1278
- | 4 | `removeLoaders` | Client only | Removes `export const loader` entirely |
1279
- | 5 | `removeMiddlewares` | Client only | Removes `middlewares: [...]` and related imports |
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
 
@@ -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).