chenzhaoyang
2025-12-17 063da0bf961e1d35e25dc107f883f7492f4c5a7c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
const CACHE_NAME = "ls-cache-v1";
const CACHE_EXPIRATION_HEADER = "x-cache-expiration";
const CACHE_EXPIRATION_DEFAULT = 50 * 1000; // 50 seconds
const CACHE_CLEANUP_INTERVAL = 60 * 60 * 1000; // 1 hour
 
function parseCacheHeaders(cachedResponse) {
  const expirationTimestamp = parseInt(
    cachedResponse.headers.get(CACHE_EXPIRATION_HEADER)
  );
 
  return {
    expirationTimestamp: isNaN(expirationTimestamp) ? 0 : expirationTimestamp,
  };
}
 
function removeExpiredItems() {
  caches.open(CACHE_NAME).then((cache) => {
    cache.keys().then((requests) => {
      requests.forEach((request) => {
        cache.match(request).then((response) => {
          const { expirationTimestamp } = parseCacheHeaders(response);
          if (expirationTimestamp && Date.now() > expirationTimestamp) {
            cache.delete(request);
          }
        });
      });
    });
  });
}
 
// Cleanup expired items every hour in case the user never refreshes the page
// This is to avoid the cache growing indefinitely, though the data is not large it is just a precaution
function startCleanupTimer() {
  if (!self.cleanupTimer) {
    removeExpiredItems(); // Perform cleanup immediately
    self.cleanupTimer = setInterval(removeExpiredItems, CACHE_CLEANUP_INTERVAL);
  }
}
 
async function handlePresignedUrl(event) {
  // For now just catch presign requests and cache them
  // to void constantly requesting the same presign URL
  // even when it is not expired.
  // This is so the server can just naively proxy the presign request
  // and the performance is not degraded.
  const requestsPathToCache = /\/presign\/|\/resolve\//;
 
  // Check if the request URL doesn't match the specified path part
  // or if it is not a request to the same origin we will just fetch it
  if (
    !event.request.url.startsWith(self.location.origin) ||
    !requestsPathToCache.test(event.request.url) ||
    // This is to avoid an error trying to load a direct presign URL in a new tab
    !event.request.referrer ||
    // Easier to leave this uncached as if we were to handle this caching
    // it would be more complex and not really worth the effort as it is not the most repeated
    // request and it is not a big deal if it is not cached.
    // If this was to be cached it forces a full resource request instead of a chunked ranged one.
    // This also avoids caching the data directly when resolving uri -> data, which is not the goal of this service worker
    event.request.headers.get("range")
  ) {
    // For other requests, just allow the network to handle it
    return false;
  }
 
  event.respondWith(
    // Cache first approach, look for the cached response and check if it's still valid
    caches
      .match(event.request)
      .then(async (cachedResponse) => {
        if (cachedResponse) {
          // Check if the expiration timestamp has passed
          const { expirationTimestamp } = parseCacheHeaders(cachedResponse);
 
          const valid =
            !expirationTimestamp || expirationTimestamp > Date.now();
          const url = cachedResponse.headers.get("Location");
 
          if (valid && url) {
            // If the cached response is a redirect, fetch the redirect URL
            return fetch(url);
          }
        }
 
        // If there's no valid cached response, fetch redirect from the network and cache the response
        // fetch the actual request to return to the user immediately
        return fetch(event.request.url).then((response) => {
          if (response.redirected) {
            // Get the Cache-Control header value if provided
            const cacheControl = response.headers.get("Cache-Control");
            // This will be the target URL if the response is a redirect
            const url = response.url;
 
            // Extract the max-age value from the Cache-Control header
            // If there's no max-age, use the default value
            const maxAgeMatch =
              cacheControl && cacheControl.match(/max-age=(\d+)/);
 
            let maxAge = CACHE_EXPIRATION_DEFAULT;
 
            if (maxAgeMatch) {
              const _age = parseInt(maxAgeMatch[1]);
              if (!isNaN(_age)) {
                maxAge = (_age - 10) * 1000; // Leave 10s of margin so there is no possible overlap
              }
            }
 
            // Calculate the expiration timestamp based on the max-age value
            const expirationTimestamp = Date.now() + maxAge;
 
            const headers = new Headers(response.headers);
            headers.append("Location", url);
            headers.append(CACHE_EXPIRATION_HEADER, expirationTimestamp);
 
            // Create a redirect response with the expiration timestamp header
            const cachedResponse = new Response(null, {
              status: 303,
              statusText: "See Other",
              headers: headers,
            });
 
            // Store the new Response object in the cache
            caches.open(CACHE_NAME).then(async (cache) => {
              try {
                await cache.put(event.request, cachedResponse);
              } catch (error) {
                if (error.name !== "QuotaExceededError") {
                  // Ignore QuotaExceededError errors, they are expected
                  console.error("Cache put failed:", error);
                }
              }
            });
          }
 
          return response;
        });
      })
      .catch((error) => {
        console.error("Fetch failed:", error);
        throw error;
      })
  );
 
  // response was handled by the serviceworker.
  // this is for later so we can create a middleware style pipeline to attempt to match and handle
  // the request in order of priority
  return true;
}
 
self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then(() => {
      console.log("Opened cache:", CACHE_NAME);
    })
  );
});
 
self.addEventListener("activate", (event) => {
  event.waitUntil(clients.claim());
});
 
self.addEventListener("fetch", async (event) => {
  await handlePresignedUrl(event);
});
 
self.addEventListener("message", (event) => {
  if (event.data && event.data.type === "awaken") {
    startCleanupTimer();
  }
});
 
startCleanupTimer();