-
Notifications
You must be signed in to change notification settings - Fork 4.6k
chore: Prefetch module apis in service worker #34003
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 9 commits
876c606
59e2a3c
200295e
d65d297
5af3a7b
88901d0
631ee97
3b045f0
700da97
787bc5a
ddd578e
fda023a
5619d00
4b25d98
c23df88
785197e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,252 @@ | ||||||
| import { Mutex } from "async-mutex"; | ||||||
| import { APP_MODE } from "entities/App"; | ||||||
| import type { Match, TokensToRegexpOptions } from "path-to-regexp"; | ||||||
| import { match } from "path-to-regexp"; | ||||||
|
|
||||||
| export const BUILDER_PATH = `/app/:applicationSlug/:pageSlug(.*\-):pageId/edit`; | ||||||
| export const BUILDER_CUSTOM_PATH = `/app/:customSlug(.*\-):pageId/edit`; | ||||||
| export const VIEWER_PATH = `/app/:applicationSlug/:pageSlug(.*\-):pageId`; | ||||||
| export const VIEWER_CUSTOM_PATH = `/app/:customSlug(.*\-):pageId`; | ||||||
| export const BUILDER_PATH_DEPRECATED = `/applications/:applicationId/pages/:pageId/edit`; | ||||||
| export const VIEWER_PATH_DEPRECATED = `/applications/:applicationId/pages/:pageId`; | ||||||
|
|
||||||
| interface TMatchResult { | ||||||
| pageId?: string; | ||||||
| applicationId?: string; | ||||||
| } | ||||||
|
|
||||||
| export interface TApplicationParams { | ||||||
| origin: string; | ||||||
| pageId?: string; | ||||||
| applicationId?: string; | ||||||
| branchName: string; | ||||||
| appMode: APP_MODE; | ||||||
| } | ||||||
|
|
||||||
| type TApplicationParamsOrNull = TApplicationParams | null; | ||||||
|
|
||||||
| export const cachedApiUrlRegex = new RegExp("/api/v1/consolidated-api/"); | ||||||
|
|
||||||
| /** | ||||||
| * Function to match the path with the builder path | ||||||
| */ | ||||||
| export const matchBuilderPath = ( | ||||||
| pathName: string, | ||||||
| options: TokensToRegexpOptions, | ||||||
| ) => | ||||||
| match<TMatchResult>(BUILDER_PATH, options)(pathName) || | ||||||
| match<TMatchResult>(BUILDER_PATH_DEPRECATED, options)(pathName) || | ||||||
| match<TMatchResult>(BUILDER_CUSTOM_PATH, options)(pathName); | ||||||
|
|
||||||
| /** | ||||||
| * Function to match the path with the viewer path | ||||||
| */ | ||||||
| export const matchViewerPath = (pathName: string) => | ||||||
| match<TMatchResult>(VIEWER_PATH)(pathName) || | ||||||
| match<TMatchResult>(VIEWER_PATH_DEPRECATED)(pathName) || | ||||||
| match<TMatchResult>(VIEWER_CUSTOM_PATH)(pathName); | ||||||
|
|
||||||
| /** | ||||||
| * returns the value in the query string for a key | ||||||
| */ | ||||||
| export const getSearchQuery = (search = "", key: string) => { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Adjust the default parameter to follow the last required parameter. - export const getSearchQuery = (search = "", key: string) => {
+ export const getSearchQuery = (key: string, search = "") => {Committable suggestion
Suggested change
ToolsBiome
|
||||||
| const params = new URLSearchParams(search); | ||||||
| return decodeURIComponent(params.get(key) || ""); | ||||||
| }; | ||||||
|
|
||||||
| export const getApplicationParamsFromUrl = ( | ||||||
| url: URL, | ||||||
| ): TApplicationParamsOrNull => { | ||||||
| // Get the branch name from the query string | ||||||
| const branchName = getSearchQuery(url.search, "branch"); | ||||||
|
|
||||||
| const matchedBuilder: Match<TMatchResult> = matchBuilderPath(url.pathname, { | ||||||
| end: false, | ||||||
| }); | ||||||
| const matchedViewer: Match<TMatchResult> = matchViewerPath(url.pathname); | ||||||
|
|
||||||
| if (matchedBuilder) { | ||||||
| return { | ||||||
| origin: url.origin, | ||||||
| pageId: matchedBuilder.params.pageId, | ||||||
| applicationId: matchedBuilder.params.applicationId, | ||||||
| branchName, | ||||||
| appMode: APP_MODE.EDIT, | ||||||
| }; | ||||||
| } | ||||||
|
|
||||||
| if (matchedViewer) { | ||||||
| return { | ||||||
| origin: url.origin, | ||||||
| pageId: matchedViewer.params.pageId, | ||||||
| applicationId: matchedViewer.params.applicationId, | ||||||
| branchName, | ||||||
| appMode: APP_MODE.PUBLISHED, | ||||||
| }; | ||||||
| } | ||||||
|
|
||||||
| return null; | ||||||
| }; | ||||||
|
|
||||||
| /** | ||||||
| * Function to get the prefetch request for consolidated api | ||||||
| */ | ||||||
| export const getConsolidatedApiPrefetchRequest = ( | ||||||
| applicationProps: TApplicationParams, | ||||||
| ) => { | ||||||
| const { applicationId, appMode, branchName, origin, pageId } = | ||||||
| applicationProps; | ||||||
|
|
||||||
| const headers = new Headers(); | ||||||
| const searchParams = new URLSearchParams(); | ||||||
|
|
||||||
| if (!pageId) { | ||||||
| return null; | ||||||
| } | ||||||
|
|
||||||
| searchParams.append("defaultPageId", pageId); | ||||||
|
|
||||||
| if (applicationId) { | ||||||
| searchParams.append("applicationId", applicationId); | ||||||
| } | ||||||
|
|
||||||
| // Add the branch name to the headers | ||||||
| if (branchName) { | ||||||
| headers.append("Branchname", branchName); | ||||||
| } | ||||||
|
|
||||||
| // If the URL matches the builder path | ||||||
| if (appMode === APP_MODE.EDIT) { | ||||||
| const requestUrl = `${origin}/api/v1/consolidated-api/edit?${searchParams.toString()}`; | ||||||
| const request = new Request(requestUrl, { method: "GET", headers }); | ||||||
| return request; | ||||||
| } | ||||||
|
|
||||||
| // If the URL matches the viewer path | ||||||
| if (appMode === APP_MODE.PUBLISHED) { | ||||||
| const requestUrl = `${origin}/api/v1/consolidated-api/view?${searchParams.toString()}`; | ||||||
| const request = new Request(requestUrl, { method: "GET", headers }); | ||||||
| return request; | ||||||
| } | ||||||
|
|
||||||
| return null; | ||||||
| }; | ||||||
|
|
||||||
| /** | ||||||
| * Function to get the prefetch request for module apis | ||||||
| */ | ||||||
| export const getPrefetchModuleApiRequests = ( | ||||||
| // eslint-disable-next-line @typescript-eslint/no-unused-vars | ||||||
| applicationProps: TApplicationParams, | ||||||
| ): Request[] => { | ||||||
| return []; | ||||||
| }; | ||||||
|
|
||||||
| /** | ||||||
| * Cache strategy for Appsmith API | ||||||
| */ | ||||||
| export class PrefetchApiCacheStrategy { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: This can be renamed to PrefetchApiCachingService
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. or PrefetchApiService |
||||||
| cacheName = "prefetch-cache-v1"; | ||||||
| cacheMaxAge = 2 * 60 * 1000; // 2 minutes in milliseconds | ||||||
| // Mutex to lock the fetch and cache operation | ||||||
| prefetchFetchMutexMap = new Map<string, Mutex>(); | ||||||
|
|
||||||
| constructor() {} | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove the unnecessary constructor to simplify the class definition. - constructor() {}Committable suggestion
Suggested change
ToolsBiome
|
||||||
|
|
||||||
| // Function to get the request key | ||||||
| getRequestKey = (request: Request) => { | ||||||
| const headersKey = Array.from(request.headers.entries()) | ||||||
| .map(([key, value]) => `${key}:${value}`) | ||||||
| .join(","); | ||||||
| return `${request.method}:${request.url}:${headersKey}`; | ||||||
| }; | ||||||
|
|
||||||
| // Function to acquire the fetch mutex for a request | ||||||
| aqcuireFetchMutex = async (request: Request) => { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we could consider moving mutex functions into a separate file to improve readability.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. and have separate jest test cases for the mutex file
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The mutex map is a state specific to this class. The methods |
||||||
| const requestKey = this.getRequestKey(request); | ||||||
| let mutex = this.prefetchFetchMutexMap.get(requestKey); | ||||||
|
|
||||||
| if (!mutex) { | ||||||
| mutex = new Mutex(); | ||||||
| this.prefetchFetchMutexMap.set(requestKey, mutex); | ||||||
| } | ||||||
|
|
||||||
| return mutex.acquire(); | ||||||
| }; | ||||||
|
|
||||||
| // Function to wait for the lock to be released for a request | ||||||
| waitForUnlock = async (request: Request) => { | ||||||
| const requestKey = this.getRequestKey(request); | ||||||
| const mutex = this.prefetchFetchMutexMap.get(requestKey); | ||||||
|
|
||||||
| if (mutex) { | ||||||
| return mutex.waitForUnlock(); | ||||||
| } | ||||||
| }; | ||||||
|
|
||||||
| // Function to release the fetch mutex for a request | ||||||
| releaseFetchMutex = (request: Request) => { | ||||||
| const requestKey = this.getRequestKey(request); | ||||||
| const mutex = this.prefetchFetchMutexMap.get(requestKey); | ||||||
|
|
||||||
| if (mutex) { | ||||||
| mutex.release(); | ||||||
| } | ||||||
| }; | ||||||
|
|
||||||
| /** | ||||||
| * Function to fetch and cache the consolidated API | ||||||
| */ | ||||||
| async cacheApi(request: Request) { | ||||||
| // Acquire the lock | ||||||
| await this.aqcuireFetchMutex(request); | ||||||
| const prefetchApiCache = await caches.open(this.cacheName); | ||||||
| try { | ||||||
| const response = await fetch(request); | ||||||
|
|
||||||
| if (response.ok) { | ||||||
| // Clone the response as the response can be consumed only once | ||||||
| const clonedResponse = response.clone(); | ||||||
| // Put the response in the cache | ||||||
| await prefetchApiCache.put(request, clonedResponse); | ||||||
| } | ||||||
| } catch (error) { | ||||||
| // Delete the existing cache if the fetch fails | ||||||
| await prefetchApiCache.delete(request); | ||||||
| } finally { | ||||||
| // Release the lock | ||||||
| this.releaseFetchMutex(request); | ||||||
|
dvj1988 marked this conversation as resolved.
|
||||||
| } | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Function to get the cached response for the request | ||||||
| */ | ||||||
| async getCachedResponse(request: Request) { | ||||||
| // Wait for the lock to be released | ||||||
| await this.waitForUnlock(request); | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. waitForUnlock returns a null value in some cases. Is it needed to handle that scenario ?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if there are no prefetched requests for a certain request, then the |
||||||
| const prefetchApiCache = await caches.open(this.cacheName); | ||||||
| // Check if the response is already in cache | ||||||
| const cachedResponse = await prefetchApiCache.match(request); | ||||||
|
|
||||||
| if (cachedResponse) { | ||||||
| const dateHeader = cachedResponse.headers.get("date"); | ||||||
| const cachedTime = dateHeader ? new Date(dateHeader).getTime() : 0; | ||||||
| const currentTime = Date.now(); | ||||||
|
|
||||||
| const isCacheValid = currentTime - cachedTime < this.cacheMaxAge; | ||||||
|
|
||||||
| if (isCacheValid) { | ||||||
| // Delete the cache as this is a one-time cache | ||||||
| await prefetchApiCache.delete(request); | ||||||
| // Return the cached response | ||||||
| return cachedResponse; | ||||||
| } | ||||||
|
|
||||||
| // If the cache is not valid, delete the cache | ||||||
| await prefetchApiCache.delete(request); | ||||||
| } | ||||||
|
|
||||||
| return null; | ||||||
| } | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export * from "../../ce/utils/serviceWorkerUtils"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,9 +7,13 @@ import { | |
| StaleWhileRevalidate, | ||
| } from "workbox-strategies"; | ||
| import { | ||
| getPrefetchConsolidatedApiRequest, | ||
| ConsolidatedApiCacheStrategy, | ||
| } from "utils/serviceWorkerUtils"; | ||
| cachedApiUrlRegex, | ||
| getApplicationParamsFromUrl, | ||
| getConsolidatedApiPrefetchRequest, | ||
| getPrefetchModuleApiRequests, | ||
| PrefetchApiCacheStrategy, | ||
| } from "@appsmith/utils/serviceWorkerUtils"; | ||
|
dvj1988 marked this conversation as resolved.
|
||
| import type { RouteHandlerCallback } from "workbox-core/types"; | ||
|
|
||
| setCacheNameDetails({ | ||
| prefix: "appsmith", | ||
|
|
@@ -30,33 +34,52 @@ const regexMap = { | |
|
|
||
| /* eslint-disable no-restricted-globals */ | ||
| // Note: if you need to filter out some files from precaching, | ||
|
|
||
| // do that in craco.build.config.js → workbox webpack plugin options | ||
| const toPrecache = self.__WB_MANIFEST; | ||
| const toPrecache = (self as any).__WB_MANIFEST; | ||
| precacheAndRoute(toPrecache); | ||
|
|
||
| self.__WB_DISABLE_DEV_DEBUG_LOGS = false; | ||
| self.__WB_DISABLE_DEV_LOGS = false; | ||
| skipWaiting(); | ||
| clientsClaim(); | ||
|
|
||
| const consolidatedApiCacheStrategy = new ConsolidatedApiCacheStrategy(); | ||
| const prefetchApiCacheStrategy = new PrefetchApiCacheStrategy(); | ||
|
|
||
| /** | ||
| * | ||
| * @param {ExtendableEvent} event | ||
| * @param {Request} request | ||
| * @param {URL} url | ||
| * @returns | ||
| * Route handler callback for HTML pages. | ||
| * This callback is responsible for prefetching the API requests for the application page. | ||
| */ | ||
| const handleFetchHtml = async (event, request, url) => { | ||
| // Get the prefetch consolidated api request if the url matches the builder or viewer path | ||
| const prefetchConsolidatedApiRequest = getPrefetchConsolidatedApiRequest(url); | ||
|
|
||
| if (prefetchConsolidatedApiRequest) { | ||
| consolidatedApiCacheStrategy | ||
| .cacheConsolidatedApi(prefetchConsolidatedApiRequest) | ||
| .catch(() => { | ||
| const htmlRouteHandlerCallback: RouteHandlerCallback = async ({ | ||
| event, | ||
| request, | ||
| url, | ||
| }) => { | ||
| // Extract application params from the URL | ||
| const applicationParams = getApplicationParamsFromUrl(url); | ||
|
|
||
| // If application params are present, prefetch the API requests for the application | ||
| if (applicationParams) { | ||
| // Prefetch the consolidated API request | ||
| const consolidatedApiPrefetchRequest = | ||
| getConsolidatedApiPrefetchRequest(applicationParams); | ||
|
|
||
| if (consolidatedApiPrefetchRequest) { | ||
| prefetchApiCacheStrategy | ||
| .cacheApi(consolidatedApiPrefetchRequest) | ||
| .catch(() => { | ||
| // Silently fail | ||
| }); | ||
| } | ||
|
|
||
| // Prefetch the module API requests | ||
| const moduleApiPrefetchRequests = | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can this logic be extracted into a generic functions that returns an array of all prefetch requests and fetches them ? Consolidated apis and prefetch apis. |
||
| getPrefetchModuleApiRequests(applicationParams); | ||
|
|
||
| moduleApiPrefetchRequests.forEach((prefetchRequest) => { | ||
| prefetchApiCacheStrategy.cacheApi(prefetchRequest).catch(() => { | ||
| // Silently fail | ||
| }); | ||
| }); | ||
|
dvj1988 marked this conversation as resolved.
|
||
| } | ||
|
|
||
| const networkHandler = new NetworkOnly(); | ||
|
|
@@ -81,21 +104,19 @@ registerRoute(({ url }) => { | |
| }, new StaleWhileRevalidate()); | ||
|
|
||
| registerRoute( | ||
| new Route( | ||
| ({ request, sameOrigin }) => { | ||
| return sameOrigin && request.destination === "document"; | ||
| }, | ||
| async ({ event, request, url }) => handleFetchHtml(event, request, url), | ||
| ), | ||
| new Route(({ request, sameOrigin }) => { | ||
| return sameOrigin && request.destination === "document"; | ||
| }, htmlRouteHandlerCallback), | ||
| ); | ||
|
|
||
| // Route for fetching the API directly | ||
| registerRoute( | ||
| new RegExp("/api/v1/consolidated-api/"), | ||
| // Intercept requests to the consolidated API and module instances API | ||
| cachedApiUrlRegex, | ||
| async ({ event, request }) => { | ||
| // Check for cached response | ||
| const cachedResponse = | ||
| await consolidatedApiCacheStrategy.getCachedResponse(request); | ||
| await prefetchApiCacheStrategy.getCachedResponse(request); | ||
|
|
||
| // If the response is cached, return the response | ||
| if (cachedResponse) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider using a regular expression literal for better performance and readability.
Committable suggestion
Tools
Biome