Skip to content

Commit cbdbf57

Browse files
authored
Merge pull request #609 from BloomBooks/BL-15694_support_navigation_links_on_blorg
BL-15694 Support navigation links on blorg (#609)
2 parents 181adc5 + 920ad19 commit cbdbf57

11 files changed

Lines changed: 570 additions & 113 deletions
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { getDataSourceForHostname } from "./connection/DataSource";
2+
import { createParseConnection } from "./connection/ParseConnectionConfig";
3+
import {
4+
getUrlOfHtmlOfDigitalVersion,
5+
getHarvesterBaseUrlFromBaseUrl,
6+
} from "./model/BookUrlUtils";
7+
8+
const bloomPlayerPath = "/bloom-player/bloomplayer.htm";
9+
10+
// Activate a newly installed interceptor immediately for the current read session.
11+
self.addEventListener("install", (event) => {
12+
event.waitUntil(self.skipWaiting());
13+
});
14+
15+
// Take control of already-open matching pages so /book/... requests are intercepted right away.
16+
self.addEventListener("activate", (event) => {
17+
event.waitUntil(self.clients.claim());
18+
});
19+
20+
self.addEventListener("fetch", (event) => {
21+
const requestUrl = new URL(event.request.url);
22+
if (
23+
event.request.method !== "GET" ||
24+
!requestUrl.pathname.includes("/book/")
25+
) {
26+
return;
27+
}
28+
29+
event.respondWith(interceptBookRequest(event));
30+
});
31+
32+
async function interceptBookRequest(event) {
33+
if (!(await requestCameFromBloomPlayer(event))) {
34+
return fetch(event.request);
35+
}
36+
const requestUrl = new URL(event.request.url);
37+
const requestInfo = parseBookRequest(requestUrl);
38+
if (!requestInfo) {
39+
console.error("Failed to parse book request URL");
40+
return new Response("Invalid book request URL", { status: 400 });
41+
}
42+
try {
43+
// enhance: better handling of cases where multiple books have the same bookInstanceId
44+
const query = new URLSearchParams({
45+
where: JSON.stringify({
46+
bookInstanceId: requestInfo.bookInstanceId,
47+
harvestState: "Done",
48+
inCirculation: true,
49+
}),
50+
limit: "1",
51+
keys: "baseUrl,harvestState,bookInstanceId",
52+
});
53+
const bookData = await retrieveBookData(query);
54+
if (!bookData || !bookData.baseUrl) {
55+
console.error("Book not found or not ready for reading", {
56+
bookInstanceId: requestInfo.bookInstanceId,
57+
harvestState: bookData?.harvestState,
58+
hasBaseUrl: !!bookData?.baseUrl,
59+
});
60+
return new Response("Book not found or not ready for reading", {
61+
status: 404,
62+
});
63+
}
64+
const harvesterBaseUrl = getHarvesterBaseUrlFromBaseUrl(
65+
bookData.baseUrl,
66+
self.location.hostname === "localhost"
67+
);
68+
if (!harvesterBaseUrl) {
69+
console.error("Failed to construct harvester base URL");
70+
return new Response("Failed to construct book URL", {
71+
status: 500,
72+
});
73+
}
74+
75+
const redirectUrl = `${getUrlOfHtmlOfDigitalVersion(
76+
harvesterBaseUrl,
77+
requestInfo.filePath
78+
)}${requestUrl.search}`;
79+
// e.g. http://localhost:5174/s3/bloomharvest-sandbox/TkG1dWsW40%2f1768316502115/bloomdigital%2f${filePath}
80+
return Response.redirect(redirectUrl, 302);
81+
} catch (error) {
82+
console.error("Failed to redirect Bloom Player book request", error);
83+
return new Response("Failed to load book: " + error.message, {
84+
status: 500,
85+
});
86+
}
87+
}
88+
89+
function parseBookRequest(requestUrl) {
90+
// e.g. http://localhost:5174/book/36befbb8-8201-42cc-8faa-5c9432a985dd/index.htm
91+
const requestPath = requestUrl.pathname.split("/book/")[1];
92+
if (!requestPath) {
93+
return undefined;
94+
}
95+
96+
const firstSlashIndex = requestPath.indexOf("/");
97+
if (firstSlashIndex < 0 || firstSlashIndex === requestPath.length - 1) {
98+
return undefined;
99+
}
100+
101+
return {
102+
bookInstanceId: decodeURIComponent(
103+
requestPath.substring(0, firstSlashIndex)
104+
),
105+
filePath: requestPath.substring(firstSlashIndex + 1),
106+
};
107+
}
108+
109+
async function retrieveBookData(query) {
110+
// The main app caches the X-Parse-Session-Token, but as of March 2026 we don't need that in the service worker
111+
// anyway so we can just create a parse connection object
112+
const connection = createParseConnection(
113+
getDataSourceForHostname(self.location.hostname)
114+
);
115+
const response = await fetch(`${connection.url}classes/books?${query}`, {
116+
headers: connection.headers,
117+
});
118+
119+
if (!response.ok) {
120+
throw new Error(`Parse lookup failed: ${response.status}`);
121+
}
122+
123+
const data = await response.json();
124+
return data.results?.[0];
125+
}
126+
127+
async function requestCameFromBloomPlayer(event) {
128+
const clientId = event.clientId || event.resultingClientId;
129+
if (clientId) {
130+
const client = await self.clients.get(clientId);
131+
if (client && client.url.includes(bloomPlayerPath)) {
132+
return true;
133+
}
134+
}
135+
136+
return event.request.referrer.includes(bloomPlayerPath);
137+
}

src/components/BookDetail/ArtifactHelper.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -223,9 +223,3 @@ function getDownloadUrl(book: Book, fileType: string): string | undefined {
223223
}
224224
return undefined;
225225
}
226-
export function getUrlOfHtmlOfDigitalVersion(book: Book) {
227-
const harvesterBaseUrl = Book.getHarvesterBaseUrl(book);
228-
// use this if you are are working on bloom-player and are using the bloom-player npm script tobloomlibrary
229-
// bloomPlayerUrl = "http://localhost:3000/bloomplayer-for-developing.htm";
230-
return harvesterBaseUrl + "bloomdigital%2findex.htm";
231-
}

