Public: anyone can read this; the crew can change it.

A fragment's tag rides in its markup too: Cloudflare strips etag from HTML #324

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

fragment-tag-in-markup → main 1 commit

  • Merged. fragment-tag-in-markup was merged into main as f30d14c.

Review

Sign in to review this change.
4 files changed +24 −7
docs/STUDIO.md +4 −2
@@ -327,8 +327,10 @@ everything the viewer may see) and works like this:
327327 fetch of the page; that is the fallback, not the design.
328328 - **Nothing painted that did not change.** A fragment's `etag` is a hash of
329329 its bytes; the client sends it back as `if-none-match` and an unchanged
330 region is a 304 with no body. When bytes do arrive, two renders that
331 differ only in a relative time ("3m ago") are the same region.
330 region is a 304 with no body. The same tag is stamped on the fragment's
331 first element (`data-etag`), because a proxy may drop the header —
332 Cloudflare strips `etag` from HTML. When bytes do arrive, two renders
333 that differ only in a relative time ("3m ago") are the same region.
332334 - **One fetch per burst.** Events within a beat of each other cost one
333335 fetch per address, never more often than every second and a half; a
334336 hidden tab fetches nothing and catches up on the first look back.
src/web/client.ts +4 −1
@@ -2077,9 +2077,12 @@ export const liveRegionsJs = /* js */ `
20772077 if (text === null) return;
20782078 if (own) {
20792079 // A fragment's icons come inlined (layout.fragment), so it needs
2080 // nothing from this page's sprite.
2080 // nothing from this page's sprite. Its tag is stamped on it too,
2081 // for when a proxy between us dropped the header.
20812082 var holder = document.createElement('template');
20822083 holder.innerHTML = text;
2084 var stamped = holder.content.querySelector('[data-etag]');
2085 if (stamped) tags[src] = '"' + stamped.getAttribute('data-etag') + '"';
20832086 swap(holder.content, ids);
20842087 return;
20852088 }
src/web/layout.ts +13 −4
@@ -646,6 +646,12 @@ export function redirect(
646646 * paint. `cache-control: no-store` keeps the browser's own cache out of it —
647647 * the live client holds the tag itself and asks conditionally on purpose,
648648 * so an unchanged region costs one round trip and no repaint.
649 *
650 * The same tag is stamped on the fragment's first element as `data-etag`,
651 * because a proxy in between may drop the header: Cloudflare strips `etag`
652 * from HTML it reserves the right to rewrite, and did on the live forge.
653 * The hash is of the unstamped body, so the server recomputes it the same
654 * way next time; the request header is what it compares.
649655 */
650656 export function fragment(
651657 body: Html,
@@ -661,11 +667,14 @@ export function fragment(
661667 "cache-control": "no-store",
662668 };
663669 if (status !== 200) return new Response(text, { status, headers });
664 const etag = `"${sha256(text).slice(0, 16)}"`;
665 headers.etag = etag;
666 if (o.request?.headers.get("if-none-match") === etag)
670 const hash = sha256(text).slice(0, 16);
671 headers.etag = `"${hash}"`;
672 if (o.request?.headers.get("if-none-match") === headers.etag)
667673 return new Response(null, { status: 304, headers });
668 return new Response(text, { status, headers });
674 return new Response(
675 text.replace(/^(\s*<[a-zA-Z][^\s>]*)/, `$1 data-etag="${hash}"`),
676 { status, headers },
677 );
669678 }
670679
671680 /** Redirect back to a page carrying a message — the shape most handlers want. */
test/studio.test.ts +3
@@ -2683,6 +2683,9 @@ describe("the app's live fragment: /:owner/:name/app/state", () => {
26832683 const etag = first.headers.get("etag");
26842684 expect(etag).toMatch(/^"[0-9a-f]{16}"$/);
26852685 const text = await first.text();
2686 // The tag rides in the markup as well, for a proxy that drops the header.
2687 expect(text).toMatch(/^\s*<div data-etag="[0-9a-f]{16}"/);
2688 expect(`"${/data-etag="([0-9a-f]{16})"/.exec(text)![1]}"`).toBe(etag!);
26862689 expect(text).toContain('id="live-now"');
26872690 expect(text).toContain('data-live="run.*,pull.*,issue.*"');
26882691 expect(text).toContain('data-live-src="/alice/vault/app/now"');

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs