meadows-web 0.1.0__tar.gz

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.
Binary file
@@ -0,0 +1,13 @@
1
+ # Meadows web host: bind address and port (Traefik terminates TLS in front of this)
2
+ MEADOWS_WEB_HOST=0.0.0.0
3
+ MEADOWS_WEB_PORT=8081
4
+
5
+ # URL of meadows-server the browser Socket.IO client connects to (injected into index.html)
6
+ MEADOWS_SERVER_URL=http://localhost:8080
7
+
8
+ # Display name shown in the chat UI
9
+ MEADOWS_SYSTEM_NAME=MEADOWS Chat
10
+
11
+ # Traefik routing (see docker-compose.yml labels)
12
+ PROJECT=meadows
13
+ HOSTINGDOMAIN=localhost
@@ -0,0 +1,19 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .venv/
6
+ venv/
7
+ dist/
8
+ build/
9
+ .pytest_cache/
10
+ .ruff_cache/
11
+
12
+ # edwh / template
13
+ .env
14
+ .toml
15
+
16
+ # OS / editors
17
+ *.swp
18
+ *.bak
19
+ .DS_Store
@@ -0,0 +1,9 @@
1
+ FROM python:3.13-slim
2
+ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
3
+ WORKDIR /app
4
+ COPY pyproject.toml .
5
+ COPY src/ ./src/
6
+ COPY README.md .
7
+ RUN uv pip install --system --no-cache .
8
+ EXPOSE 8081
9
+ CMD ["python", "-m", "meadows.web"]
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.5
2
+ Name: meadows-web
3
+ Version: 0.1.0
4
+ Summary: MEADOWS web host: serves index.html and static assets. Dumb HTTP host, no domain logic.
5
+ Author: MEADOWS
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: meadows-protocol
8
+ Requires-Dist: starlette>=0.37.0
9
+ Requires-Dist: uvicorn>=0.30.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: hatch; extra == 'dev'
12
+ Requires-Dist: httpx>=0.27.0; extra == 'dev'
13
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
14
+ Requires-Dist: pytest-cov>=5.0.0; extra == 'dev'
15
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
16
+ Requires-Dist: ruff==0.14.9; extra == 'dev'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # meadows-web
20
+
21
+ > MEADOWS web host: a dumb HTTP host that serves `index.html` and static assets.
22
+ > No Socket.IO, no auth, no domain logic. The browser is the client; the
23
+ > Socket.IO connection runs browser→meadows-server, NOT via this Python webserver.
24
+ > See `MEADOWS-migration-intent.md` section 2 line 40 and section 4 line 115.
25
+
26
+ ## Architecture
27
+
28
+ ```mermaid
29
+ graph LR
30
+ B[Browser<br>JS] -->|Socket.IO| S[meadows-server<br>:8080]
31
+ B -->|HTTP| W[meadows-web<br>:8081]
32
+ W -.->|static files only| B
33
+ ```
34
+
35
+ The browser connects directly to `meadows-server` via Socket.IO. This Python server is just a file host.
36
+
37
+ ## What this package contains
38
+
39
+ - `app.py` — the Starlette ASGI app. Serves `/` → `dist/index.html` and `/static/*` → assets. Nothing else.
40
+ - `build.py` — template injection: reads `templates/index.html`, injects protocol constants + env config, writes `dist/index.html`.
41
+ - `templates/index.html` — minimal webchat page (Socket.IO client in the browser).
42
+ - `__main__.py` — `python -m meadows.web` entrypoint (uvicorn).
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ uv pip install -e ".[dev]"
48
+ ```
49
+
50
+ This pulls in `meadows-protocol` (editable, via the sibling path) — the **only** MEADOWS dependency. The web host touches `meadows.protocol` solely to inject `EventName` constants into the template. It does **not** import `Message`, `JWTClaims`, or any domain model.
51
+
52
+ ## Build the template
53
+
54
+ ```bash
55
+ uv run python -m meadows.web.build
56
+ ```
57
+
58
+ ## Run
59
+
60
+ ```bash
61
+ uv run python -m meadows.web
62
+ # or
63
+ uv run uvicorn meadows.web.app:app --host 0.0.0.0 --port 8081
64
+ ```
65
+
66
+ ## Test
67
+
68
+ ```bash
69
+ uv run pytest -q
70
+ ```
71
+
72
+ ## Architecture invariants
73
+
74
+ 1. **Dumb host.** No Socket.IO, no auth, no JWT, no message parsing. It serves files. Period.
75
+ 2. **TLS is not a concern.** Traefik terminates TLS (section 4 line 115). No cert logic here.
76
+ 3. **Protocol constants only.** The only import from `meadows.protocol` is `EventName` (for template injection).
77
+ 4. **PEP 420 namespace.** `src/meadows/web/__init__.py` exists; there is NO `src/meadows/__init__.py`.
78
+
79
+ ## Configuration (env vars, managed via `check_env` in `tasks.py:setup`)
80
+
81
+ | variable | default | purpose |
82
+ |---|---|---|
83
+ | `MEADOWS_WEB_HOST` | `0.0.0.0` | bind address for uvicorn |
84
+ | `MEADOWS_WEB_PORT` | `8081` | bind port for uvicorn |
85
+ | `MEADOWS_SERVER_URL` | `http://localhost:8080` | server URL injected into the page (browser Socket.IO target) |
86
+ | `MEADOWS_SYSTEM_NAME` | `MEADOWS Chat` | display name injected into the page |
87
+ | `PROJECT` | `meadows` | Traefik router prefix |
88
+ | `HOSTINGDOMAIN` | `localhost` | Traefik host domain |
@@ -0,0 +1,70 @@
1
+ # meadows-web
2
+
3
+ > MEADOWS web host: a dumb HTTP host that serves `index.html` and static assets.
4
+ > No Socket.IO, no auth, no domain logic. The browser is the client; the
5
+ > Socket.IO connection runs browser→meadows-server, NOT via this Python webserver.
6
+ > See `MEADOWS-migration-intent.md` section 2 line 40 and section 4 line 115.
7
+
8
+ ## Architecture
9
+
10
+ ```mermaid
11
+ graph LR
12
+ B[Browser<br>JS] -->|Socket.IO| S[meadows-server<br>:8080]
13
+ B -->|HTTP| W[meadows-web<br>:8081]
14
+ W -.->|static files only| B
15
+ ```
16
+
17
+ The browser connects directly to `meadows-server` via Socket.IO. This Python server is just a file host.
18
+
19
+ ## What this package contains
20
+
21
+ - `app.py` — the Starlette ASGI app. Serves `/` → `dist/index.html` and `/static/*` → assets. Nothing else.
22
+ - `build.py` — template injection: reads `templates/index.html`, injects protocol constants + env config, writes `dist/index.html`.
23
+ - `templates/index.html` — minimal webchat page (Socket.IO client in the browser).
24
+ - `__main__.py` — `python -m meadows.web` entrypoint (uvicorn).
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ uv pip install -e ".[dev]"
30
+ ```
31
+
32
+ This pulls in `meadows-protocol` (editable, via the sibling path) — the **only** MEADOWS dependency. The web host touches `meadows.protocol` solely to inject `EventName` constants into the template. It does **not** import `Message`, `JWTClaims`, or any domain model.
33
+
34
+ ## Build the template
35
+
36
+ ```bash
37
+ uv run python -m meadows.web.build
38
+ ```
39
+
40
+ ## Run
41
+
42
+ ```bash
43
+ uv run python -m meadows.web
44
+ # or
45
+ uv run uvicorn meadows.web.app:app --host 0.0.0.0 --port 8081
46
+ ```
47
+
48
+ ## Test
49
+
50
+ ```bash
51
+ uv run pytest -q
52
+ ```
53
+
54
+ ## Architecture invariants
55
+
56
+ 1. **Dumb host.** No Socket.IO, no auth, no JWT, no message parsing. It serves files. Period.
57
+ 2. **TLS is not a concern.** Traefik terminates TLS (section 4 line 115). No cert logic here.
58
+ 3. **Protocol constants only.** The only import from `meadows.protocol` is `EventName` (for template injection).
59
+ 4. **PEP 420 namespace.** `src/meadows/web/__init__.py` exists; there is NO `src/meadows/__init__.py`.
60
+
61
+ ## Configuration (env vars, managed via `check_env` in `tasks.py:setup`)
62
+
63
+ | variable | default | purpose |
64
+ |---|---|---|
65
+ | `MEADOWS_WEB_HOST` | `0.0.0.0` | bind address for uvicorn |
66
+ | `MEADOWS_WEB_PORT` | `8081` | bind port for uvicorn |
67
+ | `MEADOWS_SERVER_URL` | `http://localhost:8080` | server URL injected into the page (browser Socket.IO target) |
68
+ | `MEADOWS_SYSTEM_NAME` | `MEADOWS Chat` | display name injected into the page |
69
+ | `PROJECT` | `meadows` | Traefik router prefix |
70
+ | `HOSTINGDOMAIN` | `localhost` | Traefik host domain |
@@ -0,0 +1,4 @@
1
+ #!/bin/sh
2
+ # Restic file backup (template convention).
3
+ # $HOST, $URI, $SNAPSHOT are provided by the edwh restic plugin at runtime.
4
+ restic $HOST -r $URI backup ./backups --tag files 2>/dev/null || true
@@ -0,0 +1,4 @@
1
+ #!/bin/sh
2
+ # Restic file restore (template convention).
3
+ # $HOST, $URI, $SNAPSHOT are provided by the edwh restic plugin at runtime.
4
+ restic $HOST -r $URI restore $SNAPSHOT --target ./ 2>/dev/null || true
@@ -0,0 +1,35 @@
1
+ networks:
2
+ meadows:
3
+ driver: bridge
4
+ broker:
5
+ external: true
6
+ name: broker
7
+
8
+ services:
9
+ meadows-web:
10
+ build: .
11
+ image: meadows-web:latest
12
+ restart: unless-stopped
13
+ environment:
14
+ - MEADOWS_WEB_HOST=${MEADOWS_WEB_HOST:-0.0.0.0}
15
+ - MEADOWS_WEB_PORT=${MEADOWS_WEB_PORT:-8081}
16
+ - MEADOWS_SERVER_URL=${MEADOWS_SERVER_URL:-http://localhost:8080}
17
+ - MEADOWS_SYSTEM_NAME=${MEADOWS_SYSTEM_NAME:-MEADOWS Chat}
18
+ - PROJECT=${PROJECT:-meadows}
19
+ - HOSTINGDOMAIN=${HOSTINGDOMAIN:-localhost}
20
+ networks:
21
+ - meadows
22
+ - broker
23
+ logging:
24
+ driver: "json-file"
25
+ options:
26
+ max-size: "100m"
27
+ max-file: "5"
28
+ labels:
29
+ - "traefik.enable=true"
30
+ - "traefik.http.routers.${PROJECT:-meadows}-meadows-web-secure.rule=Host(`${ROUTER_HOST:-chat.${HOSTINGDOMAIN:-localhost}}`)"
31
+ - "traefik.http.routers.${PROJECT:-meadows}-meadows-web-secure.tls=true"
32
+ - "traefik.http.routers.${PROJECT:-meadows}-meadows-web-secure.entrypoints=web-secured"
33
+ - "traefik.http.routers.${PROJECT:-meadows}-meadows-web-secure.tls.certresolver=letsencrypt"
34
+ - "traefik.http.services.${PROJECT:-meadows}-meadows-web.loadbalancer.server.port=${MEADOWS_WEB_PORT:-8081}"
35
+ - "traefik.docker.network=broker"
@@ -0,0 +1,82 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "meadows-web"
7
+ version = "0.1.0"
8
+ description = "MEADOWS web host: serves index.html and static assets. Dumb HTTP host, no domain logic."
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license-expression = "MIT"
12
+ authors = [{ name = "MEADOWS" }]
13
+ dependencies = [
14
+ "meadows-protocol",
15
+ "uvicorn>=0.30.0",
16
+ "starlette>=0.37.0",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ dev = ["hatch", "pytest>=8.0.0", "pytest-asyncio>=0.23.0", "pytest-cov>=5.0.0", "httpx>=0.27.0", "ruff==0.14.9"]
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/meadows"]
24
+
25
+ [tool.uv.sources]
26
+ meadows-protocol = { path = "../meadows-protocol", editable = true }
27
+
28
+ [tool.ruff]
29
+ target-version = "py312"
30
+ line-length = 120
31
+
32
+ [tool.ruff.lint]
33
+ select = ["F", "E", "W", "Q", "A", "SIM", "ARG", "PTH", "RUF", "C90", "N", "YTT"]
34
+
35
+ [tool.pytest.ini_options]
36
+ testpaths = ["tests"]
37
+ asyncio_mode = "auto"
38
+
39
+ [tool.vommit]
40
+ allow_breaking_bang = true
41
+ allow_breaking_footer = true
42
+ prerelease_token = "rc"
43
+ confirm = true
44
+
45
+ [tool.vommit.git]
46
+ enabled = true
47
+ origin = "origin"
48
+ branch = "main"
49
+ on_wrong_branch = "error"
50
+ tag_format = "v{version}"
51
+ commit_format = "{version}"
52
+
53
+ [tool.vommit.changelog]
54
+ enabled = true
55
+ file = "CHANGELOG.md"
56
+ include_prereleases = false
57
+ placeholder = "<!-- next-version-placeholder -->"
58
+ placeholder_regex = "<auto>"
59
+ entry_title_format = "## v{version} ({date:%Y-%m-%d})"
60
+
61
+ [tool.vommit.changelog.levels]
62
+ break = "Breaking Change{s}"
63
+ feat = "Feature{s}"
64
+ fix = "Fix{es}"
65
+ perf = "Performance"
66
+ docs = "Documentation"
67
+
68
+ [tool.vommit.pypi]
69
+ enabled = true
70
+ use_keyring = true
71
+
72
+ [tool.vommit.commands]
73
+ clean = "rm -rf ./dist"
74
+ build = "uv build"
75
+ publish = "../pypi-publish"
76
+ post_publish = ""
77
+
78
+ [tool.vommit.version_bump_map]
79
+ break = "major"
80
+ feat = "minor"
81
+ fix = "patch"
82
+ perf = "patch"
@@ -0,0 +1 @@
1
+ f66dee38fa244ab248937238aef06918868c485c8f086a5bf022a0220a8c5464
@@ -0,0 +1,3 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version("meadows-web")
@@ -0,0 +1,18 @@
1
+ """MEADOWS web host — a dumb HTTP host (MEADOWS §2 line 40).
2
+
3
+ This package serves index.html and static assets. It is deliberately the
4
+ simplest of the five MEADOWS distributions: no Socket.IO, no auth, no domain
5
+ logic. The browser is the Socket.IO client; the connection runs browser→
6
+ meadows-server, NOT via this Python webserver. TLS is terminated by Traefik
7
+ (section 4 line 115).
8
+
9
+ The only touch of meadows.protocol is in build.py, and only for EventName
10
+ (architecture invariant #3: protocol constants only).
11
+
12
+ PEP 420 (section 3.1): there is intentionally NO `src/meadows/__init__.py`.
13
+ Only this leaf package's __init__ exists.
14
+ """
15
+
16
+ from meadows.web.__about__ import __version__
17
+
18
+ __all__ = ["__version__"]
@@ -0,0 +1,28 @@
1
+ """BUSINESS RULE (MEADOWS §2 line 40): `python -m meadows.web` runs the dumb
2
+ HTTP host via uvicorn. Plain HTTP — TLS is terminated by Traefik (§4 line 115),
3
+ so no cert logic here (invariant #2). Defaults match tasks.py:check_env so the
4
+ container and local dev behave identically.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+
11
+ import uvicorn
12
+
13
+ from meadows.web.app import create_app
14
+
15
+ app = create_app()
16
+
17
+
18
+ def main() -> None:
19
+ """BUSINESS RULE: serve files on the configured host/port. Nothing else —
20
+ the browser opens its own Socket.IO connection to meadows-server using the
21
+ URL injected into index.html by build.py."""
22
+ host = os.environ.get("MEADOWS_WEB_HOST", "0.0.0.0")
23
+ port = int(os.environ.get("MEADOWS_WEB_PORT", "8081"))
24
+ uvicorn.run(app, host=host, port=port)
25
+
26
+
27
+ if __name__ == "__main__":
28
+ main()
@@ -0,0 +1,120 @@
1
+ """
2
+ BUSINESS RULE (MEADOWS §2 line 40 + §4 line 115): meadows-web is a dumb HTTP
3
+ host. It serves index.html and static assets, injects protocol constants into
4
+ the template, and does nothing else. The browser is the Socket.IO client; the
5
+ connection runs browser→meadows-server, NOT via this Python webserver.
6
+
7
+ This module deliberately has no socketio, no auth, no domain logic, no JWT, no
8
+ message parsing (architecture invariant #1). TLS is terminated by Traefik
9
+ (section 4 line 115), so this host speaks plain HTTP (invariant #2).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+
16
+ from starlette.applications import Starlette
17
+ from starlette.responses import FileResponse, JSONResponse, Response
18
+ from starlette.routing import Mount
19
+ from starlette.staticfiles import StaticFiles
20
+
21
+ from meadows.web.build import build
22
+
23
+ # Resolved relative to this file so the app works regardless of CWD (container
24
+ # WORKDIR is /app; the package lives at /app/src/meadows/web).
25
+ _PACKAGE_DIR = Path(__file__).resolve().parent
26
+ _DEFAULT_INDEX = _PACKAGE_DIR / "dist" / "index.html"
27
+ _DEFAULT_STATIC = _PACKAGE_DIR / "static"
28
+
29
+
30
+ class WebHost:
31
+ """
32
+ BUSINESS RULE (MEADOWS §2 line 40): meadows-web is a dumb HTTP host.
33
+ It serves files. The browser is the Socket.IO client, connecting directly
34
+ to meadows-server. This Python webserver never touches Socket.IO. TLS is
35
+ terminated by Traefik (MEADOWS §4 line 115).
36
+
37
+ This class wires three routes and nothing more:
38
+ - `GET /` -> the built dist/index.html (template-injected by build.py)
39
+ - `/static/*` -> static assets, if any (404 when absent)
40
+ - anything else -> 404
41
+
42
+ No auth, no sockets, no domain logic. If dist/index.html is missing on
43
+ startup, the template is built once so the host is usable without a manual
44
+ build step — this is static-asset preparation, not domain logic.
45
+ """
46
+
47
+ def __init__(self, index_path: Path = _DEFAULT_INDEX, static_dir: Path = _DEFAULT_STATIC) -> None:
48
+ self.index_path = index_path
49
+ self.static_dir = static_dir
50
+
51
+ def _ensure_built(self) -> None:
52
+ """BUSINESS RULE: guarantee the served index.html exists before serving.
53
+
54
+ build.py is the single place that injects protocol constants (invariant
55
+ #3). If the dist file is absent (fresh checkout / container start without
56
+ a prior build), render it once from the template + env. This keeps the
57
+ host a pure file server at request time while still being runnable
58
+ standalone.
59
+ """
60
+ if not self.index_path.exists():
61
+ build()
62
+
63
+ def serve_index(self, request) -> Response:
64
+ """BUSINESS RULE: serve the pre-built index.html at `/`. Dumb file host —
65
+ no templating per request, no auth, no session. The page already carries
66
+ the injected protocol constants + server URL from build.py."""
67
+ del request # request is unused: this route is a fixed file response (dumb host).
68
+ self._ensure_built()
69
+ if not self.index_path.exists():
70
+ return Response("index.html not found", status_code=404)
71
+ return FileResponse(self.index_path, media_type="text/html")
72
+
73
+ def serve_status(self, request) -> Response:
74
+ """BUSINESS RULE (MEADOWS §2 line 40): meadows-web has no auth surface.
75
+ The monolith's checkAuthStatus() fetches '/status' to detect Auth0
76
+ sessions; in MEADOWS, auth is JWT-only via the socket handshake. We
77
+ return a fixed `{"logged_in": false}` so the client JS falls through
78
+ to the manual-JWT auth overlay without a console 404 error. This is
79
+ a compatibility shim for the copied template, not domain logic."""
80
+ del request # unused: fixed response, no per-request logic.
81
+ return JSONResponse({"logged_in": False})
82
+
83
+ def not_found(self, request) -> Response:
84
+ """BUSINESS RULE: anything that isn't `/` or `/static/*` is a 404. The
85
+ host serves only the chat page and static assets — no API surface."""
86
+ del request # unused: a dumb host has no per-request routing logic here.
87
+ return Response("Not found", status_code=404)
88
+
89
+ def create_app(self) -> Starlette:
90
+ """BUSINESS RULE: assemble the ASGI app. Two real routes + a 404 catch-all.
91
+ StaticFiles is mounted (directory created empty if missing so missing
92
+ assets 404 instead of crashing the mount)."""
93
+ self.static_dir.mkdir(parents=True, exist_ok=True)
94
+ self._ensure_built()
95
+
96
+ routes = [
97
+ Mount(
98
+ "/static",
99
+ app=StaticFiles(directory=str(self.static_dir), html=False),
100
+ name="static",
101
+ ),
102
+ ]
103
+ app = Starlette(routes=routes)
104
+ app.router.add_route("/", self.serve_index, methods=["GET"])
105
+ app.router.add_route("/status", self.serve_status, methods=["GET"])
106
+ app.router.add_route("/{path:path}", self.not_found, methods=["GET"])
107
+ return app
108
+
109
+
110
+ def create_app() -> Starlette:
111
+ """Module-level factory for `uvicorn meadows.web.app:app`."""
112
+ return WebHost().create_app()
113
+
114
+
115
+ # BUSINESS RULE: a module-level `app` so `uvicorn meadows.web.app:app` works
116
+ # without the caller invoking create_app() explicitly (matches the original
117
+ # monolith's `webchat` ASGI exposure pattern).
118
+ app = create_app()
119
+
120
+ __all__ = ["WebHost", "app", "create_app"]
@@ -0,0 +1,112 @@
1
+ """
2
+ BUSINESS RULE (MEADOWS §2 line 40): meadows-web injects protocol constants
3
+ into the template. The browser needs to know event names (e.g. "message",
4
+ "authenticate", "user_typing") to talk to meadows-server. Rather than
5
+ hardcoding string literals in JS (which is what the monolith did — see
6
+ MEADOWS-migration-intent.md §1 line 11, where the protocol "zat verstopt
7
+ in de handlers en in conventies"), we inject them from the single source of
8
+ truth: meadows.protocol.EventName.
9
+
10
+ This module is the ONLY place meadows-web touches meadows.protocol, and it
11
+ touches it ONLY for EventName. Per architecture invariant #3, it does not
12
+ import Message, JWTClaims, or anything else — the web host doesn't need them.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import json
19
+ from pathlib import Path
20
+
21
+ from meadows.protocol import EventName
22
+
23
+ # BUSINESS RULE (MEADOWS §2 line 40): the template is the one artifact this host
24
+ # shapes before serving. Paths are relative to this module so the build works
25
+ # whether invoked from the repo root or from `python -m meadows.web.build`.
26
+ _TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
27
+ TEMPLATE_PATH = _TEMPLATES_DIR / "index.html"
28
+ OUTPUT_PATH = Path(__file__).resolve().parent / "dist" / "index.html"
29
+
30
+
31
+ def get_protocol_constants() -> dict[str, str]:
32
+ """BUSINESS RULE (MEADOWS §2 line 40 + §3.1): expose the closed set of event
33
+ names to the browser as a JSON blob.
34
+
35
+ The protocol package declares EventName as the single source of truth for the
36
+ Socket.IO events the system contracts (meadows-protocol/src/meadows/protocol/
37
+ events.py). The browser JS must emit and listen for these exact names. We
38
+ serialize `{MEMBER_NAME: string_value}` so JS can reference
39
+ `window.MEADOWS_PROTOCOL.MESSAGE` etc. without hardcoding literals.
40
+
41
+ This is a pure declaration pull — no behavior is imported, keeping invariant
42
+ #3 (protocol constants only).
43
+ """
44
+ return {member.name: member.value for member in EventName}
45
+
46
+
47
+ def compute_hash(content: str) -> str:
48
+ """BUSINESS RULE: produce a cache-busting token for the served page.
49
+
50
+ The original monolith build.py (chat.openit.chat/webchat/build.py) computed a
51
+ SHA-256 prefix for the same purpose. We keep the 16-char hex prefix so CDN /
52
+ browser caches invalidate whenever the rendered content (and thus the injected
53
+ protocol constants or config) changes.
54
+ """
55
+ return hashlib.sha256(content.encode()).hexdigest()[:16]
56
+
57
+
58
+ def build(
59
+ *,
60
+ server_url: str | None = None,
61
+ system_name: str | None = None,
62
+ template_path: Path | None = None,
63
+ output_path: Path | None = None,
64
+ ) -> Path:
65
+ """BUSINESS RULE (MEADOWS §2 line 40): render templates/index.html into
66
+ dist/index.html with env config + protocol constants injected.
67
+
68
+ This is the meadows-web equivalent of the monolith's build.py — but the only
69
+ protocol knowledge it carries is EventName (invariant #3). The rendered file
70
+ is what app.py serves at `/`. TLS, auth and sockets are deliberately absent
71
+ (invariants #1 and #2); the browser uses the injected MEADOWS_SERVER_URL to
72
+ open its own Socket.IO connection straight to meadows-server.
73
+
74
+ Defaults read from the environment so `python -m meadows.web.build` works in
75
+ the container (env vars come from docker-compose / check_env in tasks.py).
76
+ Returns the path to the built file so callers (tests, CLI) can locate it.
77
+ """
78
+ import os
79
+
80
+ server_url = server_url if server_url is not None else os.environ.get("MEADOWS_SERVER_URL", "http://localhost:8080")
81
+ system_name = system_name if system_name is not None else os.environ.get("MEADOWS_SYSTEM_NAME", "MEADOWS Chat")
82
+
83
+ template_path = template_path if template_path is not None else TEMPLATE_PATH
84
+ output_path = output_path if output_path is not None else OUTPUT_PATH
85
+
86
+ if not template_path.exists():
87
+ raise FileNotFoundError(f"Template not found: {template_path}")
88
+
89
+ content = template_path.read_text(encoding="utf-8")
90
+
91
+ # Inject config + protocol constants (single source of truth = EventName).
92
+ protocol_json = json.dumps(get_protocol_constants(), indent=2, sort_keys=True)
93
+ content = content.replace("{{ MEADOWS_SERVER_URL }}", server_url)
94
+ content = content.replace("{{ MEADOWS_SYSTEM_NAME }}", system_name)
95
+ content = content.replace("{{ MEADOWS_PROTOCOL }}", protocol_json)
96
+
97
+ # Cache-busting hash: computed over the rendered content, then injected
98
+ # as a meta tag before </head> (matching the monolith's build.py pattern).
99
+ client_hash = compute_hash(content)
100
+ content = content.replace(
101
+ "</head>",
102
+ f'<meta name="meadows-hash" content="{client_hash}"></head>',
103
+ )
104
+
105
+ output_path.parent.mkdir(parents=True, exist_ok=True)
106
+ output_path.write_text(content, encoding="utf-8")
107
+ print(f"Built {output_path} (hash={client_hash})")
108
+ return output_path
109
+
110
+
111
+ if __name__ == "__main__":
112
+ build()