millwork 0.1.0__py3-none-any.whl

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.
Files changed (70) hide show
  1. millwork/__init__.py +45 -0
  2. millwork/_bundle/dist/.vite/manifest.json +92 -0
  3. millwork/_bundle/dist/charts-inner-Cq0vWbjt.js +39 -0
  4. millwork/_bundle/dist/favicon.svg +15 -0
  5. millwork/_bundle/dist/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  6. millwork/_bundle/dist/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  7. millwork/_bundle/dist/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  8. millwork/_bundle/dist/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  9. millwork/_bundle/dist/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  10. millwork/_bundle/dist/icons.svg +24 -0
  11. millwork/_bundle/dist/index.global-cOFus1M1.js +952 -0
  12. millwork/_bundle/dist/index.modern-CITEfQrV.js +135 -0
  13. millwork/_bundle/dist/lib-3DI92Zxk.js +1 -0
  14. millwork/_bundle/dist/main-BoZpFGOH.js +64 -0
  15. millwork/_bundle/dist/main-F2tEXmC5.css +2 -0
  16. millwork/_bundle/dist/react-markdown-DLcbNoI3.js +14 -0
  17. millwork/_bundle/dist/remark-gfm-DoHjwpPg.js +16 -0
  18. millwork/app.py +726 -0
  19. millwork/auth.py +877 -0
  20. millwork/chat.py +397 -0
  21. millwork/cli.py +316 -0
  22. millwork/context.py +42 -0
  23. millwork/files.py +450 -0
  24. millwork/forms.py +349 -0
  25. millwork/manual/__init__.py +449 -0
  26. millwork/manual/components/Hello.jsx +24 -0
  27. millwork/manual/components/Voice.jsx +46 -0
  28. millwork/manual/pages/app/auth.md +188 -0
  29. millwork/manual/pages/app/pages.md +200 -0
  30. millwork/manual/pages/app/running.md +242 -0
  31. millwork/manual/pages/app/state.md +151 -0
  32. millwork/manual/pages/app/testing.md +147 -0
  33. millwork/manual/pages/app/theming.md +122 -0
  34. millwork/manual/pages/components/charts.md +263 -0
  35. millwork/manual/pages/components/chat.md +280 -0
  36. millwork/manual/pages/components/custom.md +148 -0
  37. millwork/manual/pages/components/data.md +112 -0
  38. millwork/manual/pages/components/feedback.md +212 -0
  39. millwork/manual/pages/components/files.md +194 -0
  40. millwork/manual/pages/components/form.md +135 -0
  41. millwork/manual/pages/components/inputs.md +360 -0
  42. millwork/manual/pages/components/layout.md +154 -0
  43. millwork/manual/pages/components/tasks.md +140 -0
  44. millwork/manual/pages/components/text.md +145 -0
  45. millwork/manual/pages/index.md +67 -0
  46. millwork/manual/public/fonts/InterVariable.woff2 +0 -0
  47. millwork/manual/public/fonts/LICENSE.txt +2 -0
  48. millwork/manual/public/mark.svg +15 -0
  49. millwork/manual/public/theme.json +97 -0
  50. millwork/nodes.py +300 -0
  51. millwork/protocol.py +238 -0
  52. millwork/proxy.py +122 -0
  53. millwork/py.typed +0 -0
  54. millwork/session.py +1422 -0
  55. millwork/shell.py +226 -0
  56. millwork/tasks.py +473 -0
  57. millwork/testing.py +336 -0
  58. millwork/theme.py +338 -0
  59. millwork/ui/__init__.py +725 -0
  60. millwork/ui/charts.py +214 -0
  61. millwork/ui/chat.py +97 -0
  62. millwork/ui/files.py +101 -0
  63. millwork/ui/form.py +102 -0
  64. millwork/ui/table.py +145 -0
  65. millwork/ws.py +220 -0
  66. millwork-0.1.0.dist-info/METADATA +131 -0
  67. millwork-0.1.0.dist-info/RECORD +70 -0
  68. millwork-0.1.0.dist-info/WHEEL +4 -0
  69. millwork-0.1.0.dist-info/entry_points.txt +2 -0
  70. millwork-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,188 @@
