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.
- millwork/__init__.py +45 -0
- millwork/_bundle/dist/.vite/manifest.json +92 -0
- millwork/_bundle/dist/charts-inner-Cq0vWbjt.js +39 -0
- millwork/_bundle/dist/favicon.svg +15 -0
- millwork/_bundle/dist/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- millwork/_bundle/dist/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- millwork/_bundle/dist/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- millwork/_bundle/dist/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- millwork/_bundle/dist/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- millwork/_bundle/dist/icons.svg +24 -0
- millwork/_bundle/dist/index.global-cOFus1M1.js +952 -0
- millwork/_bundle/dist/index.modern-CITEfQrV.js +135 -0
- millwork/_bundle/dist/lib-3DI92Zxk.js +1 -0
- millwork/_bundle/dist/main-BoZpFGOH.js +64 -0
- millwork/_bundle/dist/main-F2tEXmC5.css +2 -0
- millwork/_bundle/dist/react-markdown-DLcbNoI3.js +14 -0
- millwork/_bundle/dist/remark-gfm-DoHjwpPg.js +16 -0
- millwork/app.py +726 -0
- millwork/auth.py +877 -0
- millwork/chat.py +397 -0
- millwork/cli.py +316 -0
- millwork/context.py +42 -0
- millwork/files.py +450 -0
- millwork/forms.py +349 -0
- millwork/manual/__init__.py +449 -0
- millwork/manual/components/Hello.jsx +24 -0
- millwork/manual/components/Voice.jsx +46 -0
- millwork/manual/pages/app/auth.md +188 -0
- millwork/manual/pages/app/pages.md +200 -0
- millwork/manual/pages/app/running.md +242 -0
- millwork/manual/pages/app/state.md +151 -0
- millwork/manual/pages/app/testing.md +147 -0
- millwork/manual/pages/app/theming.md +122 -0
- millwork/manual/pages/components/charts.md +263 -0
- millwork/manual/pages/components/chat.md +280 -0
- millwork/manual/pages/components/custom.md +148 -0
- millwork/manual/pages/components/data.md +112 -0
- millwork/manual/pages/components/feedback.md +212 -0
- millwork/manual/pages/components/files.md +194 -0
- millwork/manual/pages/components/form.md +135 -0
- millwork/manual/pages/components/inputs.md +360 -0
- millwork/manual/pages/components/layout.md +154 -0
- millwork/manual/pages/components/tasks.md +140 -0
- millwork/manual/pages/components/text.md +145 -0
- millwork/manual/pages/index.md +67 -0
- millwork/manual/public/fonts/InterVariable.woff2 +0 -0
- millwork/manual/public/fonts/LICENSE.txt +2 -0
- millwork/manual/public/mark.svg +15 -0
- millwork/manual/public/theme.json +97 -0
- millwork/nodes.py +300 -0
- millwork/protocol.py +238 -0
- millwork/proxy.py +122 -0
- millwork/py.typed +0 -0
- millwork/session.py +1422 -0
- millwork/shell.py +226 -0
- millwork/tasks.py +473 -0
- millwork/testing.py +336 -0
- millwork/theme.py +338 -0
- millwork/ui/__init__.py +725 -0
- millwork/ui/charts.py +214 -0
- millwork/ui/chat.py +97 -0
- millwork/ui/files.py +101 -0
- millwork/ui/form.py +102 -0
- millwork/ui/table.py +145 -0
- millwork/ws.py +220 -0
- millwork-0.1.0.dist-info/METADATA +131 -0
- millwork-0.1.0.dist-info/RECORD +70 -0
- millwork-0.1.0.dist-info/WHEEL +4 -0
- millwork-0.1.0.dist-info/entry_points.txt +2 -0
- millwork-0.1.0.dist-info/licenses/LICENSE +202 -0
millwork/app.py
ADDED
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
"""The application: pages on paths, the Starlette app that serves them.
|
|
2
|
+
|
|
3
|
+
Everything millwork owns lives under ``/_mw``. An app adds its own routes,
|
|
4
|
+
mounts and middleware through the Starlette object in ``app.asgi``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
11
|
+
import hashlib
|
|
12
|
+
import logging
|
|
13
|
+
import re
|
|
14
|
+
import secrets
|
|
15
|
+
from collections.abc import AsyncIterator, Callable
|
|
16
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
from starlette.applications import Starlette
|
|
22
|
+
from starlette.datastructures import MutableHeaders
|
|
23
|
+
from starlette.middleware import Middleware
|
|
24
|
+
from starlette.requests import Request
|
|
25
|
+
from starlette.responses import (
|
|
26
|
+
FileResponse,
|
|
27
|
+
HTMLResponse,
|
|
28
|
+
JSONResponse,
|
|
29
|
+
PlainTextResponse,
|
|
30
|
+
RedirectResponse,
|
|
31
|
+
)
|
|
32
|
+
from starlette.routing import Mount, Route, WebSocketRoute, compile_path
|
|
33
|
+
from starlette.staticfiles import StaticFiles
|
|
34
|
+
|
|
35
|
+
from millwork import __version__
|
|
36
|
+
from millwork.auth import Auth
|
|
37
|
+
from millwork.files import Files
|
|
38
|
+
from millwork.nodes import Component
|
|
39
|
+
from millwork.protocol import Command
|
|
40
|
+
from millwork.proxy import ASGIApp, Proxied
|
|
41
|
+
from millwork.shell import Shell, plain, root_of
|
|
42
|
+
from millwork.theme import Theme, find_theme, under
|
|
43
|
+
|
|
44
|
+
# The policy for every HTML page; ``{scripts}`` becomes the page's script sources
|
|
45
|
+
# (the bundle and the nonce of its two inline lines). Styles stay inline because
|
|
46
|
+
# the theme and Tailwind's runtime inject them; images, media and frames may come
|
|
47
|
+
# from anywhere over https since an app shows external files and pages.
|
|
48
|
+
DEFAULT_CSP = "; ".join(
|
|
49
|
+
[
|
|
50
|
+
"default-src 'self'",
|
|
51
|
+
"script-src {scripts}",
|
|
52
|
+
"style-src 'self' 'unsafe-inline'",
|
|
53
|
+
"img-src 'self' data: blob: https:",
|
|
54
|
+
"media-src 'self' blob: https:",
|
|
55
|
+
"font-src 'self' data:",
|
|
56
|
+
"connect-src 'self'",
|
|
57
|
+
"frame-src 'self' blob: https:",
|
|
58
|
+
"worker-src 'self' blob:",
|
|
59
|
+
"object-src 'none'",
|
|
60
|
+
"base-uri 'self'",
|
|
61
|
+
"form-action 'self'",
|
|
62
|
+
"frame-ancestors 'self'",
|
|
63
|
+
]
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _Assets(StaticFiles):
|
|
68
|
+
"""The bundle: hashed file names are cached for a year, the few unhashed ones
|
|
69
|
+
(the manifest, the icons) are revalidated."""
|
|
70
|
+
|
|
71
|
+
def file_response(self, *args: Any, **kwargs: Any) -> Any:
|
|
72
|
+
response = super().file_response(*args, **kwargs)
|
|
73
|
+
path = str(args[0]) if args else str(kwargs.get("full_path", ""))
|
|
74
|
+
hashed = _HASHED.search(Path(path).name) is not None
|
|
75
|
+
response.headers["Cache-Control"] = (
|
|
76
|
+
"public, max-age=31536000, immutable" if hashed else "no-cache"
|
|
77
|
+
)
|
|
78
|
+
return response
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_HASHED = re.compile(r"-[A-Za-z0-9_-]{8}\.[a-z0-9]+$")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _SecurityHeaders:
|
|
85
|
+
"""Headers every response carries: no sniffing, no framing elsewhere, no referrer leaks."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, app: Any) -> None:
|
|
88
|
+
self.app = app
|
|
89
|
+
|
|
90
|
+
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
|
|
91
|
+
if scope["type"] != "http":
|
|
92
|
+
await self.app(scope, receive, send)
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
async def send_with_headers(message: Any) -> None:
|
|
96
|
+
if message["type"] == "http.response.start":
|
|
97
|
+
headers = MutableHeaders(scope=message)
|
|
98
|
+
headers.setdefault("X-Content-Type-Options", "nosniff")
|
|
99
|
+
headers.setdefault("X-Frame-Options", "SAMEORIGIN")
|
|
100
|
+
headers.setdefault("Referrer-Policy", "same-origin")
|
|
101
|
+
await send(message)
|
|
102
|
+
|
|
103
|
+
await self.app(scope, receive, send_with_headers)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if TYPE_CHECKING:
|
|
107
|
+
from millwork.session import Session
|
|
108
|
+
|
|
109
|
+
log = logging.getLogger("millwork.app")
|
|
110
|
+
|
|
111
|
+
PREFIX = "/_mw"
|
|
112
|
+
BUNDLE_DIR = Path(__file__).parent / "_bundle" / "dist"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class PageDef:
|
|
117
|
+
path: str
|
|
118
|
+
render: Callable[[Any], Component]
|
|
119
|
+
title: str | None = None
|
|
120
|
+
public: bool = False
|
|
121
|
+
state: Callable[[], Any] | type | None = None
|
|
122
|
+
on_connect: Callable[..., Any] | None = None
|
|
123
|
+
on_close: Callable[..., Any] | None = None
|
|
124
|
+
_regex: Any = field(init=False, repr=False)
|
|
125
|
+
_param_convertors: Any = field(init=False, repr=False)
|
|
126
|
+
|
|
127
|
+
def __post_init__(self) -> None:
|
|
128
|
+
self._regex, _, self._param_convertors = compile_path(self.path)
|
|
129
|
+
|
|
130
|
+
def match(self, path: str) -> dict[str, str] | None:
|
|
131
|
+
m = self._regex.match(path)
|
|
132
|
+
if m is None:
|
|
133
|
+
return None
|
|
134
|
+
return {k: self._param_convertors[k].convert(v) for k, v in m.groupdict().items()}
|
|
135
|
+
|
|
136
|
+
def make_state(self) -> Any:
|
|
137
|
+
if self.state is None:
|
|
138
|
+
return None
|
|
139
|
+
return self.state()
|
|
140
|
+
|
|
141
|
+
def connect(self, fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
142
|
+
"""Decorator: the page's connect hook, ``(session, **params)``."""
|
|
143
|
+
self.on_connect = fn
|
|
144
|
+
return fn
|
|
145
|
+
|
|
146
|
+
def close(self, fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
147
|
+
self.on_close = fn
|
|
148
|
+
return fn
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class App:
|
|
152
|
+
def __init__(
|
|
153
|
+
self,
|
|
154
|
+
name: str,
|
|
155
|
+
*,
|
|
156
|
+
state: Callable[[], Any] | type | None = None,
|
|
157
|
+
public: str | Path | None = "public",
|
|
158
|
+
origins: list[str] | None = None,
|
|
159
|
+
session_grace: float = 300.0,
|
|
160
|
+
debug: bool = False,
|
|
161
|
+
bundle_dir: Path | None = None,
|
|
162
|
+
secret: str | None = None,
|
|
163
|
+
files: Files | None = None,
|
|
164
|
+
components: str | Path | None = "components",
|
|
165
|
+
auth: Auth | None = None,
|
|
166
|
+
theme: Theme | str | Path | None = None,
|
|
167
|
+
csp: str | None = DEFAULT_CSP,
|
|
168
|
+
threads: int | None = None,
|
|
169
|
+
keep_alive: bool = False,
|
|
170
|
+
max_sessions: int = 1000,
|
|
171
|
+
max_sessions_per_client: int = 20,
|
|
172
|
+
proxied: bool = False,
|
|
173
|
+
) -> None:
|
|
174
|
+
self.name = name
|
|
175
|
+
# Behind a reverse proxy: its forwarded address and scheme, and the
|
|
176
|
+
# identity headers an auth.Headers provider names, are believed.
|
|
177
|
+
self.proxied = proxied
|
|
178
|
+
# Caps on live sessions, including those waiting out the grace period.
|
|
179
|
+
# A client is a signed-in user, or the connecting address without auth.
|
|
180
|
+
# Over a cap, the client's oldest session without a tab makes room, and
|
|
181
|
+
# only when every one still has a tab is the new one refused.
|
|
182
|
+
self.max_sessions = max_sessions
|
|
183
|
+
self.max_sessions_per_client = max_sessions_per_client
|
|
184
|
+
self._warned_no_address = False
|
|
185
|
+
self._warned_prefix = False
|
|
186
|
+
self._admit_lock = asyncio.Lock()
|
|
187
|
+
# The secret signs file URLs (and cookies, with auth). Without auth, a random
|
|
188
|
+
# per-process secret is used: fine for one replica, logged so it is not a
|
|
189
|
+
# surprise. With auth, a cookie must outlive the process and resist guessing,
|
|
190
|
+
# so the secret is required and must be long enough.
|
|
191
|
+
if auth is not None and (secret is None or len(secret) < 32):
|
|
192
|
+
raise ValueError(
|
|
193
|
+
"auth needs a secret of at least 32 characters; `millwork secret` makes one"
|
|
194
|
+
)
|
|
195
|
+
if secret is None:
|
|
196
|
+
secret = secrets.token_urlsafe(32)
|
|
197
|
+
log.info("no secret configured; using a per-process secret")
|
|
198
|
+
self.secret = secret
|
|
199
|
+
self.csp = csp
|
|
200
|
+
# The thread pool `s.thread`, uploads and file reads run on: the loop's
|
|
201
|
+
# default executor, sized here. None keeps asyncio's default.
|
|
202
|
+
self.threads = threads
|
|
203
|
+
# Ping the health route from every open tab, not only busy ones, for hosts
|
|
204
|
+
# that scale to zero on idle HTTP (the websocket does not count there).
|
|
205
|
+
self.keep_alive = keep_alive
|
|
206
|
+
self.ready = False
|
|
207
|
+
self.files = files or Files()
|
|
208
|
+
self.components_dir = Path(components) if components else None
|
|
209
|
+
self.auth = auth
|
|
210
|
+
if auth is not None:
|
|
211
|
+
auth.bind(secret, debug, origins, app=name)
|
|
212
|
+
self._startup: list[Callable[[], Any]] = []
|
|
213
|
+
self._shutdown: list[Callable[[], Any]] = []
|
|
214
|
+
self.default_state = state
|
|
215
|
+
self.public_dir = Path(public) if public else None
|
|
216
|
+
self._theme_path: Path | None = None
|
|
217
|
+
if isinstance(theme, Theme):
|
|
218
|
+
self.theme = theme
|
|
219
|
+
elif theme is not None:
|
|
220
|
+
self._theme_path = Path(theme)
|
|
221
|
+
self.theme = Theme.load(self._theme_path)
|
|
222
|
+
else:
|
|
223
|
+
candidate = self.public_dir / "theme.json" if self.public_dir else None
|
|
224
|
+
self._theme_path = candidate if candidate and candidate.is_file() else None
|
|
225
|
+
self.theme = find_theme(self.public_dir)
|
|
226
|
+
self._theme_mtime = self._theme_path.stat().st_mtime if self._theme_path else 0.0
|
|
227
|
+
self._theme_watch: asyncio.Task[None] | None = None
|
|
228
|
+
self._components_watch: asyncio.Task[None] | None = None
|
|
229
|
+
self._component_mtimes: dict[str, float] = {}
|
|
230
|
+
# A content hash per custom component: in the source URL, so a tab caches
|
|
231
|
+
# a version for good and fetches a changed one under a new URL.
|
|
232
|
+
self.component_versions: dict[str, str] = {}
|
|
233
|
+
self.origins = origins or []
|
|
234
|
+
self.session_grace = session_grace
|
|
235
|
+
self.debug = debug
|
|
236
|
+
self.bundle_dir = bundle_dir or BUNDLE_DIR
|
|
237
|
+
self.pages: list[PageDef] = []
|
|
238
|
+
# Where a session lands when no page matches the path, or connect raises
|
|
239
|
+
# NotFound or Forbidden: the framework's own pages until the app sets its
|
|
240
|
+
# own with ``@app.not_found`` and ``@app.forbidden``.
|
|
241
|
+
self.error_pages: dict[str, PageDef] = {
|
|
242
|
+
"not_found": self._error_page_def("not_found", _not_found_view),
|
|
243
|
+
"forbidden": self._error_page_def("forbidden", _forbidden_view),
|
|
244
|
+
}
|
|
245
|
+
self.sessions: dict[str, Session[Any]] = {}
|
|
246
|
+
self.shell = Shell(self.bundle_dir, prefix=PREFIX)
|
|
247
|
+
self._routes: list[Any] = []
|
|
248
|
+
self._middleware: list[Middleware] = []
|
|
249
|
+
self._asgi: ASGIApp | None = None
|
|
250
|
+
|
|
251
|
+
# ------------------------------------------------------------- pages
|
|
252
|
+
|
|
253
|
+
def page(
|
|
254
|
+
self,
|
|
255
|
+
path: str,
|
|
256
|
+
*,
|
|
257
|
+
title: str | None = None,
|
|
258
|
+
public: bool = False,
|
|
259
|
+
state: Callable[[], Any] | type | None = None,
|
|
260
|
+
) -> Callable[[Callable[[Any], Component]], PageDef]:
|
|
261
|
+
def register(fn: Callable[[Any], Component]) -> PageDef:
|
|
262
|
+
page = PageDef(
|
|
263
|
+
path=path,
|
|
264
|
+
render=fn,
|
|
265
|
+
title=title or self.name,
|
|
266
|
+
public=public,
|
|
267
|
+
state=state or self.default_state,
|
|
268
|
+
)
|
|
269
|
+
self.pages.append(page)
|
|
270
|
+
return page
|
|
271
|
+
|
|
272
|
+
return register
|
|
273
|
+
|
|
274
|
+
def resolve(self, path: str) -> tuple[PageDef, dict[str, str]] | None:
|
|
275
|
+
for page in self.pages:
|
|
276
|
+
params = page.match(path)
|
|
277
|
+
if params is not None:
|
|
278
|
+
return page, params
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
def fallback(
|
|
282
|
+
self, kind: str, path: str, message: str | None = None
|
|
283
|
+
) -> tuple[PageDef, dict[str, str]]:
|
|
284
|
+
"""The error page for ``kind`` (``not_found`` or ``forbidden``) and its params:
|
|
285
|
+
the path that was asked for and the message connect raised with, if any."""
|
|
286
|
+
return self.error_pages[kind], {"path": path, "message": message or ""}
|
|
287
|
+
|
|
288
|
+
def _error_page_def(
|
|
289
|
+
self,
|
|
290
|
+
kind: str,
|
|
291
|
+
fn: Callable[[Any], Component],
|
|
292
|
+
*,
|
|
293
|
+
title: str | None = None,
|
|
294
|
+
public: bool = False,
|
|
295
|
+
state: Callable[[], Any] | type | None = None,
|
|
296
|
+
) -> PageDef:
|
|
297
|
+
return PageDef(
|
|
298
|
+
path=f"{PREFIX}/{kind.replace('_', '-')}",
|
|
299
|
+
render=fn,
|
|
300
|
+
title=title or self.theme.text(f"{kind}_title"),
|
|
301
|
+
public=public,
|
|
302
|
+
state=state or self.default_state,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
def not_found(
|
|
306
|
+
self,
|
|
307
|
+
fn: Callable[[Any], Component] | None = None,
|
|
308
|
+
*,
|
|
309
|
+
title: str | None = None,
|
|
310
|
+
public: bool = False,
|
|
311
|
+
state: Callable[[], Any] | type | None = None,
|
|
312
|
+
) -> Any:
|
|
313
|
+
"""Decorator: the page shown when no page matches, or connect raises ``NotFound``.
|
|
314
|
+
|
|
315
|
+
An ordinary page function; ``s.params["path"]`` is the path asked for and
|
|
316
|
+
``s.params["message"]`` the text ``NotFound`` was raised with, or empty.
|
|
317
|
+
The HTTP response that serves it is a 404.
|
|
318
|
+
"""
|
|
319
|
+
return self._set_error_page("not_found", fn, title=title, public=public, state=state)
|
|
320
|
+
|
|
321
|
+
def forbidden(
|
|
322
|
+
self,
|
|
323
|
+
fn: Callable[[Any], Component] | None = None,
|
|
324
|
+
*,
|
|
325
|
+
title: str | None = None,
|
|
326
|
+
public: bool = False,
|
|
327
|
+
state: Callable[[], Any] | type | None = None,
|
|
328
|
+
) -> Any:
|
|
329
|
+
"""Decorator: the page shown when connect raises ``Forbidden``; see ``not_found``."""
|
|
330
|
+
return self._set_error_page("forbidden", fn, title=title, public=public, state=state)
|
|
331
|
+
|
|
332
|
+
def _set_error_page(self, kind: str, fn: Any, **options: Any) -> Any:
|
|
333
|
+
def register(fn: Callable[[Any], Component]) -> PageDef:
|
|
334
|
+
page = self._error_page_def(kind, fn, **options)
|
|
335
|
+
self.error_pages[kind] = page
|
|
336
|
+
return page
|
|
337
|
+
|
|
338
|
+
return register(fn) if fn is not None else register
|
|
339
|
+
|
|
340
|
+
# ------------------------------------------------------------- extension
|
|
341
|
+
|
|
342
|
+
def route(
|
|
343
|
+
self, path: str, endpoint: Callable[..., Any], methods: list[str] | None = None
|
|
344
|
+
) -> None:
|
|
345
|
+
self._routes.append(Route(path, endpoint, methods=methods or ["GET"]))
|
|
346
|
+
|
|
347
|
+
def mount(self, path: str, asgi_app: Any) -> None:
|
|
348
|
+
self._routes.append(Mount(path, app=asgi_app))
|
|
349
|
+
|
|
350
|
+
def middleware(self, cls: Any, **options: Any) -> None:
|
|
351
|
+
self._middleware.append(Middleware(cls, **options))
|
|
352
|
+
|
|
353
|
+
def on_startup(self, fn: Callable[[], Any]) -> Callable[[], Any]:
|
|
354
|
+
self._startup.append(fn)
|
|
355
|
+
return fn
|
|
356
|
+
|
|
357
|
+
def on_shutdown(self, fn: Callable[[], Any]) -> Callable[[], Any]:
|
|
358
|
+
self._shutdown.append(fn)
|
|
359
|
+
return fn
|
|
360
|
+
|
|
361
|
+
@contextlib.asynccontextmanager
|
|
362
|
+
async def _lifespan(self, app: Starlette) -> AsyncIterator[None]:
|
|
363
|
+
if self.threads is not None:
|
|
364
|
+
asyncio.get_running_loop().set_default_executor(
|
|
365
|
+
ThreadPoolExecutor(max_workers=self.threads, thread_name_prefix="millwork")
|
|
366
|
+
)
|
|
367
|
+
# On a thread: a root with many stale directories would otherwise hold
|
|
368
|
+
# the loop before the first request.
|
|
369
|
+
removed = await asyncio.to_thread(self.files.sweep, max(self.session_grace * 4, 3600))
|
|
370
|
+
if removed:
|
|
371
|
+
log.info("swept %d stale file directories", removed)
|
|
372
|
+
for fn in self._startup:
|
|
373
|
+
await _maybe_await(fn())
|
|
374
|
+
self.component_versions = self._component_hashes()
|
|
375
|
+
if self.debug and self._theme_path is not None:
|
|
376
|
+
self._theme_watch = asyncio.create_task(self._watch_theme())
|
|
377
|
+
if self.debug and self.components_dir is not None and self.components_dir.is_dir():
|
|
378
|
+
self._components_watch = asyncio.create_task(self._watch_components())
|
|
379
|
+
self.ready = True
|
|
380
|
+
try:
|
|
381
|
+
yield
|
|
382
|
+
finally:
|
|
383
|
+
self.ready = False
|
|
384
|
+
if self._theme_watch is not None:
|
|
385
|
+
self._theme_watch.cancel()
|
|
386
|
+
if self._components_watch is not None:
|
|
387
|
+
self._components_watch.cancel()
|
|
388
|
+
for fn in self._shutdown:
|
|
389
|
+
await _maybe_await(fn())
|
|
390
|
+
for session in list(self.sessions.values()):
|
|
391
|
+
await session.close()
|
|
392
|
+
|
|
393
|
+
# ------------------------------------------------------------- asgi
|
|
394
|
+
|
|
395
|
+
@property
|
|
396
|
+
def asgi(self) -> ASGIApp:
|
|
397
|
+
if self._asgi is None:
|
|
398
|
+
self._asgi = Proxied(self._build_asgi(), lambda: self.proxied)
|
|
399
|
+
return self._asgi
|
|
400
|
+
|
|
401
|
+
def _build_asgi(self) -> Starlette:
|
|
402
|
+
from millwork.ws import websocket_endpoint
|
|
403
|
+
|
|
404
|
+
routes: list[Any] = [
|
|
405
|
+
Route(f"{PREFIX}/health", self._health),
|
|
406
|
+
Route(f"{PREFIX}/login", self._login_page),
|
|
407
|
+
Route(f"{PREFIX}/components/{{name}}.jsx", self._component_source),
|
|
408
|
+
WebSocketRoute(f"{PREFIX}/ws", websocket_endpoint),
|
|
409
|
+
Route("/favicon.ico", self._favicon),
|
|
410
|
+
Mount(f"{PREFIX}/assets", app=_Assets(directory=self.bundle_dir, check_dir=False)),
|
|
411
|
+
]
|
|
412
|
+
if self.auth is not None:
|
|
413
|
+
routes += [
|
|
414
|
+
Route(f"{PREFIX}/auth/login", self.auth.route_login, methods=["POST"]),
|
|
415
|
+
Route(f"{PREFIX}/auth/logout", self.auth.route_logout, methods=["POST"]),
|
|
416
|
+
Route(f"{PREFIX}/auth/oauth/{{provider}}", self.auth.route_oauth_start),
|
|
417
|
+
Route(f"{PREFIX}/auth/oauth/{{provider}}/callback", self.auth.route_oauth_callback),
|
|
418
|
+
]
|
|
419
|
+
if self.public_dir is not None and self.public_dir.is_dir():
|
|
420
|
+
routes.append(Mount("/public", app=StaticFiles(directory=self.public_dir)))
|
|
421
|
+
routes.extend(self._routes)
|
|
422
|
+
routes.append(Route("/{path:path}", self._serve_page))
|
|
423
|
+
middleware = [Middleware(_SecurityHeaders), *self._middleware]
|
|
424
|
+
app = Starlette(
|
|
425
|
+
routes=routes,
|
|
426
|
+
middleware=middleware,
|
|
427
|
+
debug=self.debug,
|
|
428
|
+
lifespan=self._lifespan,
|
|
429
|
+
exception_handlers={500: self._server_error},
|
|
430
|
+
)
|
|
431
|
+
app.state.millwork = self
|
|
432
|
+
return app
|
|
433
|
+
|
|
434
|
+
async def _watch_theme(self) -> None:
|
|
435
|
+
"""Debug only: push a changed theme file to every open session."""
|
|
436
|
+
assert self._theme_path is not None
|
|
437
|
+
while True:
|
|
438
|
+
await asyncio.sleep(1.0)
|
|
439
|
+
try:
|
|
440
|
+
mtime = self._theme_path.stat().st_mtime
|
|
441
|
+
except OSError:
|
|
442
|
+
continue
|
|
443
|
+
if mtime == self._theme_mtime:
|
|
444
|
+
continue
|
|
445
|
+
self._theme_mtime = mtime
|
|
446
|
+
await self.reload_theme()
|
|
447
|
+
|
|
448
|
+
async def _watch_components(self) -> None:
|
|
449
|
+
"""Debug only: a changed custom component is re-fetched by every open tab."""
|
|
450
|
+
self._component_mtimes = self._component_stamps()
|
|
451
|
+
while True:
|
|
452
|
+
await asyncio.sleep(1.0)
|
|
453
|
+
await self.check_components()
|
|
454
|
+
|
|
455
|
+
def _component_hashes(self) -> dict[str, str]:
|
|
456
|
+
if self.components_dir is None or not self.components_dir.is_dir():
|
|
457
|
+
return {}
|
|
458
|
+
versions = {}
|
|
459
|
+
for path in self.components_dir.glob("*.jsx"):
|
|
460
|
+
try:
|
|
461
|
+
versions[path.stem] = hashlib.sha256(path.read_bytes()).hexdigest()[:16]
|
|
462
|
+
except OSError:
|
|
463
|
+
continue
|
|
464
|
+
return versions
|
|
465
|
+
|
|
466
|
+
def _component_stamps(self) -> dict[str, float]:
|
|
467
|
+
assert self.components_dir is not None
|
|
468
|
+
try:
|
|
469
|
+
return {p.stem: p.stat().st_mtime for p in self.components_dir.glob("*.jsx")}
|
|
470
|
+
except OSError:
|
|
471
|
+
return {}
|
|
472
|
+
|
|
473
|
+
async def check_components(self) -> list[str]:
|
|
474
|
+
"""Push a ``reload_component`` for each component file changed since the last
|
|
475
|
+
look; returns their names. The watcher calls this every second."""
|
|
476
|
+
stamps = self._component_stamps()
|
|
477
|
+
changed = [n for n, m in stamps.items() if self._component_mtimes.get(n) != m]
|
|
478
|
+
gone = set(self._component_mtimes) - set(stamps)
|
|
479
|
+
self._component_mtimes = stamps
|
|
480
|
+
if changed or gone:
|
|
481
|
+
self.component_versions = self._component_hashes()
|
|
482
|
+
for name in changed:
|
|
483
|
+
log.info("custom component %s changed; reloading it in open tabs", name)
|
|
484
|
+
args = {"name": name, "version": self.component_versions.get(name)}
|
|
485
|
+
for session in list(self.sessions.values()):
|
|
486
|
+
if not session.closed:
|
|
487
|
+
await session._send_now(Command(name="reload_component", args=args))
|
|
488
|
+
return changed
|
|
489
|
+
|
|
490
|
+
async def admit(self, session: Session[Any]) -> bool:
|
|
491
|
+
"""Take the new session in, making room by ending the session longest without
|
|
492
|
+
a tab when a cap is reached; False, and the session left out, when none can
|
|
493
|
+
be ended. One at a time, so two arriving together cannot both take the last
|
|
494
|
+
place."""
|
|
495
|
+
client = session.client
|
|
496
|
+
|
|
497
|
+
async def make_room(client: str | None) -> bool:
|
|
498
|
+
idle = [
|
|
499
|
+
s
|
|
500
|
+
for s in self.sessions.values()
|
|
501
|
+
if not s.closed and not s.has_tab and (client is None or s.client == client)
|
|
502
|
+
]
|
|
503
|
+
if not idle:
|
|
504
|
+
return False
|
|
505
|
+
oldest = min(idle, key=lambda s: s.detached_at)
|
|
506
|
+
await oldest.end(self.theme.text("made_room"))
|
|
507
|
+
return True
|
|
508
|
+
|
|
509
|
+
async with self._admit_lock:
|
|
510
|
+
mine = sum(1 for s in self.sessions.values() if s.client == client and not s.closed)
|
|
511
|
+
if mine >= self.max_sessions_per_client and not await make_room(client):
|
|
512
|
+
return False
|
|
513
|
+
if len(self.sessions) >= self.max_sessions and not await make_room(None):
|
|
514
|
+
return False
|
|
515
|
+
self.sessions[session.id] = session
|
|
516
|
+
return True
|
|
517
|
+
|
|
518
|
+
async def reload_theme(self) -> bool:
|
|
519
|
+
"""Re-read the theme file and push it to open sessions; False when it is invalid."""
|
|
520
|
+
if self._theme_path is None:
|
|
521
|
+
return False
|
|
522
|
+
try:
|
|
523
|
+
self.theme = Theme.load(self._theme_path)
|
|
524
|
+
except ValueError as exc:
|
|
525
|
+
log.error("theme not reloaded: %s", exc)
|
|
526
|
+
return False
|
|
527
|
+
log.info("theme reloaded from %s", self._theme_path)
|
|
528
|
+
for session in list(self.sessions.values()):
|
|
529
|
+
if not session.closed:
|
|
530
|
+
args = {"css": self.theme.css(session.base), "boot": self.theme.boot()}
|
|
531
|
+
await session._send_now(Command(name="theme", args=args))
|
|
532
|
+
return True
|
|
533
|
+
|
|
534
|
+
async def _favicon(self, request: Request) -> Any:
|
|
535
|
+
if self.theme.favicon:
|
|
536
|
+
href = under(root_of(request.scope), self.theme.favicon) or ""
|
|
537
|
+
return RedirectResponse(href, status_code=302)
|
|
538
|
+
icon = self.bundle_dir / "favicon.svg"
|
|
539
|
+
if icon.is_file():
|
|
540
|
+
return FileResponse(icon, media_type="image/svg+xml")
|
|
541
|
+
return PlainTextResponse("", status_code=204)
|
|
542
|
+
|
|
543
|
+
async def _component_source(self, request: Request) -> Any:
|
|
544
|
+
"""An app's custom component, as text for the tab to compile."""
|
|
545
|
+
if self.components_dir is None:
|
|
546
|
+
return PlainTextResponse("Not found", status_code=404)
|
|
547
|
+
name = request.path_params["name"]
|
|
548
|
+
base = self.components_dir.resolve()
|
|
549
|
+
path = (base / f"{name}.jsx").resolve()
|
|
550
|
+
if not path.is_relative_to(base) or not path.is_file():
|
|
551
|
+
return PlainTextResponse("Not found", status_code=404)
|
|
552
|
+
# Asked for by its current version, the source may be kept for good; the
|
|
553
|
+
# URL changes with the content. Any other request is revalidated each time.
|
|
554
|
+
current = self.component_versions.get(name)
|
|
555
|
+
version = request.query_params.get("v")
|
|
556
|
+
cache = "public, max-age=31536000, immutable" if version == current else "no-cache"
|
|
557
|
+
return FileResponse(path, media_type="text/javascript", headers={"Cache-Control": cache})
|
|
558
|
+
|
|
559
|
+
async def _health(self, request: Request) -> JSONResponse:
|
|
560
|
+
"""200 once the startup hooks are done, 503 before that and during shutdown, so
|
|
561
|
+
an orchestrator does not route to a replica that is still warming up."""
|
|
562
|
+
body = {"ok": self.ready, "version": __version__, "bundle": self.shell.available}
|
|
563
|
+
return JSONResponse(body, status_code=200 if self.ready else 503)
|
|
564
|
+
|
|
565
|
+
async def user_for(self, request_or_scope: Any) -> Any:
|
|
566
|
+
"""The signed-in user, or None; always None without auth configured."""
|
|
567
|
+
return await self.auth.identify(request_or_scope) if self.auth is not None else None
|
|
568
|
+
|
|
569
|
+
def _html(self, page: str, nonce: str, status: int = 200) -> HTMLResponse:
|
|
570
|
+
headers = {}
|
|
571
|
+
if self.csp:
|
|
572
|
+
scripts = f"'self' 'nonce-{nonce}'"
|
|
573
|
+
if self.components_dir is not None and self.components_dir.is_dir():
|
|
574
|
+
# Custom components are compiled in the tab (react-runner uses Function).
|
|
575
|
+
scripts += " 'unsafe-eval'"
|
|
576
|
+
headers["Content-Security-Policy"] = self.csp.format(scripts=scripts)
|
|
577
|
+
return HTMLResponse(page, status_code=status, headers=headers)
|
|
578
|
+
|
|
579
|
+
def _check_stripped(self, scope: Any, path: str) -> None:
|
|
580
|
+
"""A path that still starts with the prefix means the proxy passed it
|
|
581
|
+
through whole: every route then misses, and only the log can say why."""
|
|
582
|
+
base = root_of(scope)
|
|
583
|
+
if base and (path == base or path.startswith(base + "/")) and not self._warned_prefix:
|
|
584
|
+
self._warned_prefix = True
|
|
585
|
+
log.warning(
|
|
586
|
+
"the request path %s still carries the prefix %s: the proxy must strip it",
|
|
587
|
+
path,
|
|
588
|
+
base,
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
def _plain(self, request: Request, status: int, title: str, text: str) -> HTMLResponse:
|
|
592
|
+
"""A themed page for a failure before the bundle can run."""
|
|
593
|
+
nonce = secrets.token_urlsafe(16)
|
|
594
|
+
try:
|
|
595
|
+
base = root_of(request.scope)
|
|
596
|
+
except ValueError:
|
|
597
|
+
base = ""
|
|
598
|
+
page = plain(title=title, text=text, theme=self.theme, nonce=nonce, base=base)
|
|
599
|
+
return self._html(page, nonce, status)
|
|
600
|
+
|
|
601
|
+
async def _server_error(self, request: Request, exc: Exception) -> Any:
|
|
602
|
+
"""An unhandled exception in a route: a page in the app's colours, never the
|
|
603
|
+
exception; the traceback is in the log. Debug mode shows Starlette's own page."""
|
|
604
|
+
text = self.theme.text
|
|
605
|
+
return self._plain(request, 500, text("something_went_wrong"), text("try_again"))
|
|
606
|
+
|
|
607
|
+
async def _login_page(self, request: Request) -> Any:
|
|
608
|
+
if self.auth is None:
|
|
609
|
+
return PlainTextResponse("Not found", status_code=404)
|
|
610
|
+
if not self.shell.available:
|
|
611
|
+
return PlainTextResponse("millwork: the bundle is not built.", status_code=503)
|
|
612
|
+
from millwork.auth import _safe_next
|
|
613
|
+
|
|
614
|
+
next_path = _safe_next(request.query_params.get("next"))
|
|
615
|
+
base = root_of(request.scope)
|
|
616
|
+
if await self.user_for(request) is not None:
|
|
617
|
+
# Already signed in, by a cookie or by the proxy: nothing to show.
|
|
618
|
+
return RedirectResponse(f"{base}{next_path}", status_code=302)
|
|
619
|
+
error = request.query_params.get("error")
|
|
620
|
+
if error is None and self.auth.proxy_identity(request):
|
|
621
|
+
error = self.theme.text("proxy_denied")
|
|
622
|
+
boot = {
|
|
623
|
+
**self.auth.describe(next_path, error),
|
|
624
|
+
"name": self.name,
|
|
625
|
+
"image": self.theme.login_image,
|
|
626
|
+
}
|
|
627
|
+
nonce = secrets.token_urlsafe(16)
|
|
628
|
+
return self._html(
|
|
629
|
+
self.shell.render(
|
|
630
|
+
title=self.name,
|
|
631
|
+
path=f"{PREFIX}/login",
|
|
632
|
+
login=boot,
|
|
633
|
+
theme=self.theme,
|
|
634
|
+
nonce=nonce,
|
|
635
|
+
base=base,
|
|
636
|
+
),
|
|
637
|
+
nonce,
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
async def _serve_page(self, request: Request) -> Any:
|
|
641
|
+
path = "/" + request.path_params.get("path", "")
|
|
642
|
+
self._check_stripped(request.scope, path)
|
|
643
|
+
resolved = self.resolve(path)
|
|
644
|
+
status = 200
|
|
645
|
+
if resolved is None:
|
|
646
|
+
# The not-found page, served as a 404 so monitors and crawlers see one.
|
|
647
|
+
resolved = self.fallback("not_found", path)
|
|
648
|
+
status = 404
|
|
649
|
+
page, _ = resolved
|
|
650
|
+
base = root_of(request.scope)
|
|
651
|
+
user = await self.user_for(request)
|
|
652
|
+
if self.auth is not None and not page.public and user is None:
|
|
653
|
+
from urllib.parse import urlencode
|
|
654
|
+
|
|
655
|
+
login = f"{base}{PREFIX}/login?{urlencode({'next': path})}"
|
|
656
|
+
return RedirectResponse(login, status_code=302)
|
|
657
|
+
if not self.shell.available:
|
|
658
|
+
if status == 404:
|
|
659
|
+
text = self.theme.text
|
|
660
|
+
title, body = text("not_found_title"), text("not_found_text", path=path)
|
|
661
|
+
return self._plain(request, 404, title, body)
|
|
662
|
+
hint = "The bundle is not built. Run `pnpm build` in bundle/."
|
|
663
|
+
return self._plain(request, 503, "millwork", hint)
|
|
664
|
+
user_boot = (
|
|
665
|
+
{"id": user.id, "name": user.name, "picture_url": user.picture_url} if user else None
|
|
666
|
+
)
|
|
667
|
+
if user_boot is not None and request.scope.get("millwork.identified_by") == "header":
|
|
668
|
+
# The proxy signed this person in, so sign-out is its URL, or nothing to offer.
|
|
669
|
+
user_boot["sign_out"] = (self.auth.sign_out if self.auth else None) or None
|
|
670
|
+
nonce = secrets.token_urlsafe(16)
|
|
671
|
+
return self._html(
|
|
672
|
+
self.shell.render(
|
|
673
|
+
title=page.title or self.name,
|
|
674
|
+
path=path,
|
|
675
|
+
theme=self.theme,
|
|
676
|
+
user=user_boot,
|
|
677
|
+
nonce=nonce,
|
|
678
|
+
keep_alive=self.keep_alive,
|
|
679
|
+
components=self.component_versions,
|
|
680
|
+
base=base,
|
|
681
|
+
),
|
|
682
|
+
nonce,
|
|
683
|
+
status,
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
def file_response(self, path: Path) -> FileResponse:
|
|
687
|
+
return FileResponse(path)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _error_view(s: Any, kind: str) -> Component:
|
|
691
|
+
"""The framework's own error page: the logo, a title, the reason, a way home.
|
|
692
|
+
The same frame as the login page, in the theme's tokens, with no app chrome."""
|
|
693
|
+
from millwork import ui
|
|
694
|
+
|
|
695
|
+
message = s.params.get("message") or s.text(f"{kind}_text", path=s.params.get("path", ""))
|
|
696
|
+
home = ui.Button(
|
|
697
|
+
label=s.text("home"),
|
|
698
|
+
variant="outline",
|
|
699
|
+
icon="house",
|
|
700
|
+
on_click=lambda s, _: s.navigate("/"),
|
|
701
|
+
)
|
|
702
|
+
card = ui.Stack(
|
|
703
|
+
children=[
|
|
704
|
+
ui.Logo(cls="h-9 self-start"),
|
|
705
|
+
ui.Text(s.text(f"{kind}_title"), variant="title"),
|
|
706
|
+
ui.Text(message, variant="muted"),
|
|
707
|
+
ui.Row(children=[home]),
|
|
708
|
+
],
|
|
709
|
+
gap=3,
|
|
710
|
+
cls="w-full max-w-sm",
|
|
711
|
+
)
|
|
712
|
+
return ui.Stack(children=[card], align="center", justify="center", cls="min-h-dvh p-6")
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _not_found_view(s: Any) -> Component:
|
|
716
|
+
return _error_view(s, "not_found")
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _forbidden_view(s: Any) -> Component:
|
|
720
|
+
return _error_view(s, "forbidden")
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
async def _maybe_await(value: Any) -> Any:
|
|
724
|
+
if hasattr(value, "__await__"):
|
|
725
|
+
return await value
|
|
726
|
+
return value
|