From 523a3c01ac035bfb22bbebbc3acebc57e3bf9569 Mon Sep 17 00:00:00 2001 From: ael-dev3 <183124839+ael-dev3@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:11:20 +0100 Subject: [PATCH] fix: recover a missing startup entry script once --- CHANGELOG.md | 2 + docs/ARCHITECTURE.md | 2 + e2e/boot-recovery.fixture.spec.ts | 125 +++++++++++++++++++++++++++++- web/_headers | 2 +- web/index.html | 23 +++++- 5 files changed, 151 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71afba99..2fb91c32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f9bf7dc6..cc50ccc7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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`. diff --git a/e2e/boot-recovery.fixture.spec.ts b/e2e/boot-recovery.fixture.spec.ts index 6944db13..6ee6aa30 100644 --- a/e2e/boot-recovery.fixture.spec.ts +++ b/e2e/boot-recovery.fixture.spec.ts @@ -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((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) { diff --git a/web/_headers b/web/_headers index 4d49c160..ea1f1ae8 100644 --- a/web/_headers +++ b/web/_headers @@ -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 diff --git a/web/index.html b/web/index.html index 04fcc06b..a64bb10f 100644 --- a/web/index.html +++ b/web/index.html @@ -29,7 +29,28 @@ {{PAINT}} - + + +