create-voltapp 0.1.0 → 0.2.0

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.
@@ -18,8 +18,9 @@ const here = dirname(fileURLToPath(import.meta.url));
18
18
  const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
19
19
  const TEMPLATES = {
20
20
  minimal: "Client app with Vite: signals, JSX, For/Show. No server.",
21
- fullstack: "SSR + Vite dev server, router, server functions, live queries, optimistic mutations.",
21
+ fullstack: "SSR + Vite dev server with HMR, typed router, server functions. The default starter.",
22
22
  api: "Server only: Web-standard app with validation, sessions and server functions.",
23
+ todo: "Full-stack demo: live queries, optimistic mutations, offline queue, cross-tab sync.",
23
24
  };
24
25
 
25
26
  const args = process.argv.slice(2);
@@ -45,7 +46,7 @@ try {
45
46
  console.log(`\n${bold("Template")}`);
46
47
  const names = Object.keys(TEMPLATES);
47
48
  names.forEach((n, i) => console.log(` ${accent(String(i + 1))} ${n.padEnd(10)} ${dim(TEMPLATES[n])}`));
48
- const answer = (await rl.question(`\nChoose ${dim("(1-3, default 2)")}: `)).trim();
49
+ const answer = (await rl.question(`\nChoose ${dim("(1-4, default 2)")}: `)).trim();
49
50
  template = names[Number(answer) - 1] ?? (names.includes(answer) ? answer : "fullstack");
50
51
  }
