Skip to content
Open
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
59 changes: 58 additions & 1 deletion server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,29 @@ function serveArtifact(req: IncomingMessage, res: ServerResponse) {
createReadStream(resolvedFile).pipe(res);
}

function cacheControlFor(relative: string): string {
const name = relative.replace(/^\/+/, "");
const base = name.split("/").pop() || name;
// The service-worker script, its registration shim, and the workbox runtime
// MUST never be cached: if iOS Safari serves a stale /sw.js it never detects
// a new service worker, so the old SW keeps serving the old precached bundle
// forever (refresh appears to do nothing). Force revalidation every load.
if (base === "sw.js" || base === "registerSW.js" || /^workbox-[A-Za-z0-9_-]+\.js$/.test(base)) {
return "no-cache, no-store, must-revalidate";
}
// HTML / the app shell is served network-first and must stay fresh so it
// always references the current hashed asset names.
if (base === "index.html" || base.endsWith(".html") || base === "manifest.webmanifest") {
return "no-cache";
}
// Content-hashed build assets are immutable — safe to cache aggressively.
if (name.startsWith("assets/") && /-[A-Za-z0-9_-]{6,}\.[a-z0-9]+$/.test(base)) {
return "public, max-age=31536000, immutable";
}
// Everything else (icons, svg, etc.): revalidate to stay safe.
return "no-cache";
}

function serveStatic(req: IncomingMessage, res: ServerResponse) {
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
const pathname = decodeURIComponent(url.pathname);
Expand All @@ -155,7 +178,10 @@ function serveStatic(req: IncomingMessage, res: ServerResponse) {
return;
}

res.writeHead(200, { "content-type": contentTypes[extname(file)] || "application/octet-stream" });
res.writeHead(200, {
"content-type": contentTypes[extname(file)] || "application/octet-stream",
"cache-control": cacheControlFor(relative),
});
createReadStream(file).pipe(res);
}

