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
140 changes: 140 additions & 0 deletions src/book-navigation-interceptor-sw.js
Original file line number Diff line number Diff line change
@@ -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);
}
6 changes: 0 additions & 6 deletions src/components/BookDetail/ArtifactHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
185 changes: 155 additions & 30 deletions src/components/ReadBookPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -27,13 +27,147 @@ 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<void>((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<ServiceWorkerRegistration>((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<void>((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<IReadBookPageProps> = (props) => {
const id = props.id;
const history = useHistory();
const location = useLocation();
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.
Expand Down Expand Up @@ -103,6 +237,19 @@ const ReadBookPage: React.FunctionComponent<IReadBookPageProps> = (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.
Expand Down Expand Up @@ -193,7 +340,12 @@ const ReadBookPage: React.FunctionComponent<IReadBookPageProps> = (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
Expand Down Expand Up @@ -304,7 +456,7 @@ const ReadBookPage: React.FunctionComponent<IReadBookPageProps> = (props) => {
// });
return (
<React.Fragment>
{url === "working" || (
{url === "working" || !iframeInterceptionReady || (
<iframe
title="bloom player"
css={css`
Expand Down Expand Up @@ -339,32 +491,5 @@ function exitFullscreen() {
}
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
function getHarvesterBaseUrl(book: Book) {
// typical input url:
// https://s3.amazonaws.com/BloomLibraryBooks-Sandbox/ken%40example.com%2faa647178-ed4d-4316-b8bf-0dc94536347d%2fsign+language+test%2f
// want:
// https://s3.amazonaws.com/bloomharvest-sandbox/ken%40example.com%2faa647178-ed4d-4316-b8bf-0dc94536347d/
// We come up with that URL by
// (a) changing BloomLibraryBooks{-Sandbox} to bloomharvest{-sandbox}
// (b) strip off everything after the next-to-final slash
let folderWithoutLastSlash = book.baseUrl;
if (book.baseUrl.endsWith("%2f")) {
folderWithoutLastSlash = book.baseUrl.substring(
0,
book.baseUrl.length - 3
);
}
const index = folderWithoutLastSlash.lastIndexOf("%2f");
const pathWithoutBookName = folderWithoutLastSlash.substring(0, index);
return (
pathWithoutBookName
.replace("BloomLibraryBooks-Sandbox", "bloomharvest-sandbox")
.replace("BloomLibraryBooks", "bloomharvest") + "/"
);
// Using slash rather than %2f at the end helps us download as the filename we want.
// Otherwise, the filename can be something like ken@example.com_007b3c03-52b7-4689-80bd-06fd4b6f9f28_Fox+and+Frog.bloompub
}

// though we normally don't like to export defaults, this is required for react.lazy (code splitting)
export default ReadBookPage;
Loading