mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-21 20:31:21 +00:00
The runtime stylesheet moves to CONFIG_DIR. static/tailwind.min.css is never written again.
━━━ TWO DIFFERENT THINGS WERE SHARING ONE PATH ━━━
static/tailwind.min.css a BUILD ARTEFACT. Committed, image-baked, generated by scanning
the in-tree plugins only. CI's tailwind-fresh check verifies it.
the RUNTIME sheet PER-INSTALL STATE. Additionally scans whatever the user installed
into FEEDBACK_PLUGINS_DIR, so it differs machine to machine.
Writing the second over the first meant that MERELY RUNNING THE DEV SERVER from a git checkout
silently modified a tracked file. `git add -A` then swept a 100KB reshuffle of minified CSS
into the commit and ci/tailwind-fresh went red with a diff that explains nothing — on a PR
whose real change touched no Tailwind classes at all. It also wrote app state into the app
directory, which is read-only in some deploys.
A new route serves the runtime sheet when there is one and falls back to the committed one
otherwise. It is registered BEFORE the /static mount, which would otherwise swallow the path.
━━━ A PERSISTED SHEET MUST NOT OUTLIVE ITS REASON (Codex [P2] x2) ━━━
1. THE USER REMOVES THEIR PLUGINS. Startup only rebuilds when user plugins exist, so nothing
would ever overwrite the stale sheet — and it still carries classes for plugins that are
gone. With no user plugins the COMMITTED sheet is complete by definition. Guarded.
2. THE APP IS UPGRADED, and my first guard for this was WRONG. I compared mtimes. Codex: that
is not a freshness signal across install methods — archives and container images routinely
PRESERVE SOURCE MTIMES, so a just-shipped stylesheet can carry an OLDER timestamp than a
runtime sheet a user built days ago. The mtime check then calls the stale one FRESH and it
masks the new core CSS indefinitely — permanently, if no Tailwind toolchain is present to
trigger a rebuild.
Freshness is decided by CONTENT now. Each runtime build stamps a sidecar with the sha256 of
the committed sheet it was made from. Core ships new CSS -> that file changes -> the hash
changes -> the runtime sheet is correctly judged stale. Timestamps only gesture at the
question that hashing answers.
Falling back to the committed sheet is always safe: at worst it lacks a just-installed plugin's
classes for the seconds until the async rebuild lands.
VERIFIED END TO END. Ran the real dev server with 3 plugins installed: it rebuilt Tailwind over
them (123,291 bytes), wrote the sheet + sidecar to CONFIG_DIR, still served /static/
tailwind.min.css at 200 — and `git diff` on the tracked file came back CLEAN.
8 tests. Bite-tested: reverting to the shared path fails 3, dropping the staleness guards fails
2 more.
pytest 2425, pyflakes 0, Codex 0.
Closes #911
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
288 lines
11 KiB
Python
288 lines
11 KiB
Python
"""Regenerate the runtime stylesheet over the full installed-plugin set.
|
|
|
|
Core's committed (and image-baked) stylesheet is built scanning only the
|
|
in-tree plugins. A plugin installed at runtime — into ``FEEDBACK_PLUGINS_DIR``
|
|
— ships Tailwind classes the sheet never saw, so it renders unstyled. The
|
|
Play CDN's runtime JIT that used to cover this was removed (feedBack#411),
|
|
so we rebuild the sheet ourselves with node + the pinned ``tailwindcss``,
|
|
scanning the baked-in plugins *and* the user plugins dir.
|
|
|
|
Best-effort: a logged no-op (returns ``False``) when the toolchain or inputs
|
|
are absent — e.g. a native dev run with no node, or a desktop bundle that
|
|
already baked a complete sheet — so plugin install / startup never hard-fails
|
|
on a missing optional engine.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import threading
|
|
from pathlib import Path
|
|
|
|
from env_compat import getenv_compat
|
|
|
|
log = logging.getLogger("feedBack.tailwind")
|
|
|
|
# Pin matches scripts/build-tailwind.sh and the Dockerfile build stage so every
|
|
# sheet — committed, image-baked, and runtime-regenerated — comes from the same
|
|
# Tailwind 3.x.
|
|
_TAILWIND_VERSION = "3.4.19"
|
|
|
|
# Serialize rebuilds: concurrent installs (or install racing the startup scan)
|
|
# must not run the CLI against the same output file at once.
|
|
_lock = threading.Lock()
|
|
# Set by a trigger that arrives while a rebuild is already running, so the
|
|
# in-flight build re-runs once more to pick up the newer plugin set instead of
|
|
# every concurrent trigger stacking its own redundant build.
|
|
_rerun = threading.Event()
|
|
_fingerprint_cache: dict = {}
|
|
|
|
# lib/ lives at ``<app>/lib``; the app root (static/, tailwind.config.js) is its
|
|
# grandparent.
|
|
APP_DIR = Path(__file__).resolve().parent.parent
|
|
|
|
|
|
def _committed_css_fingerprint() -> str:
|
|
"""Content hash of the SHIPPED stylesheet, cached on (mtime, size).
|
|
|
|
This is the marker that says WHICH CORE the runtime sheet was built against. Any change to
|
|
core's CSS regenerates static/tailwind.min.css, which changes this hash.
|
|
"""
|
|
committed = APP_DIR / "static" / "tailwind.min.css"
|
|
try:
|
|
st = committed.stat()
|
|
except OSError:
|
|
return ""
|
|
key = (st.st_mtime_ns, st.st_size)
|
|
cached = _fingerprint_cache.get("k")
|
|
if cached == key:
|
|
return _fingerprint_cache["v"]
|
|
h = hashlib.sha256(committed.read_bytes()).hexdigest()
|
|
_fingerprint_cache["k"] = key
|
|
_fingerprint_cache["v"] = h
|
|
return h
|
|
|
|
|
|
def runtime_meta_path() -> Path:
|
|
"""Sidecar recording which core the runtime sheet was built against."""
|
|
return runtime_css_path().with_suffix(".meta.json")
|
|
|
|
|
|
def runtime_css_is_current() -> bool:
|
|
"""True when the runtime sheet was built against the core we are running NOW.
|
|
|
|
WHY NOT mtime. Codex [P2] on the second cut of #911, and it was right: filesystem
|
|
timestamps are not a freshness signal across install methods. Archives and container images
|
|
routinely PRESERVE SOURCE MTIMES, so a just-shipped stylesheet can carry an OLDER mtime than
|
|
a runtime sheet a user built days ago. The mtime comparison then reports the stale sheet as
|
|
fresh and it masks the new core CSS indefinitely — permanently, if no Tailwind toolchain is
|
|
present to trigger a rebuild.
|
|
|
|
Content answers the question timestamps only gesture at: the sidecar records the hash of the
|
|
committed sheet this runtime build was made from. Core ships new CSS -> that file changes ->
|
|
the hash changes -> the runtime sheet is correctly judged stale.
|
|
"""
|
|
try:
|
|
meta = json.loads(runtime_meta_path().read_text())
|
|
except (OSError, ValueError):
|
|
return False
|
|
return bool(meta.get("committed_sha256")) and meta["committed_sha256"] == _committed_css_fingerprint()
|
|
|
|
|
|
def runtime_css_path() -> Path:
|
|
"""Where the RUNTIME-augmented stylesheet is written.
|
|
|
|
NOT ``static/tailwind.min.css``. That file is a BUILD ARTEFACT: committed, image-baked,
|
|
and generated by scanning only the in-tree plugins. This one is PER-INSTALL STATE — it
|
|
additionally scans whatever the user has installed into FEEDBACK_PLUGINS_DIR, so it differs
|
|
from machine to machine. They are different things and must not share a path.
|
|
|
|
Writing the runtime sheet over the committed one had two costs:
|
|
|
|
* IN A GIT CHECKOUT it silently modifies a TRACKED file. `git add -A` then sweeps a
|
|
100KB reshuffle of minified CSS into the commit and `ci/tailwind-fresh` goes red with a
|
|
diff that explains nothing. That is issue #911, and it cost a red run on a PR whose
|
|
real diff touched no Tailwind classes at all.
|
|
* IN A DEPLOY the app directory may be read-only. Writing app state into it is wrong on
|
|
principle and fatal in practice.
|
|
|
|
CONFIG_DIR is where per-install state already lives.
|
|
"""
|
|
cfg = (getenv_compat("CONFIG_DIR", "") or "").strip()
|
|
base = Path(cfg) if cfg else (Path.home() / ".local" / "share" / "feedback")
|
|
return base / "tailwind.min.css"
|
|
|
|
|
|
def _user_plugins_dir() -> Path | None:
|
|
raw = (getenv_compat("FEEDBACK_PLUGINS_DIR", "") or "").strip()
|
|
if not raw:
|
|
return None
|
|
p = Path(raw)
|
|
return p if p.is_dir() else None
|
|
|
|
|
|
def user_plugin_count() -> int:
|
|
"""Number of installed plugins in the runtime user plugins dir (0 if unset).
|
|
|
|
Counts only directories that contain a ``plugin.json`` — so stray caches/tmp
|
|
dirs (which have none) don't trigger rebuilds.
|
|
"""
|
|
d = _user_plugins_dir()
|
|
if not d:
|
|
return 0
|
|
return sum(1 for p in d.iterdir() if p.is_dir() and (p / "plugin.json").is_file())
|
|
|
|
|
|
def _tailwind_cmd() -> list[str] | None:
|
|
"""Prefer a globally-installed ``tailwindcss`` (offline, no fetch); fall back
|
|
to ``npx`` which resolves/fetches the pinned version on demand."""
|
|
exe = shutil.which("tailwindcss")
|
|
if exe:
|
|
return [exe]
|
|
npx = shutil.which("npx")
|
|
if npx:
|
|
return [npx, "-y", f"tailwindcss@{_TAILWIND_VERSION}"]
|
|
return None
|
|
|
|
|
|
def can_rebuild() -> bool:
|
|
return (
|
|
_tailwind_cmd() is not None
|
|
and (APP_DIR / "tailwind.config.js").is_file()
|
|
and (APP_DIR / "static" / "_tailwind.src.css").is_file()
|
|
)
|
|
|
|
|
|
def _write_runtime_config(tmpdir: Path) -> Path:
|
|
"""Wrapper config that reuses the base theme/safelist/exclusions but widens
|
|
``content`` to absolute paths covering the user plugins dir as well."""
|
|
base_cfg = APP_DIR / "tailwind.config.js"
|
|
# Use forward-slash (POSIX) globs/paths: Tailwind's fast-glob matcher needs
|
|
# forward slashes even on Windows, and node `require()` accepts them too.
|
|
content = [
|
|
(APP_DIR / "static" / "**" / "*.{html,js}").as_posix(),
|
|
(APP_DIR / "plugins" / "**" / "*.{js,html}").as_posix(),
|
|
]
|
|
user = _user_plugins_dir()
|
|
if user:
|
|
content.append((user / "**" / "*.{js,html}").as_posix())
|
|
# Exclude a user-installed highway_3d too (it ships its own sheet).
|
|
content.append("!" + (user / "highway_3d" / "**").as_posix())
|
|
# highway_3d ships its own sheet via the `styles` capability — keep it out
|
|
# of the core sheet, mirroring tailwind.config.js.
|
|
content.append("!" + (APP_DIR / "plugins" / "highway_3d" / "**").as_posix())
|
|
cfg = tmpdir / "tailwind.runtime.config.js"
|
|
cfg_js = (
|
|
"const base = require({base});\n"
|
|
"base.content = {content};\n"
|
|
"module.exports = base;\n"
|
|
).format(
|
|
base=json.dumps(base_cfg.as_posix()),
|
|
content=json.dumps(content),
|
|
)
|
|
cfg.write_text(cfg_js)
|
|
return cfg
|
|
|
|
|
|
def _run_build(cmd_prefix: list[str], out: Path, src: Path) -> bool:
|
|
"""Run one Tailwind build over the current plugin set. Never raises."""
|
|
with tempfile.TemporaryDirectory() as td:
|
|
cfg = _write_runtime_config(Path(td))
|
|
# Stage the output next to the live sheet so the final swap is an
|
|
# atomic same-filesystem os.replace (a reader never sees a partial).
|
|
staged = out.with_name(f".tailwind.min.css.{os.getpid()}.tmp")
|
|
cmd = cmd_prefix + [
|
|
"-c", str(cfg),
|
|
"-i", str(src),
|
|
"-o", str(staged),
|
|
"--minify",
|
|
]
|
|
try:
|
|
subprocess.run(
|
|
cmd, check=True, capture_output=True, text=True,
|
|
cwd=str(APP_DIR), timeout=120,
|
|
)
|
|
os.replace(staged, out)
|
|
# Stamp WHICH CORE this was built against. Without it, an upgraded app cannot tell a
|
|
# current runtime sheet from one that predates its new CSS.
|
|
try:
|
|
runtime_meta_path().write_text(json.dumps({
|
|
"committed_sha256": _committed_css_fingerprint(),
|
|
}))
|
|
except OSError:
|
|
log.warning("tailwind: could not write the runtime sheet's meta sidecar")
|
|
return True
|
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
|
stderr = (getattr(e, "stderr", "") or "")[-500:]
|
|
log.warning("tailwind rebuild failed: %s", stderr)
|
|
return False
|
|
except Exception:
|
|
log.exception("tailwind rebuild errored")
|
|
return False
|
|
finally:
|
|
if staged.exists():
|
|
try:
|
|
staged.unlink()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def rebuild(reason: str = "") -> bool:
|
|
"""Regenerate the RUNTIME stylesheet (see runtime_css_path) over baked-in + user plugins.
|
|
|
|
Returns ``True`` on a successful rebuild, ``False`` on any skip/failure.
|
|
Never raises — callers treat CSS freshness as best-effort. Concurrent
|
|
triggers are coalesced: only one build runs at a time, and triggers that
|
|
arrive mid-build cause a single extra rerun rather than stacking builds.
|
|
"""
|
|
tag = f" [{reason}]" if reason else ""
|
|
cmd_prefix = _tailwind_cmd()
|
|
if cmd_prefix is None or not can_rebuild():
|
|
log.info("tailwind rebuild skipped — engine/inputs unavailable%s", tag)
|
|
return False
|
|
|
|
out = runtime_css_path()
|
|
src = APP_DIR / "static" / "_tailwind.src.css"
|
|
try:
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
except OSError:
|
|
log.warning("tailwind rebuild skipped — cannot create %s%s", out.parent, tag)
|
|
return False
|
|
|
|
# If a rebuild is already running, flag a rerun and return instead of
|
|
# queueing a redundant build behind it.
|
|
if not _lock.acquire(blocking=False):
|
|
_rerun.set()
|
|
log.info("tailwind rebuild already running — coalesced%s", tag)
|
|
return False
|
|
|
|
ok = False
|
|
try:
|
|
while True:
|
|
_rerun.clear()
|
|
ok = _run_build(cmd_prefix, out, src)
|
|
# A trigger arrived while we were building — run once more to pick
|
|
# up the newer plugin set, then stop.
|
|
if not _rerun.is_set():
|
|
break
|
|
finally:
|
|
_lock.release()
|
|
|
|
if not ok:
|
|
return False
|
|
|
|
# Guard the stat so the "never raises" contract holds even if the freshly
|
|
# written sheet is somehow not stat-able (odd FS / external cleanup).
|
|
try:
|
|
size = out.stat().st_size
|
|
except OSError:
|
|
size = -1
|
|
log.info("tailwind rebuilt over installed plugins%s (%d bytes)", tag, size)
|
|
return True
|