From b2f467cc72b28a45300999b7b0072476a49e1829 Mon Sep 17 00:00:00 2001 From: pat <42132632+gherkster@users.noreply.github.com> Date: Fri, 24 May 2024 00:01:56 +1000 Subject: [PATCH] Add versioning middleware to handle offline versioning checks (#42) * Add versioning middleware to handle offline versioning checks * Fix loading search index on initial page load --- .gitignore | 1 - website/.gitignore | 2 + website/composables/useSearch.ts | 60 +++++--------- website/layouts/default.vue | 7 +- website/middleware/versioning.global.ts | 100 ++++++++++++++++++++++++ website/modules/recipe/index.ts | 16 +++- website/pages/index.vue | 5 -- website/types/version.ts | 4 + 8 files changed, 145 insertions(+), 50 deletions(-) create mode 100644 website/middleware/versioning.global.ts create mode 100644 website/types/version.ts diff --git a/.gitignore b/.gitignore index b3baff3..3209510 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,6 @@ obj .env.local .env.*.local chunk-sizes.html -website/.dev.vars # Log files npm-debug.log* diff --git a/website/.gitignore b/website/.gitignore index 9b9646a..1069d69 100644 --- a/website/.gitignore +++ b/website/.gitignore @@ -6,6 +6,7 @@ dist .wrangler public/search-index.json +public/version.json stats.html .data @@ -35,3 +36,4 @@ logs .env .env.* !.env.example +.dev.vars diff --git a/website/composables/useSearch.ts b/website/composables/useSearch.ts index eb22539..4a069a2 100644 --- a/website/composables/useSearch.ts +++ b/website/composables/useSearch.ts @@ -4,75 +4,56 @@ import { searchIndexSettings, type SearchIndexStoredFields } from "~/types/searc // Store these outside the function in the global scope for re-use const miniSearch = ref>(); -let indexDownload: Promise | null = null; export interface RecipeSearchResult extends SearchResult, SearchIndexStoredFields {} export function useSearch() { - const config = useRuntimeConfig(); - const currentSearchIndexHash = config.public.searchIndexHash; - async function ensureIndex() { if (miniSearch.value) { return; } - if (!import.meta.dev) { - verifySearchIndexIsCached(); - } + verifySearchIndexIsCached(); // If a valid copy of the search index wasn't found in localstorage, // trigger an async download of the index in the background if (!miniSearch.value) { - downloadIndex(); + await refreshIndex(); } } function verifySearchIndexIsCached() { if (process.client) { - const storedSearchIndexHash = localStorage.getItem("search-index-hash"); - - if (currentSearchIndexHash && currentSearchIndexHash === storedSearchIndexHash) { - const storedIndex = localStorage.getItem("search-index"); - if (storedIndex) { - try { - miniSearch.value = MiniSearch.loadJSON(storedIndex, searchIndexSettings); - miniSearch.value.search("a"); // Do a search to validate this is a valid search index - } catch (error) { - miniSearch.value = undefined; - console.error(error); - } + const storedIndex = localStorage.getItem("search-index"); + if (storedIndex) { + try { + miniSearch.value = MiniSearch.loadJSON(storedIndex, searchIndexSettings); + miniSearch.value.search("a"); // Do a search to validate this is a valid search index + } catch (error) { + miniSearch.value = undefined; + console.error(error); } } } } - async function downloadIndex() { + async function refreshIndex() { if (!process.client) { return; } - // Load lazily so that downloading the search index does not block page loads. - indexDownload = $fetch("/search-index.json").then((index: JSON) => { - if (index) { - const jsonString = JSON.stringify(index); - miniSearch.value = MiniSearch.loadJSON(jsonString, searchIndexSettings); + const { data: index } = await useFetch("/search-index.json"); + if (index.value) { + const jsonString = JSON.stringify(index.value); + miniSearch.value = MiniSearch.loadJSON(jsonString, searchIndexSettings); - localStorage.setItem("search-index", jsonString); - localStorage.setItem("search-index-hash", currentSearchIndexHash); - } - }); + localStorage.setItem("search-index", jsonString); + } } async function search(query: string) { - if (indexDownload) { - await indexDownload; - } - - // If for some the search index is still missing then try downloading again - // Could happen from a network error if (!miniSearch.value) { - await downloadIndex(); + await refreshIndex(); } if (!miniSearch.value) { @@ -85,8 +66,8 @@ export function useSearch() { } async function allItems() { - if (indexDownload) { - await indexDownload; + if (!miniSearch.value) { + await refreshIndex(); } if (!miniSearch.value) { @@ -98,6 +79,7 @@ export function useSearch() { return { ensureIndex, + refreshIndex, search, allItems, }; diff --git a/website/layouts/default.vue b/website/layouts/default.vue index b5bf8eb..03e9ef4 100644 --- a/website/layouts/default.vue +++ b/website/layouts/default.vue @@ -21,8 +21,11 @@ import LogoHead from "~icons/custom/head"; const searchClient = useSearch(); -// Ensure the search index exists on each page load, -// so that if it is missing it can trigger a background download +/* + Kick off a background download of the search index if it hasn't been downloaded yet. + Periodic checks are done after page load within the versioning middleware. + This is also needed to pull in the data from localStorage on a fresh page load. +*/ searchClient.ensureIndex(); const route = useRoute(); diff --git a/website/middleware/versioning.global.ts b/website/middleware/versioning.global.ts new file mode 100644 index 0000000..8cec862 --- /dev/null +++ b/website/middleware/versioning.global.ts @@ -0,0 +1,100 @@ +import type { Version } from "~/types/version"; + +let searchIndexDownload: Promise | null = null; +let isBuildStale = false; + +const buildVersionStorageKey = "build-version"; +const searchIndexHashStorageKey = "search-index-hash"; +const lastCheckTimeStorageKey = "last-version-check"; + +/** + * Periodically check for an outdated client build or search index. + * This prevents clients that were downloaded and cached a while ago from persisting index.html or an old search index, + * which would potentially lead to errors or stale content. + + * This is done seamlessly in the background, either doing a full page load during a navigation + * or downloading and swapping in the new search index in the background + */ +export default defineNuxtRouteMiddleware((to) => { + if (!process.client) { + return; + } + + const lastVersionCheckMs = Number(localStorage.getItem(lastCheckTimeStorageKey)); + + const fiveMinutesInMs = 1000 * 60 * 5; + if (lastVersionCheckMs && lastVersionCheckMs > Date.now() - fiveMinutesInMs) { + return; + } + + if (isBuildStale) { + isBuildStale = false; + // Do a full page refresh to reload index.html and any changed scripts/styles during an existing user navigation + reloadNuxtApp({ + path: to.path, + }); + } + + // Check for newer versions in the background to avoid delaying navigation + getLatestVersionNumbers().then((version) => { + if (!version) { + return; + } + + const isSearchIndexStale = checkForStaleSearchIndex(version.searchIndex); + if (isSearchIndexStale) { + /* + Trigger a background download of the latest search index, + skipping if there are any already active requests to prevent duplicate requests + */ + if (!searchIndexDownload) { + searchIndexDownload = useSearch() + .refreshIndex() + .finally(() => { + localStorage.setItem(searchIndexHashStorageKey, version.searchIndex); + searchIndexDownload = null; + }); + } + } + + /* + If the search index is downloading we don't want to trigger a full page load and cancel a pending request. + It can always happen after the next navigation, an old build won't immediately affect the user experience. + */ + if (!searchIndexDownload && checkForStaleBuild(version.build)) { + localStorage.setItem("build-version", version.build); + isBuildStale = true; + } + }); + + localStorage.setItem(lastCheckTimeStorageKey, Date.now().toString()); +}); + +async function getLatestVersionNumbers(): Promise { + const { data: data } = await useFetch("/version.json"); + return data.value; +} + +function checkForStaleBuild(latestVersion: string) { + const currentVersion = localStorage.getItem(buildVersionStorageKey); + // If the version is not set then we can assume it is a fresh page load and the build will be up to date + if (!currentVersion) { + localStorage.setItem(buildVersionStorageKey, latestVersion); + return false; + } + + if (currentVersion === latestVersion) { + return false; + } + + return true; +} + +function checkForStaleSearchIndex(latestVersion: string) { + const currentVersion = localStorage.getItem(searchIndexHashStorageKey); + if (currentVersion === latestVersion) { + return false; + } + + return true; +} diff --git a/website/modules/recipe/index.ts b/website/modules/recipe/index.ts index 782ae87..4c3e235 100644 --- a/website/modules/recipe/index.ts +++ b/website/modules/recipe/index.ts @@ -6,9 +6,14 @@ import { searchIndexSettings, type SearchIndexIndexed } from "../../types/search import * as fs from "fs/promises"; import * as crypto from "crypto"; import type { Nuxt } from "nuxt/schema"; +import type { Version } from "~/types/version"; export default defineNuxtModule({ async setup(options, nuxt) { + if (!process.env.CF_PAGES_COMMIT_SHA) { + throw new Error("CF_PAGES_COMMIT_SHA environment variable is undefined. A build ID cannot be determined."); + } + const recipes = await getAllRecipes(); const searchIndex = generateRecipeSearchIndex(recipes); @@ -70,9 +75,14 @@ async function saveRecipeSearchIndex(index: string, nuxt: Nuxt) { const hash = crypto.createHash("md5").update(index).digest("hex"); console.log("Generated search index hash:", hash); + const currentVersion: Version = { + build: process.env.CF_PAGES_COMMIT_SHA!, + searchIndex: hash, + }; + /* - Store the hash in the runtimeConfig instead of appConfig - to avoid baking it into the JS and causing cascading cache invalidation + Store the current versions of assets in /public to allow the client + to periodically check for newer content */ - nuxt.options.runtimeConfig.public.searchIndexHash = hash; + await fs.writeFile(`${publicFolderPath}/version.json`, JSON.stringify(currentVersion), "utf8"); } diff --git a/website/pages/index.vue b/website/pages/index.vue index 9ec64bb..11838e0 100644 --- a/website/pages/index.vue +++ b/website/pages/index.vue @@ -130,11 +130,6 @@ useServerSeoMeta({ useHead({ title: content?.title, }); - -const headerIcon = { - name: "gravity-ui:circle-chevron-right", - size: "32", -};