Skip to content
This repository was archived by the owner on Oct 28, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ All notable changes to Allthing are recorded here. The format follows [Keep a Ch

### Fixed

- Recover one confirmed missing entry-loader request without reloading or changing drafts and sessions; persistent failures show an accessible Reload button even if early Home data arrives later. Healthy startup adds no recovery traffic.

- Serve HTML from the same deployment as its asset manifest with no-store caching; missing scripts and WebAssembly return real 404s instead of immutable HTML. A missing startup asset can recover once when a bounded check confirms a different same-origin build; other startup errors retain the manual Reload panel.
- Keep Cast available on Notifications and while Settings is open, including the phone button; one press dismisses Settings and opens the composer.
- Leave enough room between phone post actions for the Save button across platform fonts, preserving their tap targets.
Expand Down
2 changes: 2 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ Allthing is a Farcaster client for reading: it shows Farcaster, and the Worker p

HTML document routes run through `allthing-worker::shell`: the versioned `ASSETS` binding reads `/index.html`, and the response carries `no-store` for browsers/CDNs plus `X-Allthing-Commit`. It reuses the global security policy from `web/_headers`, including the checked inline CSP hashes. Existing fingerprinted code, styles, fonts and pack assets remain free asset-first requests with immutable caching. Automatic SPA/HTML fallbacks are disabled: absent asset URLs return a no-store 404 rather than an HTML200 cached under an old JavaScript path. Known app routes and unknown document navigations still render the client router. A document invocation adds no API payload read; its separate request cost is in [BUDGET.md](BUDGET.md#1-what-a-page-view-costs).

The CSP-hashed inline `entry-recovery` guard covers the earlier failure where the fingerprinted `boot.js` itself cannot load. Its capture listener reacts only to that script’s resource-load error. A same-origin Resource Timing status of 404 or 410 permits one new module URL with `__allthing_entry_retry=1`, bypassing the failed module-map entry; successful startup adds no requests. Other or persistent failures retain an accessible Reload panel in `#app`, which also prevents a delayed early Home response from drawing inert cards over the error. The guard does not reload automatically, change the document URL, or write reader storage. Browsers without the status expose the manual panel. This narrow pre-WebAssembly JavaScript boundary cannot run in Rust before the Rust app’s loader arrives. A production cold-load trace recorded the same entry URL returning 404 and then 200; it does not establish the underlying CDN cause. The existing Wasm/glue recovery and cold-performance checks remain unchanged.

## Language boundaries

Rust is the default for new application logic and native tooling. Pure shared behavior belongs in `allthing-core`; the interactive app, data layer, Worker and SpacetimeDB module keep their separate Rust targets. Build, filesystem, validation and deployment policy belongs in `xtask`.
Expand Down
125 changes: 124 additions & 1 deletion e2e/boot-recovery.fixture.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,134 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { expect, test } from "@playwright/test";
import { expect, test, type Page } from "@playwright/test";

test.skip(Boolean(process.env.E2E_PAGES), "uses the Worker API contract");

// The module comes from the Worker at /wasm/ and, failing that, from
// /assets/; its JavaScript from the build's folder in /assets/.
const paths = { wasm: "**/{assets,wasm}/allthing-*.wasm", js: "**/assets/allthing-*/allthing.js" };
const savedEntryDraft = { drafts: [{ key: "cast:home", draft: { text: "Keep my unsent entry recovery draft" }, updated_at: Math.floor(Date.now() / 1000) }] };
const savedEntryFavorites = [{ fid: 1234, username: "reader" }];

// An entry-script failure happens before boot.js can install its own recovery.
// Keep the real document/CSP and every successful entry/app response intact.
async function entryFailure(page: Page, missing: (attempt: number) => boolean, delayHome = false) {
const entries: string[] = [];
const navigations: string[] = [];
const home: string[] = [];
const errors: string[] = [];
let releaseHome = () => {};
const homeReady = delayHome ? new Promise<void>((resolve) => { releaseHome = resolve; }) : Promise.resolve();
page.on("pageerror", (error) => errors.push(error.message));
page.on("request", (request) => {
if (request.isNavigationRequest() && request.frame() === page.mainFrame()) navigations.push(request.url());
});
await page.addInitScript(() => {
(window as Window & { scriptPolicyViolations?: string[] }).scriptPolicyViolations = [];
document.addEventListener("securitypolicyviolation", (event) => {
if (event.effectiveDirective.startsWith("script-src")) {
(window as Window & { scriptPolicyViolations: string[] }).scriptPolicyViolations.push(event.blockedURI);
}
});
});
await page.addInitScript(({ draft, favorites }) => {
localStorage.setItem("allthing-drafts", JSON.stringify(draft));
localStorage.setItem("allthing.favorites", JSON.stringify(favorites));
sessionStorage.setItem("allthing-entry-fixture-reader", "keep this reader session");
}, { draft: savedEntryDraft, favorites: savedEntryFavorites });
await page.route(/\/assets\/boot-[0-9a-f]+\.js(?:\?.*)?$/, (route) => {
entries.push(route.request().url());
return missing(entries.length)
? route.fulfill({ status: 404, contentType: "text/plain", body: "entry asset not yet available" })
: route.continue();
});
await page.route("**/api/v1/**", async (route) => {
if (new URL(route.request().url()).pathname !== "/api/v1/home") {
return route.fulfill({ status: 404, json: { error: "Fixture endpoint" } });
}
home.push(route.request().url());
await homeReady;
// A normal 100-cast first page keeps this bootstrap check away from the
// infinite-scroll boundary. Delay the same drawable page to test paint.js.
const pool = Array.from({ length: 100 }, (_, i) => ({
hash: `0x${(i + 1).toString(16).padStart(40, "0")}`,
author: { fid: 42, username: "late-payload", display_name: "Late payload" },
text: `Entry recovery fixture ${i + 1}. A delayed Home response must leave Reload usable.`,
ts: Math.floor(Date.now() / 1000) - i * 60, likes: 100 - i, recasts: 0, replies: 0,
}));
return route.fulfill({ json: { pool, notes: {}, meta: { backend: "hypersnap", fetched_at: Date.now() } } });
});
return { entries, navigations, home, errors, releaseHome };
}

async function entryLoaded(page: Page) {
await expect(page.getByRole("combobox", { name: "Search casts, people and channels", exact: true })).toBeVisible();
await expect(page.locator("#boot")).toHaveCount(0);
await expect(page.getByRole("alert")).toHaveCount(0);
expect(await page.evaluate(() => (window as Window & { scriptPolicyViolations: string[] }).scriptPolicyViolations)).toEqual([]);
expect(await page.evaluate(() => JSON.parse(localStorage.getItem("allthing-drafts")!))).toEqual(savedEntryDraft);
expect(await page.evaluate(() => JSON.parse(localStorage.getItem("allthing.favorites")!))).toEqual(savedEntryFavorites);
expect(await page.evaluate(() => sessionStorage.getItem("allthing-entry-fixture-reader"))).toBe("keep this reader session");
}

for (const width of [1280, 390]) {
test(`the first missing boot script recovers before any loader can run at width ${width}`, async ({ page, baseURL }) => {
await page.setViewportSize({ width, height: 844 });
const proof = await entryFailure(page, (attempt) => attempt === 1);
const originalURL = new URL("/?sort=top&t=week#first-entry", baseURL).href;
await page.goto(originalURL);
await entryLoaded(page);
expect(proof.entries).toHaveLength(2);
expect(new URL(proof.entries[1]).pathname).toBe(new URL(proof.entries[0]).pathname);
expect(proof.entries[1]).not.toBe(proof.entries[0]); // Bypass the failed module-map entry.
expect(new URL(proof.entries[1]).searchParams.get("__allthing_entry_retry")).toBe("1");
expect(proof.navigations).toEqual([originalURL.split("#")[0]]);
expect(proof.home).toHaveLength(1);
await expect(page).toHaveURL(originalURL);
expect(proof.errors).toEqual([]);
});

test(`a persistently missing boot script stops retrying and offers manual Reload at width ${width}`, async ({ page, baseURL }) => {
await page.setViewportSize({ width, height: 844 });
let blocked = true;
const proof = await entryFailure(page, () => blocked, true);
const originalURL = new URL("/?sort=top&t=week#first-entry", baseURL).href;
await page.goto(originalURL);
await expect(page.getByRole("alert")).toContainText("Allthing couldn’t load");
const reload = page.getByRole("button", { name: "Reload", exact: true });
await expect(reload).toBeVisible();
const attempts = proof.entries.length;
expect(attempts).toBe(2);
const lateHome = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/v1/home");
proof.releaseHome();
await (await lateHome).finished();
await page.waitForTimeout(2200);
await expect(page.getByRole("alert")).toContainText("Allthing couldn’t load");
await expect(reload).toBeVisible();
await expect(page.locator("#boot .cast-card")).toHaveCount(0);
expect(proof.entries).toHaveLength(attempts);
expect(proof.navigations).toEqual([originalURL.split("#")[0]]);
expect(proof.home).toHaveLength(1);
blocked = false;
await reload.click();
await entryLoaded(page);
expect(proof.entries).toHaveLength(attempts + 1);
expect(proof.navigations).toEqual([originalURL.split("#")[0], originalURL.split("#")[0]]);
expect(proof.home).toHaveLength(2);
await expect(page).toHaveURL(originalURL);
expect(proof.errors).toEqual([]);
});

test(`a healthy boot script adds no entry recovery requests at width ${width}`, async ({ page }) => {
await page.setViewportSize({ width, height: 844 });
const proof = await entryFailure(page, () => false);
await page.goto("/?sort=top&t=week");
await entryLoaded(page);
expect(proof.entries).toHaveLength(1);
expect(proof.navigations).toEqual([page.url()]);
expect(proof.home).toHaveLength(1);
expect(proof.errors).toEqual([]);
});
}

for (const width of [1280, 390]) {
for (const asset of ["wasm", "js"] as const) {
Expand Down
2 changes: 1 addition & 1 deletion web/_headers
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()
Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'inline-speculation-rules' https://static.cloudflareinsights.com 'sha256-7MR8usYANrh7vyUz5TYodWF71FASRetYK9s8dhW7ohY=' 'sha256-sRrwKjqHT1qGbGjlpIO5jxMbyYntwB4HLAzVyr2HXFA=' 'sha256-OCdcCySGf2C2Sgd561xC2Uhc6d8FplAkPrG7w63LhsE='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; media-src 'self' blob: https:; connect-src 'self' https: wss://maincloud.spacetimedb.com; frame-src https://www.youtube-nocookie.com https://player.vimeo.com https://www.loom.com https://streamable.com; font-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'
Content-Security-Policy: default-src 'self'; script-src 'self' 'sha256-C/UN2cMx8K42Ewy7ReCQ5LhThrnxmZsdT2t/b5552SY=' 'wasm-unsafe-eval' 'inline-speculation-rules' https://static.cloudflareinsights.com 'sha256-7MR8usYANrh7vyUz5TYodWF71FASRetYK9s8dhW7ohY=' 'sha256-sRrwKjqHT1qGbGjlpIO5jxMbyYntwB4HLAzVyr2HXFA=' 'sha256-OCdcCySGf2C2Sgd561xC2Uhc6d8FplAkPrG7w63LhsE='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; media-src 'self' blob: https:; connect-src 'self' https: wss://maincloud.spacetimedb.com; frame-src https://www.youtube-nocookie.com https://player.vimeo.com https://www.loom.com https://streamable.com; font-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'

/assets/*
Cache-Control: public, max-age=31536000, immutable
Expand Down
23 changes: 22 additions & 1 deletion web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,28 @@
<link rel="stylesheet" href="{{CSS}}">
<!-- Draws the front page's first cards from the early fetch while the app downloads; the app takes over (not in the static copy). -->
{{PAINT}}
<script type="module" src="{{BOOT}}"></script>
<!-- A pinned, pre-Wasm guard can recover the entry script itself once, only after a confirmed same-origin 404/410. Successful loads add no requests. -->
<script id="entry-recovery">(function(){
var retried=false;
function stop(){document.removeEventListener("error",failed,true);document.removeEventListener("load",loaded,true)}
function loaded(event){if(event.target===document.getElementById("allthing-boot"))stop()}
function panel(){
var root=document.getElementById("app"),boot=document.getElementById("boot");
if(!root){document.addEventListener("DOMContentLoaded",panel,{once:true});return}
if(boot){boot.querySelector(".boot-glow")?.remove();boot.querySelector("main")?.remove();boot.removeAttribute("data-painted");boot.removeAttribute("aria-hidden")}
var main=document.createElement("main"),box=document.createElement("section"),title=document.createElement("h1"),message=document.createElement("p"),reload=document.createElement("button");
main.id="main";main.className="page";box.className="error-panel";box.setAttribute("role","alert");title.textContent="Allthing couldn’t load";message.textContent="Reload to try again.";reload.type="button";reload.className="button";reload.textContent="Reload";reload.addEventListener("click",function(){location.reload()});box.append(title,message,reload);main.append(box);root.replaceChildren(main);if(document.activeElement===document.body)reload.focus()
}
function failed(event){
var entry=document.getElementById("allthing-boot");if(!entry||event.target!==entry)return;
var url=new URL(entry.src,location.href),timing=performance.getEntriesByName(url.href,"resource"),status=timing.length?timing[timing.length-1].responseStatus:0;
if(!retried&&url.origin===location.origin&&(status===404||status===410)){
retried=true;url.searchParams.set("__allthing_entry_retry","1");var next=document.createElement("script");next.type="module";next.src=url.href;next.id="allthing-boot";entry.replaceWith(next)
}else{stop();panel()}
}
document.addEventListener("error",failed,true);document.addEventListener("load",loaded,true)
})()</script>
<script type="module" src="{{BOOT}}" id="allthing-boot"></script>
</head>
<body>
<div id="boot" class="boot" aria-hidden="true">
Expand Down
Loading