diff --git a/src/book-navigation-interceptor-sw.js b/src/book-navigation-interceptor-sw.js new file mode 100644 index 00000000..a78a7cad --- /dev/null +++ b/src/book-navigation-interceptor-sw.js @@ -0,0 +1,140 @@ +import { getDataSourceForHostname } from "./connection/DataSource"; +import { createParseConnection } from "./connection/ParseConnectionConfig"; +import { + getUrlOfHtmlOfDigitalVersion, + getHarvesterBaseUrlFromBaseUrl, +} from "./model/BookUrlUtils"; + +const bloomPlayerPath = "/bloom-player/bloomplayer.htm"; + +// Activate a newly installed interceptor immediately for the current read session. +self.addEventListener("install", (event) => { + event.waitUntil(self.skipWaiting()); +}); + +// Take control of already-open matching pages so /book/... requests are intercepted right away. +self.addEventListener("activate", (event) => { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener("fetch", (event) => { + const requestUrl = new URL(event.request.url); + if ( + event.request.method !== "GET" || + !requestUrl.pathname.includes("/book/") + ) { + return; + } + + event.respondWith(interceptBookRequest(event)); +}); + +async function interceptBookRequest(event) { + if (!(await requestCameFromBloomPlayer(event))) { + return fetch(event.request); + } + const requestUrl = new URL(event.request.url); + const requestInfo = parseBookRequest(requestUrl); + if (!requestInfo) { + console.error("Failed to parse book request URL"); + return new Response("Invalid book request URL", { status: 400 }); + } + try { + const query = constructParseBookQuery(requestInfo.bookInstanceId); + const bookData = await retrieveBookData(query); + if ( + !bookData || + bookData.harvestState !== "Done" || + !bookData.baseUrl + ) { + console.error("Book not found or not ready for reading", { + bookInstanceId: requestInfo.bookInstanceId, + harvestState: bookData?.harvestState, + hasBaseUrl: !!bookData?.baseUrl, + }); + return new Response("Book not found or not ready for reading", { + status: 404, + }); + } + const harvesterBaseUrl = getHarvesterBaseUrlFromBaseUrl( + bookData.baseUrl, + self.location.hostname === "localhost" + ); + if (!harvesterBaseUrl) { + console.error("Failed to construct harvester base URL"); + return new Response("Failed to construct book URL", { + status: 500, + }); + } + + const redirectUrl = `${getUrlOfHtmlOfDigitalVersion( + harvesterBaseUrl, + requestInfo.filePath + )}${requestUrl.search}`; + // e.g. http://localhost:5174/s3/bloomharvest-sandbox/TkG1dWsW40%2f1768316502115/bloomdigital%2f${filePath} + return Response.redirect(redirectUrl, 302); + } catch (error) { + console.error("Failed to redirect Bloom Player book request", error); + return new Response("Failed to load book: " + error.message, { + status: 500, + }); + } +} + +function parseBookRequest(requestUrl) { + // e.g. http://localhost:5174/book/36befbb8-8201-42cc-8faa-5c9432a985dd/index.htm + const requestPath = requestUrl.pathname.split("/book/")[1]; + if (!requestPath) { + return undefined; + } + + const firstSlashIndex = requestPath.indexOf("/"); + if (firstSlashIndex < 0 || firstSlashIndex === requestPath.length - 1) { + return undefined; + } + + return { + bookInstanceId: decodeURIComponent( + requestPath.substring(0, firstSlashIndex) + ), + filePath: requestPath.substring(firstSlashIndex + 1), + }; +} + +function constructParseBookQuery(bookInstanceId) { + return new URLSearchParams({ + where: JSON.stringify({ bookInstanceId }), + limit: "1", + keys: "baseUrl,harvestState,bookInstanceId", + }); +} + +async function retrieveBookData(query) { + // The main app caches the X-Parse-Session-Token, but as of March 2026 we don't need that in the service worker + // anyway so we can just create a parse connection object + const connection = createParseConnection( + getDataSourceForHostname(self.location.hostname) + ); + const response = await fetch(`${connection.url}classes/books?${query}`, { + headers: connection.headers, + }); + + if (!response.ok) { + throw new Error(`Parse lookup failed: ${response.status}`); + } + + const data = await response.json(); + return data.results?.[0]; +} + +async function requestCameFromBloomPlayer(event) { + const clientId = event.clientId || event.resultingClientId; + if (clientId) { + const client = await self.clients.get(clientId); + if (client && client.url.includes(bloomPlayerPath)) { + return true; + } + } + + return event.request.referrer.includes(bloomPlayerPath); +} diff --git a/src/components/BookDetail/ArtifactHelper.ts b/src/components/BookDetail/ArtifactHelper.ts index 2b5422f1..65566e84 100644 --- a/src/components/BookDetail/ArtifactHelper.ts +++ b/src/components/BookDetail/ArtifactHelper.ts @@ -223,9 +223,3 @@ function getDownloadUrl(book: Book, fileType: string): string | undefined { } return undefined; } -export function getUrlOfHtmlOfDigitalVersion(book: Book) { - const harvesterBaseUrl = Book.getHarvesterBaseUrl(book); - // use this if you are are working on bloom-player and are using the bloom-player npm script tobloomlibrary - // bloomPlayerUrl = "http://localhost:3000/bloomplayer-for-developing.htm"; - return harvesterBaseUrl + "bloomdigital%2findex.htm"; -} diff --git a/src/components/ReadBookPage.tsx b/src/components/ReadBookPage.tsx index 8c55b3ef..504c27eb 100644 --- a/src/components/ReadBookPage.tsx +++ b/src/components/ReadBookPage.tsx @@ -9,7 +9,7 @@ import React, { } from "react"; import { useGetBookDetail } from "../connection/LibraryQueryHooks"; import { Book } from "../model/Book"; -import { getUrlOfHtmlOfDigitalVersion } from "./BookDetail/ArtifactHelper"; +import { getUrlOfHtmlOfDigitalVersion } from "../model/BookUrlUtils"; import { useHistory, useLocation } from "react-router-dom"; import { useTrack } from "../analytics/Analytics"; import { getBookAnalyticsInfo } from "../analytics/BookAnalyticsInfo"; @@ -27,6 +27,137 @@ import { useMediaQuery } from "@material-ui/core"; import { OSFeaturesContext } from "./OSFeaturesContext"; import { IReadBookPageProps } from "./ReadBookPageCodeSplit"; +// To make links in books able to open other books, we need to intercept requests from the Bloom Player iframe to our +// /book/... URLs. To do that, we register a service worker that intercepts those requests +// instead of trying to load them as pages. This file is the service worker that does that interception. +// It is registered in ReadBookPage.tsx and bundled into our build by vite.config.ts. +const bookNavigationInterceptorServiceWorkerUrl = + "/book-navigation-interceptor-sw.js"; + +// Let's not risk totally blocking the page if something goes wrong +// with the service worker, since most books won't have links anyway +const serviceWorkerRegistrationTimeoutMs = 5000; + +function waitForServiceWorkerActivation( + worker: ServiceWorker, + timeoutMs = serviceWorkerRegistrationTimeoutMs +) { + if (worker.state === "activated") { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + const timeoutId = window.setTimeout(() => { + worker.removeEventListener("statechange", onStateChange); + console.error( + "Book navigation interceptor service worker activation timed out", + { state: worker.state, timeoutMs } + ); + reject(new Error("Service worker activation timed out")); + }, timeoutMs); + + const onStateChange = () => { + if (worker.state === "activated") { + window.clearTimeout(timeoutId); + worker.removeEventListener("statechange", onStateChange); + resolve(); + } else if (worker.state === "redundant") { + window.clearTimeout(timeoutId); + worker.removeEventListener("statechange", onStateChange); + console.error( + "Book navigation interceptor service worker became redundant" + ); + reject(new Error("Service worker became redundant")); + } + }; + + worker.addEventListener("statechange", onStateChange); + }); +} + +function waitForServiceWorkerReady( + timeoutMs = serviceWorkerRegistrationTimeoutMs +) { + return new Promise((resolve, reject) => { + const timeoutId = window.setTimeout(() => { + console.error( + "Timed out waiting for book navigation interceptor service worker readiness", + { timeoutMs } + ); + reject(new Error("Timed out waiting for service worker readiness")); + }, timeoutMs); + + navigator.serviceWorker.ready + .then((registration) => { + window.clearTimeout(timeoutId); + resolve(registration); + }) + .catch((error) => { + window.clearTimeout(timeoutId); + console.error( + "Failed while waiting for book navigation interceptor service worker readiness", + error + ); + reject(error); + }); + }); +} + +function waitForServiceWorkerControl( + timeoutMs = serviceWorkerRegistrationTimeoutMs +) { + if (navigator.serviceWorker.controller) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + const timeoutId = window.setTimeout(() => { + navigator.serviceWorker.removeEventListener( + "controllerchange", + onControllerChange + ); + console.error( + "Book navigation interceptor service worker did not take control before timeout", + { timeoutMs } + ); + resolve(); + }, timeoutMs); + + const onControllerChange = () => { + window.clearTimeout(timeoutId); + navigator.serviceWorker.removeEventListener( + "controllerchange", + onControllerChange + ); + resolve(); + }; + + navigator.serviceWorker.addEventListener( + "controllerchange", + onControllerChange + ); + }); +} + +async function ensureBookNavigationInterceptorRegistered() { + if (!("serviceWorker" in navigator)) { + return; + } + + const registration = await navigator.serviceWorker.register( + bookNavigationInterceptorServiceWorkerUrl, + { scope: "/" } + ); + const worker = + registration.installing || registration.waiting || registration.active; + if (worker) { + await waitForServiceWorkerActivation(worker); + } + + await waitForServiceWorkerReady(); + await waitForServiceWorkerControl(); +} + const ReadBookPage: React.FunctionComponent = (props) => { const id = props.id; const history = useHistory(); @@ -34,6 +165,9 @@ const ReadBookPage: React.FunctionComponent = (props) => { const { mobile } = useContext(OSFeaturesContext); const widerThanPhone = useMediaQuery("(min-width:450px)"); // a bit more than the largest phone width in the chrome debugger (411px) const higherThanPhone = useMediaQuery("(min-height:450px)"); + const [iframeInterceptionReady, setIframeInterceptionReady] = useState( + !("serviceWorker" in navigator) + ); // If either dimension is smaller than a phone, we'll guess we are on one // and go full screen automatically. @@ -103,6 +237,19 @@ const ReadBookPage: React.FunctionComponent = (props) => { }, [history]); useEffect(() => startingBook(), [id]); + useEffect(() => { + ensureBookNavigationInterceptorRegistered() + .catch((error) => { + console.error( + "Unable to register book navigation interceptor service worker", + error + ); + }) + .finally(() => { + setIframeInterceptionReady(true); + }); + }, []); + // We don't use rotateParams here, because one caller wants to call it // immediately after calling setRotateParams, when the new values won't be // available. @@ -193,7 +340,12 @@ const ReadBookPage: React.FunctionComponent = (props) => { getBookAnalyticsInfo(book, contextLangTag, "read"), !!book ); - const url = book ? getUrlOfHtmlOfDigitalVersion(book) : "working"; // url=working shows a loading icon + const url = book + ? getUrlOfHtmlOfDigitalVersion( + Book.getHarvesterBaseUrl(book), + "index.htm" + ) || "working" + : "working"; // url=working shows a loading icon // use the bloomplayer.htm we copy into our public/ folder, where CRA serves from // TODO: this isn't working with react-router, but I don't know how RR even gets run inside of this iframe @@ -304,7 +456,7 @@ const ReadBookPage: React.FunctionComponent = (props) => { // }); return ( - {url === "working" || ( + {url === "working" || !iframeInterceptionReady || (