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
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