Skip to content

Commit e11122e

Browse files
committed
support PWA, resolve #5
1 parent 92ecedb commit e11122e

34 files changed

+924
-19
lines changed

app/entry.client.tsx

+69
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,72 @@ import { RemixBrowser } from '@remix-run/react';
22
import { hydrate } from 'react-dom';
33

44
hydrate(<RemixBrowser />, document);
5+
6+
function urlBase64ToUint8Array(base64String: string): Uint8Array {
7+
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
8+
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
9+
10+
const rawData = window.atob(base64);
11+
const outputArray = new Uint8Array(rawData.length);
12+
13+
for (let i = 0; i < rawData.length; ++i) {
14+
outputArray[i] = rawData.charCodeAt(i);
15+
}
16+
return outputArray;
17+
}
18+
19+
if ('serviceWorker' in navigator) {
20+
// Use the window load event to keep the page load performant
21+
window.addEventListener('load', () => {
22+
navigator.serviceWorker
23+
.register('/entry.worker.js')
24+
.then(() => navigator.serviceWorker.ready)
25+
.then(() => {
26+
if (navigator.serviceWorker.controller) {
27+
navigator.serviceWorker.controller.postMessage({
28+
type: 'SYNC_REMIX_MANIFEST',
29+
manifest: window.__remixManifest,
30+
});
31+
} else {
32+
navigator.serviceWorker.addEventListener('controllerchange', () => {
33+
navigator.serviceWorker.controller?.postMessage({
34+
type: 'SYNC_REMIX_MANIFEST',
35+
manifest: window.__remixManifest,
36+
});
37+
});
38+
}
39+
})
40+
.catch((error) => {
41+
console.error('Service worker registration failed', error);
42+
});
43+
});
44+
}
45+
46+
navigator.serviceWorker.ready
47+
.then((registration) => {
48+
const subscription = registration.pushManager.getSubscription();
49+
return { subscription, registration };
50+
})
51+
.then(async (sub) => {
52+
if (await sub.subscription) {
53+
return sub.subscription;
54+
}
55+
56+
const subInfo = await fetch('/resources/subscribe');
57+
const returnedSubscription = await subInfo.text();
58+
59+
const convertedVapidKey = urlBase64ToUint8Array(returnedSubscription);
60+
return sub.registration.pushManager.subscribe({
61+
userVisibleOnly: true,
62+
applicationServerKey: convertedVapidKey,
63+
});
64+
})
65+
.then(async (subscription) => {
66+
await fetch('./resources/subscribe', {
67+
method: 'POST',
68+
body: JSON.stringify({
69+
subscription: subscription,
70+
type: 'POST_SUBSCRIPTION',
71+
}),
72+
});
73+
});

app/entry.worker.ts

