Skip to content

Commit e50a583

Browse files
committed
fix(#1468): fingerprint framework runtime bundle + no-cache serving safety net
The page-runtime bundle (/static/dist/dazzle.min.{js,css}) was emitted non-fingerprinted with a multi-hour max-age, so returning users ran the cached OLD bundle for ~4h after a deploy — the #1465 dzTable crash fix "didn't take" until the cache expired. Fix (two layers): 1. build_app_chrome content-hashes the bundle URLs (dazzle.min.<hash>.js) in production/staging — immutable + instant propagation — for the dominant app/workspace/page path and every site reading app.state.fragment_chrome_*. Gated by asset_fingerprint.should_fingerprint() (mirrors should_bundle_assets: on in prod/staging, off in dev/test and under [ui] active_development), so dev/test keep plain URLs (stable assertions; the existing 18 literal-URL tests are unaffected). 2. CombinedStaticFiles serves any /static/dist/* requested at its plain (non-fingerprinted) URL no-cache — a safety net so emission sites that hardcode the path (auth/profile/signing pages) still propagate fixes immediately instead of staling. Fingerprinted requests stay immutable. Adds [ui] active_development (no-cache iteration opt-out) and [ui] static_max_age (cache duration for non-fingerprinted non-bundle assets) — the "mature site vs active development" distinction. Adversarial review caught that fingerprinting alone missed the hardcoded emission sites; the serving-layer safety net covers them in one place. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6c52129 commit e50a583

13 files changed

Lines changed: 374 additions & 17 deletions

File tree

.claude/CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -374,4 +374,4 @@ Example: `examples/ops_dashboard` has working `bar_chart` (FK `group_by: system`
374374
- **KG re-seeding**: `ensure_seeded()` checks a version key; bump it in `seed.py` when TOML data changes.
375375

376376
---
377-
**Version**: 0.86.15 | **Python**: 3.12+ | **Status**: Production Ready
377+
**Version**: 0.86.16 | **Python**: 3.12+ | **Status**: Production Ready

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
## [Unreleased]
1111

12+
## [0.86.16] - 2026-06-24
13+
14+
### Fixed
15+
- **#1468 framework runtime bundle no longer serves stale to returning users after a deploy.** `/static/dist/dazzle.min.{js,css}` (+ `dazzle-icons.min.js`) was emitted non-fingerprinted with a multi-hour `max-age`, so after a deploy returning visitors kept running the cached old bundle for up to ~4h — a JS *crash* fix (e.g. #1465) "didn't take" until the cache expired. Two-layer fix: **(1)** `build_app_chrome` now content-hashes the framework bundle URLs (`dazzle.min.<hash>.js`) in production/staging — immutable long cache + instant propagation — for the dominant app/workspace/page path (and every site that reads `app.state.fragment_chrome_*`); **(2)** a serving-layer safety net in `CombinedStaticFiles` serves any `/static/dist/*` requested at its plain URL `no-cache`, so emission sites that hardcode the path (auth/profile/signing pages) still propagate fixes immediately rather than staling for hours.
16+
17+
### Added
18+
- **`[ui] active_development` and `[ui] static_max_age` (dazzle.toml).** `active_development = true` opts a deployed site out of fingerprinting/immutable caching in favour of `no-cache` revalidation (fast iteration on a staging/dev deploy). `static_max_age = <seconds>` configures the cache duration for non-fingerprinted, non-bundle static assets (default 1h; fingerprinted assets are always immutable). Realises the "mature site vs site under active development" distinction.
19+
20+
### Agent Guidance
21+
- **Fingerprinting is env-gated.** `dazzle.page.runtime.asset_fingerprint.should_fingerprint()` is on only in `DAZZLE_ENV=production`/`staging` and off under `[ui] active_development` — so dev/test see plain `/static/dist/...` URLs (stable assertions). New framework HTML that emits a `/static/dist/*` URL should route it through `fingerprint_static_url()` for the immutable optimization; if it can't, the `no-cache` serving safety net still guarantees correctness.
22+
1223
## [0.86.15] - 2026-06-24
1324

1425
### Fixed

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# DAZZLE Development Roadmap
22

33
**Last Updated**: 2026-06-16
4-
**Current Version**: v0.86.15
4+
**Current Version**: v0.86.16
55

66
For past releases, see [CHANGELOG.md](CHANGELOG.md).
77

homebrew/dazzle.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ class Dazzle < Formula
1010

1111
desc "DSL-first application framework with LLM-assisted development"
1212
homepage "https://github.com/manwithacat/dazzle"
13-
version "0.86.15"
13+
version "0.86.16"
1414
license "MIT"
1515

16-
url "https://github.com/manwithacat/dazzle/archive/refs/tags/v0.86.15.tar.gz"
16+
url "https://github.com/manwithacat/dazzle/archive/refs/tags/v0.86.16.tar.gz"
1717
sha256 "PLACEHOLDER_SOURCE_SHA256"
1818

1919
# pydantic-core requires Rust to build from source, so use pre-built wheels

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "dazzle-dsl"
7-
version = "0.86.15"
7+
version = "0.86.16"
88
description = "DAZZLE — declarative SaaS framework with built-in compliance (SOC 2, ISO 27001), provable RBAC, and graph features"
99
readme = "README.md"
1010
requires-python = ">=3.12"

src/dazzle/core/manifest.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,18 @@ class ProjectManifest:
661661
signing: SigningConfig = field(default_factory=SigningConfig) # #1283 phase 8
662662
framework_version: str | None = None
663663
cdn: bool = False # Local-first; opt-in via [ui] cdn = true in dazzle.toml
664+
# #1468: a site under active development can opt out of content-hash
665+
# fingerprinting + immutable caching of the framework runtime bundle.
666+
# When true, asset URLs stay plain (`/static/dist/dazzle.min.js`) and
667+
# `/static/dist/*` is served `Cache-Control: no-cache` so every rebuilt
668+
# bundle is picked up on the next load. Default false = mature-site
669+
# behaviour: fingerprinted URLs + immutable long cache in production.
670+
# Set via `[ui] active_development = true` in dazzle.toml.
671+
active_development: bool = False
672+
# Cache duration (seconds) for NON-fingerprinted static assets in
673+
# production. Fingerprinted assets are always immutable/1yr regardless.
674+
# None = framework default (1h). Set via `[ui] static_max_age = 300`.
675+
static_max_age: int | None = None
664676
# Asset bundling mode. Resolved at request time by `should_bundle_assets()`:
665677
# "auto" = bundle when DAZZLE_ENV=production, individual scripts in dev (default)
666678
# "always" = bundle in every environment (perf testing / staging)
@@ -1034,6 +1046,14 @@ def load_manifest(path: Path) -> ProjectManifest:
10341046
assets_mode = ui_data.get("assets", "auto")
10351047
if assets_mode not in ("auto", "always", "never"):
10361048
raise ValueError(f"[ui] assets must be 'auto', 'always', or 'never'; got {assets_mode!r}")
1049+
active_development_enabled = bool(ui_data.get("active_development", False))
1050+
static_max_age_value = ui_data.get("static_max_age")
1051+
if static_max_age_value is not None and (
1052+
not isinstance(static_max_age_value, int) or static_max_age_value < 0
1053+
):
1054+
raise ValueError(
1055+
f"[ui] static_max_age must be a non-negative integer; got {static_max_age_value!r}"
1056+
)
10371057

10381058
# Parse [extensions] section (#786)
10391059
extensions_data = data.get("extensions", {})
@@ -1127,6 +1147,8 @@ def load_manifest(path: Path) -> ProjectManifest:
11271147
signing=signing_config,
11281148
framework_version=project.get("framework_version"),
11291149
cdn=cdn_enabled,
1150+
active_development=active_development_enabled,
1151+
static_max_age=static_max_age_value,
11301152
assets=assets_mode,
11311153
favicon=favicon_path,
11321154
app_theme=app_theme_name,

src/dazzle/http/runtime/static_files.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,19 @@ class CombinedStaticFiles(StaticFiles):
4545
the original file and served with ``Cache-Control: immutable``.
4646
"""
4747

48-
def __init__(self, directories: list[Path], **kwargs: Any) -> None:
48+
def __init__(
49+
self,
50+
directories: list[Path],
51+
*,
52+
default_max_age: int | None = None,
53+
active_development: bool = False,
54+
**kwargs: Any,
55+
) -> None:
4956
self._extra_dirs = [d for d in directories[:-1] if d.is_dir()]
57+
# #1468: cache policy for NON-fingerprinted assets. Fingerprinted
58+
# (content-hashed) assets are always immutable regardless of these.
59+
self._default_max_age = _DEFAULT_MAX_AGE if default_max_age is None else default_max_age
60+
self._active_development = active_development
5061
primary = directories[-1] if directories else Path(".")
5162
super().__init__(directory=str(primary), **kwargs)
5263

@@ -96,10 +107,28 @@ def file_response(
96107
is_fingerprinted = bool(FINGERPRINT_RE.search(os.path.basename(request_path)))
97108

98109
ext = os.path.splitext(str(full_path))[1].lower()
99-
if is_fingerprinted or ext in _IMMUTABLE_EXTENSIONS:
110+
if self._active_development:
111+
# #1468: a site under active development serves everything
112+
# no-cache so each rebuild is picked up on the next load. The
113+
# content hash (when present) still guarantees byte-correctness;
114+
# no-cache just forces ETag revalidation → 304 when unchanged.
115+
# Checked first so even a stale fingerprinted URL from a prior
116+
# deploy revalidates instead of staying immutable.
117+
response.headers["Cache-Control"] = "no-cache"
118+
elif is_fingerprinted or ext in _IMMUTABLE_EXTENSIONS:
100119
response.headers["Cache-Control"] = (
101120
f"public, max-age={_IMMUTABLE_MAX_AGE}, immutable"
102121
)
122+
elif "/dist/" in request_path:
123+
# #1468 safety net: the framework runtime bundle requested at its
124+
# plain (non-fingerprinted) URL — i.e. from an HTML emission site
125+
# that hardcodes `/static/dist/...` instead of reading the
126+
# fingerprinted app-chrome URLs — must revalidate every load so a
127+
# deploy's JS/CSS fix is never served stale for hours. The
128+
# fingerprinted emissions (the dominant app-page path) take the
129+
# immutable branch above; this guarantees correctness for any
130+
# emission site that isn't (yet) wired to fingerprint.
131+
response.headers["Cache-Control"] = "no-cache"
103132
else:
104-
response.headers["Cache-Control"] = f"public, max-age={_DEFAULT_MAX_AGE}"
133+
response.headers["Cache-Control"] = f"public, max-age={self._default_max_age}"
105134
return response

src/dazzle/http/runtime/subsystems/system_routes.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ def _mount_static_files(
2828
*,
2929
project_root: Any = None,
3030
extra_static_dirs: Any = None,
31+
active_development: bool = False,
32+
static_max_age: int | None = None,
3133
) -> None:
3234
"""Mount the framework + project static file routes on ``app``.
3335
@@ -78,7 +80,15 @@ def _mount_static_files(
7880
name="project_themes",
7981
)
8082

81-
app.mount("/static", CombinedStaticFiles(directories=dirs), name="static")
83+
app.mount(
84+
"/static",
85+
CombinedStaticFiles(
86+
directories=dirs,
87+
default_max_age=static_max_age,
88+
active_development=active_development,
89+
),
90+
name="static",
91+
)
8292

8393

8494
class SystemRoutesSubsystem:
@@ -690,6 +700,11 @@ async def diagnostics(
690700
# sites read `app.state.fragment_chrome` and thread the
691701
# css_links / js_scripts / theme / font_preconnect kwargs into
692702
# `dispatch_render_page`.
703+
# #1468: static-cache policy from [ui], resolved here and applied at the
704+
# mount below. Defaults are mature-site behaviour (immutable fingerprints
705+
# + framework default max-age for non-fingerprinted assets).
706+
static_active_development = False
707+
static_max_age: int | None = None
693708
try:
694709
from pathlib import Path
695710

@@ -700,6 +715,9 @@ async def diagnostics(
700715
manifest_path = Path(ctx.project_root) / "dazzle.toml"
701716
if manifest_path.exists():
702717
mf = load_manifest(manifest_path)
718+
if mf is not None:
719+
static_active_development = bool(getattr(mf, "active_development", False))
720+
static_max_age = getattr(mf, "static_max_age", None)
703721

704722
chrome = resolve_app_chrome(
705723
appspec,
@@ -726,6 +744,8 @@ async def diagnostics(
726744
ctx.app,
727745
project_root=ctx.project_root,
728746
extra_static_dirs=ctx.extra_static_dirs,
747+
active_development=static_active_development,
748+
static_max_age=static_max_age,
729749
)
730750
except ImportError:
731751
pass # dazzle_page not installed — static files served externally

src/dazzle/mcp/semantics_kb/core.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
[meta]
55
category = "Core Constructs"
6-
version = "0.86.15"
6+
version = "0.86.16"
77

88
[concepts.entity]
99
category = "Core Construct"

src/dazzle/page/runtime/app_chrome.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from typing import Any
2424

2525
from dazzle.core import ir
26+
from dazzle.page.runtime.asset_fingerprint import fingerprint_static_url
2627

2728
logger = logging.getLogger(__name__)
2829

@@ -153,11 +154,13 @@ def resolve_app_chrome(
153154
# Manifest-level config (favicon, cdn toggle).
154155
favicon = _DEFAULT_FAVICON
155156
use_cdn = False
157+
active_development = False
156158
manifest_theme: str | None = None
157159
if manifest is not None:
158160
if getattr(manifest, "favicon", None):
159161
favicon = str(manifest.favicon)
160162
use_cdn = bool(getattr(manifest, "cdn", False))
163+
active_development = bool(getattr(manifest, "active_development", False))
161164
manifest_theme = getattr(manifest, "app_theme", None) or None
162165

163166
# Theme name: env > DSL > manifest > None.
@@ -232,6 +235,17 @@ def resolve_app_chrome(
232235
if appspec is not None and getattr(appspec, "guides", None):
233236
js_scripts.append("/static/js/dz-onboarding.js")
234237

238+
# #1468: content-hash the framework bundle URLs (prod/staging only) so a
239+
# deploy's JS/CSS fixes reach returning visitors immediately instead of
240+
# after the cached bundle's max-age. No-op in dev/test/active-development.
241+
css_links = [
242+
fingerprint_static_url(u, active_development=active_development) for u in css_links
243+
]
244+
js_scripts = [
245+
fingerprint_static_url(u, active_development=active_development) for u in js_scripts
246+
]
247+
favicon = fingerprint_static_url(favicon, active_development=active_development)
248+
235249
return AppChrome(
236250
css_links=tuple(css_links),
237251
js_scripts=tuple(js_scripts),

0 commit comments

Comments
 (0)