Skip to content

Commit a6fc8f1

Browse files
committed
Align docs with api
1 parent f37504e commit a6fc8f1

25 files changed

Lines changed: 296 additions & 260 deletions

docs/app/content/docs/framework/auth.md

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,24 +10,27 @@ SPRAG includes a built-in auth surface for session management and access control
1010

1111
## Setup
1212

13-
Wire auth providers into your App:
13+
Wire auth providers into your `App`. SPRAG looks for `session_store` and `auth` in `app.providers`, and falls back to in-memory sessions plus anonymous auth if you do not provide them:
1414

1515
```python
16-
from sprag import App, shell
17-
from sprag import InMemorySessionStore, AnonymousAuthService
16+
from sprag import App, InMemorySessionStore, SessionPolicy
1817

1918
app = App(
2019
routes="app.routes",
21-
shell=app_shell,
2220
providers={
2321
"session_store": InMemorySessionStore(),
24-
"auth": AnonymousAuthService(),
22+
"auth": MyAuthService(),
2523
},
24+
session_policy=SessionPolicy(
25+
idle_ttl_seconds=3600,
26+
absolute_ttl_seconds=86400,
27+
remember_me_ttl_seconds=2592000,
28+
),
2629
)
2730
```
2831

2932
- **`InMemorySessionStore`** — stores sessions in memory. Fine for development; swap for Redis/database-backed store in production.
30-
- **`AnonymousAuthService`**no-op auth that treats every request as unauthenticated. Replace with your auth provider.
33+
- **`auth` provider**implements user loading, login session stamping, authorization, and the public auth snapshot.
3134

3235
## Login and logout
3336

@@ -38,18 +41,18 @@ From any controller:
3841
"email": Field(str, required=True),
3942
"password": Field(str, required=True),
4043
}))
41-
def login(self, email, password):
44+
def submit_login(self, email, password):
4245
user = authenticate(email, password)
4346
if not user:
4447
return {"errors": {"email": "Invalid credentials"}}
4548

46-
# Sets session, rotates session ID
49+
# Sets session data and rotates the session ID
4750
self.login(user, viewer=user.id, active_profile=user.profile)
4851
return self.redirect("/dashboard")
4952

50-
@action()
51-
def logout(self):
52-
self.logout() # Invalidates session
53+
@action(name="logout", schema=Schema("logout", {}))
54+
def sign_out(self):
55+
self.logout()
5356
return self.redirect("/")
5457
```
5558

@@ -61,8 +64,9 @@ Inside any controller method:
6164

6265
```python
6366
self.request.user # Authenticated user object (or None)
64-
self.request.session # Session dict
67+
self.request.session # RequestSession helper
6568
self.request.session_id # Session ID string
69+
self.request.active_profile # Active profile object (or None)
6670
self.request.cookies # Request cookies
6771
```
6872

@@ -102,30 +106,36 @@ class ProfileController(Controller):
102106

103107
## Browser-side auth
104108

105-
The auth state is included in the browser boot payload at `window.__SPRAG_PAYLOAD__.auth`. Your Module can read it to conditionally render UI:
109+
SPRAG injects an auth snapshot into route data under `__sprag_auth__`. That same snapshot is also shipped in the browser boot payload, but the stable author-facing name in route data is `__sprag_auth__`:
106110

107111
```python
108-
class NavModule(Module):
109-
def on_start(self):
110-
auth = self.state.get("auth", {})
111-
if auth.get("user"):
112-
self.set_state({"logged_in": True, "username": auth["user"]["name"]})
112+
class DashboardScreen(Screen):
113+
def render(self, data):
114+
auth = data["__sprag_auth__"]
115+
viewer = auth.get("viewer") or {}
116+
return ui.div(viewer.get("name", "anonymous"))
113117
```
114118

119+
For SSR and route data flows, prefer reading `data["__sprag_auth__"]` or explicitly projecting the fields your Module needs from `load()`.
120+
115121
## Custom auth providers
116122

117123
To integrate a real auth backend (Firebase, Auth0, database), implement the auth service interface and provide it to the App:
118124

119125
```python
120126
class MyAuthService:
121-
def authenticate(self, request):
122-
# Return user object or None
127+
def load_user(self, session, request):
123128
token = request.cookies.get("auth_token")
124129
return verify_token(token)
125130