src/components/ReadBookPage.tsx

Lines changed: 155 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import React, {
99
} from "react";
1010
import { useGetBookDetail } from "../connection/LibraryQueryHooks";
1111
import { Book } from "../model/Book";
12-
import { getUrlOfHtmlOfDigitalVersion } from "./BookDetail/ArtifactHelper";
12+
import { getUrlOfHtmlOfDigitalVersion } from "../model/BookUrlUtils";
1313
import { useHistory, useLocation } from "react-router-dom";
1414
import { useTrack } from "../analytics/Analytics";
1515
import { getBookAnalyticsInfo } from "../analytics/BookAnalyticsInfo";
@@ -27,13 +27,147 @@ import { useMediaQuery } from "@material-ui/core";
2727
import { OSFeaturesContext } from "./OSFeaturesContext";
2828
import { IReadBookPageProps } from "./ReadBookPageCodeSplit";
2929

30+
// To make links in books able to open other books, we need to intercept requests from the Bloom Player iframe to our
31+
// /book/... URLs. To do that, we register a service worker that intercepts those requests
32+
// instead of trying to load them as pages. This file is the service worker that does that interception.
33+
// It is registered in ReadBookPage.tsx and bundled into our build by vite.config.ts.
34+
const bookNavigationInterceptorServiceWorkerUrl =
35+
"/book-navigation-interceptor-sw.js";
36+
37+
// Let's not risk totally blocking the page if something goes wrong
38+
// with the service worker, since most books won't have links anyway
39+
const serviceWorkerRegistrationTimeoutMs = 5000;
40+
41+
function waitForServiceWorkerActivation(
42+
worker: ServiceWorker,
43+
timeoutMs = serviceWorkerRegistrationTimeoutMs
44+
) {
45+
if (worker.state === "activated") {
46+
return Promise.resolve();
47+
}
48+
49+
return new Promise<void>((resolve, reject) => {
50+
const timeoutId = window.setTimeout(() => {
51+
worker.removeEventListener("statechange", onStateChange);
52+
console.error(
53+
"Book navigation interceptor service worker activation timed out",
54+
{ state: worker.state, timeoutMs }
55+
);
56+
reject(new Error("Service worker activation timed out"));
57+
}, timeoutMs);
58+
59+
const onStateChange = () => {
60+
if (worker.state === "activated") {
61+
window.clearTimeout(timeoutId);
62+
worker.removeEventListener("statechange", onStateChange);
63+
resolve();
64+
} else if (worker.state === "redundant") {
65+
window.clearTimeout(timeoutId);
66+
worker.removeEventListener("statechange", onStateChange);
67+
console.error(
68+
"Book navigation interceptor service worker became redundant"
69+
);
70+
reject(new Error("Service worker became redundant"));
71+
}
72+
};
73+
74+
worker.addEventListener("statechange", onStateChange);
75+
});
76+
}
77+
78+
function waitForServiceWorkerReady(
79+
timeoutMs = serviceWorkerRegistrationTimeoutMs
80+
) {
81+
return new Promise<ServiceWorkerRegistration>((resolve, reject) => {
82+
const timeoutId = window.setTimeout(() => {
83+
console.error(
84+
"Timed out waiting for book navigation interceptor service worker readiness",
85+
{ timeoutMs }
86+
);
87+
reject(new Error("Timed out waiting for service worker readiness"));
88+
}, timeoutMs);
89+
90+
navigator.serviceWorker.ready
91+
.then((registration) => {
92+
window.clearTimeout(timeoutId);
93+
resolve(registration);
94+
})
95+
.catch((error) => {
96+
window.clearTimeout(timeoutId);
97+
console.error(
98+
"Failed while waiting for book navigation interceptor service worker readiness",
99+
error
100+
);
101+
reject(error);
102+
});
103+
});
104+
}
105+
106+
function waitForServiceWorkerControl(
107+
timeoutMs = serviceWorkerRegistrationTimeoutMs
108+
) {
109+
if (navigator.serviceWorker.controller) {
110+
return Promise.resolve();
111+
}
112+
113+
return new Promise<void>((resolve) => {
114+
const timeoutId = window.setTimeout(() => {
115+
navigator.serviceWorker.removeEventListener(
116+
"controllerchange",
117+
onControllerChange
118+
);
119+
console.error(
120+
"Book navigation interceptor service worker did not take control before timeout",
121+
{ timeoutMs }
122+
);
123+
resolve();
124+
}, timeoutMs);
125+
126+
const onControllerChange = () => {
127+
window.clearTimeout(timeoutId);
128+
navigator.serviceWorker.removeEventListener(
129+
"controllerchange",
130+
onControllerChange
131+
);
132+
resolve();
133+
};
134+
135+
navigator.serviceWorker.addEventListener(
136+
"controllerchange",
137+
onControllerChange
138+
);
139+
});
140+
}
141+
142+
async function ensureBookNavigationInterceptorRegistered() {
143+
if (!("serviceWorker" in navigator)) {
144+
return;
145+
}
146+
147+
const registration = await navigator.serviceWorker.register(
148+
bookNavigationInterceptorServiceWorkerUrl,
149+
{ scope: "/" }
150+
);
151+
const worker =
152+
registration.installing || registration.waiting || registration.active;
153+
if (worker) {
154+
await waitForServiceWorkerActivation(worker);
155+
}
156+
157+
await waitForServiceWorkerReady();
158+
await waitForServiceWorkerControl();
159+
}
160+
30161
const ReadBookPage: React.FunctionComponent<IReadBookPageProps> = (props) => {
31162
const id = props.id;
32163
const history = useHistory();
33164
const location = useLocation();
34165
const { mobile } = useContext(OSFeaturesContext);
35166
const widerThanPhone = useMediaQuery("(min-width:450px)"); // a bit more than the largest phone width in the chrome debugger (411px)
36167
const higherThanPhone = useMediaQuery("(min-height:450px)");
168+
const [iframeInterceptionReady, setIframeInterceptionReady] = useState(
169+
!("serviceWorker" in navigator)
170+
);
37171

38172
// If either dimension is smaller than a phone, we'll guess we are on one
39173
// and go full screen automatically.
@@ -103,6 +237,19 @@ const ReadBookPage: React.FunctionComponent<IReadBookPageProps> = (props) => {
103237
}, [history]);
104238
useEffect(() => startingBook(), [id]);
105239

