Skip to content
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
13 changes: 8 additions & 5 deletions docs/architecture/frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,11 +283,14 @@ to the Tasks page's `startTaskCreation`, as Start Task does.
A replacement is `ready` only after its complete shell cache is available, and
Reload explicitly transitions to that prepared generation. The server sends
every `/assets/` file except the manifest, which carries the server's name,
with `Cache-Control: no-cache` and an `ETag` taken from the file's contents, so
filling a replacement's shell cache revalidates the files the browser already
holds and downloads only those that changed. The viewport-fixed
`caffold-build-mismatch-alert` remains a separate exceptional diagnostic and
appears only when UI and server builds differ after the lifecycle is `settled`.
with `Cache-Control: no-cache` and an `ETag` taken from the file's contents.
While filling a replacement's shell cache, the service worker asks about each
file an earlier build's shell cache holds with that copy's tag and keeps the
copy when the server answers that it is unchanged, so an update downloads only
the files that changed whether or not the browser kept its own copies. The
viewport-fixed `caffold-build-mismatch-alert` remains a separate exceptional
diagnostic and appears only when UI and server builds differ after the
lifecycle is `settled`.

The private update-handoff graph contains `detached`, `idle`, `activating`,
`claiming`, and `applying`. Prepared-worker metadata, registration and
Expand Down
56 changes: 51 additions & 5 deletions frontend/service-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -427,11 +427,7 @@ const APP_SHELL_ASSETS = [
const APP_SHELL_ASSET_PATHS = new Set(APP_SHELL_ASSETS);

self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => cache.addAll(APP_SHELL_ASSETS)),
);
event.waitUntil(installShell());
});