+237
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
/// <reference lib="WebWorker" />
2+
3+
import { json } from '@remix-run/server-runtime';
4+
5+
export type {};
6+
declare let self: ServiceWorkerGlobalScope;
7+
8+
const STATIC_ASSETS = ['/build/', '/icons/', '/'];
9+
10+
const ASSET_CACHE = 'asset-cache';
11+
const DATA_CACHE = 'data-cache';
12+
const DOCUMENT_CACHE = 'document-cache';
13+
14+
function debug(...messages: any[]) {
15+
if (process.env.NODE_ENV === 'development') {
16+
console.debug(...messages);
17+
}
18+
}
19+
20+
async function handleInstall(event: ExtendableEvent) {
21+
debug('Service worker installed');
22+
}
23+
24+
async function handleActivate(event: ExtendableEvent) {
25+
debug('Service worker activated');
26+
}
27+
28+
async function handleMessage(event: ExtendableMessageEvent) {
29+
const cachePromises: Map<string, Promise<void>> = new Map();
30+
31+
if (event.data.type === 'REMIX_NAVIGATION') {
32+
const { isMount, location, matches, manifest } = event.data;
33+
const documentUrl = location.pathname + location.search + location.hash;
34+
35+
const [dataCache, documentCache, existingDocument] = await Promise.all([
36+
caches.open(DATA_CACHE),
37+
caches.open(DOCUMENT_CACHE),
38+
caches.match(documentUrl),
39+
]);
40+
41+
if (!existingDocument || !isMount) {
42+
debug('Caching document for', documentUrl);
43+
cachePromises.set(
44+
documentUrl,
45+
documentCache.add(documentUrl).catch((error) => {
46+
debug(`Failed to cache document for ${documentUrl}:`, error);
47+
}),
48+
);
49+
}
50+
51+
if (isMount) {
52+
for (const match of matches) {
53+
if (manifest.routes[match.id].hasLoader) {
54+
const params = new URLSearchParams(location.search);
55+
params.set('_data', match.id);
56+
let search = params.toString();
57+
search = search ? `?${search}` : '';
58+
const url = location.pathname + search + location.hash;
59+
if (!cachePromises.has(url)) {
60+
debug('Caching data for', url);
61+
cachePromises.set(
62+
url,
63+
dataCache.add(url).catch((error) => {
64+
debug(`Failed to cache data for ${url}:`, error);
65+
}),
66+
);
67+
}
68+
}
69+
}
70+
}
71+
}
72+
73+
await Promise.all(cachePromises.values());
74+
}
75+
76+
async function handleFetch(event: FetchEvent): Promise<Response> {
77+
const url = new URL(event.request.url);
78+
79+
if (isAssetRequest(event.request)) {
80+
const cached = await caches.match(event.request, {
81+
cacheName: ASSET_CACHE,
82+
ignoreVary: true,
83+
ignoreSearch: true,
84+
});
85+
if (cached) {
86+
debug('Serving asset from cache', url.pathname);
87+
return cached;
88+
}
89+
90+
debug('Serving asset from network', url.pathname);
91+
const response = await fetch(event.request);
92+
if (response.status === 200) {
93+
const cache = await caches.open(ASSET_CACHE);
94+
await cache.put(event.request, response.clone());
95+
}
96+
return response;
97+
}
98+
99+
if (isLoaderRequest(event.request)) {
100+
try {
101+
debug('Serving data from network', url.pathname + url.search);
102+
const response = await fetch(event.request.clone());
103+
const cache = await caches.open(DATA_CACHE);
104+
await cache.put(event.request, response.clone());
105+
return response;
106+
} catch (error) {
107+
debug(
108+
'Serving data from network failed, falling back to cache',
109+
url.pathname + url.search,
110+
);
111+
const response = await caches.match(event.request);
112+
if (response) {
113+
response.headers.set('X-Remix-Worker', 'yes');
114+
return response;
115+
}
116+
117+
return json(
118+
{ message: 'Network Error' },
119+
{
120+
status: 500,
121+
headers: { 'X-Remix-Catch': 'yes', 'X-Remix-Worker': 'yes' },
122+
},
123+
);
124+
}
125+
}
126+
127+
if (isDocumentGetRequest(event.request)) {
128+
try {
129+
debug('Serving document from network', url.pathname);
130+
const response = await fetch(event.request);
131+
const cache = await caches.open(DOCUMENT_CACHE);
132+
await cache.put(event.request, response.clone());
133+
return response;
134+
} catch (error) {
135+
debug(
136+
'Serving document from network failed, falling back to cache',
137+
url.pathname,
138+
);
139+
const response = await caches.match(event.request);
140+
if (response) {
141+
return response;
142+
}
143+
throw error;
144+
}
145+
}
146+
147+
return fetch(event.request.clone());
148+
}
149+
150+
const handlePush = (event: PushEvent) => {
151+
const data = JSON.parse(event?.data!.text());
152+
const title = data.title ? data.title : 'Remix PWA';
153+
154+
const options = {
155+
body: data.body ? data.body : 'Notification Body Text',
156+
icon: data.icon ? data.icon : '/icons/android-icon-192x192.png',
157+
badge: data.badge ? data.badge : '/icons/android-icon-48x48.png',
158+
dir: data.dir ? data.dir : 'auto',
159+
image: data.image ? data.image : undefined,
160+
silent: data.silent ? data.silent : false,
161+
};
162+
163+
self.registration.showNotification(title, {
164+
...options,
165+
});
166+
};
167+
168+
function isMethod(request: Request, methods: string[]) {
169+
return methods.includes(request.method.toLowerCase());
170+
}
171+
172+
function isAssetRequest(request: Request) {
173+
return (
174+
isMethod(request, ['get']) &&
175+
STATIC_ASSETS.some((publicPath) => request.url.startsWith(publicPath))
176+
);
177+
}
178+
179+
function isLoaderRequest(request: Request) {
180+
const url = new URL(request.url);
181+
return isMethod(request, ['get']) && url.searchParams.get('_data');
182+
}
183+
184+
function isDocumentGetRequest(request: Request) {
185+
return isMethod(request, ['get']) && request.mode === 'navigate';
186+
}
187+
188+
self.addEventListener('install', (event) => {
189+
event.waitUntil(handleInstall(event).then(() => self.skipWaiting()));
190+
});
191+
192+
self.addEventListener('activate', (event) => {
193+
event.waitUntil(handleActivate(event).then(() => self.clients.claim()));
194+
});
195+
196+
self.addEventListener('message', (event) => {
197+
event.waitUntil(handleMessage(event));
198+
});
199+
200+
self.addEventListener('push', (event) => {
201+
// self.clients.matchAll().then(function (c) {
202+
// if (c.length === 0) {
203+
event.waitUntil(handlePush(event));
204+
// } else {
205+
// console.log("Application is already open!");
206+
// }
207+
// });
208+
});
209+
210+
self.addEventListener('fetch', (event) => {
211+
event.respondWith(
212+
(async () => {
213+
const result = {} as
214+
| { error: unknown; response: Response }
215+
| { error: undefined; response: Response };
216+
try {
217+
result.response = await handleFetch(event);
218+
} catch (error) {
219+
result.error = error;
220+
}
221+
222+
return appHandleFetch(event, result);
223+
})(),
224+
);
225+
});
226+
227+
async function appHandleFetch(
228+
event: FetchEvent,
229+
{
230+
error,
231+
response,
232+
}:
233+
| { error: unknown; response: Response }
234+
| { error: undefined; response: Response },
235+
): Promise<Response> {
236+
return response;
237+
}

0 commit comments

Comments
 (0)