131+
def load_active_profile(self, user, session, request):
132+
return None
133+
134+
def authorize(self, user, active_profile, session, request, *, roles=None, permissions=None):
135+
return user is not None
136+
126137
app = App(
127138
routes="app.routes",
128-
shell=app_shell,
129139
providers={
130140
"session_store": RedisSessionStore(redis_url),
131141
"auth": MyAuthService(),

docs/app/content/docs/framework/codegen.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,8 @@ Compiles to an arrow function: `(item) => item["id"]`.
127127
### Walrus operator
128128

129129
```python
130-
if (result := self.call_action("check", {})):
131-
self.set_state({"result": result})
130+
if (count := self.state.get("count", 0)) > 10:
131+
self.set_state({"is_large": True})
132132
```
133133

134134
Works in statements. Not supported inside comprehensions or lambdas.
@@ -223,17 +223,19 @@ self.on(el, "click", self.handle) # → this.on(el, "click", (...args) => this.
223223

224224
## `__init__` (Module only)
225225

226-
Module `__init__` supports field assignments only:
226+
Module `__init__` can contain normal compilable statements. The main restriction is that `self.state` and `self.screen` are runtime-owned:
227227

228228
```python
229229
class MyModule(Module):
230230
def __init__(self, screen=None, state=None):
231231
super().__init__(screen=screen, state=state or {})
232232
self.draft = None
233-
self.child = ChildModule()
233+
self.config = {"mode": "compact"}
234+
if state and state.get("expanded"):
235+
self.open = True
234236
```
235237

236-
`super().__init__()` calls are stripped. Other statements raise an error.
238+
`super().__init__()` calls are stripped. Direct assignment to `self.state` or `self.screen` raises an error.
237239

238240
## `env()` — environment variables
239241

docs/app/content/docs/framework/controllers.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,14 @@ class TodoController(Controller):
1717
route = "/todos"
1818

1919
def load(self):
20-
return {"items": [], "filter": "all"}
20+
todos = self.service("todos")
21+
return {"items": todos.list_items(), "filter": "all"}
2122

2223
@action(schema=Schema("add_item", {"text": Field(str, required=True)}))
2324
def add_item(self, text):
24-
items = self.request.data.get("items", [])
25-
items.append({"text": text, "done": False})
26-
return {"items": items}
25+
todos = self.service("todos")
26+
todos.add_item(text)
27+
return {"items": todos.list_items()}
2728
```
2829

2930
## `load()`
@@ -85,10 +86,10 @@ Inside any controller method, `self.request` gives you the current request:
8586
|---|---|
8687
| `self.request.params` | URL params (`slug`, `segments`, etc.) |
8788
| `self.request.query` | Query string as a dict |
88-
| `self.request.data` | Current page state (from load or previous action) |
89-
| `self.request.session` | Session dict |
89+
| `self.request.session` | `RequestSession` helper |
9090
| `self.request.session_id` | Session ID string |
9191
| `self.request.user` | Authenticated user (or `None`) |
92+
| `self.request.active_profile` | Active auth profile (or `None`) |
9293
| `self.request.cookies` | Request cookies |
9394
| `self.request.file(name)` | Uploaded file by field name |
9495
| `self.request.files_list(name)` | List of uploaded files |
@@ -126,7 +127,7 @@ def build_routes(self, router):
126127

127128
@router.route("/api/items", methods=["POST"])
128129
def create_item():
129-
data = request.json
130+
data = self.request.body
130131
return {"created": True}
131132
```
132133

docs/app/content/docs/framework/realtime.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Instead of pushing full state snapshots over the socket, the server emits a ligh
1414

1515
```
1616
Server: emit_socket("items_changed", {})
17-
Browser: on_socket("items_changed") → call_action("get_items", {})
17+
Browser: on_socket("items_changed") → call_action("get_items", {}).then(...)
1818
```
1919

2020
This keeps the socket channel thin and ensures the browser always has validated, authoritative state.
@@ -49,7 +49,10 @@ class ChatModule(Module):
4949

5050
def _on_message(self, data):
5151
# Refetch authoritative state from server
52-
self.call_action("get_messages", {})
52+
self.call_action("get_messages", {}).then(self._on_messages)
53+
54+
def _on_messages(self, result):
55+
self.set_state(result.value or {})
5356
```
5457

5558
## Topics (rooms)
@@ -89,7 +92,7 @@ On the server side, there's a matching shortcut:
8992

9093
```python
9194
# Emit the signal and include the refetch hint in one call
92-
self.emit_socket_refetch("items_changed", action="get_items")
95+
self.emit_socket_refetch("get_items", event="items_changed")
9396
```
9497

9598
## Session targeting
@@ -111,4 +114,4 @@ Store the `session_id` when you need to push from a background job or service la
111114
sprag routes
112115
```
113116

114-
Controllers that use the socket bridge are tagged with `[socket]` in the output.
117+
Use this to confirm the route exists and to inspect the action names and schemas your browser code is expected to call.

docs/app/content/docs/framework/routes.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ app/routes/
1616
├── counter/ → /counter
1717
├── about/ → /about
1818
└── blog/
19-
└── [slug]/ → /blog/:slug
19+
└── [slug]/ → /blog/[slug]
2020
```
2121

2222
Route directories are discovered automatically. No manual registration.
@@ -45,9 +45,10 @@ my_page = page(
4545
| `path` | Yes | URL path for this route |
4646
| `controller` | Yes | Controller class that handles data and actions |
4747
| `screen` | Yes | Screen class that renders the page |
48-
| `mode` | No | `"document"`, `"hybrid"` (default), or `"mount"` |
48+
| `mode` | No | `"document"` or `"hybrid"` (default) |
4949
| `shell` | No | Override the app-level shell for this route |
5050
| `css` | No | Route-specific CSS files |
51+
| `js` | No | Extra classic scripts to include for this route |
5152
| `modules` | No | JS import aliases: `{"alias": "path/to/module.js"}` |
5253
| `static_paths` | No | Function returning path params for static builds |
5354
| `metadata` | No | Dict of metadata (title, description, etc.) |
@@ -81,7 +82,7 @@ my_page = page(
8182

8283
```python
8384
class BlogController(Controller):
84-
route = "/blog/:slug"
85+
route = "/blog/[slug]"
8586

8687
def load(self):
8788
post = get_post(self.request.params["slug"])
@@ -132,11 +133,11 @@ Merge order: **app metadata → page metadata → `__sprag_meta__`** (last wins)
132133

133134
## Route modes
134135

135-
- **`document`**Pure SSR. The server renders HTML and sends it. No JavaScript is loaded. Best for content pages, marketing pages, and anything that doesn't need interactivity.
136+
- **`document`**Server-first rendering for content-heavy pages. The route still ships the standard SPRAG boot payload, but you typically avoid browser-owned Module logic here.
136137

137138
- **`hybrid`** — SSR first, then hydrate. The server renders the initial HTML for a fast first paint, then the browser loads JavaScript to make it interactive. This is the default and the right choice for most pages.
138139

139-
- **`mount`** — No SSR body. The server sends a shell, and the browser mounts everything. Use when the page content is entirely dynamic (dashboards, editors, etc.).
140+
If you want a browser-owned client app instead of a page route, use `mount(...)` under `app/mounts/`. Mounts are separate from page modes.
140141

141142
## Dynamic routes
142143

@@ -155,7 +156,7 @@ from .web import BlogScreen
155156
from app.content import blog_static_paths
156157

157158
blog_page = page(
158-
path="/blog/:slug",
159+
path="/blog/[slug]",
159160
controller=BlogController,
160161
screen=BlogScreen,
161162
mode="document",
@@ -186,4 +187,4 @@ sprag add route about --mode document
186187
sprag routes
187188
```
188189

189-
This prints all discovered routes, their modes, and tags like `[socket]` for controllers that use the socket bridge.
190+
This prints discovered routes, mounts, actions, and any schema fields declared on those actions.

docs/app/content/docs/framework/stores.md

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ counter_store = store("counter", initial={"count": 0})
1818

1919
This single declaration works in both runtimes:
2020

21-
- **Server**: backed by a Specter `Model` — persistent, shared across requests
21+
- **Server**: backed by a Specter `Model`
2222
- **Browser**: compiled to a Ragot `createStateStore` shim via `stores.js` — reactive, local to the tab
2323

2424
## API
@@ -27,20 +27,27 @@ The full store API is identical on both sides:
2727

2828
```python
2929
# Read
30-
value = counter_store.get("count") # Single key
31-
state = counter_store.get_state() # Full state dict
30+
value = counter_store.get("count") # Single path/key
31+
state = counter_store.get_state() # Full state snapshot
32+
snap = counter_store.snapshot() # Deep snapshot
3233

3334
# Write
34-
counter_store.set("count", 42) # Set single key
35-
counter_store.patch({"count": 99}) # Merge partial state
36-
counter_store.update("count", lambda v: v + 1) # Transform a value
37-
counter_store.delete("count") # Remove a key
35+
counter_store.set("count", 42) # Set a path/key
36+
counter_store.patch({"count": 99}) # Root-level merge
37+
counter_store.delete("count") # Remove a path/key
38+
39+
def bump(state):
40+
state["count"] = state.get("count", 0) + 1
41+
42+
counter_store.update(bump) # Atomic mutator
43+
counter_store.reset() # Reset to declared initial state
3844

3945
# Subscribe to changes
40-
counter_store.subscribe(lambda state, meta, store: print(state))
46+
counter_store.subscribe(lambda state: print(state), immediate=True)
47+
counter_store.listen("count", lambda value: print(value))
4148

4249
# Select a derived value
43-
counter_store.select("count", lambda count: count * 2)
50+
double = counter_store.select(lambda s: s.get("count", 0) * 2, default=0)
4451
```
4552

4653
## Server-side usage
@@ -56,7 +63,7 @@ class CounterService(Service):
5663
def on_start(self):
5764
counter_store.subscribe(self._on_change)
5865

59-
def _on_change(self, state, meta, s):
66+
def _on_change(self, state):
6067
if state["count"] > 100:
6168
self.emit("counter:overflow", state)
6269
```
@@ -74,15 +81,15 @@ class CounterModule(Module):
7481
def on_start(self):
7582
self.subscribe(counter_store, self._on_store)
7683

77-
def _on_store(self, state, meta, s):
84+
def _on_store(self, state):
7885
self.set_state({"count": state["count"]})
7986
```
8087

8188
`self.subscribe()` auto-cleans on Module stop — no manual unsubscribe needed.
8289

8390
## When to use stores
8491

85-
**Use `store()`** for state that needs to be shared across routes or between multiple components/modules in the same page. Stores persist across navigations on the server and are reactive on the browser.
92+
**Use `store()`** for state that needs to be shared across routes or between multiple components/modules in the same page. It gives you one authoring surface backed by Specter on the server and a generated store shim in the browser.
8693

8794
**Use controller state** (the dict from `load()`) for per-page state that lives within a single route. This is simpler and covers most cases.
8895

docs/app/content/docs/framework/two-runtimes.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ These patterns are intentionally identical across runtimes:
4343
Each runtime owns its boundary:
4444

4545
- **DOM** — browser only. Components render `ui.*` trees; the server never touches the DOM.
46-
- **Sockets**the server *sends* socket events, the browser *receives* them. The Module has `on_socket()`; the Controller has `emit_socket()`.
46+
- **Sockets**both runtimes can participate through the shared socket bridge. Controllers can emit and handle socket traffic; Modules can subscribe with `on_socket()` and emit with `emit_socket()`.
4747
- **HTTP** — the server handles requests. The browser calls `call_action()` to invoke server actions over HTTP.
4848
- **File system** — server only. The browser has no access.
4949

docs/app/content/docs/getting-started/first-app.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,14 @@ class CounterModule(Module):
5050

5151
def on_increment(self, event, target):
5252
event.prevent_default()
53-
self.call_action("increment", {"count": self.state["count"]})
53+
self.call_action("increment", {"count": self.state["count"]}).then(self.on_result)
54+
55+
def on_result(self, result):
56+
self.set_state(result.value)
5457
```
5558

5659
- `Module` owns non-visual lifecycle — event listeners, server calls, state.
57-
- `self.call_action("increment", payload)` calls the server `@action` and applies the returned state.
60+
- `self.call_action("increment", payload)` calls the server `@action` and returns a Promise-like result; update local state in the success handler.
5861
- This Python compiles to Ragot ESM JavaScript at build time.
5962

6063
### The browser component: `app/routes/counter/components.py`

docs/app/content/docs/getting-started/installation.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,8 @@ order: 1
88

99
## Prerequisites
1010

11-
- **Python 3.11+** — SPRAG uses modern Python features throughout
11+
- **Python 3.9+**
1212
- **pip** — or any Python package manager (uv, poetry, etc.)
13-
- **gevent** — the server runtime uses gevent for cooperative concurrency
1413

1514
## Install SPRAG
1615

0 commit comments

Comments
 (0)