self.addEventListener("activate", (event) => {
Expand Down Expand Up @@ -494,6 +490,56 @@ self.addEventListener("fetch", (event) => {
}
});

// Fills this build's cache. A file an earlier build's cache holds is asked
// about by its tag, and that copy goes in when the server says it is
// unchanged, so an update downloads only what changed even when the browser
// no longer keeps its own copies. Each answer is stored as it arrives, since
// unread bodies hold their connections and would leave later requests waiting.
// A file that cannot be fetched discards the cache, leaving nothing half made.
async function installShell() {
const cache = await caches.open(CACHE_NAME);
const previousCaches = await Promise.all(
(await caches.keys())
.filter((key) => key.startsWith(CACHE_PREFIX) && key !== CACHE_NAME)
.reverse()
.map((key) => caches.open(key)),
);
try {
await Promise.all(
APP_SHELL_ASSETS.map(async (path) => {
await cache.put(path, await shellAssetResponse(path, previousCaches));
}),
);
} catch (error) {
await caches.delete(CACHE_NAME);
throw error;
}
}

async function shellAssetResponse(path, previousCaches) {
let previous = null;
for (const previousCache of previousCaches) {
const copy = await previousCache.match(path);
if (copy?.headers.get("ETag")) {
previous = copy;
break;
}
}
const response = previous
? await fetch(path, {
cache: "no-store",
headers: { "If-None-Match": previous.headers.get("ETag") },
})
: await fetch(path);
if (previous && response.status === 304) {
return previous;
}
if (!response.ok) {
throw new Error(`The app shell could not fetch ${path}: ${response.status}`);
}
return response;
}

async function activeShellFirst(request, cachePath) {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(cachePath, { ignoreSearch: true });
Expand Down
152 changes: 144 additions & 8 deletions frontend/service-worker.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,20 @@ function createHarness(options = {}) {
const claimCalls = [];
const notifications = [];
const openedWindows = [];
const cache = {
addAll: options.addAll ?? (async () => {}),
match: options.match ?? (async () => undefined),
const puts = [];
const cacheEntries = options.cacheEntries ?? {
"caffold-shell-test-build": new Map(),
};
const openCache = (key) => {
cacheEntries[key] ??= new Map();
const entries = cacheEntries[key];
return {
match: options.match ?? (async (path) => entries.get(path)),
async put(path, response) {
puts.push({ cache: key, path, response });
entries.set(path, response);
},
};
};
const controlledClients = options.controlledClients ?? [];
const allClients = options.allClients ?? controlledClients;
Expand All @@ -44,10 +55,10 @@ function createHarness(options = {}) {
deletedCaches.push(key);
return true;
},
keys: async () => options.cacheKeys ?? ["caffold-shell-test-build"],
keys: async () => options.cacheKeys ?? Object.keys(cacheEntries),
open: async (key) => {
openedCaches.push(key);
return cache;
return openCache(key);
},
},
clearTimeout: options.clearTimeout ?? clearTimeout,
Expand Down Expand Up @@ -88,6 +99,7 @@ function createHarness(options = {}) {
openedCaches,
openedWindows,
notifications,
puts,
skipWaitingCalls,
async dispatchExtendable(type, properties = {}) {
let completion = Promise.resolve();
Expand All @@ -114,11 +126,12 @@ function createHarness(options = {}) {

test("installs the complete shell without replacing the current worker", async () => {
const gate = deferred();
let installedAssets;
const fetched = [];
const harness = createHarness({
addAll: async (assets) => {
installedAssets = [...assets];
fetch: async (path) => {
fetched.push(path);
await gate.promise;
return new Response(`network ${path}`);
},
});

Expand All @@ -129,6 +142,11 @@ test("installs the complete shell without replacing the current worker", async (
await completion;

assert.equal(harness.skipWaitingCalls.length, 0);
const installedAssets = harness.puts.map(({ path }) => path);
assert.deepEqual([...installedAssets].sort(), [...fetched].sort());
assert.ok(
harness.puts.every(({ cache }) => cache === "caffold-shell-test-build"),
);
assert.ok(installedAssets.includes("/assets/file-status.js"));
assert.ok(installedAssets.includes("/assets/pages/foreground-recovery.js"));
assert.ok(
Expand Down Expand Up @@ -165,6 +183,124 @@ test("installs the complete shell without replacing the current worker", async (
),
);
});

function taggedResponse(body, tag) {
return new Response(body, { headers: { ETag: tag } });
}

function installedResponse(harness, path) {
return harness.puts.find(
(put) => put.cache === "caffold-shell-test-build" && put.path === path,
)?.response;
}

test("copies a file the server calls unchanged from an earlier build's cache", async () => {
const requests = [];
const harness = createHarness({
cacheEntries: {
"caffold-shell-older-build": new Map([
["/assets/app.js", taggedResponse("older app", '"app-older"')],
]),
"caffold-shell-current-build": new Map([
["/assets/app.js", taggedResponse("current app", '"app-current"')],
]),
"caffold-shell-test-build": new Map(),
},
fetch: async (path, init) => {
requests.push({ path, init });
return path === "/assets/app.js"
? new Response(null, { status: 304 })
: new Response(`network ${path}`);
},
});

await harness.dispatchExtendable("install");

assert.equal(await installedResponse(harness, "/assets/app.js").text(), "current app");
// The worker builds the request options in its own context.
assert.deepEqual(
JSON.parse(JSON.stringify(requests.filter(({ path }) => path === "/assets/app.js"))),
[{
path: "/assets/app.js",
init: { cache: "no-store", headers: { "If-None-Match": '"app-current"' } },
}],
);
assert.equal(
await installedResponse(harness, "/assets/styles.css").text(),
"network /assets/styles.css",
);
});

test("downloads a file the server says changed since the earlier build", async () => {
const harness = createHarness({
cacheEntries: {
"caffold-shell-current-build": new Map([
["/assets/app.js", taggedResponse("current app", '"app-current"')],
]),
"caffold-shell-test-build": new Map(),
},
fetch: async (path) =>
new Response(path === "/assets/app.js" ? "replacement app" : `network ${path}`),
});

await harness.dispatchExtendable("install");

assert.equal(
await installedResponse(harness, "/assets/app.js").text(),
"replacement app",
);
});

test("downloads a file without asking when no earlier copy carries a tag", async () => {
const requests = [];
const harness = createHarness({
cacheEntries: {
"caffold-shell-current-build": new Map([["/", new Response("current shell")]]),
"other-cache": new Map([
["/assets/app.js", taggedResponse("other app", '"app-other"')],
]),
"caffold-shell-test-build": new Map([
["/assets/styles.css", taggedResponse("partial styles", '"styles-partial"')],
]),
},
fetch: async (path, init) => {
requests.push({ path, init });
return new Response(`network ${path}`);
},
});

await harness.dispatchExtendable("install");

for (const path of ["/", "/assets/app.js", "/assets/styles.css"]) {
assert.deepEqual(requests.filter((request) => request.path === path), [
{ path, init: undefined },
]);
assert.equal(await installedResponse(harness, path).text(), `network ${path}`);
}
});

test("discards the build's cache when any shell file cannot be fetched", async () => {
const harness = createHarness({
cacheEntries: {
"caffold-shell-current-build": new Map([
["/assets/app.js", taggedResponse("current app", '"app-current"')],
]),
"caffold-shell-test-build": new Map(),
},
fetch: async (path) => {
if (path === "/assets/styles.css") {
return new Response("unavailable", { status: 503 });
}
return path === "/assets/app.js"
? new Response(null, { status: 304 })
: new Response(`network ${path}`);
},
});

await assert.rejects(harness.dispatchExtendable("install"), /\/assets\/styles\.css: 503/);
assert.deepEqual(harness.deletedCaches, ["caffold-shell-test-build"]);
});

for (const [name, url, cachePath] of [
["navigation", "https://caffold.test/tasks/83", "/"],
["asset", "https://caffold.test/assets/app.js", "/assets/app.js"],
Expand Down
14 changes: 10 additions & 4 deletions frontend/tests/contracts/service-worker.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,30 @@ const source = readFileSync(

test("precaches every bundled runtime asset", async () => {
let installListener;
let installedAssets;
const installedAssets = new Set();
const context = {
AbortController,
Promise,
Response,
Set,
URL,
caches: {
async keys() {
return [];
},
async open() {
return {
async addAll(assets) {
installedAssets = new Set(assets);
async match() {
return undefined;
},
async put(asset) {
installedAssets.add(asset);
},
};
},
},
clearTimeout,
fetch,
fetch: async () => new Response(""),
self: {
addEventListener(type, listener) {
if (type === "install") {
Expand Down
Loading
Loading