240+
useEffect(() => {
241+
ensureBookNavigationInterceptorRegistered()
242+
.catch((error) => {
243+
console.error(
244+
"Unable to register book navigation interceptor service worker",
245+
error
246+
);
247+
})
248+
.finally(() => {
249+
setIframeInterceptionReady(true);
250+
});
251+
}, []);
252+
106253
// We don't use rotateParams here, because one caller wants to call it
107254
// immediately after calling setRotateParams, when the new values won't be
108255
// available.
@@ -193,7 +340,12 @@ const ReadBookPage: React.FunctionComponent<IReadBookPageProps> = (props) => {
193340
getBookAnalyticsInfo(book, contextLangTag, "read"),
194341
!!book
195342
);
196-
const url = book ? getUrlOfHtmlOfDigitalVersion(book) : "working"; // url=working shows a loading icon
343+
const url = book
344+
? getUrlOfHtmlOfDigitalVersion(
345+
Book.getHarvesterBaseUrl(book),
346+
"index.htm"
347+
) || "working"
348+
: "working"; // url=working shows a loading icon
197349

198350
// use the bloomplayer.htm we copy into our public/ folder, where CRA serves from
199351
// 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<IReadBookPageProps> = (props) => {
304456
// });
305457
return (
306458
<React.Fragment>
307-
{url === "working" || (
459+
{url === "working" || !iframeInterceptionReady || (
308460
<iframe
309461
title="bloom player"
310462
css={css`
@@ -339,32 +491,5 @@ function exitFullscreen() {
339491
}
340492
}
341493

342-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
343-
function getHarvesterBaseUrl(book: Book) {
344-
// typical input url:
345-
// https://s3.amazonaws.com/BloomLibraryBooks-Sandbox/ken%40example.com%2faa647178-ed4d-4316-b8bf-0dc94536347d%2fsign+language+test%2f
346-
// want:
347-
// https://s3.amazonaws.com/bloomharvest-sandbox/ken%40example.com%2faa647178-ed4d-4316-b8bf-0dc94536347d/
348-
// We come up with that URL by
349-
// (a) changing BloomLibraryBooks{-Sandbox} to bloomharvest{-sandbox}
350-
// (b) strip off everything after the next-to-final slash
351-
let folderWithoutLastSlash = book.baseUrl;
352-
if (book.baseUrl.endsWith("%2f")) {
353-
folderWithoutLastSlash = book.baseUrl.substring(
354-
0,
355-
book.baseUrl.length - 3
356-
);
357-
}
358-
const index = folderWithoutLastSlash.lastIndexOf("%2f");
359-
const pathWithoutBookName = folderWithoutLastSlash.substring(0, index);
360-
return (
361-
pathWithoutBookName
362-
.replace("BloomLibraryBooks-Sandbox", "bloomharvest-sandbox")
363-
.replace("BloomLibraryBooks", "bloomharvest") + "/"
364-
);
365-
// Using slash rather than %2f at the end helps us download as the filename we want.
366-
// Otherwise, the filename can be something like ken@example.com_007b3c03-52b7-4689-80bd-06fd4b6f9f28_Fox+and+Frog.bloompub
367-
}
368-
369494
// though we normally don't like to export defaults, this is required for react.lazy (code splitting)
370495
export default ReadBookPage;

0 commit comments

Comments
 (0)