Skip to content

Commit

Permalink
Add versioning middleware to handle offline versioning checks (#42)
Browse files Browse the repository at this point in the history
* Add versioning middleware to handle offline versioning checks

* Fix loading search index on initial page load
  • Loading branch information
gherkster authored May 23, 2024
1 parent 95b8617 commit b2f467c
Show file tree
Hide file tree
Showing 8 changed files with 145 additions and 50 deletions.
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ obj
.env.local
.env.*.local
chunk-sizes.html
website/.dev.vars

# Log files
npm-debug.log*
Expand Down
2 changes: 2 additions & 0 deletions website/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
dist
.wrangler
public/search-index.json
public/version.json
stats.html
.data

Expand Down Expand Up @@ -35,3 +36,4 @@ logs
.env
.env.*
!.env.example
.dev.vars
60 changes: 21 additions & 39 deletions website/composables/useSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MiniSearch<ServerRecipe>>();
let indexDownload: Promise<void> | 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<JSON>("/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) {
Expand All @@ -85,8 +66,8 @@ export function useSearch() {
}

async function allItems() {
if (indexDownload) {
await indexDownload;
if (!miniSearch.value) {
await refreshIndex();
}

if (!miniSearch.value) {
Expand All @@ -98,6 +79,7 @@ export function useSearch() {

return {
ensureIndex,
refreshIndex,
search,
allItems,
};
Expand Down
7 changes: 5 additions & 2 deletions website/layouts/default.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
100 changes: 100 additions & 0 deletions website/middleware/versioning.global.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { Version } from "~/types/version";

let searchIndexDownload: Promise<void> | 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<Version | null> {
const { data: data } = await useFetch<Version>("/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;
}
16 changes: 13 additions & 3 deletions website/modules/recipe/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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");
}
5 changes: 0 additions & 5 deletions website/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -130,11 +130,6 @@ useServerSeoMeta({
useHead({
title: content?.title,
});
const headerIcon = {
name: "gravity-ui:circle-chevron-right",
size: "32",
};
</script>

<style lang="scss" scoped>
Expand Down
4 changes: 4 additions & 0 deletions website/types/version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type Version = {
build: string;
searchIndex: string;
};

0 comments on commit b2f467c

Please sign in to comment.