1
+ ---
2
+ title: Signing in
3
+ order: 2
4
+ ---
5
+
6
+ # Signing in
7
+
8
+ Without auth every page is open to anyone who reaches the server. With
9
+ auth every page asks for a login first, a page is opened to anonymous
10
+ visitors with `public=True`, and `s.user` says who is there. Identity comes
11
+ from a provider; who gets in, and what the app keeps on them, is one
12
+ function of the app's own.
13
+
14
+ ## Turning it on
15
+
16
+ ```python
17
+ import datetime as dt
18
+
19
+ import millwork as mw
20
+ from millwork import auth, ui
21
+
22
+ app = mw.App(
23
+ "projects",
24
+ secret=SECRET,
25
+ auth=auth.Auth(
26
+ [auth.AzureAD(tenant=TENANT, client_id=CLIENT_ID, client_secret=CLIENT_SECRET)],
27
+ on_login=admit,
28
+ session=dt.timedelta(hours=8),
29
+ ),
30
+ )
31
+
32
+
33
+ @app.page("/")
34
+ def home(s: mw.Session) -> ui.Component:
35
+ return ui.Page(ui.Text(f"Hello, {s.user.name}."))
36
+
37
+
38
+ @app.page("/privacy", public=True)
39
+ def privacy(s: mw.Session) -> ui.Component:
40
+ return ui.Page(ui.Markdown(PRIVACY))
41
+ ```
42
+
43
+ `secret` signs the session cookie and must be at least 32 characters;
44
+ `millwork secret` prints one. Values such as the secret and the provider's
45
+ credentials come from wherever the app keeps its secrets.
46
+
47
+ The login page is part of millwork: the app's name, the theme's
48
+ `login_image`, one button per OAuth provider, a username and password form
49
+ when there is a password provider, and the message when a login was
50
+ refused. A visitor who opens a gated page lands there and comes back to
51
+ that page after signing in.
52
+
53
+ ## Providers
54
+
55
+ `auth.AzureAD(tenant=, client_id=, client_secret=)` is Microsoft Entra ID,
56
+ single tenant unless `single_tenant=False`. `auth.Google(client_id=,
57
+ client_secret=)` is Google. Both are presets over `auth.OAuth(id, label=,
58
+ authorize_url=, token_url=, userinfo_url=, client_id=, client_secret=,
59
+ scopes=)`, which fits any OpenID Connect issuer. The provider's callback
60
+ URL is `/_mw/auth/oauth/<id>/callback` on the app's origin.
61
+
62
+ `auth.Password(verify)` takes a callable `(username, password) -> bool`,
63
+ sync or async, and millwork runs it off the event loop. Hashes are
64
+ scrypt from the standard library: `millwork hash-password --user ada`
65
+ prints a line for a credentials file, and `auth.check_password(password,
66
+ stored)` checks one against it.
67
+
68
+ ```python
69
+ USERS = {"ada": "scrypt$..."}
70
+
71
+
72
+ def verify(username: str, password: str) -> bool:
73
+ stored = USERS.get(username)
74
+ return stored is not None and auth.check_password(password, stored)
75
+ ```
76
+
77
+ `auth.Dev(user)` signs every visitor in as that user; it is allowed with
78
+ `--debug` and refused otherwise.
79
+
80
+ Several providers can be listed together; the login page offers each.
81
+
82
+ ## Behind an authenticating proxy
83
+
84
+ When a proxy in front of the app does the signing in (oauth2-proxy, a
85
+ platform's built-in authentication, an access gateway), it names who it saw
86
+ in request headers, and `auth.Headers` reads them. Give it the header names
87
+ the proxy uses: `user` or `email` is required, `name`, `groups` and
88
+ `picture` are optional, and `groups` is split on commas into
89
+ `identity.raw["groups"]`. The person goes through `on_login` on every page
90
+ load and socket connect, so keep that hook cheap; a sync hook runs off
91
+ the event loop, and one that raises shows the error page and the tab
92
+ stops retrying. A visitor the hook refuses sees the login page say so.
93
+ `sign_out` is the proxy's own sign-out URL, which the user menu and
94
+ `s.logout()` go to; without one the menu offers no sign-out, and
95
+ `s.logout()` ends the person's sessions on this process, after which the
96
+ proxy signs them in again on the next page. The proxy replaces these
97
+ headers on the way through; a header that arrives twice is ignored.
98
+
99
+ ```python
100
+ # oauth2-proxy in front of the app, with its default headers
101
+ auth.Headers(
102
+ user="X-Forwarded-User",
103
+ email="X-Forwarded-Email",
104
+ name="X-Forwarded-Preferred-Username",
105
+ groups="X-Forwarded-Groups",
106
+ sign_out="/oauth2/sign_out",
107
+ )
108
+ ```
109
+
110
+ The headers are read when the app runs with `--proxied` or
111
+ `App(proxied=True)`, as the Running chapter describes. A request that
112
+ arrives without an identity lands on the login page, which says that
113
+ sign-in happens before the app. The proxy's or platform's documentation
114
+ lists the header names it sets and its sign-out URL.
115
+
116
+ ## Deciding who gets in
117
+
118
+ After a provider has identified the visitor, `on_login(identity)` decides.
119
+ `identity` carries `provider`, `subject`, `email` (lower-cased), `name`,
120
+ `picture_url`, the provider's `raw` document and an `intent`. The hook
121
+ returns a `User` to let the visitor in, or `None` to refuse with a message
122
+ on the login page and a line in the log. It may be async and may look the
123
+ person up somewhere.
124
+
125
+ ```python
126
+ async def admit(identity: auth.Identity) -> auth.User | None:
127
+ if not identity.email or not identity.email.endswith("@example.com"):
128
+ return None
129
+ projects = await db.projects_of(identity.email)
130
+ if not projects:
131
+ return None
132
+ return auth.User(
133
+ id=identity.email,
134
+ name=identity.name or identity.email,
135
+ claims={"projects": [p.id for p in projects], "admin": identity.email in ADMINS},
136
+ picture_url=identity.picture_url,
137
+ )
138
+ ```
139
+
140
+ A `User` is `id`, `name`, `claims` and `picture_url`. `claims` is the
141
+ app's own dictionary, capped at 2 KB: roles, the projects a person may
142
+ see, flags. A picture is a URL. Without `on_login` every
143
+ identified visitor becomes a `User` with the e-mail as `id`, and a provider
144
+ that gives no e-mail is refused.
145
+
146
+ `Auth(intents={"demo": "Try the demo"})` adds a secondary button to the
147
+ login page for each intent; the one pressed arrives as `identity.intent`,
148
+ and the hook decides what it means.
149
+
150
+ ## In a page
151
+
152
+ `s.user` is the `User`, or `None` on a public page when nobody is signed
153
+ in. Authorisation is the app's: read `s.user.claims` in `connect` and raise
154
+ `mw.Forbidden(message)` when this page or this project is not for this
155
+ person. The Pages chapter shows the hook.
156
+
157
+ ```python live
158
+ def example(s):
159
+ who = f"{s.user.name} ({s.user.id})" if s.user else "nobody; the manual runs without auth"
160
+ return ui.Text(f"Signed in as {who}.", variant="muted")
161
+ ```
162
+
163
+ The `ui.Header` shows the signed-in person's picture or initials with a
164
+ menu that holds the theme choice and sign-out; `user_menu=False` hides it.
165
+ `s.logout()` signs out from a handler: the cookie is cleared, the person's
166
+ other sessions on this process end with a note in their tabs, and the tab
167
+ goes to the login page.
168
+
169
+ ## The session cookie
170
+
171
+ The cookie is a signed token holding the `User`, `HttpOnly`, `SameSite=Lax`,
172
+ `Secure` over HTTPS, with a fixed lifetime set by `session` (seven days by
173
+ default). Its name and path carry the app's prefix, `mw_auth_sales` under
174
+ `/sales`, so apps side by side on one host each read their own, and it is
175
+ signed for the app by name, so apps that share a secret still keep their
176
+ sign-ins apart. After that long a person signs in again. The cookie holds
177
+ the `User` and is valid on any replica with the same secret. Signing out
178
+ clears the browser's cookie and ends the person's sessions on that
179
+ process; a copy of the cookie stays valid until it expires, so the
180
+ lifetime is the bound, and an app with sensitive data sets `session` to a
181
+ working day. The socket is bound to the cookie, so a session is resumed
182
+ by its own user. Logins and refusals are logged with the identifier;
183
+ `log_identity="hashed"` logs a hash instead.
184
+
185
+ Identity is what the provider says and the record is what `on_login`
186
+ returns; `claims` is a dictionary the app reads. An access token the app
187
+ needs for its own API calls is obtained in `on_login` and kept by the
188
+ app.
@@ -0,0 +1,200 @@
1
+ ---
2
+ title: Pages and routing
3
+ order: 0
4
+ ---
5
+
6
+ # Pages and routing
7
+
8
+ An app is a `mw.App` with pages on it. A page is a path and a function of
9
+ the session that returns the component tree for that path. The browser
10
+ opens the path, the tab connects a socket, and from then on Python renders
11
+ and handles events over that socket. There is one session per tab.
12
+
13
+ ## An app and its pages
14
+
15
+ ```python
16
+ import millwork as mw
17
+ from millwork import ui
18
+
19
+ app = mw.App("projects")
20
+
21
+
22
+ @app.page("/", title="Projects")
23
+ def home(s: mw.Session) -> ui.Component:
24
+ return ui.Page(ui.Text("All projects"), title="Projects")
25
+
26
+
27
+ @app.page("/about")
28
+ def about(s: mw.Session) -> ui.Component:
29
+ return ui.Page(ui.Text("About this app"))
30
+ ```
31
+
32
+ `title` is the browser tab's title; without one the app's name is used.
33
+ Paths match in the order the pages are registered, and a path no page
34
+ matches is a 404.
35
+
36
+ `millwork run app.py` serves the file's `app`; `millwork run package.module:app`
37
+ names the object. `--port` picks the port, `--reload` restarts the process
38
+ when a Python file changes, `--debug` shows page errors in the tab and
39
+ watches the theme file and the custom components for changes. Under
40
+ uvicorn or another ASGI server, `app.asgi` is the application.
41
+
42
+ ## Parameters in the path
43
+
44
+ A path can carry parameters in Starlette's syntax. `{name}` takes one
45
+ segment as a string; `{name:int}`, `{name:float}` and `{name:uuid}` convert
46
+ it, and a path that does not convert does not match. `{name:path}` takes
47
+ the rest of the path. The values arrive in `s.params`, and `s.path` is the
48
+ path as opened.
49
+
50
+ ```python
51
+ @app.page("/projects/{project_id:int}")
52
+ def project(s: mw.Session) -> ui.Component:
53
+ return ui.Page(ui.Text(f"Project {s.params['project_id']}"))
54
+ ```
55
+
56
+ ## Moving between pages
57
+
58
+ A `ui.Link` whose `href` is a path inside the app navigates over the
59
+ session: no reload, the tab's history gets an entry, the back button
60
+ works. From a handler, `s.navigate(path)` does the same. A `#heading` on
61
+ the path scrolls to that heading once the page is up, which is how this
62
+ manual's search opens a section.
63
+
64
+ ```python live
65
+ def example(s):
66
+ return ui.Row(
67
+ ui.Button("Open the Charts chapter", on_click=lambda s, _: s.navigate("/components/charts")),
68
+ ui.Link(label="or follow a link", href="/components/charts#stat"),
69
+ ui.Text(f"You are at {s.path}", variant="muted"),
70
+ gap=4,
71
+ )
72
+ ```
73
+
74
+ A link to another site is an ordinary link; give it `target="_blank"` to
75
+ open a new tab.
76
+
77
+ ## When a page is missing
78
+
79
+ A path no page matches lands on the app's not-found page: over HTTP as a
80
+ 404 that serves the shell, and over the session when a link or
81
+ `s.navigate` asks for it, so the tab keeps its session and can go on from
82
+ there. `connect` reaches the same pages by raising: `mw.NotFound(message)`
83
+ when what the path names does not exist, `mw.Forbidden(message)` when this
84
+ person may not open it. The session lands on the error page under the path
85
+ that was asked for, with the message if one was given.
86
+
87
+ Without any app code the framework's own pages are shown: the theme's
88
+ logo, a title and a line from the string table, and a way home, in the
89
+ same frame as the login page. An app replaces them with pages of its own,
90
+ written like any other page: `s.params["path"]` is the path asked for and
91
+ `s.params["message"]` what `connect` raised with, or empty.
92
+
93
+ ```python
94
+ @app.not_found
95
+ def missing(s):
96
+ return ui.Page(
97
+ ui.Stack(
98
+ ui.Logo(cls="h-9 self-start"),
99
+ ui.Text("Nothing here", variant="title"),
100
+ ui.Text(s.params["message"] or f"There is no page at {s.params['path']}.", variant="muted"),
101
+ ui.Input(kind="search", placeholder="Search instead", on_submit=search),
102
+ gap=3,
103
+ cls="max-w-sm",
104
+ ),
105
+ )
106
+
107
+
108
+ @app.forbidden(title="Private")
109
+ def private(s):
110
+ return ui.Page(ui.Text(s.params["message"] or "Ask the owner for access."))
111
+ ```
112
+
113
+ `ui.Logo` is the theme's logo, light or dark with the scheme, for a page
114
+ outside the app bar. The link below goes to a page this manual does not
115
+ have, which is the framework's own not-found page in this manual's theme.
116
+
117
+ ```python live
118
+ ui.Row(
119
+ ui.Logo(cls="h-6"),
120
+ ui.Link(label="Open a page that does not exist", href="/components/nothing-here"),
121
+ gap=4,
122
+ )
123
+ ```
124
+
125
+ A failure before the bundle can run, a crash in an app's own route or the
126
+ bundle missing, is a plain HTML page with the same title and text, in the
127
+ theme's colours and font; the traceback goes to the log. In debug mode a
128
+ crash shows Starlette's own traceback page.
129
+
130
+ ## State and the session
131
+
132
+ A page's state is a Pydantic model named on the page, or on the app as the
133
+ default for every page. `s.state` is an instance of it, made new when the
134
+ page is opened and again on every navigation to a page. The session itself
135
+ outlives the page: its files, its identity and its tasks stay while the tab
136
+ moves around the app.
137
+
138
+ ```python
139
+ class State(BaseModel):
140
+ selected: list[int] = []
141
+
142
+
143
+ @app.page("/projects/{project_id:int}", state=State)
144
+ def project(s: mw.Session[State]) -> ui.Component:
145
+ ...
146
+ ```
147
+
148
+ Everything a page shows comes from `s.state` and from what its handlers put
149
+ there, so a page is a plain function and handlers are where things
150
+ change.
151
+
152
+ ## Connect and close
153
+
154
+ A page can have a `connect` hook, run once each time the page is opened,
155
+ before the first render, with the path parameters as keyword arguments. It
156
+ may be async, and it is where a page loads what it needs, starts a task,
157
+ or decides that the visitor may not be here. `mw.NotFound()` and
158
+ `mw.Forbidden(message)` raised from it keep the tab on the page it came
159
+ from and show the message; a page opened directly shows the message and
160
+ stops there.
161
+
162
+ ```python
163
+ @project.connect
164
+ async def load(s: mw.Session[State], project_id: int) -> None:
165
+ project = await db.project(project_id)
166
+ if project is None:
167
+ raise mw.NotFound()
168
+ if s.user and s.user.id not in project.members:
169
+ raise mw.Forbidden("Not your project.")
170
+ s.state.project = project
171
+ s.task(refresh_documents(s))
172
+
173
+
174
+ @project.close
175
+ async def leave(s: mw.Session[State]) -> None:
176
+ await db.release(s.state.project.id)
177
+ ```
178
+
179
+ The `close` hook runs once, with the state still intact, when the session
180
+ ends: the tab said goodbye, or it has been gone longer than the grace
181
+ period. `App(session_grace=300)` sets that period in seconds; a tab that
182
+ reconnects within it, after a sleep or a dropped network, gets its session
183
+ back with everything in it.
184
+
185
+ ## Navigation in the frame
186
+
187
+ `ui.Header(links=[...])` puts links in the app bar and `ui.Sidebar` holds
188
+ a longer list. This manual's sidebar is a `Sidebar` of `Link`s with the
189
+ current page drawn as text instead; the Layout chapter shows both.
190
+
191
+ ## Beyond pages
192
+
193
+ `App` is a Starlette application, and an app adds to it through Starlette:
194
+ `app.route(path, endpoint, methods=[...])` for an API endpoint,
195
+ `app.mount(path, asgi_app)` for anything ASGI, FastAPI included,
196
+ `app.middleware(cls)` for pure ASGI middleware, and `app.on_startup` and
197
+ `app.on_shutdown` for hooks around the process. The `public/` folder next
198
+ to the app is served at `/public/`. Everything millwork owns lives under
199
+ `/_mw/`, leaving every other path to the app; `/_mw/health` answers 200
200
+ once the startup hooks are done.
@@ -0,0 +1,242 @@
1
+ ---
2
+ title: Running
3
+ order: 5
4
+ ---
5
+
6
+ # Running
7
+
8
+ One process serves an app: Python with the built bundle inside the
9
+ package. What the app needs is said on the `App`, in Python, with values
10
+ from wherever the app keeps its secrets. `millwork run` starts the
11
+ process, and a handful of flags cover a reverse proxy, a unix socket and
12
+ a path prefix.
13
+
14
+ ## The App's options
15
+
16
+ ```python
17
+ app = mw.App(
18
+ "projects",
19
+ state=State, # the default state model for every page
20
+ public="public", # served at /public/; None for no static folder
21
+ components="components", # custom components, .jsx files; None for none
22
+ theme="themes/dark.json", # a path or a Theme; public/theme.json is found on its own
23
+ secret=SECRET, # signs cookies; 32 characters or more, required with auth
24
+ auth=..., # see Signing in
25
+ origins=["https://projects.example.com"],
26
+ session_grace=300, # seconds a session waits for its tab to come back
27
+ max_sessions_per_client=20, # live sessions per user, or per address without auth
28
+ max_sessions=1000, # live sessions in the process
29
+ files=mw.Files("/var/lib/projects/files", max_session_mb=200, max_total_mb=2000),
30
+ threads=8, # the pool sync tasks and handlers run in
31
+ keep_alive=False,
32
+ proxied=False, # True behind a reverse proxy; see Serving
33
+ )
34
+ ```
35
+
36
+ - `origins` is the allow-list for the socket and the login route; the
37
+ default is the app's own origin.
38
+ - `files` is where uploads and generated files live while a session
39
+ lives, with a quota per session and one for the process. The folder is
40
+ private to the process's user, and directories left by old sessions are
41
+ swept. The default is the system's temporary directory, which on some
42
+ hosts is memory, so a deployment names a disk.
43
+ - `session_grace` is how long a session waits for its tab to come back
44
+ after the socket drops, with its state intact.
45
+ - `max_sessions_per_client` and `max_sessions` count live sessions,
46
+ those in grace included. Over the client's cap, that client's session
47
+ longest without a tab makes room; over the process cap, anyone's. A
48
+ new session is refused when every one still has a tab. A session ended
49
+ that way, or by a sign-out in another tab, tells its tab so: the page
50
+ stays readable, takes no more input, and offers a reload.
51
+ - `threads` sizes the pool that runs sync tasks; the default is Python's.
52
+ - `keep_alive=True` makes every open tab fetch the health route every
53
+ thirty seconds, for a host that stops an idle process; by default a tab
54
+ does so while a task or a reply runs.
55
+ - `proxied=True` says a reverse proxy is in front; the Serving section
56
+ below.
57
+
58
+ The settings this manual runs with:
59
+
60
+ ```python live
61
+ def example(s):
62
+ app = s.app
63
+ return ui.Table(
64
+ rows=[
65
+ {"option": "name", "value": app.name},
66
+ {"option": "session_grace", "value": f"{app.session_grace:.0f} s"},
67
+ {"option": "max_sessions_per_client", "value": app.max_sessions_per_client},
68
+ {"option": "max_sessions", "value": app.max_sessions},
69
+ {"option": "files", "value": f"{app.files.max_session_mb} MB per session, {app.files.max_total_mb} MB in all"},
70
+ {"option": "origins", "value": ", ".join(app.origins) or "same origin"},
71
+ {"option": "threads", "value": app.threads or "Python's default"},
72
+ {"option": "keep_alive", "value": app.keep_alive},
73
+ {"option": "proxied", "value": app.proxied},
74
+ {"option": "debug", "value": app.debug},
75
+ {"option": "auth", "value": "on" if app.auth else "off"},
76
+ ],
77
+ row_key="option",
78
+ height=400,
79
+ )
80
+ ```
81
+
82
+ ## Development
83
+
84
+ `millwork run app.py --reload --debug` is the loop. `--reload` restarts
85
+ the process when a Python file changes; the tab shows "Reconnecting" and
86
+ remounts when the server is back. `--debug` shows page errors in the
87
+ tab, turns the framework's warnings into toasts, pushes a changed theme
88
+ file or custom component to every open tab, and allows the `Dev` auth
89
+ provider. Tests drive a session without a browser, so for most changes
90
+ the loop is edit, run pytest, and the browser is for looking.
91
+
92
+ ## Serving
93
+
94
+ In production one process serves one app behind a reverse proxy that
95
+ ends TLS and passes the websocket upgrade through:
96
+
97
+ ```
98
+ uv run millwork run app.py --host 127.0.0.1 --port 8000 --proxied
99
+ ```
100
+
101
+ `--proxied`, or `App(proxied=True)`, says a reverse proxy is in front.
102
+ The app then takes the visitor's address and scheme from the proxy's
103
+ `X-Forwarded-For` and `X-Forwarded-Proto`, and the visitor's identity from
104
+ the headers an `auth.Headers` provider names. Anyone who can reach the
105
+ process can send those headers too, so a proxied app listens where only
106
+ its proxy connects: a unix socket, a loopback port, or a container
107
+ network.
108
+
109
+ `/_mw/health` answers 200 once the startup hooks have run and 503 before
110
+ that and during shutdown, for a host that waits for readiness. The
111
+ process exits cleanly on SIGTERM: sessions get their `close` hook, tasks
112
+ are cancelled, uploads in flight are cut. Logs go to standard error.
113
+
114
+ `app.asgi` is the ASGI application itself, for a server of your own
115
+ choosing; under uvicorn directly, pass `--no-proxy-headers`, since
116
+ millwork applies the forwarded headers, and `--ws-max-size 1048576`, the
117
+ bound `millwork run` puts on a message from the tab (uploads travel in
118
+ smaller frames).
119
+
120
+ ## A unix socket
121
+
122
+ On one machine with the proxy, `--uds` listens on a unix socket. The
123
+ socket is created with mode 0660, whatever the umask, so the proxy
124
+ connects when it runs in the app's group. A socket is reached through a
125
+ proxy, so `--uds` implies `--proxied`.
126
+
127
+ ```
128
+ uv run millwork run app.py --uds /run/sales/app.sock
129
+ ```
130
+
131
+ ```nginx
132
+ location / {
133
+ proxy_pass http://unix:/run/sales/app.sock:/;
134
+ proxy_http_version 1.1;
135
+ proxy_set_header Upgrade $http_upgrade;
136
+ proxy_set_header Connection "upgrade";
137
+ proxy_set_header Host $host;
138
+ proxy_set_header X-Forwarded-Proto $scheme;
139
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
140
+ }
141
+ ```
142
+
143
+ Like a port, the path is refused while another process answers on it. A
144
+ socket file nobody answers on is replaced at the next start, and anything
145
+ else at the path stops the start. The health route is reached the same
146
+ way: `curl --unix-socket /run/sales/app.sock http://app/_mw/health`.
147
+
148
+ ## Under a path prefix
149
+
150
+ An app can live under a path on a shared host, `https://apps.example/sales/`,
151
+ next to other apps. The proxy strips the prefix and the server is told it:
152
+
153
+ ```
154
+ uv run millwork run app.py --port 8000 --root-path /sales --proxied
155
+ ```
156
+
157
+ ```nginx
158
+ location /sales/ {
159
+ proxy_pass http://127.0.0.1:8000/;
160
+ proxy_http_version 1.1;
161
+ proxy_set_header Upgrade $http_upgrade;
162
+ proxy_set_header Connection "upgrade";
163
+ proxy_set_header Host $host;
164
+ proxy_set_header X-Forwarded-Proto $scheme;
165
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
166
+ }
167
+ ```
168
+
169
+ Every URL the server writes into the page is under the prefix, and the
170
+ cookies carry it in name and path, so apps side by side on one host each
171
+ have their own. Inside the app everything is an app path: page paths,
172
+ `ui.Link` targets, `s.navigate`, an image's `src`, a path in Markdown;
173
+ the tab puts each one under the prefix as it renders it. A custom
174
+ component that builds a URL itself reads the prefix from
175
+ `window.__mw.base`, which is empty at the root. The manual takes the flag
176
+ too: `millwork docs --root-path /manual`.
177
+
178
+ ## What a host needs
179
+
180
+ Any host that runs one process and routes HTTPS to it serves a millwork
181
+ app. It provides:
182
+
183
+ - TLS at the proxy or the platform's edge, with the websocket upgrade
184
+ passed through.
185
+ - `--proxied`, and a listener only the proxy can reach: a unix socket, a
186
+ loopback port, or the container's network.
187
+ - `/_mw/health` as the readiness probe: 200 once the startup hooks have
188
+ run, 503 before that and during shutdown. As a liveness probe it would
189
+ restart a slow start, so that one gets a generous threshold.
190
+ - A files directory with room, named with `Files(...)`, when uploads are
191
+ larger than the temporary directory should hold.
192
+ - The `secret` and the provider's credentials from the environment or the
193
+ platform's secret store.
194
+ - `keep_alive=True` when the host stops an idle container, since
195
+ platforms differ on whether an open websocket counts as activity and
196
+ the health pings count everywhere.
197
+ - A path prefix per app, or a hostname per app, when several share a
198
+ host.
199
+
200
+ The millwork parts of a service definition are the command line. Under
201
+ systemd, with the socket in the unit's runtime directory and the files in
202
+ its state directory:
203
+
204
+ ```ini
205
+ RuntimeDirectory=millwork
206
+ StateDirectory=millwork/sales
207
+ ExecStart=/srv/apps/sales/.venv/bin/millwork run app.py --uds /run/millwork/sales.sock --root-path /sales
208
+ ```
209
+
210
+ In a container, behind the platform's router:
211
+
212
+ ```dockerfile
213
+ CMD ["/app/.venv/bin/millwork", "run", "app.py", "--host", "0.0.0.0", "--port", "8000", "--proxied"]
214
+ ```
215
+
216
+ ## More than one replica
217
+
218
+ HTTP is stateless and the socket is the session: identity comes from the
219
+ cookie, and everything a session owns, its state, its files, its tasks,
220
+ travels over the socket to the process that holds it. Replicas need no
221
+ sticky routing and no shared storage. When a replica goes away, its
222
+ sessions go with it, and their tabs start fresh sessions elsewhere, as
223
+ they would on a reload. The cookie is valid on every replica that has the
224
+ same `secret`.
225
+
226
+ ## The page's security headers
227
+
228
+ Every page carries a Content Security Policy: scripts from the app's
229
+ origin with a nonce, connections to the app's origin, images and media
230
+ from anywhere over HTTPS, and no framing by other sites. An app with a
231
+ `components/` folder also allows `eval`, since custom components are
232
+ compiled in the tab. A custom component that needs a library loads it
233
+ from `public/`, as the Charts chapter shows. `App(csp=None)` drops the
234
+ header, and a string of your own replaces it; `{scripts}` in it is where
235
+ millwork puts the script sources.
236
+
237
+ ## Hooks around the process
238
+
239
+ `app.on_startup` and `app.on_shutdown` register functions, sync or async,
240
+ that run once around the process: warm a cache, open and close a pool.
241
+ `app.route`, `app.mount` and `app.middleware` add to the Starlette
242
+ application, as the Pages chapter shows.