Expand Down Expand Up @@ -2301,6 +2327,37 @@ const server = createServer(async (req, res) => {
return sendJson(res, 404, { ok: false, error: "Unknown API route" });
}

// Server-side cache-bust: when PI_WEB_SW_RESET=1, serve a self-destroying
// service worker at /sw.js. A client captured by a stale SW re-fetches the
// SW script on its next navigation (the spec bypasses the HTTP cache for
// the script, and our no-store header guarantees it), installs this one,
// which deletes every cache and unregisters itself — freeing the client to
// load fresh content straight from the network. Loop-safe: it force-reloads
// clients only the first time (guarded by a marker cache), so re-registering
// it does not bounce the page repeatedly. Flip the flag off once clients
// have recovered to restore the normal PWA service worker.
if (req.method === "GET" && url.pathname === "/sw.js" && (process.env.PI_WEB_SW_RESET === "1" || existsSync(join(appDir, ".pi-sw-reset")))) {
const body = `// pi-web self-destroying service worker (PI_WEB_SW_RESET=1)
const KILL_FLAG = 'pi-sw-killed';
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
const firstTime = !(await caches.has(KILL_FLAG));
for (const key of await caches.keys()) { if (key !== KILL_FLAG) await caches.delete(key); }
try { await self.registration.unregister(); } catch (e) {}
if (firstTime) {
await caches.open(KILL_FLAG);
const clients = await self.clients.matchAll({ type: 'window' });
for (const client of clients) { try { client.navigate(client.url); } catch (e) {} }
}
})());
});
`;
res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-cache, no-store, must-revalidate" });
res.end(body);
return;
}

if (viteDevServer) {
viteDevServer.middlewares(req, res, () => {
if (!res.writableEnded) sendJson(res, 404, { ok: false, error: "Not found" });
Expand Down
31 changes: 29 additions & 2 deletions src/status/statusBar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export function createStatusBar(options: {
const originalValue = input.value.trim();

let finished = false;
let blurArmed = false;
const finish = (save: boolean) => {
if (finished) return;
finished = true;
Expand All @@ -114,12 +115,27 @@ export function createStatusBar(options: {
finish(false);
}
});
input.addEventListener("blur", () => finish(true));
// iOS Safari fires a spurious `blur` immediately after we focus the input
// (while the on-screen keyboard animates in and the visual viewport
// resizes). If we acted on that blur we would tear the rename field back
// down to the static title before the user could type a single character
// — which looks exactly like "tapping the header does nothing". Only arm
// the blur-to-save handler once focus has settled; an early blur instead
// re-focuses the input so the keyboard stays up.
input.addEventListener("blur", () => {
if (finished) return;
if (!blurArmed && document.body.contains(input)) {
requestAnimationFrame(() => { if (!finished) input.focus(); });
return;
}
finish(true);
});

elements.statusTitleEl.textContent = "";
elements.statusTitleEl.append(input);
input.focus();
input.select();
window.setTimeout(() => { blurArmed = true; }, 350);
}

function clearConnectionTimers() {
Expand Down Expand Up @@ -303,7 +319,18 @@ export function createStatusBar(options: {
elements.statusTitleEl.setAttribute("role", "button");
elements.statusTitleEl.tabIndex = 0;
setStatusTitle(state.currentSessionTitle);
elements.statusTitleEl.addEventListener("click", beginRenameSessionTitle);

// The title <span> is role="button" and opens an inline rename input on
// activation. On iOS Safari two things matter:
// - text selection must be disabled (user-select:none + touch-action:
// manipulation in CSS) so a finger tap is treated as a tap, not the
// start of a text-selection gesture that swallows the click; and
// - the input must be focused inside the *click* handler. iOS only
// raises the on-screen keyboard when focus() runs in a click/touchend
// user gesture; doing it from pointerup (or after preventDefault)
// opens the field but leaves the keyboard down on real devices.
// So we trigger purely on click — the canonical, device-tested path.
elements.statusTitleEl.addEventListener("click", () => beginRenameSessionTitle());
elements.statusTitleEl.addEventListener("keydown", (event) => {
if (event.target !== elements.statusTitleEl || (event.key !== "Enter" && event.key !== " ")) return;
event.preventDefault();
Expand Down
10 changes: 10 additions & 0 deletions src/styles/statusBar.css
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@
white-space: nowrap;
max-width: 240px;
cursor: pointer;
/* The status title acts as a button (role="button") that swaps in a
rename input on click. On iOS Safari a tap on a plain <span> with
selectable text is interpreted as native text-selection (showing the
blue selection handles + magnifier), which suppresses the click event
and the rename input never appears. Disable text selection and the
callout so iOS reliably treats the tap as a button activation. */
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
touch-action: manipulation;
}
.statusTitle:hover {
color: var(--accent);
Expand Down
93 changes: 93 additions & 0 deletions tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -820,3 +820,96 @@ describe("WebSocket authentication", () => {
}
}, 25_000);
});

describe("static asset cache headers (iOS service-worker freshness)", () => {
let child: ChildProcess;
let baseUrl: string;

beforeAll(async () => {
const port = await freePort();
baseUrl = `http://127.0.0.1:${port}`;
// Production mode (PI_WEB_DEV=0) so the built-in static file server runs.
// In dev mode Vite's middleware serves these files with its own headers,
// which is not the code path real deployments use.
child = spawn(process.execPath, ["--import", "tsx", "server.ts"], {
env: { ...process.env, PI_WEB_MOCK: "1", PI_WEB_DEV: "0", NODE_ENV: "production", HOST: "127.0.0.1", PORT: String(port), PI_WEB_TOKEN: "" },
stdio: ["ignore", "pipe", "pipe"],
});
child.stderr?.on("data", (data) => process.stderr.write(data));
await waitForServer(baseUrl);
}, 20_000);

afterAll(() => {
child?.kill();
});

it("never caches the service worker or its registration shim", async () => {
// Regression: with no Cache-Control, iOS Safari heuristically caches
// /sw.js, never detects a new service worker, and keeps serving the old
// precached bundle forever (a deploy looks like it "did nothing" on
// iPhone). The SW script + register shim must always revalidate.
const sw = await fetch(`${baseUrl}/sw.js`);
if (sw.status === 404) {
// dist/ not built in this environment — nothing to assert.
return;
}
expect(sw.headers.get("cache-control")).toContain("no-store");

const reg = await fetch(`${baseUrl}/registerSW.js`);
expect(reg.status).toBe(200);
expect(reg.headers.get("cache-control")).toContain("no-store");
});

it("serves the app shell HTML as no-cache so it references current asset hashes", async () => {
const res = await fetch(`${baseUrl}/`);
expect(res.status).toBe(200);
expect((res.headers.get("cache-control") || "")).toContain("no-cache");
});

it("caches content-hashed build assets immutably", async () => {
const html = await (await fetch(`${baseUrl}/`)).text();
const match = html.match(/assets\/index-[A-Za-z0-9_-]+\.(?:js|css)/);
if (!match) return; // dist/ not built
const asset = await fetch(`${baseUrl}/${match[0]}`);
expect(asset.status).toBe(200);
const cc = asset.headers.get("cache-control") || "";
expect(cc).toContain("immutable");
expect(cc).toContain("max-age=31536000");
});
});

describe("service-worker kill switch (PI_WEB_SW_RESET)", () => {
let child: ChildProcess;
let baseUrl: string;

beforeAll(async () => {
const port = await freePort();
baseUrl = `http://127.0.0.1:${port}`;
child = spawn(process.execPath, ["--import", "tsx", "server.ts"], {
env: { ...process.env, PI_WEB_MOCK: "1", PI_WEB_DEV: "0", NODE_ENV: "production", PI_WEB_SW_RESET: "1", HOST: "127.0.0.1", PORT: String(port), PI_WEB_TOKEN: "" },
stdio: ["ignore", "pipe", "pipe"],
});
child.stderr?.on("data", (data) => process.stderr.write(data));
await waitForServer(baseUrl);
}, 20_000);

afterAll(() => {
child?.kill();
});

it("serves a self-destroying service worker that clears caches and unregisters", async () => {
// Server-side cache bust: a client captured by a stale SW re-fetches
// /sw.js on navigation, installs this one, which wipes caches and
// unregisters itself so fresh content loads from the network.
const res = await fetch(`${baseUrl}/sw.js`);
expect(res.status).toBe(200);
expect(res.headers.get("cache-control")).toContain("no-store");
const body = await res.text();
expect(body).toContain("self.registration.unregister()");
expect(body).toContain("caches.delete");
expect(body).toContain("skipWaiting");
// It must NOT be the precaching workbox SW (that would re-cache the bundle).
expect(body).not.toContain("precacheAndRoute");
expect(body).not.toContain("__WB_MANIFEST");
});
});
52 changes: 52 additions & 0 deletions tests/e2e/pi-web.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,58 @@ test.describe("composer layout", () => {
expect(sessions.sessions.find((item: any) => item.id === "mock-current").name).toBe("Renamed from title");
});

test("status title is not text-selectable on iOS so taps reach the rename handler", async ({ page }) => {
// Regression: on iOS Safari, tapping a <span> with selectable text (the
// status title) shows the native blue selection handles + magnifier and
// suppresses the click event, so the rename input never appears. The fix
// is to mark the status title as non-user-selectable. Verify the rendered
// styles still enforce that contract.
await page.goto("/");
await expect(page.locator("#statusTitle")).toHaveText("Current mock session");

const styles = await page.locator("#statusTitle").evaluate((el) => {
const cs = getComputedStyle(el);
return {
userSelect: cs.userSelect || (cs as any).webkitUserSelect,
webkitUserSelect: (cs as any).webkitUserSelect,
webkitTouchCallout: (cs as any).webkitTouchCallout,
touchAction: cs.touchAction,
cursor: cs.cursor,
role: el.getAttribute("role"),
};
});
expect(styles.role).toBe("button");
expect(styles.cursor).toBe("pointer");
expect([styles.userSelect, styles.webkitUserSelect]).toContain("none");
// touch-action: manipulation removes the iOS 300ms tap delay too.
expect(styles.touchAction).toBe("manipulation");
});

test("tapping the status title on a touch viewport opens the rename input and survives an iOS keyboard blur", async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== "mobile", "Touch-tap + iOS blur race is only meaningful on the mobile project.");
// End-to-end version of the iOS bug: simulate a real tap (not a synthetic
// mouse click) and assert the rename input is rendered and focused.
await page.goto("/");
await expect(page.locator("#statusTitle")).toHaveText("Current mock session");

const box = await page.locator("#statusTitle").boundingBox();
expect(box).not.toBeNull();
await page.touchscreen.tap(box!.x + Math.min(40, box!.width / 2), box!.y + box!.height / 2);

await expect(page.locator("#statusTitle input")).toBeVisible();
await expect(page.locator("#statusTitle input")).toBeFocused();

// iOS Safari fires a spurious blur right after focus while the on-screen
// keyboard animates in. The editor must NOT be torn down by it (otherwise
// the tap appears to "do nothing"). Fire that blur within the grace window.
await page.locator("#statusTitle input").evaluate((el) => el.dispatchEvent(new FocusEvent("blur")));
await expect(page.locator("#statusTitle input")).toBeVisible();

await page.locator("#statusTitle input").fill("Renamed via tap");
await page.locator("#statusTitle input").press("Enter");
await expect(page.locator("#statusTitle")).toHaveText("Renamed via tap");
});

test("new session resets status title", async ({ page }) => {
await page.goto("/");
await expect(page.locator("#statusTitle")).toHaveText("Current mock session");
Expand Down
43 changes: 43 additions & 0 deletions tests/sw-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";

/**
* Guards the generated service worker's navigation strategy.
*
* Regression: vite-plugin-pwa registers an SPA NavigationRoute bound to the
* *precached* index.html, and it is registered before runtimeCaching. Left
* alone it shadows our NetworkFirst navigate route, so a stale service worker
* keeps serving the old precached HTML/JS forever — a deploy looks like it
* "did nothing" on the client (especially iOS). We deny-list every navigation
* from that precache fallback so navigations fall through to NetworkFirst and
* a reload always fetches fresh HTML.
*/
describe("service worker navigation strategy", () => {
const swPath = join(process.cwd(), "dist", "sw.js");

it("denylists the precache navigation fallback and serves navigations NetworkFirst", async () => {
if (!existsSync(swPath)) {
// dist/ not built in this environment (e.g. unit-only CI without build).
return;
}
const sw = await readFile(swPath, "utf8");

// The SPA precache NavigationRoute must be present but neutralised by a
// catch-all denylist so it never serves stale precached HTML.
expect(sw).toMatch(/NavigationRoute/);
expect(sw).toMatch(/denylist:\[\/\.\/\]/);

// Navigations must be handled by a NetworkFirst route (fresh HTML online,
// cached fallback offline).
expect(sw).toMatch(/"navigate"===\w+\.mode/);
expect(sw).toMatch(/NetworkFirst\(\{cacheName:"pages"/);

// The auto-update primitives must stay in place so a new SW activates and
// claims open clients immediately.
expect(sw).toMatch(/skipWaiting\(\)/);
expect(sw).toMatch(/clientsClaim\(\)/);
expect(sw).toMatch(/cleanupOutdatedCaches\(\)/);
});
});
20 changes: 16 additions & 4 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,27 @@ export default defineConfig({
],
},
workbox: {
// Network-first: always try server, fall back to cache
// Only precache the app shell assets
navigateFallback: "/index.html",
// Navigations must be NETWORK-FIRST so a reload always fetches fresh
// HTML (and therefore the current hashed asset names). vite-plugin-pwa
// always registers an SPA NavigationRoute bound to the *precached*
// index.html, and it is registered before runtimeCaching — so left
// alone it shadows the NetworkFirst route and a stale service worker
// keeps serving old HTML/JS forever (a deploy looks like it "did
// nothing", especially on iOS). Deny-list every navigation from that
// precache fallback so navigations fall through to the NetworkFirst
// route below, which serves fresh HTML online and the cached copy
// offline.
navigateFallback: "index.html",
navigateFallbackDenylist: [/./],
globPatterns: ["index.html", "assets/index-*.{js,css}", "*.{svg,png,webmanifest}"],
runtimeCaching: [
{
urlPattern: ({ request }) => request.mode === "navigate",
handler: "NetworkFirst",
options: { cacheName: "pages" },
options: {
cacheName: "pages",
networkTimeoutSeconds: 3,
},
},
],
},
Expand Down
Loading