create-caspian-app 1.3.27 → 1.4.1
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 +158 -326
- package/dist/.github/copilot-instructions.md +16 -10
- package/dist/AGENTS.md +28 -3
- package/dist/index.js +1 -1
- package/dist/src/app/error.py +22 -39
- package/dist/src/app/not_found.py +3 -7
- package/dist/src/lib/auth/auth_config.py +0 -4
- package/dist/src/lib/websocket/sockets.py +15 -38
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,77 +1,57 @@
|
|
|
1
1
|
# Caspian — The Native Python Web Framework for the Reactive Web
|
|
2
2
|
|
|
3
|
-
Caspian is a FastAPI-powered full-stack framework that brings reactive UI to Python without
|
|
3
|
+
Caspian is a FastAPI-powered full-stack framework that brings reactive UI to Python without a JavaScript backend. You write file-system routes, plain HTML templates, and `async def` Python — Caspian wires the browser to your server.
|
|
4
4
|
|
|
5
|
-
- **FastAPI engine** — async-native, with the Starlette/FastAPI middleware
|
|
6
|
-
- **PulsePoint** — a shipped browser
|
|
7
|
-
- **"Zero-API" RPC** — call Python functions from the browser with `pp.rpc()`; no controllers, no
|
|
5
|
+
- **FastAPI engine** — async-native, with the Starlette/FastAPI middleware ecosystem underneath
|
|
6
|
+
- **PulsePoint** — a shipped browser runtime with a React-style hook API and **plain HTML** templates (no JSX, no build step required)
|
|
7
|
+
- **"Zero-API" RPC** — call Python functions from the browser with `pp.rpc()`; no controllers, no fetch boilerplate
|
|
8
8
|
- **File-system routing** with nested layouts, dynamic segments, and route groups (Next.js App Router mental model)
|
|
9
9
|
- **Python components** — reusable `@component` functions rendered as HTML-first `x-*` tags
|
|
10
|
-
- **Prisma ORM** with a generated,
|
|
10
|
+
- **Prisma ORM** with a generated, typed Python client
|
|
11
11
|
- **Session auth** with RBAC and OAuth providers, plus fail-closed security defaults
|
|
12
12
|
- **Optional**: Tailwind CSS, TypeScript tooling, MCP server, WebSockets — each gated by one config flag
|
|
13
13
|
|
|
14
|
+
> **The full manual ships inside every project** at `node_modules/caspian-utils/dist/docs/` (start at `index.md`). This README is the tour; that folder is the reference.
|
|
15
|
+
|
|
14
16
|
---
|
|
15
17
|
|
|
16
18
|
## Quick Start
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
- **Python** `3.14` or newer
|
|
21
|
-
- **Node.js** with `npm` and `npx` available (used for the CLI, Prisma, Tailwind, and the dev stack)
|
|
22
|
-
|
|
23
|
-
```bash
|
|
24
|
-
node -v
|
|
25
|
-
python -V
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
### Create an app
|
|
20
|
+
Requires **Python 3.14+** and **Node.js** with `npm`/`npx` (used for the CLI, Prisma, Tailwind, and the dev stack).
|
|
29
21
|
|
|
30
22
|
```bash
|
|
31
23
|
npx create-caspian-app@latest
|
|
32
24
|
```
|
|
33
25
|
|
|
34
|
-
The
|
|
35
|
-
|
|
36
|
-
- Project name
|
|
37
|
-
- Feature toggles: backend-only, Tailwind CSS, Prisma, MCP, TypeScript
|
|
38
|
-
- Starter kit selection (`basic`, `fullstack`, `api`, `realtime`, or a custom Git source)
|
|
39
|
-
|
|
40
|
-
### Run the dev server
|
|
26
|
+
The wizard asks for a project name, feature toggles (backend-only, Tailwind, Prisma, MCP, TypeScript), and a starter kit (`basic`, `fullstack`, `api`, `realtime`, or a custom Git source). Then:
|
|
41
27
|
|
|
42
28
|
```bash
|
|
43
|
-
cd my-app
|
|
44
29
|
npm run dev
|
|
45
30
|
```
|
|
46
31
|
|
|
47
|
-
|
|
32
|
+
`npm run dev` runs a **BrowserSync proxy plus asset watchers**, not a Vite dev server that owns the page. The proxy does not always land on its default port — check `settings/bs-config.json` for the active URL.
|
|
48
33
|
|
|
49
34
|
---
|
|
50
35
|
|
|
51
36
|
## What "Reactive Python" looks like
|
|
52
37
|
|
|
53
|
-
A route is a folder with one file: `index.py`.
|
|
54
|
-
|
|
55
|
-
### The route — `src/app/todos/index.py`
|
|
38
|
+
A route is a folder with one file: `index.py`. Markup lives inline in `html(r"""...""")`, next to the server logic.
|
|
56
39
|
|
|
57
40
|
```python
|
|
41
|
+
# src/app/todos/index.py
|
|
58
42
|
from casp.component_decorator import html
|
|
59
43
|
from casp.layout import Metadata
|
|
60
44
|
from casp.rpc import rpc
|
|
61
45
|
from casp.validate import Rule, Validate
|
|
62
|
-
from src.components.ui.Badge import Badge
|
|
63
46
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
)
|
|
47
|
+
from src.lib.prisma import prisma
|
|
48
|
+
|
|
49
|
+
metadata = Metadata(title="Todos", description="A tiny Caspian todo list.")
|
|
68
50
|
|
|
69
51
|
|
|
70
52
|
async def page():
|
|
71
53
|
return html(r"""
|
|
72
54
|
<section>
|
|
73
|
-
<x-badge variant="default">Tasks: {todos.length}</x-badge>
|
|
74
|
-
|
|
75
55
|
<form onsubmit="{addTodo(event)}">
|
|
76
56
|
<input name="title" required />
|
|
77
57
|
<button type="submit" disabled="{isSaving}">Add</button>
|
|
@@ -79,7 +59,7 @@ async def page():
|
|
|
79
59
|
|
|
80
60
|
<ul>
|
|
81
61
|
<template pp-for="(todo, index) in todos">
|
|
82
|
-
<li key="{todo.id}" class="p-2
|
|
62
|
+
<li key="{todo.id}" class="border-b p-2">
|
|
83
63
|
{index + 1}. {todo.title}
|
|
84
64
|
<button onclick="{removeTodo(todo.id)}">Remove</button>
|
|
85
65
|
</li>
|
|
@@ -103,8 +83,7 @@ async def page():
|
|
|
103
83
|
const data = Object.fromEntries(
|
|
104
84
|
new FormData(event.currentTarget).entries(),
|
|
105
85
|
);
|
|
106
|
-
|
|
107
|
-
setTodos([created, ...todos]);
|
|
86
|
+
setTodos([await pp.rpc("create_todo", data), ...todos]);
|
|
108
87
|
event.currentTarget.reset();
|
|
109
88
|
} finally {
|
|
110
89
|
setIsSaving(false);
|
|
@@ -131,7 +110,6 @@ async def create_todo(title: str):
|
|
|
131
110
|
checked = Validate.with_rules(title, [Rule.REQUIRED, Rule.min(3)])
|
|
132
111
|
if checked is not True:
|
|
133
112
|
raise ValueError("Title must be at least 3 characters.")
|
|
134
|
-
|
|
135
113
|
todo = await prisma.todo.create(data={"title": title.strip(), "completed": False})
|
|
136
114
|
return todo.to_dict()
|
|
137
115
|
|
|
@@ -142,7 +120,7 @@ async def delete_todo(id: int):
|
|
|
142
120
|
return {"deleted": True}
|
|
143
121
|
```
|
|
144
122
|
|
|
145
|
-
That is the whole loop
|
|
123
|
+
That is the whole loop — no API routes, no client, no serializer. `pp.rpc("create_todo", data)` posts to the current route; Caspian resolves the decorated function, filters the payload against its signature, runs it, and returns JSON.
|
|
146
124
|
|
|
147
125
|
---
|
|
148
126
|
|
|
@@ -150,33 +128,30 @@ That is the whole loop: no API routes, no client, no serializer. `pp.rpc("create
|
|
|
150
128
|
|
|
151
129
|
### 1. Routing
|
|
152
130
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
| File | URL |
|
|
156
|
-
| ----------------------------- | ------------- |
|
|
157
|
-
| `src/app/index.py` | `/` |
|
|
158
|
-
| `src/app/about/index.py` | `/about` |
|
|
159
|
-
| `src/app/blog/posts/index.py` | `/blog/posts` |
|
|
131
|
+
The directory structure under `src/app` is the URL structure.
|
|
160
132
|
|
|
161
133
|
```
|
|
162
|
-
src/app/
|
|
163
|
-
src/app/
|
|
164
|
-
src/app/
|
|
134
|
+
src/app/index.py -> /
|
|
135
|
+
src/app/blog/posts/index.py -> /blog/posts
|
|
136
|
+
src/app/users/[id]/index.py -> /users/123 (dynamic segment)
|
|
137
|
+
src/app/docs/[...slug]/index.py -> /docs/a/b/c (catch-all)
|
|
138
|
+
src/app/(auth)/login/index.py -> /login (route group, no URL segment)
|
|
165
139
|
src/app/dashboard/layout.py -> wraps every /dashboard/* page
|
|
166
140
|
```
|
|
167
141
|
|
|
168
|
-
|
|
142
|
+
Path params arrive as one positional dict (`async def page(params: dict)`); query params inject by name; `request` injects when declared. `page()` returns `html(r"""...""", **context)`, or a `(page_html, layout_props)` tuple whose keys become `{{ layout.* }}` in a parent layout.
|
|
143
|
+
|
|
144
|
+
**Special files.** Only `index.py` is required — the rest are optional, and each owns a behavior you should not hand-build.
|
|
169
145
|
|
|
170
|
-
| File
|
|
171
|
-
|
|
|
172
|
-
| `index.py`
|
|
173
|
-
| `layout.py`
|
|
174
|
-
| `loading.py`
|
|
175
|
-
| `not_found.py`
|
|
176
|
-
| `error.py`
|
|
146
|
+
| File | Export | Owns |
|
|
147
|
+
| -------------- | ---------------------------------- | ----------------------------------------------------------------------------- |
|
|
148
|
+
| `index.py` | `page()` | The page, plus `metadata`, route-owned `@rpc()`, redirects, first-render data |
|
|
149
|
+
| `layout.py` | `layout()` | A subtree shell containing `<slot />`, plus optional props and metadata |
|
|
150
|
+
| `loading.py` | `loading()` | Loading UI shown while navigating **between routes** |
|
|
151
|
+
| `not_found.py` | `page()` | Global 404 page |
|
|
152
|
+
| `error.py` | `page(error_message, error_trace)` | Global 500 page |
|
|
177
153
|
|
|
178
|
-
`
|
|
179
|
-
`(page_html, layout_props)` tuple, whose dict keys become `{{ layout.* }}` in a parent layout.
|
|
154
|
+
`loading.py` is worth knowing because it is easy to reinvent: `loading()` is **synchronous and takes no parameters**, its URL scope comes from its folder (the closest ancestor wins), and its markup is injected as **raw HTML** — Jinja `{{ }}` interpolates, but `<x-*>` tags, `{ }` bindings, and `<script>` do nothing inside it. Mark the pane it replaces with `pp-loading-content="true"` in the layout. Most subtrees have none, and a route with no loader simply fades; add one only when a section wants it, and never replace it with a spinner component or a navigation-event listener.
|
|
180
155
|
|
|
181
156
|
---
|
|
182
157
|
|
|
@@ -185,9 +160,8 @@ src/app/dashboard/layout.py -> wraps every /dashboard/* page
|
|
|
185
160
|
PulsePoint borrows React's **hook API** and its **component decomposition model**. It does **not** borrow JSX. This is the single most common source of broken pages.
|
|
186
161
|
|
|
187
162
|
```html
|
|
188
|
-
<!-- ❌
|
|
163
|
+
<!-- ❌ Silently corrupts the page -->
|
|
189
164
|
<div class="{cls}">…</div>
|
|
190
|
-
<!-- unquoted brace attr -->
|
|
191
165
|
{isOpen &&
|
|
192
166
|
<div>Panel</div>
|
|
193
167
|
} {items.map(item => (
|
|
@@ -204,114 +178,60 @@ PulsePoint borrows React's **hook API** and its **component decomposition model*
|
|
|
204
178
|
|
|
205
179
|
An unquoted `class={...}` is **invalid HTML**: the parser splits it on spaces into junk attributes, the component root never compiles, and the route serves a blank page **with no console error**.
|
|
206
180
|
|
|
207
|
-
**Sanity check:** delete every `{}` from
|
|
208
|
-
|
|
209
|
-
There is no `pp-if`, `pp-show`, `pp-else`, or `pp-key`. Conditionals are `hidden="{...}"` or a ternary inside `{...}`. Keyed lists use plain `key`.
|
|
181
|
+
**Sanity check:** delete every `{}` from the template. What remains must still be valid HTML.
|
|
210
182
|
|
|
211
|
-
|
|
183
|
+
**The directive list is closed.** There is no `pp-if`, `pp-show`, `pp-else`, `pp-model`, or `pp-key`.
|
|
212
184
|
|
|
213
|
-
| Syntax
|
|
214
|
-
|
|
|
215
|
-
| `{expression}`
|
|
216
|
-
| `onclick`, `oninput`, `
|
|
217
|
-
| `pp-for="item in items"` / `"(item, index) in items"`
|
|
218
|
-
| `key="{expr}"`
|
|
219
|
-
| `pp-ref="name"` / `pp-ref="{expr}"`
|
|
220
|
-
| `
|
|
221
|
-
| `pp-
|
|
222
|
-
|
|
|
223
|
-
|
|
|
224
|
-
| `pp-
|
|
225
|
-
| `pp-
|
|
185
|
+
| Syntax | Where | Purpose |
|
|
186
|
+
| ----------------------------------------------------- | ------------------------------------------ | ---------------------------------- |
|
|
187
|
+
| `{expression}` | Text nodes and **quoted** attribute values | Interpolation |
|
|
188
|
+
| `onclick`, `oninput`, `onsubmit`, any `on*` | Any element | Event binding |
|
|
189
|
+
| `pp-for="item in items"` / `"(item, index) in items"` | **`<template>` only** | List rendering |
|
|
190
|
+
| `key="{expr}"` | The repeated element | Diffing identity |
|
|
191
|
+
| `pp-ref="name"` / `pp-ref="{expr}"` | Native elements and `x-*` tags | Imperative element access |
|
|
192
|
+
| `defaultvalue` / `defaultchecked` | Form controls | Uncontrolled seed (lowercase) |
|
|
193
|
+
| `pp-style="{cssText}"` | Any element | Dynamic inline style (string) |
|
|
194
|
+
| `pp-spread="{...obj}"` | Any element | Spread object into attributes |
|
|
195
|
+
| `<token.provider value="{v}">` (lowercase) | Anywhere | Context provider |
|
|
196
|
+
| `pp-spa="false"` | An `<a>` | Opt one link out of SPA navigation |
|
|
197
|
+
| `pp-reset-scroll`, `pp-scroll-key` | A scroll container | Scroll restoration control |
|
|
198
|
+
| `pp-loading-content="true"` | The pane swapped during navigation | Where `loading.py` markup lands |
|
|
226
199
|
|
|
227
|
-
Never
|
|
200
|
+
A form control is controlled (`value="{state}"` + `oninput`) **or** uncontrolled (`defaultvalue="{expr}"`) for its lifetime, never both. Never hand-write runtime-managed attributes (`pp-component`, `pp-owner`, `pp-ref-forward`, `pp-loading-url`, `data-pp-*`, …) — the pipeline injects them.
|
|
228
201
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
Every route, layout, and component template must have **exactly one** top-level element (or one imported `x-*` root), with any owned `<script>` **inside** that root. Caspian injects `pp-component` on the final root and errors if it cannot find one.
|
|
202
|
+
**Root shape.** Default to one top-level element with the owned `<script>` **inside** it. Beyond that: a **component** with sibling top-level nodes becomes a fragment (the `<>…</>` equivalent) that adds no element to the DOM — but a fragment has no root, so it **cannot receive props**; give it a single native root when it takes any. A **page or layout** with sibling top-level nodes gets a layout-neutral `display: contents` boundary host instead, so skip the meaningless wrapper `<div>` when the sections really are siblings.
|
|
232
203
|
|
|
233
204
|
---
|
|
234
205
|
|
|
235
206
|
### 3. PulsePoint hooks
|
|
236
207
|
|
|
237
|
-
Component `<script>` blocks are plain JavaScript.
|
|
238
|
-
|
|
239
|
-
| Hook | Returns / purpose |
|
|
240
|
-
| --------------------------------------------- | ---------------------------------------------------------------------- |
|
|
241
|
-
| `pp.state(initial)` | `[value, setValue]` — setter accepts a value or an updater |
|
|
242
|
-
| `pp.effect(fn, deps?)` | After render; may return a cleanup function |
|
|
243
|
-
| `pp.layoutEffect(fn, deps?)` | Synchronously after DOM mutation |
|
|
244
|
-
| `pp.ref(initial?)` | `{ current }` |
|
|
245
|
-
| `pp.memo(fn, deps)` / `pp.callback(fn, deps)` | Memoized value / stable function |
|
|
246
|
-
| `pp.reducer(reducer, initial)` | `[state, dispatch]` |
|
|
247
|
-
| `pp.context(token)` | Read a context value from an ancestor provider |
|
|
248
|
-
| `pp.portal(ref, target?)` | Render into another DOM target, preserving logical ancestry |
|
|
249
|
-
| `pp.id()` | Stable unique id for `id`/`for`/`aria-*` pairing |
|
|
250
|
-
| `pp.errorBoundary()` | `[error, reset]` — catches render and effect throws in descendants |
|
|
251
|
-
| `pp.syncExternalStore(subscribe, snapshot)` | Subscribe to a source the component doesn't own (`matchMedia`, stores) |
|
|
252
|
-
| `pp.imperativeHandle(ref, create, deps?)` | Publish an imperative API to a parent's ref |
|
|
253
|
-
| `pp.transition()` | `[isPending, startTransition]` |
|
|
254
|
-
| `pp.deferredValue(value, initial?)` | Lags one commit behind the source |
|
|
255
|
-
| `pp.optimistic(passthrough, reducer?)` | Optimistic UI that reconciles against a confirmed value |
|
|
256
|
-
| `pp.props` | Props bag derived from the rendered root's attributes |
|
|
257
|
-
|
|
258
|
-
Runtime utilities: `pp.createContext`, `pp.mount`, `pp.redirect`, `pp.rpc`, `pp.socket`, `pp.enablePerf`, `pp.disablePerf`, `pp.getPerfStats`, `pp.resetPerfStats`.
|
|
259
|
-
|
|
260
|
-
React APIs with **no** PulsePoint equivalent: `forwardRef`, `memo()` as a wrapper, `lazy`, `Suspense`, `useInsertionEffect`, `useActionState`, `useFormStatus`, free-function `startTransition`.
|
|
208
|
+
Component `<script>` blocks are plain JavaScript, evaluated in component scope. Only **top-level** declarations reach the template. Props are read from `pp.props` — there is no injected `props` variable.
|
|
261
209
|
|
|
262
|
-
|
|
210
|
+
- **State & derivation** — `pp.state`, `pp.reducer`, `pp.memo`, `pp.callback`, `pp.deferredValue`, `pp.optimistic`
|
|
211
|
+
- **Lifecycle** — `pp.effect`, `pp.layoutEffect` (cleanups must be synchronous; always pass a dependency array)
|
|
212
|
+
- **DOM & identity** — `pp.ref`, `pp.id` (for `id`/`for`/`aria-*` — never index-derived ids), `pp.portal`, `pp.imperativeHandle`
|
|
213
|
+
- **Cross-tree** — `pp.createContext` + a lowercase `<themecontext.provider value="{theme}">` tag + `pp.context(token)` in descendants
|
|
214
|
+
- **Resilience & external data** — `pp.errorBoundary` (`[error, reset]`), `pp.syncExternalStore` (subscribe must be `pp.callback(..., [])`-stable), `pp.transition`
|
|
263
215
|
|
|
264
|
-
|
|
216
|
+
Utilities: `pp.mount`, `pp.redirect`, `pp.rpc`, `pp.socket`, and `pp.enablePerf` / `disablePerf` / `getPerfStats` / `resetPerfStats`.
|
|
265
217
|
|
|
266
|
-
|
|
218
|
+
React APIs with **no** equivalent: `forwardRef`, `memo()` as a wrapper, `lazy`, `Suspense`, `useInsertionEffect`, `useActionState`, `useFormStatus`, free-function `startTransition`. `pp.transition()` reports an accurate `isPending` but does not time-slice — rendering is synchronous.
|
|
267
219
|
|
|
268
|
-
|
|
269
|
-
<section>
|
|
270
|
-
<script>
|
|
271
|
-
const ThemeContext = pp.createContext("light");
|
|
272
|
-
const [theme, setTheme] = pp.state("dark");
|
|
273
|
-
</script>
|
|
274
|
-
|
|
275
|
-
<themecontext.provider value="{theme}">
|
|
276
|
-
<button onclick="setTheme(theme === 'dark' ? 'light' : 'dark')">
|
|
277
|
-
Theme: {theme}
|
|
278
|
-
</button>
|
|
279
|
-
<x-child-panel />
|
|
280
|
-
</themecontext.provider>
|
|
281
|
-
</section>
|
|
282
|
-
```
|
|
220
|
+
Inside an `on*` attribute the runtime injects `event` plus the aliases `e`, `$event`, `target`, `currentTarget`, and `el`.
|
|
283
221
|
|
|
284
|
-
|
|
222
|
+
**Performance ownership:** `pp.state` means "a render is required". Timers, request generations, cursors, and RPC-only query text belong in `pp.ref`, whose mutation never renders. Debouncing a setter limits frequency, not render cost.
|
|
285
223
|
|
|
286
224
|
---
|
|
287
225
|
|
|
288
226
|
### 4. Components
|
|
289
227
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
#### Return a string (small, presentational)
|
|
293
|
-
|
|
294
|
-
```python
|
|
295
|
-
from casp.component_decorator import component
|
|
296
|
-
from casp.html_attrs import get_attributes, merge_classes
|
|
297
|
-
|
|
298
|
-
@component
|
|
299
|
-
def Container(children: str = "", **props) -> str:
|
|
300
|
-
final_class = merge_classes("mx-auto max-w-7xl px-4", props.pop("class", ""))
|
|
301
|
-
attributes = get_attributes({"class": final_class}, props)
|
|
302
|
-
return f"<div {attributes}>{children}</div>"
|
|
303
|
-
```
|
|
304
|
-
|
|
305
|
-
#### Single-file with `html(...)` (small/medium, the common case)
|
|
306
|
-
|
|
307
|
-
Keep markup, server interpolation, and the PulsePoint script inline. Three brace dialects coexist:
|
|
308
|
-
`{{ value }}` is server-side Jinja, `{{ value | json }}` safely serializes into a `<script>`,
|
|
309
|
-
`{# … #}` is a Jinja comment, and `{ value }` is left untouched for PulsePoint.
|
|
228
|
+
A component is a `@component` function whose markup is authored inline and rendered as a kebab-cased `x-*` tag.
|
|
310
229
|
|
|
311
230
|
```python
|
|
312
231
|
from casp.component_decorator import component, html
|
|
313
232
|
from casp.html_attrs import get_attributes, merge_classes
|
|
314
233
|
|
|
234
|
+
|
|
315
235
|
@component
|
|
316
236
|
def UserCard(user=None, **props):
|
|
317
237
|
attributes = get_attributes({
|
|
@@ -319,55 +239,40 @@ def UserCard(user=None, **props):
|
|
|
319
239
|
"user-name": user["name"],
|
|
320
240
|
}, props)
|
|
321
241
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
""", attributes=attributes, user=user)
|
|
242
|
+
return html(r"""
|
|
243
|
+
<div {{ attributes }}>
|
|
244
|
+
<h3>{{ user.name }}</h3>
|
|
245
|
+
<button onclick="{setLikes(likes + 1)}">Likes: {likes}</button>
|
|
246
|
+
<script>
|
|
247
|
+
const [likes, setLikes] = pp.state({{ user.likes | json }});
|
|
248
|
+
</script>
|
|
249
|
+
</div>
|
|
250
|
+
""", attributes=attributes, user=user)
|
|
332
251
|
```
|
|
333
252
|
|
|
334
|
-
|
|
253
|
+
**`html(r"""...""")` is the one markup form** — always a raw triple-quoted literal. A non-raw string rewrites backslashes, so a regex or `\n` in the component script means one thing in the source and another at render. Never build markup as an **f-string**: it inverts the brace dialects (`{x}` becomes server interpolation), skips autoescaping while still marking the output trusted, and skips the `<x-*>` scope stash.
|
|
254
|
+
|
|
255
|
+
Three brace dialects coexist inside that literal: `{{ value }}` is server-side Jinja (autoescaped), `{{ value | json }}` safely serializes a server value into a `<script>`, `{# … #}` is a Jinja comment, and `{ value }` is left untouched for PulsePoint in the browser.
|
|
335
256
|
|
|
336
|
-
|
|
257
|
+
**Composition is Python-import-driven** — the import _is_ the registration:
|
|
337
258
|
|
|
338
259
|
```python
|
|
339
|
-
from casp.component_decorator import html
|
|
340
260
|
from src.components.Container import Container
|
|
341
261
|
from src.components.ui.Button import Button
|
|
342
|
-
from src.
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
def page():
|
|
346
|
-
return html(r"""
|
|
347
|
-
<x-container class="py-10">
|
|
348
|
-
<x-button variant="outline">Continue</x-button>
|
|
349
|
-
</x-container>
|
|
350
|
-
""")
|
|
262
|
+
from src.lib.ppicons import ArrowRight, Search # -> <x-arrow-right />, <x-search />
|
|
351
263
|
```
|
|
352
264
|
|
|
353
|
-
|
|
354
|
-
- A module's `x-*` tags resolve from the Component objects imported into that module — the Python import **is** the registration.
|
|
355
|
-
- If one Python file exports several components, import them **from that module**, not from per-name modules that do not exist.
|
|
356
|
-
- Slot content resolves in the scope where it was authored, so the module that writes an `x-*` tag must import that component.
|
|
265
|
+
`Container` → `<x-container />`, `CommandDialog` → `<x-command-dialog />`. If one file exports several components, import them from that file. Slot content resolves in the scope where it was **authored**, so the module writing an `x-*` tag must import it.
|
|
357
266
|
|
|
358
|
-
|
|
267
|
+
**Every prop the template reads must be re-emitted on the root.** This is the most common silent failure:
|
|
359
268
|
|
|
360
|
-
|
|
269
|
+
1. Attributes on the `x-*` tag reach Python as **raw string kwargs**, kebab-case converted to camelCase — `open="{permOpen}"` arrives as the literal string `"{permOpen}"`.
|
|
270
|
+
2. The component must deliberately re-emit them onto its single rendered root via `get_attributes({...}, props)` + `{{ attributes }}`.
|
|
271
|
+
3. PulsePoint derives `pp.props` from **the rendered root's attributes**, evaluating brace expressions in the parent's scope.
|
|
361
272
|
|
|
362
|
-
|
|
363
|
-
2. The Python component must deliberately re-emit them onto its single rendered root via `get_attributes({...}, props)` + `{{ attributes }}`.
|
|
364
|
-
3. PulsePoint derives `pp.props` from **the rendered root's attributes** and evaluates pure `{expr}` values in the parent's scope.
|
|
273
|
+
A prop accepted in Python but not re-emitted is silently `undefined` — no server error, no console warning. Forwarding does not preserve types either: a brace expression keeps its real type, a literal (`volume="0"`) arrives as a **string**, a valueless attribute arrives as `true`, `None`/`False`/`""` are omitted entirely, and JS reserved words such as `class` are dropped from `pp.props`.
|
|
365
274
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
#### Tailwind class merging
|
|
369
|
-
|
|
370
|
-
When `tailwindcss: true`, Python `merge_classes(...)` emits a frontend-ready `{twMerge(...)}` expression, and the browser's global `twMerge(...)` resolves conflicts. That pair is the only supported merge path.
|
|
275
|
+
When `tailwindcss: true`, `merge_classes(...)` emits a frontend-ready `{twMerge(...)}` expression that the browser's `twMerge(...)` resolves. Pass it straight through — never wrap or re-merge it.
|
|
371
276
|
|
|
372
277
|
---
|
|
373
278
|
|
|
@@ -377,44 +282,28 @@ When `tailwindcss: true`, Python `merge_classes(...)` emits a frontend-ready `{t
|
|
|
377
282
|
await pp.rpc(name, data?, optionsOrAbort?)
|
|
378
283
|
```
|
|
379
284
|
|
|
380
|
-
|
|
381
|
-
- Smart serialization — switches to `FormData` automatically when a `File` is present.
|
|
382
|
-
- CSRF token injected as `X-CSRF-Token`.
|
|
383
|
-
- Server redirect headers are honored through `pp.redirect()`.
|
|
384
|
-
- Passing `true` as the third argument means `{ abortPrevious: true }`.
|
|
285
|
+
Posts to the **current route** and resolves the `@rpc()` function of that name in its `index.py`. Serialization switches to `FormData` automatically when a `File` is present, the CSRF token is injected as `X-CSRF-Token`, and server redirects are honored through `pp.redirect()`. Passing `true` as the third argument means `{ abortPrevious: true }`.
|
|
385
286
|
|
|
386
287
|
Options: `abortPrevious`, `url`, `csrfUrl`, `credentials`, `onStream`, `onStreamError`, `onStreamComplete`, `onUploadProgress`, `onUploadComplete`.
|
|
387
288
|
|
|
388
|
-
**Payload safety:** RPC keys are filtered against the function signature, so a parameter is client-settable only when
|
|
289
|
+
**Payload safety:** RPC keys are filtered against the function signature, so a parameter is client-settable only when declared. Declaring `**kwargs` opts into the whole payload — do that deliberately, and always derive identity and ownership server-side from the session.
|
|
389
290
|
|
|
390
|
-
|
|
291
|
+
**Uploads** — pass the `File` through and read progress:
|
|
391
292
|
|
|
392
|
-
```
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
await pp.rpc(
|
|
402
|
-
"upload_asset",
|
|
403
|
-
{ file },
|
|
404
|
-
{
|
|
405
|
-
onUploadProgress: ({ percent }) => setPercent(percent),
|
|
406
|
-
onUploadComplete: () => setPercent(100),
|
|
407
|
-
},
|
|
408
|
-
);
|
|
409
|
-
}
|
|
410
|
-
</script>
|
|
293
|
+
```js
|
|
294
|
+
await pp.rpc(
|
|
295
|
+
"upload_asset",
|
|
296
|
+
{ file },
|
|
297
|
+
{
|
|
298
|
+
onUploadProgress: ({ percent }) => setPercent(percent),
|
|
299
|
+
onUploadComplete: () => setPercent(100),
|
|
300
|
+
},
|
|
301
|
+
);
|
|
411
302
|
```
|
|
412
303
|
|
|
413
304
|
`onUploadProgress` receives `{ loaded, total, percent }`; `total` and `percent` are `null` when the length is not computable.
|
|
414
305
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
A generator `@rpc()` becomes a `text/event-stream` response:
|
|
306
|
+
**Streaming** (the path for AI/LLM token output) — a generator `@rpc()` becomes a `text/event-stream` response:
|
|
418
307
|
|
|
419
308
|
```python
|
|
420
309
|
@rpc()
|
|
@@ -460,7 +349,7 @@ Browser-side checks are UX only. Server-side validation at the RPC/route boundar
|
|
|
460
349
|
|
|
461
350
|
### 7. Authentication
|
|
462
351
|
|
|
463
|
-
Session-based, configured centrally in `src/lib/auth/auth_config.py` and wired in `main.py
|
|
352
|
+
Session-based, configured centrally in `src/lib/auth/auth_config.py` and wired in `main.py`:
|
|
464
353
|
|
|
465
354
|
```python
|
|
466
355
|
from casp.auth import Auth, GithubProvider, GoogleProvider, configure_auth
|
|
@@ -470,26 +359,17 @@ configure_auth(build_auth_settings())
|
|
|
470
359
|
Auth.set_providers(GithubProvider(), GoogleProvider())
|
|
471
360
|
```
|
|
472
361
|
|
|
473
|
-
|
|
474
|
-
| ------------------------------------------------------------ | ----------------------------------------------------------------- |
|
|
475
|
-
| `auth.sign_in(data, token_validity=None, redirect_to=False)` | Store the payload, set a CSRF token; returns `"ok"` or a redirect |
|
|
476
|
-
| `auth.sign_out(redirect_to=None)` | Clear the session and redirect |
|
|
477
|
-
| `auth.is_authenticated()` | `False` when the payload is missing, malformed, or expired |
|
|
478
|
-
| `auth.get_payload()` | Read the signed-in payload |
|
|
479
|
-
| `auth.refresh_session()` | Extend expiry when `token_auto_refresh=True` |
|
|
480
|
-
| `auth.check_role(user, allowed_roles)` | RBAC check against the configured role field |
|
|
481
|
-
|
|
482
|
-
Guards: `@rpc(require_auth=True)` for actions, `@guest_only()` / route-level guards for pages, and central public-vs-private route policy in `auth_config.py`.
|
|
362
|
+
The `auth` instance exposes `sign_in(data, token_validity=None, redirect_to=False)`, `sign_out(redirect_to=None)`, `is_authenticated()`, `get_payload()`, `refresh_session()`, and `check_role(user, allowed_roles)`. Guards are `@rpc(require_auth=True)` for actions and `@require_auth()` / `@guest_only()` for pages, with public-vs-private route policy declared centrally.
|
|
483
363
|
|
|
484
364
|
**OAuth is already wired.** `Auth.set_providers(...)` registers `/api/auth/signin/{google,github}` and `/api/auth/callback/{google,github}`. Link a button and set the credentials in `.env` — do not hand-roll the flow.
|
|
485
365
|
|
|
486
|
-
**Redirect ownership is centralized.** Do not re-implement `next=` handling or post-login routing in a sign-in page; `auth_config.py` owns protected-route redirects, auth-route redirects, and `default_signin_redirect
|
|
366
|
+
**Redirect ownership is centralized.** Do not re-implement `next=` handling or post-login routing in a sign-in page; `auth_config.py` owns protected-route redirects, auth-route redirects, and `default_signin_redirect`.
|
|
487
367
|
|
|
488
368
|
---
|
|
489
369
|
|
|
490
370
|
### 8. Database (Prisma)
|
|
491
371
|
|
|
492
|
-
Enabled by `"prisma": true`. Define one `prisma/schema.prisma
|
|
372
|
+
Enabled by `"prisma": true`. Define one `prisma/schema.prisma`; the typed Python client is generated into `src/lib/prisma/`.
|
|
493
373
|
|
|
494
374
|
```python
|
|
495
375
|
from src.lib.prisma import prisma
|
|
@@ -501,7 +381,7 @@ users = await prisma.user.find_many(
|
|
|
501
381
|
)
|
|
502
382
|
```
|
|
503
383
|
|
|
504
|
-
After schema
|
|
384
|
+
**Two generators, one schema.** After any schema change, run exactly two commands in order — sync the database, then regenerate the Python ORM:
|
|
505
385
|
|
|
506
386
|
```bash
|
|
507
387
|
npx prisma migrate dev
|
|
@@ -511,9 +391,9 @@ npx prisma migrate dev
|
|
|
511
391
|
npx ppy generate
|
|
512
392
|
```
|
|
513
393
|
|
|
514
|
-
|
|
394
|
+
`npx prisma generate` builds the **Node** client used by `prisma/seed.ts` and writes zero Python — it is never a substitute for `npx ppy generate`. Run it before `npx prisma db seed`, and note that **`db seed` may clear or overwrite tables; confirm the datasource first.**
|
|
515
395
|
|
|
516
|
-
`src/lib/prisma
|
|
396
|
+
`src/lib/prisma/**` and `settings/prisma-schema.json` are generated. Never hand-edit them, and never add a second data layer alongside the ORM.
|
|
517
397
|
|
|
518
398
|
---
|
|
519
399
|
|
|
@@ -530,15 +410,14 @@ Every optional capability is gated by one flag in `caspian.config.json`. **That
|
|
|
530
410
|
| `mcp` | A FastMCP server mounted into the same app (`/mcp`) |
|
|
531
411
|
| `websocket` | Named sockets — `@socket()` in Python, `pp.socket()` in the browser |
|
|
532
412
|
|
|
533
|
-
####
|
|
534
|
-
|
|
535
|
-
Use RPC for ordinary reads, writes, uploads, and SSE streams. Reach for a socket only for **long-lived bidirectional** channels: chat, collaboration, presence, multiplayer state.
|
|
413
|
+
#### Named sockets
|
|
536
414
|
|
|
537
|
-
|
|
415
|
+
Use RPC for ordinary reads, writes, uploads, and one-way streams. Reach for a socket only when **both sides may speak at any time**: chat, collaboration, presence, multiplayer state. A named socket is the socket counterpart of `@rpc()`/`pp.rpc()` — one decorated Python function, one browser call.
|
|
538
416
|
|
|
539
417
|
```python
|
|
540
418
|
from src.lib.websocket.sockets import Socket, socket
|
|
541
419
|
|
|
420
|
+
|
|
542
421
|
@socket()
|
|
543
422
|
async def echo(label: str, socket: Socket):
|
|
544
423
|
while (text := await socket.recv()) is not None:
|
|
@@ -546,59 +425,51 @@ async def echo(label: str, socket: Socket):
|
|
|
546
425
|
break # The browser is gone.
|
|
547
426
|
```
|
|
548
427
|
|
|
549
|
-
```
|
|
550
|
-
|
|
551
|
-
const sock = pp.ref(null);
|
|
552
|
-
|
|
553
|
-
pp.effect(() => {
|
|
554
|
-
sock.current = pp.socket(
|
|
555
|
-
"echo",
|
|
556
|
-
{ label: "you" },
|
|
557
|
-
{
|
|
558
|
-
onMessage: (value) => append(value),
|
|
559
|
-
onError: (error) => setStatus(error.message),
|
|
560
|
-
},
|
|
561
|
-
);
|
|
562
|
-
return () => sock.current.close();
|
|
563
|
-
}, []);
|
|
564
|
-
</script>
|
|
565
|
-
```
|
|
566
|
-
|
|
567
|
-
Open the socket inside `pp.effect(..., [])`, keep the handle in `pp.ref(...)`, and close it in the effect's cleanup. The handle exposes `send(value)`, `close(code?, reason?)`, and `readyState`; handlers are `onOpen`, `onMessage(value)`, `onError(error)`, `onClose({ code, reason, wasClean })`.
|
|
568
|
-
|
|
569
|
-
**The wire:**
|
|
428
|
+
```js
|
|
429
|
+
const sock = pp.ref(null);
|
|
570
430
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
431
|
+
pp.effect(() => {
|
|
432
|
+
sock.current = pp.socket(
|
|
433
|
+
"echo",
|
|
434
|
+
{ label: "you" },
|
|
435
|
+
{
|
|
436
|
+
onMessage: (value) => append(value),
|
|
437
|
+
onError: (error) => setStatus(error.message),
|
|
438
|
+
},
|
|
439
|
+
);
|
|
440
|
+
return () => sock.current.close();
|
|
441
|
+
}, []);
|
|
442
|
+
```
|
|
576
443
|
|
|
577
|
-
|
|
444
|
+
Open the socket inside `pp.effect(..., [])`, keep the handle in `pp.ref(...)`, close it in the cleanup. The handle exposes `send(value)`, `close(code?, reason?)`, and `readyState`; handlers are `onOpen`, `onMessage(value)`, `onError(error)`, `onClose({ code, reason, wasClean })`.
|
|
578
445
|
|
|
579
|
-
|
|
446
|
+
The wire, in short:
|
|
580
447
|
|
|
581
|
-
|
|
448
|
+
- Every socket connects to **one endpoint**, wired once in `main.py`. The function is named in a query parameter, so socket names are **application-wide** and a duplicate is refused at registration.
|
|
449
|
+
- Arguments travel as the connection's **first frame** — one JSON object, exactly the payload `pp.rpc` would have posted — not in the URL, which every proxy logs. Keys are filtered against the handler signature, like RPC.
|
|
450
|
+
- There is no status line inside an open connection, so **failure is a frame**: `{"error": "..."}`, then a close — routed to `onError`, never `onMessage`. A handler that returns ends the conversation, and `await socket.send(...)` returning `False` means the browser is gone: a signal to stop, not an error to report.
|
|
451
|
+
- **Broadcast:** `socket.sender()` returns a detached `SocketSender` safe to hold in shared state; a room is a `SocketPool` of senders that prunes departed connections as it broadcasts. Keep authenticated and guest traffic in **separate pools**.
|
|
452
|
+
- **Auth is declared per socket:** `@socket()` is public, `@socket(require_auth=True)` needs a session, `@socket(allowed_roles=[...])` adds RBAC.
|
|
582
453
|
|
|
583
|
-
A socket
|
|
454
|
+
A socket in a route's `index.py` registers when that route first renders; one shared by several routes belongs in `src/lib/**`. A hand-written `@app.websocket(...)` route stays the escape hatch for wires the JSON-frame contract cannot carry (binary frames, non-JSON protocols) — and then the app owns every security check itself.
|
|
584
455
|
|
|
585
456
|
#### MCP
|
|
586
457
|
|
|
587
|
-
When `mcp: true`, a FastMCP server is mounted into the same app so one deploy serves both web and MCP. The endpoint
|
|
458
|
+
When `mcp: true`, a FastMCP server is mounted into the same app so one deploy serves both web and MCP. The endpoint sits **outside** the routing tree, so `AuthMiddleware` does not cover it — `MCP_AUTH_TOKEN` is its credential. With no token it stays open in development and returns **503 in production**.
|
|
588
459
|
|
|
589
460
|
---
|
|
590
461
|
|
|
591
462
|
## Security defaults
|
|
592
463
|
|
|
593
|
-
Caspian ships fail-closed
|
|
464
|
+
Caspian ships fail-closed. Worth knowing before changing any of it:
|
|
594
465
|
|
|
595
466
|
- **`APP_ENV` resolves fail-closed.** Only an explicit development value (`dev`, `development`, `local`, `staging`, `test`, `testing`) enables relaxations. Unset or misspelled counts as **production**.
|
|
596
|
-
- **Server-interpolated values never carry live PulsePoint syntax.**
|
|
597
|
-
- **Authenticated renders are never cached.** The page cache keys on the URI alone, so
|
|
467
|
+
- **Server-interpolated values never carry live PulsePoint syntax.** Jinja encodes `{` / `}` as entities on every non-`Markup` value, so stored user data cannot execute as a template expression. `Markup` is the trust boundary — `| safe`, `get_attributes(...)`, `merge_classes(...)`, and the `json` filter legitimately keep their braces.
|
|
468
|
+
- **Authenticated renders are never cached.** The page cache keys on the URI alone, so an eligibility check gates both read and write; a route's `Cache(...)` cannot override it.
|
|
598
469
|
- **RPC payload keys are filtered against the function signature.**
|
|
599
470
|
- **`/uploads` serves user content in attachment mode**; only real image types render inline. First-party `/css`, `/js`, and `/assets` stay inline.
|
|
471
|
+
- **Sockets authorize themselves.** The HTTP middleware stack early-returns on `scope["type"] == "websocket"`, so `AuthMiddleware` never sees a handshake — HTTP route privacy does not extend to a socket.
|
|
600
472
|
- **CSRF protection, strict Origin validation, HttpOnly cookies, security headers, and page rate limiting** are on by default.
|
|
601
|
-
- **Sockets authorize themselves.** The HTTP middleware stack early-returns on `scope["type"] == "websocket"`, so `AuthMiddleware` never sees a handshake. The named-socket endpoint runs the origin check, connection cap, and per-socket auth itself — HTTP route privacy does not extend to a socket.
|
|
602
473
|
|
|
603
474
|
Security-relevant environment variables: `MCP_AUTH_TOKEN`, `RATE_LIMIT_PAGES` (default `200/minute`), `CONTENT_SECURITY_POLICY` (replaces the default policy wholesale), `MAX_WEBSOCKET_CONNECTIONS`, `MAX_WEBSOCKET_MESSAGE_BYTES`, `MAX_WEBSOCKET_MESSAGES_PER_WINDOW`, `WEBSOCKET_RATE_WINDOW_SECONDS`, `WEBSOCKET_IDLE_TIMEOUT_SECONDS`, and `WEBSOCKET_ALLOWED_ORIGINS` (**required in production** — the same-origin fallback is derived from the client-supplied `Host` header and is development-only).
|
|
604
475
|
|
|
@@ -608,23 +479,18 @@ Security-relevant environment variables: `MCP_AUTH_TOKEN`, `RATE_LIMIT_PAGES` (d
|
|
|
608
479
|
|
|
609
480
|
```
|
|
610
481
|
my-app/
|
|
611
|
-
├── main.py # FastAPI entry point, middleware stack,
|
|
482
|
+
├── main.py # FastAPI entry point, middleware stack, socket + MCP mounts
|
|
612
483
|
├── caspian.config.json # Feature flags — the single source of truth
|
|
613
|
-
├──
|
|
614
|
-
├── package.json # CLI/tooling scripts
|
|
615
|
-
├── prisma/
|
|
616
|
-
│ ├── schema.prisma
|
|
617
|
-
│ └── seed.ts
|
|
484
|
+
├── prisma/schema.prisma
|
|
618
485
|
├── src/
|
|
619
486
|
│ ├── app/ # File-system routes
|
|
620
|
-
│ │ ├── layout.py # Root layout
|
|
487
|
+
│ │ ├── layout.py # Root layout
|
|
621
488
|
│ │ ├── index.py # Home page (markup + logic in one file)
|
|
622
489
|
│ │ ├── globals.css
|
|
623
|
-
│ │ ├──
|
|
624
|
-
│ │ ├──
|
|
625
|
-
│ │ ├── dashboard/loading.py # /dashboard navigation loading UI
|
|
626
|
-
│ │ └── users/[id]/
|
|
627
|
-
│ │ └── index.py # /users/:id
|
|
490
|
+
│ │ ├── not_found.py # Global 404
|
|
491
|
+
│ │ ├── error.py # Global 500
|
|
492
|
+
│ │ ├── dashboard/loading.py # Optional: /dashboard navigation loading UI
|
|
493
|
+
│ │ └── users/[id]/index.py # /users/:id
|
|
628
494
|
│ ├── components/ # Reusable UI (@component)
|
|
629
495
|
│ └── lib/ # Non-UI code
|
|
630
496
|
│ ├── auth/auth_config.py
|
|
@@ -635,19 +501,17 @@ my-app/
|
|
|
635
501
|
└── settings/ # Dev stack config and generated indexes
|
|
636
502
|
```
|
|
637
503
|
|
|
638
|
-
|
|
504
|
+
**Placement rules**
|
|
639
505
|
|
|
640
|
-
- **Route-owned** logic (first-render query, route `@rpc()` actions, redirects,
|
|
506
|
+
- **Route-owned** logic (first-render query, route `@rpc()` actions, redirects, validation) stays in that route's `index.py`. Move it to `src/lib/**` only when genuinely shared.
|
|
641
507
|
- **Reusable UI** goes in `src/components/`; **helpers, services, adapters** go in `src/lib/`.
|
|
642
|
-
- **Compose pages from components.** A route's
|
|
643
|
-
- **Generated, never hand-edited:** `src/lib/prisma/**`, `settings/prisma-schema.json`, `settings/files-list.json`, `settings/component-map.json`, `public/css/styles.css
|
|
508
|
+
- **Compose pages from components.** A route's template should read as a short assembly of `x-*` chunks (topbar, sidebar, sections, forms, footer), not a wall of markup. Plan the breakdown before writing the route.
|
|
509
|
+
- **Generated, never hand-edited:** `src/lib/prisma/**`, `settings/prisma-schema.json`, `settings/files-list.json`, `settings/component-map.json`, `public/css/styles.css`.
|
|
644
510
|
|
|
645
511
|
---
|
|
646
512
|
|
|
647
513
|
## CLI reference
|
|
648
514
|
|
|
649
|
-
### Create
|
|
650
|
-
|
|
651
515
|
```bash
|
|
652
516
|
npx create-caspian-app my-app
|
|
653
517
|
```
|
|
@@ -664,21 +528,9 @@ npx create-caspian-app my-app
|
|
|
664
528
|
| `--starter-kit-source=<url>` | Git repository for `--starter-kit=custom` |
|
|
665
529
|
| `--list-starter-kits` | Print the built-in starter catalog |
|
|
666
530
|
|
|
667
|
-
In `-y` mode every feature defaults to `false
|
|
668
|
-
|
|
669
|
-
`websocket` has no create flag — enable it in `caspian.config.json` after scaffold, then run the update command.
|
|
531
|
+
In `-y` mode every feature defaults to `false`, and starter-kit presets can still be overridden by explicit flags. `websocket` has no create flag — enable it in `caspian.config.json` after scaffold, then run `npx casp update project` (which also accepts `--tag beta` / `--version 1.2.3` and `-y`). Use `excludeFiles` in `caspian.config.json` to protect files you have customized (commonly `./src/lib/auth/auth_config.py`) from being overwritten on update.
|
|
670
532
|
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
```bash
|
|
674
|
-
npx casp update project
|
|
675
|
-
```
|
|
676
|
-
|
|
677
|
-
Also accepts a positional tag or version, or the named forms `--tag beta`, `--tag=beta`, `--version 1.2.3`, `--version=1.2.3`, plus `-y`. Supplying more than one version source is an error. On Windows the updater resolves `npx.cmd`, and the creator reuses an existing `.venv` rather than recreating it.
|
|
678
|
-
|
|
679
|
-
Use `excludeFiles` in `caspian.config.json` to protect files you have customized (a common entry is `./src/lib/auth/auth_config.py`) from being overwritten on update. Excluded files are yours to merge going forward.
|
|
680
|
-
|
|
681
|
-
### Project scripts
|
|
533
|
+
**Project scripts**
|
|
682
534
|
|
|
683
535
|
| Command | What it does |
|
|
684
536
|
| ---------------------- | ------------------------------------------------------------------ |
|
|
@@ -687,50 +539,30 @@ Use `excludeFiles` in `caspian.config.json` to protect files you have customized
|
|
|
687
539
|
| `npm run static` | Export every static route to `static/` (SSG) |
|
|
688
540
|
| `npm run static:serve` | Preview the exported folder on an auto-selected free loopback port |
|
|
689
541
|
|
|
690
|
-
These are opt-in workflows
|
|
542
|
+
These are opt-in workflows — don't run them as a validation step just because source files changed.
|
|
691
543
|
|
|
692
544
|
---
|
|
693
545
|
|
|
694
546
|
## Static export (SSG)
|
|
695
547
|
|
|
696
|
-
`npm run static` boots the app and writes `static/<route>/index.html` plus copied public assets — the equivalent of Next.js `output: export
|
|
548
|
+
`npm run static` boots the app and writes `static/<route>/index.html` plus copied public assets — the equivalent of Next.js `output: export`, running `npm run build` first so the export walks a fresh route index. Policy is **warn & skip**: dynamic routes are pre-rendered only when their `index.py` exports `static_paths` (the `getStaticPaths` equivalent); auth-gated, non-200, and non-HTML routes are reported and skipped.
|
|
697
549
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
`npm run static:serve` serves only `static/`, binds loopback `127.0.0.1` (network exposure is opt-in via `HOST=0.0.0.0`), and walks upward from port 8000 until it finds a free one — **read the port it prints**.
|
|
701
|
-
|
|
702
|
-
`pp.rpc()`, auth, WebSockets, streaming, and per-request server data are all inert in a static export.
|
|
550
|
+
`npm run static:serve` serves only `static/`, binds loopback `127.0.0.1` (network exposure is opt-in via `HOST=0.0.0.0`), and walks upward from port 8000 until it finds a free one — **read the port it prints**. `pp.rpc()`, auth, WebSockets, streaming, and per-request server data are all inert in a static export.
|
|
703
551
|
|
|
704
552
|
---
|
|
705
553
|
|
|
706
554
|
## Ecosystem
|
|
707
555
|
|
|
708
|
-
|
|
556
|
+
Two CLIs install ready-made Python components into `src/lib/`, where they behave like any other `x-*` tag:
|
|
709
557
|
|
|
710
558
|
```bash
|
|
711
|
-
npx ppicons add Rocket
|
|
559
|
+
npx ppicons add Rocket # 1,500+ Lucide-based icons
|
|
560
|
+
npx maddex add button card dialog # shadcn-style UI kit
|
|
712
561
|
```
|
|
713
562
|
|
|
714
563
|
```python
|
|
715
|
-
from src.lib.ppicons
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
```html
|
|
719
|
-
<x-rocket class="w-6 h-6 text-primary" />
|
|
720
|
-
```
|
|
721
|
-
|
|
722
|
-
### maddex — shadcn-style UI component kit for Caspian
|
|
723
|
-
|
|
724
|
-
```bash
|
|
725
|
-
npx maddex add button card dialog
|
|
726
|
-
```
|
|
727
|
-
|
|
728
|
-
```python
|
|
729
|
-
from src.lib.maddex.Button import Button
|
|
730
|
-
```
|
|
731
|
-
|
|
732
|
-
```html
|
|
733
|
-
<x-button variant="outline">Continue</x-button>
|
|
564
|
+
from src.lib.ppicons import Rocket # -> <x-rocket class="size-6" />
|
|
565
|
+
from src.lib.maddex.Button import Button # -> <x-button variant="outline">Continue</x-button>
|
|
734
566
|
```
|
|
735
567
|
|
|
736
568
|
---
|