Skip to main content

max / makenotwork

Release mnw-server v0.11.18: fingerprint the module directory 0.11.17's no-cache on /static/dist was correct and insufficient: it changes what the CDN does from now on, and does nothing about the object it already holds under the old week-long policy. testnot kept serving the stale dispatch.js and the page still had no JavaScript. The hash moves from the query to the DIRECTORY. A relative import resolves against the document URL, so /static/dist-<v>/core/index.js asking for './dispatch.js' gets /static/dist-<v>/core/dispatch.js for free: every member of the graph moves together by construction, members from two deploys can never meet, and a stale edge object is unreachable because the URL is new. The long cache comes back with it and is now honest -- these URLs really are immutable, since the next deploy has a different directory. Two references had to change, the shell's core script and build.rs's island macro, both already deriving from STATIC_VERSION. The unversioned path stays, revalidating, for a browser still holding a cached document that names it.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-14 20:20 UTC
Signed with PGP, not checked
Commit: 32efbeb41f8bbcb3f770a596cace01294388a34d
Parent: 8210910
6 files changed, +56 insertions, -37 deletions
M server/Cargo.lock +13 -13
@@ -5196,7 +5196,7 @@
5196 5196
5197 5197 [[package]]
5198 5198 name = "makenotwork"
5199 - version = "0.11.17"
5199 + version = "0.11.18"
5200 5200 dependencies = [
5201 5201 "ammonia",
5202 5202 "anyhow",
@@ -10688,16 +10688,12 @@
10688 10688 ]
10689 10689
10690 10690 [[patch.unused]]
10691 - name = "quasi-immediate"
10692 - version = "0.3.0"
10691 + name = "synckit-client"
10692 + version = "0.8.0"
10693 10693
10694 10694 [[patch.unused]]
10695 - name = "quasi-store"
10696 - version = "0.1.0"
10697 -
10698 - [[patch.unused]]
10699 - name = "quasi-tauri"
10700 - version = "0.3.0"
10695 + name = "synckit-config"
10696 + version = "0.2.0"
10701 10697
10702 10698 [[patch.unused]]
10703 10699 name = "kberg"
@@ -10712,9 +10708,13 @@
10712 10708 version = "0.1.0"
10713 10709
10714 10710 [[patch.unused]]
10715 - name = "synckit-client"
10716 - version = "0.8.0"
10711 + name = "quasi-immediate"
10712 + version = "0.3.0"
10717 10713
10718 10714 [[patch.unused]]
10719 - name = "synckit-config"
10720 - version = "0.2.0"
10715 + name = "quasi-store"
10716 + version = "0.1.0"
10717 +
10718 + [[patch.unused]]
10719 + name = "quasi-tauri"
10720 + version = "0.3.0"
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.11.17"
3 + version = "0.11.18"
4 4 edition = "2024"
5 5 license = "LicenseRef-PolyForm-Noncommercial-1.0.0"
6 6 # Server binary: never published to a registry. Marks the crate private so
M server/build.rs +1 -1
@@ -124,7 +124,7 @@
124 124 // `{% import "_island.html" as island %}{% call island::island("name") %}`,
125 125 // cache-busted by the same content hash as the head assets.
126 126 let island_partial = r#"{% macro island(name) -%}
127 - <script type="module" src="/static/dist/islands/{{ name }}.js?v=__VER__"></script>
127 + <script type="module" src="/static/dist-__VER__/islands/{{ name }}.js"></script>
128 128 {%- endmacro %}
129 129 "#
130 130 .replace("__VER__", version);
@@ -6,7 +6,7 @@
6 6 "license": {
7 7 "name": "PolyForm Noncommercial 1.0.0"
8 8 },
9 - "version": "0.11.17"
9 + "version": "0.11.18"
10 10 },
11 11 "paths": {
12 12 "/api/git/{owner}/{repo}/notes": {
M server/src/lib.rs +39 -20
@@ -591,29 +591,48 @@
591 591 "/api-docs/openapi.json",
592 592 <openapi::ApiDoc as utoipa::OpenApi>::openapi(),
593 593 ))
594 - // The ES module graph, which cannot use the `?v=` fingerprint.
594 + // The ES module graph, served under a fingerprinted DIRECTORY.
595 595 //
596 - // Every other static asset is referenced from a template with
597 - // `?v=<content hash>`, so a week-long cache is safe: a deploy changes
598 - // the URL. A module graph is the exception and it bit on 2026-08-14.
599 - // The templates fingerprint the ENTRY point (`core/index.js?v=...`),
600 - // but the entry's own `import './dispatch.js'` is a bare relative URL
601 - // that a deploy never changes. Cloudflare kept serving a seven-day-old
602 - // `dispatch.js` beside a fresh `index.js`, the two disagreed about an
603 - // export name, and the whole bundle died with a SyntaxError -- which
604 - // takes every island on the page with it, since `core/index.ts`
605 - // side-effect-imports all of them.
596 + // Every other static asset is referenced from a template as
597 + // `path?v=<hash>`, so a week-long cache is safe: a deploy changes the
598 + // URL. A module graph is the exception, and it took the whole site's
599 + // JavaScript down on 2026-08-14. Only the ENTRY point is named by a
600 + // template; the entry's own `import './dispatch.js'` is a bare relative
601 + // URL that a deploy never changes. Cloudflare went on serving a
602 + // week-old `dispatch.js` beside a fresh `index.js`, the two disagreed
603 + // about an export name, and the graph failed to link with a
604 + // SyntaxError. That takes down every island on the page, because
605 + // `core/index.ts` side-effect-imports all of them.
606 606 //
607 - // `no-cache` is "cache it, revalidate every time", not "do not cache".
608 - // These files are a few KB each and carry an ETag, so the common answer
609 - // is a 304 with no body. That is the right trade for a graph whose
610 - // members must agree with each other.
607 + // Putting the hash in the directory rather than in a query is what
608 + // fixes it, and it fixes it for imports nobody has written yet: a
609 + // relative import resolves against the document URL, so
610 + // `/static/dist-ab12/core/index.js` asking for `./dispatch.js` gets
611 + // `/static/dist-ab12/core/dispatch.js` for free. Every member of the
612 + // graph moves together, by construction, and members from two different
613 + // deploys can never meet.
611 614 //
612 - // The alternative considered was serving the whole directory under a
613 - // fingerprinted path (`/static/dist-<hash>/...`), which relative
614 - // imports would inherit for free and which keeps the long cache. It is
615 - // the better answer and it is a build.rs change; filed rather than done
616 - // here, because this had a page down.
615 + // The long cache comes back with it, and is now honest: these URLs are
616 + // immutable, because the next deploy has a different directory.
617 + //
618 + // `?v=` was tried first and is not enough. A middle attempt set
619 + // `no-cache` on `/static/dist`, which is correct but gives up edge
620 + // caching on the JS and, more to the point, does nothing about an
621 + // object the CDN already holds under the old policy.
622 + .nest_service(
623 + concat!("/static/dist-", env!("STATIC_VERSION")),
624 + tower::ServiceBuilder::new()
625 + .layer(SetResponseHeaderLayer::overriding(
626 + axum::http::header::CACHE_CONTROL,
627 + HeaderValue::from_static("public, max-age=604800, immutable"),
628 + ))
629 + .service(ServeDir::new("static/dist")),
630 + )
631 + // The unversioned path stays, and revalidates. Nothing this build emits
632 + // points at it; it is here for a page a browser is still holding from
633 + // before the versioned directory existed, whose cached entry document
634 + // still names `/static/dist/...`. Remove it once no such document can
635 + // be in flight.
617 636 .nest_service(
618 637 "/static/dist",
619 638 tower::ServiceBuilder::new()
@@ -65,7 +65,7 @@
65 65 .with_head(format!(
66 66 "<link rel=\"icon\" href=\"/static/images/favicon.ico\" type=\"image/x-icon\">\
67 67 <script src=\"/static/upload.js?v={V}\"></script>\
68 - <script type=\"module\" src=\"/static/dist/core/index.js?v={V}\"></script>"
68 + <script type=\"module\" src=\"/static/dist-{V}/core/index.js\"></script>"
69 69 ))
70 70 .parts()
71 71 })