51
52
  } finally {
@@ -77,7 +78,7 @@ const walk = (d) => readdirSync(d).flatMap((f) => (statSync(join(d, f)).isDirect
77
78
  for (const file of walk(target)) {
78
79
  if (!/\.(json|ts|tsx|html|md|css)$/.test(file)) continue;
79
80
  const text = readFileSync(file, "utf8");
80
- const next = text.replaceAll("{{name}}", name).replaceAll("{{version}}", pkg.version);
81
+ const next = text.replaceAll("{{name}}", name).replaceAll("{{version}}", pkg.frameworkVersion ?? pkg.version);
81
82
  if (next !== text) writeFileSync(file, next);
82
83
  }
83
84
 
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "create-voltapp",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
+ "frameworkVersion": "0.1.0",
4
5
  "description": "Scaffold a new Volt app: npm create voltapp@latest my-app",
5
6
  "license": "MIT",
6
7
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  # {{name}}
2
2
 
3
- A full-stack Volt app: server-side rendering, a Vite dev server with HMR, typed routing, server functions and live queries with automatic invalidation.
3
+ A Volt app with server-side rendering, a Vite dev server with HMR and typed routing.
4
4
 
5
5
  ```sh
6
6
  npm install
@@ -13,16 +13,21 @@ npm start # production server
13
13
 
14
14
  ```
15
15
  server.ts HTTP server (dev: Vite middleware; prod: dist/)
16
- src/todos.server.ts queries and mutations — never shipped to the browser
17
- src/App.tsx routes and components
16
+ src/App.tsx routes: / and /about
17
+ src/Welcome.tsx starter content — delete it when you begin
18
18
  src/entry-client.tsx render() in the browser
19
19
  src/entry-server.tsx renderToString() for SSR
20
20
  ```
21
21
 
22
- ## How data flows
22
+ ## Add server code
23
23
 
24
- 1. `listTodos` is a `query`: while it runs, `todos.read()` records a dependency.
25
- 2. `addTodo` is a `mutation`: `todos.write()` records a change.
26
- 3. The client's `useQuery(listTodos, filter)` refetches whenever a mutation writes `todos`, in every open tab, pushed over `/_volt/live`.
24
+ Create `src/hello.server.ts`:
25
+
26
+ ```ts
27
+ import { serverFn } from "@prouzbek/server/fn";
28
+ export const greet = serverFn(async (name: string) => `Hello, ${name}!`);
29
+ ```
30
+
31
+ Then call `greet("world")` from any component. The file never ships to the browser; the client gets a typed RPC stub. For live queries with automatic invalidation, add `@prouzbek/data` or start from the `todo` template.
27
32
 
28
33
  Requires Node 24 or newer (TypeScript runs natively).
@@ -10,10 +10,8 @@
10
10
  "check": "tsc --noEmit"
11
11
  },
12
12
  "dependencies": {
13
- "@prouzbek/data": "{{version}}",
14
13
  "@prouzbek/dom": "{{version}}",
15
14
  "@prouzbek/router": "{{version}}",
16
- "@prouzbek/serialize": "{{version}}",
17
15
  "@prouzbek/server": "{{version}}",
18
16
  "@prouzbek/signals": "{{version}}"
19
17
  },
@@ -4,22 +4,21 @@
4
4
  * npm run build → client + server bundles in dist/
5
5
  * npm start → serves dist/ with SSR
6
6
  *
7
- * Server functions (`*.server.ts`) and the live channel are plain middleware.
7
+ * Server functions (`*.server.ts`) are plain middleware.
8
8
  */
9
9
  import { createServer } from "node:http";
10
10
  import { existsSync, readFileSync } from "node:fs";
11
- import { fileURLToPath } from "node:url";
11
+ import { fileURLToPath, pathToFileURL } from "node:url";
12
12
  import { dirname, extname, join } from "node:path";
13
13
  import { createApp, logger } from "@prouzbek/server";
14
14
  import { serverFunctions } from "@prouzbek/server/fn";
15
- import { live } from "@prouzbek/data/server";
16
15
  import { toRequest, sendResponse } from "@prouzbek/server/node";
17
16
 
18
17
  const here = dirname(fileURLToPath(import.meta.url));
19
18
  const isProd = process.argv.includes("--prod") || process.env.NODE_ENV === "production";
20
19
  const port = Number(process.env.PORT ?? 5173);
21
20
 
22
- const app = createApp().use(logger()).use(live()).use(serverFunctions());
21
+ const app = createApp().use(logger()).use(serverFunctions());
23
22
 
24
23
  let vite: import("vite").ViteDevServer | undefined;
25
24
  let template: string;
@@ -31,7 +30,7 @@ if (!isProd) {
31
30
  template = readFileSync(join(here, "index.html"), "utf8");
32
31
  } else {
33
32
  template = readFileSync(join(here, "dist/client/index.html"), "utf8");
34
- renderPage = (await import(join(here, "dist/server/entry-server.js").replace(/\\/g, "/"))).render;
33
+ renderPage = (await import(pathToFileURL(join(here, "dist/server/entry-server.js")).href)).render;
35
34
  }
36
35
 
37
36
  app.get("*", async (c) => {
@@ -1,14 +1,11 @@
1
- import { signal } from "@prouzbek/signals";
2
- import { For, Show } from "@prouzbek/dom";
3
- import { route, Link, Outlet, useSearchParams, type RouteDef } from "@prouzbek/router";
4
- import { useQuery, useMutation } from "@prouzbek/data/client";
5
- import { listTodos, addTodo, toggleTodo, removeTodo, type Filter, type Todo } from "./todos.server.ts";
1
+ import { route, Link, Outlet, type RouteDef } from "@prouzbek/router";
2
+ import { Welcome } from "./Welcome.tsx";
6
3
 
7
4
  function Layout() {
8
5
  return (
9
6
  <>
10
7
  <nav>
11
- <Link href="/" activeClass="active">Todos</Link>
8
+ <Link href="/" activeClass="active">Home</Link>
12
9
  <Link href="/about" activeClass="active">About</Link>
13
10
  </nav>
14
11
  <Outlet />
@@ -16,79 +13,12 @@ function Layout() {
16
13
  );
17
14
  }
18
15
 
19
- function Todos() {
20
- const [params, setParams] = useSearchParams();
21
- const filter = () => (params.value.get("filter") as Filter | null) ?? "all";
22
- const list = useQuery(listTodos, filter);
23
- const draft = signal("");
24
-
25
- const add = useMutation(addTodo, {
26
- optimistic: (cache, title) =>
27
- cache.updateAll(listTodos, (cur) => [...cur, { id: -Date.now(), title, done: false, createdAt: new Date() }]),
28
- });
29
- const toggle = useMutation(toggleTodo, {
30
- optimistic: (cache, id) => cache.updateAll(listTodos, (cur) => cur.map((t) => (t.id === id ? { ...t, done: !t.done } : t))),
31
- });
32
- const remove = useMutation(removeTodo, {
33
- optimistic: (cache, id) => cache.updateAll(listTodos, (cur) => cur.filter((t) => t.id !== id)),
34
- });
35
-
36
- return (
37
- <section>
38
- <h1>⚡ {{name}}</h1>
39
- <p class="muted">
40
- Server-rendered, live-synced todos. Open this page in two tabs.
41
- <Show when={add.queued.value > 0}> · {add.queued.value} queued offline</Show>
42
- </p>
43
- <p class="filters">
44
- {(["all", "open", "done"] as Filter[]).map((f) => (
45
- <button type="button" disabled={filter() === f} onClick={() => void setParams({ filter: f === "all" ? null : f })}>
46
- {f}
47
- </button>
48
- ))}
49
- </p>
50
- <form
51
- onSubmit={(e) => {
52
- e.preventDefault();
53
- const title = draft.value;
54
- draft.value = "";
55
- void add.run(title).catch(() => {});
56
- }}
57
- >
58
- <input value={draft} onInput={(e) => (draft.value = e.currentTarget.value)} placeholder="What needs doing?" />
59
- <button type="submit" disabled={!draft.value.trim()}>Add</button>
60
- </form>
61
- <Show when={add.error.value}>{(err) => <p class="muted">⚠ {String((err.value as Error).message)}</p>}</Show>
62
- <Show when={list.data.value} fallback={<p class="muted">Loading…</p>}>
63
- <ul>
64
- <For each={list.data.value ?? []} fallback={<li class="muted">Nothing here.</li>}>
65
- {(todo: Todo) => (
66
- <li classList={{ done: todo.done, pending: todo.id < 0 }}>
67
- <label>
68
- <input type="checkbox" checked={todo.done} disabled={todo.id < 0} onChange={() => void toggle.run(todo.id)} />
69
- <span>{todo.title}</span>
70
- </label>
71
- <button class="link" type="button" disabled={todo.id < 0} onClick={() => void remove.run(todo.id)}>
72
- remove
73
- </button>
74
- </li>
75
- )}
76
- </For>
77
- </ul>
78
- </Show>
79
- </section>
80
- );
81
- }
82
-
83
16
  function About() {
84
17
  return (
85
- <section>
18
+ <main>
86
19
  <h1>About</h1>
87
- <p>
88
- <code>src/todos.server.ts</code> never reaches the browser. The client calls its exports as typed RPC stubs, and
89
- every query refetches automatically when a mutation writes the source it read.
90
- </p>
91
- </section>
20
+ <p>Routes live in <code>src/App.tsx</code>. Each page is a component; the server renders the first one and the browser takes over from there.</p>
21
+ </main>
92
22
  );
93
23
  }
94
24
 
@@ -96,6 +26,6 @@ export const routes: RouteDef[] = [
96
26
  route({
97
27
  path: "/",
98
28
  component: Layout,
99
- children: [route({ path: "", component: Todos }), route({ path: "about", component: About })],
29
+ children: [route({ path: "", component: Welcome }), route({ path: "about", component: About })],
100
30
  }),
101
31
  ];
@@ -0,0 +1,28 @@
1
+ import { signal } from "@prouzbek/signals";
2
+
3
+ /**
4
+ * Starter content. Delete this file and its route in App.tsx when you begin.
5
+ */
6
+ export function Welcome() {
7
+ const count = signal(0);
8
+
9
+ return (
10
+ <main class="welcome">
11
+ <a href="https://www.npmjs.com/package/create-voltapp" target="_blank" rel="noreferrer">
12
+ <svg class="logo" viewBox="0 0 24 24" aria-hidden="true">
13
+ <path d="M13.5 2 4 14h7l-1.5 8L20 10h-7z" />
14
+ </svg>
15
+ </a>
16
+ <h1>{{name}}</h1>
17
+ <div class="card">
18
+ <button type="button" onClick={() => count.value++}>
19
+ count is {count}
20
+ </button>
21
+ <p>
22
+ Edit <code>src/Welcome.tsx</code> and save to test HMR
23
+ </p>
24
+ </div>
25
+ <p class="muted">This page was rendered on the server, then hydrated. Add a file ending in <code>.server.ts</code> to call server code as typed functions.</p>
26
+ </main>
27
+ );
28
+ }
@@ -1,11 +1,8 @@
1
1
  import { render } from "@prouzbek/dom";
2
2
  import { createRouter, RouterProvider } from "@prouzbek/router";
3
- import { createQueryClient, provideQueryClient } from "@prouzbek/data/client";
4
3
  import { routes } from "./App.tsx";
5
4
 
6
- const client = createQueryClient({ storage: localStorage });
7
5
  const router = createRouter(routes);
8
-
9
6
  const root = document.getElementById("app")!;
10
7
  root.innerHTML = "";
11
- render(() => provideQueryClient(client, () => <RouterProvider router={router} />), root);
8
+ render(() => <RouterProvider router={router} />, root);
@@ -1,14 +1,11 @@
1
1
  import { renderToString } from "@prouzbek/dom";
2
2
  import { createRouter, RouterProvider } from "@prouzbek/router";
3
- import { createQueryClient, provideQueryClient } from "@prouzbek/data/client";
4
3
  import { routes } from "./App.tsx";
5
4
 
6
5
  export async function render(url: string, request: Request): Promise<string> {
7
- const client = createQueryClient({ liveUrl: false });
8
6
  const router = createRouter(routes, { url, request });
9
7
  await router.ready;
10
- const html = renderToString(() => provideQueryClient(client, () => <RouterProvider router={router} />));
11
- client.dispose();
8
+ const html = renderToString(() => <RouterProvider router={router} />);
12
9
  router.dispose();
13
10
  return html;
14
11
  }
@@ -1,80 +1,65 @@
1
1
  :root {
2
2
  color-scheme: light dark;
3
3
  font: 16px/1.5 system-ui, sans-serif;
4
+ --accent: #8b8bff;
4
5
  --border: color-mix(in srgb, currentColor 18%, transparent);
5
6
  }
6
7
  body {
7
- max-width: 560px;
8
- margin: 2rem auto;
9
- padding: 0 1rem;
8
+ max-width: 640px;
9
+ margin: 0 auto;
10
+ padding: 1.5rem 1rem;
10
11
  }
11
12
  nav {
12
13
  display: flex;
13
14
  gap: 1rem;
14
- margin-bottom: 1.5rem;
15
15
  }
16
16
  nav a {
17
17
  color: inherit;
18
18
  opacity: 0.7;
19
+ text-decoration: none;
19
20
  }
20
21
  nav a.active {
21
22
  opacity: 1;
22
23
  font-weight: 600;
23
- text-decoration: underline;
24
24
  }
25
- h1 {
26
- margin: 0 0 0.25rem;
25
+ main {
26
+ padding-top: 3rem;
27
27
  }
28
- .muted {
29
- opacity: 0.65;
30
- font-size: 0.9rem;
28
+ .welcome {
29
+ text-align: center;
31
30
  }
32
- .filters,
33
- form {
34
- display: flex;
35
- gap: 0.5rem;
36
- margin: 1rem 0;
31
+ .logo {
32
+ width: 5rem;
33
+ height: 5rem;
34
+ fill: var(--accent);
35
+ filter: drop-shadow(0 0 1.5rem color-mix(in srgb, var(--accent) 60%, transparent));
37
36
  }
38
- form input {
39
- flex: 1;
40
- padding: 0.5rem;
41
- border: 1px solid var(--border);
42
- border-radius: 6px;
43
- background: transparent;
44
- color: inherit;
37
+ h1 {
38
+ font-size: 2.5rem;
39
+ margin: 0.5rem 0 1.5rem;
40
+ }
41
+ .card {
42
+ display: grid;
43
+ gap: 1rem;
44
+ justify-items: center;
45
+ padding: 2rem;
45
46
  }
46
47
  button {
47
- padding: 0.4rem 0.9rem;
48
+ padding: 0.6rem 1.2rem;
49
+ font: inherit;
48
50
  border: 1px solid var(--border);
49
- border-radius: 999px;
51
+ border-radius: 8px;
50
52
  background: transparent;
51
53
  color: inherit;
52
54
  cursor: pointer;
53
55
  }
54
- button:disabled {
55
- opacity: 0.4;
56
- cursor: not-allowed;
57
- }
58
- button.link {
59
- border: 0;
60
- opacity: 0.6;
61
- font-size: 0.85rem;
56
+ button:hover {
57
+ border-color: var(--accent);
62
58
  }
63
- ul {
64
- list-style: none;
65
- padding: 0;
66
- margin: 0;
67
- }
68
- li {
69
- display: flex;
70
- justify-content: space-between;
71
- align-items: center;
72
- padding: 0.3rem 0;
59
+ code {
60
+ font-size: 0.9em;
73
61
  }
74
- li.done span {
75
- text-decoration: line-through;
76
- opacity: 0.6;
77
- }
78
- li.pending {
79
- opacity: 0.5;
62
+ .muted {
63
+ opacity: 0.65;
64
+ font-size: 0.9rem;
80
65
  }
@@ -5,5 +5,5 @@ export default defineConfig({
5
5
  plugins: [volt()],
6
6
  appType: "custom",
7
7
  server: { middlewareMode: true },
8
- build: { manifest: true, rollupOptions: { input: "/src/entry-client.tsx" } },
8
+ build: { manifest: true },
9
9
  });
@@ -8,6 +8,6 @@ npm run dev # http://localhost:5173
8
8
  npm run build # production build in dist/
9
9
  ```
10
10
 
11
- - `src/main.tsx` is the whole app. Components run once; signals drive updates.
11
+ - `src/main.tsx` is the whole app. Components run once; signals drive updates. Replace the `App` component to begin.
12
12
  - `For`, `Show` and friends come from `@prouzbek/dom`; `signal`/`computed` from `@prouzbek/signals`.
13
- - Need a server, routing or live data? Scaffold the `fullstack` template: `npm create voltapp@latest -- --template fullstack`.
13
+ - Need SSR and routing? Scaffold the `fullstack` template. Want to see live queries? Try `todo`: `npm create voltapp@latest -- --template todo`.
@@ -1,74 +1,27 @@
1
- import { signal, computed } from "@prouzbek/signals";
2
- import { render, For, Show } from "@prouzbek/dom";
1
+ import { signal } from "@prouzbek/signals";
2
+ import { render } from "@prouzbek/dom";
3
3
  import "./styles.css";
4
4
 
5
- type Item = { id: number; text: string; done: boolean };
6
-
7
5
  function App() {
8
6
  const count = signal(0);
9
- const double = computed(() => count.value * 2);
10
-
11
- const items = signal<Item[]>([
12
- { id: 1, text: "Read the docs", done: false },
13
- { id: 2, text: "Build something", done: false },
14
- ]);
15
- const draft = signal("");
16
- let nextId = 3;
17
-
18
- const add = (e: Event) => {
19
- e.preventDefault();
20
- const text = draft.value.trim();
21
- if (!text) return;
22
- items.value = [...items.value, { id: nextId++, text, done: false }];
23
- draft.value = "";
24
- };
25
7
 
26
8
  return (
27
9
  <main>
28
- <h1>⚡ {{name}}</h1>
29
- <p class="muted">
30
- This component ran once. Every value below is a signal bound to exactly one DOM node.
31
- </p>
32
-
33
- <section class="card">
34
- <h2>Counter</h2>
35
- <div class="row">
36
- <button onClick={() => count.value--}>−</button>
37
- <output>{count}</output>
38
- <button onClick={() => count.value++}>+</button>
39
- </div>
40
- <p class="muted">double = {double}</p>
41
- </section>
42
-
43
- <section class="card">
44
- <h2>List</h2>
45
- <form class="row" onSubmit={add}>
46
- <input value={draft} onInput={(e) => (draft.value = e.currentTarget.value)} placeholder="Add an item" />
47
- <button type="submit" disabled={!draft.value.trim()}>Add</button>
48
- </form>
49
- <ul>
50
- <For each={items.value} fallback={<li class="muted">Nothing here yet.</li>}>
51
- {(item) => (
52
- <li classList={{ done: item.done }}>
53
- <label>
54
- <input
55
- type="checkbox"
56
- checked={item.done}
57
- onChange={() => (items.value = items.value.map((i) => (i.id === item.id ? { ...i, done: !i.done } : i)))}
58
- />
59
- {item.text}
60
- </label>
61
- <button class="link" onClick={() => (items.value = items.value.filter((i) => i.id !== item.id))}>
62
- remove
63
- </button>
64
- </li>
65
- )}
66
- </For>
67
- </ul>
68
- <Show when={items.value.some((i) => i.done)}>
69
- <p class="muted">{items.value.filter((i) => i.done).length} done</p>
70
- </Show>
71
- </section>
10
+ <a href="https://www.npmjs.com/package/create-voltapp" target="_blank" rel="noreferrer">
11
+ <svg class="logo" viewBox="0 0 24 24" aria-hidden="true">
12
+ <path d="M13.5 2 4 14h7l-1.5 8L20 10h-7z" />
13
+ </svg>
14
+ </a>
15
+ <h1>{{name}}</h1>
16
+ <div class="card">
17
+ <button type="button" onClick={() => count.value++}>
18
+ count is {count}
19
+ </button>
20
+ <p>
21
+ Edit <code>src/main.tsx</code> and save to test HMR
22
+ </p>
23
+ </div>
24
+ <p class="muted">Components run once. <code>count</code> is a signal bound to exactly one text node.</p>
72
25
  </main>
73
26
  );
74
27
  }
@@ -1,84 +1,50 @@
1
1
  :root {
2
2
  color-scheme: light dark;
3
3
  font: 16px/1.5 system-ui, sans-serif;
4
+ --accent: #8b8bff;
4
5
  --border: color-mix(in srgb, currentColor 18%, transparent);
5
6
  }
6
7
  body {
7
8
  margin: 0;
8
9
  }
9
10
  main {
10
- max-width: 560px;
11
- margin: 3rem auto;
12
- padding: 0 1rem;
13
- display: grid;
14
- gap: 1rem;
11
+ max-width: 640px;
12
+ margin: 0 auto;
13
+ padding: 4rem 1rem;
14
+ text-align: center;
15
15
  }
16
- h1,
17
- h2,
18
- p {
19
- margin: 0;
16
+ .logo {
17
+ width: 5rem;
18
+ height: 5rem;
19
+ fill: var(--accent);
20
+ filter: drop-shadow(0 0 1.5rem color-mix(in srgb, var(--accent) 60%, transparent));
20
21
  }
21
- .muted {
22
- opacity: 0.65;
23
- font-size: 0.9rem;
22
+ h1 {
23
+ font-size: 2.5rem;
24
+ margin: 0.5rem 0 1.5rem;
24
25
  }
25
26
  .card {
26
- border: 1px solid var(--border);
27
- border-radius: 10px;
28
- padding: 1rem;
29
27
  display: grid;
30
- gap: 0.75rem;
31
- }
32
- .row {
33
- display: flex;
34
- gap: 0.5rem;
35
- align-items: center;
36
- }
37
- output {
38
- min-width: 3ch;
39
- text-align: center;
40
- font-size: 1.5rem;
41
- font-weight: 600;
42
- }
43
- input[type="text"],
44
- input:not([type]) {
45
- flex: 1;
46
- padding: 0.5rem;
47
- border: 1px solid var(--border);
48
- border-radius: 6px;
49
- background: transparent;
50
- color: inherit;
28
+ gap: 1rem;
29
+ justify-items: center;
30
+ padding: 2rem;
51
31
  }
52
32
  button {
53
- padding: 0.45rem 0.9rem;
33
+ padding: 0.6rem 1.2rem;
34
+ font: inherit;
54
35
  border: 1px solid var(--border);
55
- border-radius: 999px;
36
+ border-radius: 8px;
56
37
  background: transparent;
57
38
  color: inherit;
58
39
  cursor: pointer;
59
40
  }
60
- button:disabled {
61
- opacity: 0.4;
62
- cursor: not-allowed;
41
+ button:hover {
42
+ border-color: var(--accent);
63
43
  }
64
- button.link {
65
- border: 0;
66
- opacity: 0.6;
67
- font-size: 0.85rem;
44
+ code {
45
+ font-size: 0.9em;
68
46
  }
69
- ul {
70
- list-style: none;
71
- margin: 0;
72
- padding: 0;
73
- display: grid;
74
- gap: 0.25rem;
75
- }
76
- li {
77
- display: flex;
78
- justify-content: space-between;
79
- align-items: center;
80
- }
81
- li.done label {
82
- text-decoration: line-through;
83
- opacity: 0.6;
47
+ .muted {
48
+ opacity: 0.65;
49
+ font-size: 0.9rem;
84
50
  }
@@ -0,0 +1,28 @@
1
+ # {{name}}
2
+
3
+ A full-stack Volt demo: server-side rendering, a Vite dev server with HMR, typed routing, server functions and live queries with automatic invalidation.
4
+
5
+ ```sh
6
+ npm install
7
+ npm run dev # http://localhost:5173
8
+ npm run build # client + server bundles in dist/
9
+ npm start # production server
10
+ ```
11
+
12
+ ## Layout
13
+
14
+ ```
15
+ server.ts HTTP server (dev: Vite middleware; prod: dist/)
16
+ src/todos.server.ts queries and mutations — never shipped to the browser
17
+ src/App.tsx routes and components
18
+ src/entry-client.tsx render() in the browser
19
+ src/entry-server.tsx renderToString() for SSR
20
+ ```
21
+
22
+ ## How data flows
23
+
24
+ 1. `listTodos` is a `query`: while it runs, `todos.read()` records a dependency.
25
+ 2. `addTodo` is a `mutation`: `todos.write()` records a change.
26
+ 3. The client's `useQuery(listTodos, filter)` refetches whenever a mutation writes `todos`, in every open tab, pushed over `/_volt/live`.
27
+
28
+ Requires Node 24 or newer (TypeScript runs natively).
@@ -0,0 +1,3 @@
1
+ node_modules/
2
+ dist/
3
+ .DS_Store
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>{{name}}</title>
7
+ <link rel="stylesheet" href="/src/styles.css" />
8
+ </head>
9
+ <body>
10
+ <div id="app"><!--ssr-outlet--></div>
11
+ <script type="module" src="/src/entry-client.tsx"></script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "{{name}}",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "node server.ts",
8
+ "build": "vite build --outDir dist/client && vite build --ssr src/entry-server.tsx --outDir dist/server",
9
+ "start": "node server.ts --prod",
10
+ "check": "tsc --noEmit"
11
+ },
12
+ "dependencies": {
13
+ "@prouzbek/data": "{{version}}",
14
+ "@prouzbek/dom": "{{version}}",
15
+ "@prouzbek/router": "{{version}}",
16
+ "@prouzbek/serialize": "{{version}}",
17
+ "@prouzbek/server": "{{version}}",
18
+ "@prouzbek/signals": "{{version}}"
19
+ },
20
+ "devDependencies": {
21
+ "@types/node": "^24.0.0",
22
+ "@prouzbek/vite": "{{version}}",
23
+ "typescript": "^5.9.0",
24
+ "vite": "^8.0.0"
25
+ }
26
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * One server for development and production.
3
+ * npm run dev → Vite in middleware mode (HMR) + SSR compiled on the fly
4
+ * npm run build → client + server bundles in dist/
5
+ * npm start → serves dist/ with SSR
6
+ *
7
+ * Server functions (`*.server.ts`) and the live channel are plain middleware.
8
+ */
9
+ import { createServer } from "node:http";
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { fileURLToPath, pathToFileURL } from "node:url";
12
+ import { dirname, extname, join } from "node:path";
13
+ import { createApp, logger } from "@prouzbek/server";
14
+ import { serverFunctions } from "@prouzbek/server/fn";
15
+ import { live } from "@prouzbek/data/server";
16
+ import { toRequest, sendResponse } from "@prouzbek/server/node";
17
+
18
+ const here = dirname(fileURLToPath(import.meta.url));
19
+ const isProd = process.argv.includes("--prod") || process.env.NODE_ENV === "production";
20
+ const port = Number(process.env.PORT ?? 5173);
21
+
22
+ const app = createApp().use(logger()).use(live()).use(serverFunctions());
23
+
24
+ let vite: import("vite").ViteDevServer | undefined;
25
+ let template: string;
26
+ let renderPage: (url: string, request: Request) => Promise<string>;
27
+
28
+ if (!isProd) {
29
+ const { createServer: createVite } = await import("vite");
30
+ vite = await createVite({ root: here, server: { middlewareMode: true }, appType: "custom" });
31
+ template = readFileSync(join(here, "index.html"), "utf8");
32
+ } else {
33
+ template = readFileSync(join(here, "dist/client/index.html"), "utf8");
34
+ renderPage = (await import(pathToFileURL(join(here, "dist/server/entry-server.js")).href)).render;
35
+ }
36
+
37
+ app.get("*", async (c) => {
38
+ const url = c.url.pathname + c.url.search;
39
+ let html = template;
40
+ if (vite) {
41
+ html = await vite.transformIndexHtml(url, template);
42
+ renderPage = (await vite.ssrLoadModule("/src/entry-server.tsx")).render;
43
+ }
44
+ return c.html(html.replace("<!--ssr-outlet-->", await renderPage(url, c.request)));
45
+ });
46
+
47
+ const types: Record<string, string> = { ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".woff2": "font/woff2" };
48
+
49
+ createServer((req, res) => {
50
+ const handle = () =>
51
+ app
52
+ .fetch(toRequest(req))
53
+ .then((r) => sendResponse(r, res))
54
+ .catch((e) => {
55
+ console.error(e);
56
+ if (!res.headersSent) res.writeHead(500);
57
+ res.end("Internal Server Error");
58
+ });
59
+ if (vite) return vite.middlewares(req, res, () => void handle());
60
+ const asset = join(here, "dist/client", req.url ?? "");
61
+ if (isProd && req.url?.startsWith("/assets/") && existsSync(asset)) {
62
+ res.writeHead(200, { "content-type": types[extname(asset)] ?? "application/octet-stream", "cache-control": "public, max-age=31536000, immutable" });
63
+ return res.end(readFileSync(asset));
64
+ }
65
+ void handle();
66
+ }).listen(port, () => console.log(`⚡ {{name}} → http://localhost:${port}`));
@@ -0,0 +1,101 @@
1
+ import { signal } from "@prouzbek/signals";
2
+ import { For, Show } from "@prouzbek/dom";
3
+ import { route, Link, Outlet, useSearchParams, type RouteDef } from "@prouzbek/router";
4
+ import { useQuery, useMutation } from "@prouzbek/data/client";
5
+ import { listTodos, addTodo, toggleTodo, removeTodo, type Filter, type Todo } from "./todos.server.ts";
6
+
7
+ function Layout() {
8
+ return (
9
+ <>
10
+ <nav>
11
+ <Link href="/" activeClass="active">Todos</Link>
12
+ <Link href="/about" activeClass="active">About</Link>
13
+ </nav>
14
+ <Outlet />
15
+ </>
16
+ );
17
+ }
18
+
19
+ function Todos() {
20
+ const [params, setParams] = useSearchParams();
21
+ const filter = () => (params.value.get("filter") as Filter | null) ?? "all";
22
+ const list = useQuery(listTodos, filter);
23
+ const draft = signal("");
24
+
25
+ const add = useMutation(addTodo, {
26
+ optimistic: (cache, title) =>
27
+ cache.updateAll(listTodos, (cur) => [...cur, { id: -Date.now(), title, done: false, createdAt: new Date() }]),
28
+ });
29
+ const toggle = useMutation(toggleTodo, {
30
+ optimistic: (cache, id) => cache.updateAll(listTodos, (cur) => cur.map((t) => (t.id === id ? { ...t, done: !t.done } : t))),
31
+ });
32
+ const remove = useMutation(removeTodo, {
33
+ optimistic: (cache, id) => cache.updateAll(listTodos, (cur) => cur.filter((t) => t.id !== id)),
34
+ });
35
+
36
+ return (
37
+ <section>
38
+ <h1>⚡ {{name}}</h1>
39
+ <p class="muted">
40
+ Server-rendered, live-synced todos. Open this page in two tabs.
41
+ <Show when={add.queued.value > 0}> · {add.queued.value} queued offline</Show>
42
+ </p>
43
+ <p class="filters">
44
+ {(["all", "open", "done"] as Filter[]).map((f) => (
45
+ <button type="button" disabled={filter() === f} onClick={() => void setParams({ filter: f === "all" ? null : f })}>
46
+ {f}
47
+ </button>
48
+ ))}
49
+ </p>
50
+ <form
51
+ onSubmit={(e) => {
52
+ e.preventDefault();
53
+ const title = draft.value;
54
+ draft.value = "";
55
+ void add.run(title).catch(() => {});
56
+ }}
57
+ >
58
+ <input value={draft} onInput={(e) => (draft.value = e.currentTarget.value)} placeholder="What needs doing?" />
59
+ <button type="submit" disabled={!draft.value.trim()}>Add</button>
60
+ </form>
61
+ <Show when={add.error.value}>{(err) => <p class="muted">⚠ {String((err.value as Error).message)}</p>}</Show>
62
+ <Show when={list.data.value} fallback={<p class="muted">Loading…</p>}>
63
+ <ul>
64
+ <For each={list.data.value ?? []} fallback={<li class="muted">Nothing here.</li>}>
65
+ {(todo: Todo) => (
66
+ <li classList={{ done: todo.done, pending: todo.id < 0 }}>
67
+ <label>
68
+ <input type="checkbox" checked={todo.done} disabled={todo.id < 0} onChange={() => void toggle.run(todo.id)} />
69
+ <span>{todo.title}</span>
70
+ </label>
71
+ <button class="link" type="button" disabled={todo.id < 0} onClick={() => void remove.run(todo.id)}>
72
+ remove
73
+ </button>
74
+ </li>
75
+ )}
76
+ </For>
77
+ </ul>
78
+ </Show>
79
+ </section>
80
+ );
81
+ }
82
+
83
+ function About() {
84
+ return (
85
+ <section>
86
+ <h1>About</h1>
87
+ <p>
88
+ <code>src/todos.server.ts</code> never reaches the browser. The client calls its exports as typed RPC stubs, and
89
+ every query refetches automatically when a mutation writes the source it read.
90
+ </p>
91
+ </section>
92
+ );
93
+ }
94
+
95
+ export const routes: RouteDef[] = [
96
+ route({
97
+ path: "/",
98
+ component: Layout,
99
+ children: [route({ path: "", component: Todos }), route({ path: "about", component: About })],
100
+ }),
101
+ ];
@@ -0,0 +1,11 @@
1
+ import { render } from "@prouzbek/dom";
2
+ import { createRouter, RouterProvider } from "@prouzbek/router";
3
+ import { createQueryClient, provideQueryClient } from "@prouzbek/data/client";
4
+ import { routes } from "./App.tsx";
5
+
6
+ const client = createQueryClient({ storage: localStorage });
7
+ const router = createRouter(routes);
8
+
9
+ const root = document.getElementById("app")!;
10
+ root.innerHTML = "";
11
+ render(() => provideQueryClient(client, () => <RouterProvider router={router} />), root);
@@ -0,0 +1,14 @@
1
+ import { renderToString } from "@prouzbek/dom";
2
+ import { createRouter, RouterProvider } from "@prouzbek/router";
3
+ import { createQueryClient, provideQueryClient } from "@prouzbek/data/client";
4
+ import { routes } from "./App.tsx";
5
+
6
+ export async function render(url: string, request: Request): Promise<string> {
7
+ const client = createQueryClient({ liveUrl: false });
8
+ const router = createRouter(routes, { url, request });
9
+ await router.ready;
10
+ const html = renderToString(() => provideQueryClient(client, () => <RouterProvider router={router} />));
11
+ client.dispose();
12
+ router.dispose();
13
+ return html;
14
+ }
@@ -0,0 +1,80 @@
1
+ :root {
2
+ color-scheme: light dark;
3
+ font: 16px/1.5 system-ui, sans-serif;
4
+ --border: color-mix(in srgb, currentColor 18%, transparent);
5
+ }
6
+ body {
7
+ max-width: 560px;
8
+ margin: 2rem auto;
9
+ padding: 0 1rem;
10
+ }
11
+ nav {
12
+ display: flex;
13
+ gap: 1rem;
14
+ margin-bottom: 1.5rem;
15
+ }
16
+ nav a {
17
+ color: inherit;
18
+ opacity: 0.7;
19
+ }
20
+ nav a.active {
21
+ opacity: 1;
22
+ font-weight: 600;
23
+ text-decoration: underline;
24
+ }
25
+ h1 {
26
+ margin: 0 0 0.25rem;
27
+ }
28
+ .muted {
29
+ opacity: 0.65;
30
+ font-size: 0.9rem;
31
+ }
32
+ .filters,
33
+ form {
34
+ display: flex;
35
+ gap: 0.5rem;
36
+ margin: 1rem 0;
37
+ }
38
+ form input {
39
+ flex: 1;
40
+ padding: 0.5rem;
41
+ border: 1px solid var(--border);
42
+ border-radius: 6px;
43
+ background: transparent;
44
+ color: inherit;
45
+ }
46
+ button {
47
+ padding: 0.4rem 0.9rem;
48
+ border: 1px solid var(--border);
49
+ border-radius: 999px;
50
+ background: transparent;
51
+ color: inherit;
52
+ cursor: pointer;
53
+ }
54
+ button:disabled {
55
+ opacity: 0.4;
56
+ cursor: not-allowed;
57
+ }
58
+ button.link {
59
+ border: 0;
60
+ opacity: 0.6;
61
+ font-size: 0.85rem;
62
+ }
63
+ ul {
64
+ list-style: none;
65
+ padding: 0;
66
+ margin: 0;
67
+ }
68
+ li {
69
+ display: flex;
70
+ justify-content: space-between;
71
+ align-items: center;
72
+ padding: 0.3rem 0;
73
+ }
74
+ li.done span {
75
+ text-decoration: line-through;
76
+ opacity: 0.6;
77
+ }
78
+ li.pending {
79
+ opacity: 0.5;
80
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "jsx": "preserve",
8
+ "jsxImportSource": "@prouzbek/dom",
9
+ "strict": true,
10
+ "noEmit": true,
11
+ "allowImportingTsExtensions": true,
12
+ "verbatimModuleSyntax": true,
13
+ "skipLibCheck": true,
14
+ "types": ["node", "vite/client"]
15
+ },
16
+ "include": ["src", "server.ts", "vite.config.ts"]
17
+ }
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from "vite";
2
+ import volt from "@prouzbek/vite";
3
+
4
+ export default defineConfig({
5
+ plugins: [volt()],
6
+ appType: "custom",
7
+ server: { middlewareMode: true },
8
+ build: { manifest: true },
9
+ });