diff --git a/app/client/craco.dev.config.js b/app/client/craco.dev.config.js index ab636d0f40e..8fcf53ac7f9 100644 --- a/app/client/craco.dev.config.js +++ b/app/client/craco.dev.config.js @@ -1,5 +1,5 @@ const { merge } = require("webpack-merge"); - +const WorkboxPlugin = require("workbox-webpack-plugin"); const common = require("./craco.common.config.js"); module.exports = merge(common, { @@ -21,4 +21,30 @@ module.exports = merge(common, { experiments: { cacheUnaffected: true, }, + webpack: { + plugins: [ + new WorkboxPlugin.InjectManifest({ + swSrc: "./src/serviceWorker.js", + mode: "development", + swDest: "./pageService.js", + exclude: [ + // Don’t cache source maps and PWA manifests. + // (These are the default values of the `exclude` option: https://developer.chrome.com/docs/workbox/reference/workbox-build/#type-WebpackPartial, + // so we need to specify them explicitly if we’re extending this array.) + /\.map$/, + /^manifest.*\.js$/, + // Don’t cache the root html file + /index\.html/, + // Don’t cache LICENSE.txt files emitted by CRA + // when a chunk includes some license comments + /LICENSE\.txt/, + // Don’t cache static icons as there are hundreds of them, and caching them all + // one by one (as the service worker does it) keeps the network busy for a long time + // and delays the service worker installation + /\/*\.svg$/, + /\.(js|css|html|png|jpg|jpeg|gif)$/, // Exclude JS, CSS, HTML, and image files + ], + }), + ], + }, }); diff --git a/app/client/package.json b/app/client/package.json index bda37257e6e..f553b592f09 100644 --- a/app/client/package.json +++ b/app/client/package.json @@ -99,6 +99,7 @@ "appsmith-icons": "workspace:^", "assert-never": "^1.2.1", "astring": "^1.7.5", + "async-mutex": "^0.5.0", "axios": "^1.6.0", "classnames": "^2.3.1", "clsx": "^1.2.1", diff --git a/app/client/src/serviceWorker.js b/app/client/src/serviceWorker.js index e47f767146a..d97b6cc6cfd 100644 --- a/app/client/src/serviceWorker.js +++ b/app/client/src/serviceWorker.js @@ -6,10 +6,14 @@ import { NetworkOnly, StaleWhileRevalidate, } from "workbox-strategies"; +import { + getPrefetchConsolidatedApiRequest, + ConsolidatedApiCacheStrategy, +} from "utils/serviceWorkerUtils"; setCacheNameDetails({ prefix: "appsmith", - suffix: undefined, + suffix: "", precache: "precache-v1", runtime: "runtime", googleAnalytics: "appsmith-ga", @@ -34,6 +38,31 @@ self.__WB_DISABLE_DEV_DEBUG_LOGS = false; skipWaiting(); clientsClaim(); +const consolidatedApiCacheStrategy = new ConsolidatedApiCacheStrategy(); + +/** + * + * @param {ExtendableEvent} event + * @param {Request} request + * @param {URL} url + * @returns + */ +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(() => { + // Silently fail + }); + } + + const networkHandler = new NetworkOnly(); + return networkHandler.handle({ event, request }); +}; + // This route's caching seems too aggressive. // TODO(abhinav): Figure out if this is really necessary. // Maybe add the assets locally? @@ -52,7 +81,30 @@ registerRoute(({ url }) => { }, new StaleWhileRevalidate()); registerRoute( - new Route(({ request, sameOrigin }) => { - return sameOrigin && request.destination === "document"; - }, new NetworkOnly()), + new Route( + ({ request, sameOrigin }) => { + return sameOrigin && request.destination === "document"; + }, + async ({ event, request, url }) => handleFetchHtml(event, request, url), + ), +); + +// Route for fetching the API directly +registerRoute( + new RegExp("/api/v1/consolidated-api/"), + async ({ event, request }) => { + // Check for cached response + const cachedResponse = + await consolidatedApiCacheStrategy.getCachedResponse(request); + + // If the response is cached, return the response + if (cachedResponse) { + return cachedResponse; + } + + // If the response is not cached, fetch the response + const networkHandler = new NetworkOnly(); + return networkHandler.handle({ event, request }); + }, + "GET", ); diff --git a/app/client/src/utils/serviceWorkerUtils.js b/app/client/src/utils/serviceWorkerUtils.js new file mode 100644 index 00000000000..f0d1464d22b --- /dev/null +++ b/app/client/src/utils/serviceWorkerUtils.js @@ -0,0 +1,177 @@ +/* eslint-disable no-console */ +import { match } from "path-to-regexp"; +import { Mutex } from "async-mutex"; + +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`; + +/** + * Function to get the search query from the URL + * @param {string} search + * @param {string} key + * @returns {string | null} + */ +export const getSearchQuery = (search = "", key) => { + const params = new URLSearchParams(search); + return decodeURIComponent(params.get(key) || ""); +}; + +/** + * Function to match the path with the builder path + * @param {string} pathName + * @param {Object} options + * @param {boolean} options.end + * @returns {Match | boolean} + */ +export const matchBuilderPath = (pathName, options) => + match(BUILDER_PATH, options)(pathName) || + match(BUILDER_PATH_DEPRECATED, options)(pathName) || + match(BUILDER_CUSTOM_PATH, options)(pathName); + +/** + * Function to match the path with the viewer path + * @param {string} pathName + * @returns {Match | boolean} + */ +export const matchViewerPath = (pathName) => + match(VIEWER_PATH)(pathName) || + match(VIEWER_PATH_DEPRECATED)(pathName) || + match(VIEWER_CUSTOM_PATH)(pathName); + +/** + * Function to get the consolidated API search params + * @param {Match} params + * @returns + */ +export const getConsolidatedAPISearchParams = (params = {}) => { + if (!params || !params?.pageId) { + return ""; + } + + const { applicationId, pageId } = params; + const searchParams = new URLSearchParams(); + + searchParams.append("defaultPageId", pageId); + + if (applicationId) { + searchParams.append("applicationId", applicationId); + } + + return searchParams.toString(); +}; + +/** + * Function to get the prefetch request for consolidated api + * @param {URL} url + * @returns {Request | null} + */ +export const getPrefetchConsolidatedApiRequest = (url) => { + if (!url) { + return null; + } + + // Match the URL with the builder and viewer paths + const matchedBuilder = matchBuilderPath(url.pathname, { end: false }); + const matchedViewer = matchViewerPath(url.pathname, { end: false }); + + // Get the branch name from the search query + const branchName = getSearchQuery(url.search, "branch"); + + let headers = new Headers(); + + // Add the branch name to the headers + if (branchName) { + headers.append("Branchname", branchName); + } + + // If the URL matches the builder path + if (matchedBuilder && matchedBuilder.params?.pageId) { + const searchParams = getConsolidatedAPISearchParams(matchedBuilder.params); + const requestUrl = `${url.origin}/api/v1/consolidated-api/edit?${searchParams}`; + const request = new Request(requestUrl, { method: "GET", headers }); + return request; + } + + // If the URL matches the viewer path + if (matchedViewer && matchedViewer.params?.pageId) { + const searchParams = getConsolidatedAPISearchParams(matchedViewer.params); + const requestUrl = `${url.origin}/api/v1/consolidated-api/view?${searchParams}`; + const request = new Request(requestUrl, { method: "GET", headers }); + return request; + } + + // Return null if the URL does not match the builder or viewer path + return null; +}; + +/** + * Cache strategy for Appsmith API + */ +export class ConsolidatedApiCacheStrategy { + cacheName = "prefetch-cache-v1"; + cacheMaxAge = 2 * 60 * 1000; // 2 minutes in milliseconds + + constructor() { + // Mutex to lock the fetch and cache operation + this.consolidatedApiFetchmutex = new Mutex(); + } + + /** + * Function to fetch and cache the consolidated API + * @param {Request} request + * @returns + */ + async cacheConsolidatedApi(request) { + // Acquire the lock + await this.consolidatedApiFetchmutex.acquire(); + 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.consolidatedApiFetchmutex.release(); + } + } + + async getCachedResponse(request) { + // Wait for the lock to be released + await this.consolidatedApiFetchmutex.waitForUnlock(); + 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 = new Date(dateHeader).getTime(); + 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; + } +} diff --git a/app/client/src/utils/serviceWorkerUtils.test.js b/app/client/src/utils/serviceWorkerUtils.test.js new file mode 100644 index 00000000000..04b03cdbfaf --- /dev/null +++ b/app/client/src/utils/serviceWorkerUtils.test.js @@ -0,0 +1,383 @@ +import { + getSearchQuery, + getConsolidatedAPISearchParams, + getPrefetchConsolidatedApiRequest, + ConsolidatedApiCacheStrategy, + matchBuilderPath, + matchViewerPath, +} from "./serviceWorkerUtils"; +import { Headers, Request, Response } from "node-fetch"; + +global.fetch = jest.fn(); +global.caches = { + open: jest.fn().mockResolvedValue({ + match: jest.fn(), + put: jest.fn(), + delete: jest.fn(), + }), +}; + +describe("serviceWorkerUtils", () => { + describe("getSearchQuery", () => { + it("should return the search query from the URL", () => { + const search = "?key=value"; + const key = "key"; + const result = getSearchQuery(search, key); + expect(result).toEqual("value"); + }); + + it("should return an empty string if the key is not present in the URL", () => { + const search = "?key=value"; + const key = "invalid"; + const result = getSearchQuery(search, key); + expect(result).toEqual(""); + }); + + it("should return an empty string if the search query is empty", () => { + const search = ""; + const key = "key"; + const result = getSearchQuery(search, key); + expect(result).toEqual(""); + }); + + it("should return an empty string if the search query is null", () => { + const search = null; + const key = "key"; + const result = getSearchQuery(search, key); + expect(result).toEqual(""); + }); + }); + + describe("getConsolidatedAPISearchParams", () => { + it("should return the consolidated API search params", () => { + const params = { + applicationId: "appId", + pageId: "pageId", + }; + const result = getConsolidatedAPISearchParams(params); + expect(result).toEqual("defaultPageId=pageId&applicationId=appId"); + }); + + it("should return empty string search params with only the applicationId", () => { + const params = { + applicationId: "appId", + }; + const result = getConsolidatedAPISearchParams(params); + expect(result).toEqual(""); + }); + + it("should return the consolidated API search params with only the pageId", () => { + const params = { + pageId: "pageId", + }; + const result = getConsolidatedAPISearchParams(params); + expect(result).toEqual("defaultPageId=pageId"); + }); + + it("should return an empty string if the params are empty", () => { + const result = getConsolidatedAPISearchParams(); + expect(result).toEqual(""); + }); + + it("should return an empty string if the params are null", () => { + const result = getConsolidatedAPISearchParams(null); + expect(result).toEqual(""); + }); + + it("should return an empty string if the params are undefined", () => { + const result = getConsolidatedAPISearchParams(undefined); + expect(result).toEqual(""); + }); + }); + + describe("getPrefetchConsolidatedApiRequest", () => { + beforeAll(() => { + global.Request = Request; + global.Headers = Headers; + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should return null if url is not provided", () => { + expect(getPrefetchConsolidatedApiRequest(null)).toBeNull(); + }); + + it("should return null if url does not match any paths", () => { + const url = new URL("https://app.appsmith.com/unknown/path"); + expect(getPrefetchConsolidatedApiRequest(url)).toBeNull(); + }); + + it("should return a request for builder path", async () => { + const url = new URL( + "http://example.com/app/test-slug/test-page-123/edit?branch=test-branch", + ); + + const result = getPrefetchConsolidatedApiRequest(url); + const expectedResult = new Request( + "http://example.com/api/v1/consolidated-api/edit?defaultPageId=123", + { + method: "GET", + headers: { + BranchName: "test-branch", + }, + }, + ); + + expect(result).toEqual(expectedResult); + }); + + it("should return a request for viewer path", () => { + const url = new URL( + "https://app.appsmith.com/app/test-slug/test-page-123?branch=test-branch", + ); + + const expectedResult = new Request( + "https://app.appsmith.com/api/v1/consolidated-api/view?defaultPageId=123", + { + method: "GET", + headers: { + BranchName: "test-branch", + }, + }, + ); + + const result = getPrefetchConsolidatedApiRequest(url); + + expect(result).toEqual(expectedResult); + }); + + it("should return a request without branch name in headers", () => { + const url = new URL( + "https://app.appsmith.com/app/test-slug/test-page-123/edit", + ); + + const expectedResult = new Request( + "https://app.appsmith.com/api/v1/consolidated-api/edit?defaultPageId=123", + { + method: "GET", + headers: {}, + }, + ); + + const result = getPrefetchConsolidatedApiRequest(url); + + expect(result).toEqual(expectedResult); + }); + }); + + describe("ConsolidatedApiCacheStrategy", () => { + let strategy; + let mockCache; + + beforeAll(() => { + global.Request = Request; + global.Headers = Headers; + global.Response = Response; + }); + + beforeEach(async () => { + // Reset all mocks + jest.clearAllMocks(); + + mockCache = { + match: jest.fn(), + delete: jest.fn(), + put: jest.fn(), + }; + + global.caches = { + open: jest.fn().mockResolvedValue(mockCache), + }; + + strategy = new ConsolidatedApiCacheStrategy(); + }); + + it("should cache the API response", async () => { + const mockRequest = new Request("https://example.com/api"); + const mockResponse = new Response("mock data", { + status: 200, + headers: { date: new Date().toUTCString() }, + }); + + fetch.mockResolvedValue(mockResponse); + + await strategy.cacheConsolidatedApi(mockRequest); + + expect(fetch).toHaveBeenCalledWith(mockRequest); + expect(mockCache.put).toHaveBeenCalledWith( + mockRequest, + expect.any(Response), + ); + }); + + it("should handle errors when fetching the API response", async () => { + const mockRequest = new Request("https://example.com/api"); + + fetch.mockRejectedValue(new Error("Network error")); + + await strategy.cacheConsolidatedApi(mockRequest); + + expect(fetch).toHaveBeenCalledWith(mockRequest); + expect(mockCache.delete).toHaveBeenCalledWith(mockRequest); + }); + + it("should return a valid cached response", async () => { + const mockRequest = new Request("https://example.com/api"); + const mockResponse = new Response("mock data", { + status: 200, + headers: { date: new Date().toUTCString() }, + }); + + mockCache.match.mockResolvedValue(mockResponse); + + const cachedResponse = await strategy.getCachedResponse(mockRequest); + + expect(mockCache.match).toHaveBeenCalledWith(mockRequest); + expect(cachedResponse).toEqual(mockResponse); + }); + + it("should return null for an invalid cached response", async () => { + const mockRequest = new Request("https://example.com/api"); + const mockResponse = new Response("mock data", { + status: 200, + headers: { + date: new Date(Date.now() - (2 * 60 * 1000 + 1)).toUTCString(), + }, // 2 minutes 1 second old cache + }); + + mockCache.match.mockResolvedValue(mockResponse); + + const cachedResponse = await strategy.getCachedResponse(mockRequest); + + expect(mockCache.match).toHaveBeenCalledWith(mockRequest); + expect(mockCache.delete).toHaveBeenCalledWith(mockRequest); + expect(cachedResponse).toBeNull(); + }); + + it("should return null if no cached response is found", async () => { + const mockRequest = new Request("https://example.com/api"); + + mockCache.match.mockResolvedValue(null); + + const cachedResponse = await strategy.getCachedResponse(mockRequest); + + expect(mockCache.match).toHaveBeenCalledWith(mockRequest); + expect(cachedResponse).toBeNull(); + }); + }); + + describe("matchBuilderPath", () => { + it("should match the standard builder path", () => { + const pathName = "/app/applicationSlug/pageSlug-123/edit"; + const options = { end: false }; + const result = matchBuilderPath(pathName, options); + + expect(result).toBeTruthy(); + expect(result.params).toHaveProperty("applicationSlug"); + expect(result.params).toHaveProperty("pageSlug"); + expect(result.params).toHaveProperty("pageId", "123"); + }); + + it("should match the standard builder path for alphanumeric pageId", () => { + const pathName = + "/app/applicationSlug/pageSlug-6616733a6e70274710f21a07/edit"; + const options = { end: false }; + const result = matchBuilderPath(pathName, options); + + expect(result).toBeTruthy(); + expect(result.params).toHaveProperty("applicationSlug"); + expect(result.params).toHaveProperty("pageSlug"); + expect(result.params).toHaveProperty( + "pageId", + "6616733a6e70274710f21a07", + ); + }); + + it("should match the custom builder path", () => { + const pathName = "/app/customSlug-custom-456/edit"; + const options = { end: false }; + const result = matchBuilderPath(pathName, options); + + expect(result).toBeTruthy(); + expect(result.params).toHaveProperty("customSlug"); + expect(result.params).toHaveProperty("pageId", "456"); + }); + + it("should match the deprecated builder path", () => { + const pathName = "/applications/appId123/pages/456/edit"; + const options = { end: false }; + const result = matchBuilderPath(pathName, options); + + expect(result).toBeTruthy(); + expect(result.params).toHaveProperty("applicationId", "appId123"); + expect(result.params).toHaveProperty("pageId", "456"); + }); + + it("should not match incorrect builder path", () => { + const pathName = "/app/applicationSlug/nonMatching-123"; + const options = { end: false }; + const result = matchBuilderPath(pathName, options); + + expect(result).toBeFalsy(); + }); + + it("should not match when no pageId is present", () => { + const pathName = "/app/applicationSlug/pageSlug-edit"; + const options = { end: false }; + const result = matchBuilderPath(pathName, options); + + expect(result).toBeFalsy(); + }); + + it("should match when the path is edit widgets", () => { + const pathName = + "/app/applicationSlug/pageSlug-123/edit/widgets/t36hb2zukr"; + const options = { end: false }; + const result = matchBuilderPath(pathName, options); + + expect(result).toBeTruthy(); + expect(result.params).toHaveProperty("applicationSlug"); + expect(result.params).toHaveProperty("pageSlug"); + expect(result.params).toHaveProperty("pageId", "123"); + }); + }); + + describe("matchViewerPath", () => { + it("should match the standard viewer path", () => { + const pathName = "/app/applicationSlug/pageSlug-123"; + const result = matchViewerPath(pathName); + + expect(result).toBeTruthy(); + expect(result.params).toHaveProperty("applicationSlug"); + expect(result.params).toHaveProperty("pageSlug"); + expect(result.params).toHaveProperty("pageId", "123"); + }); + + it("should match the custom viewer path", () => { + const pathName = "/app/customSlug-custom-456"; + const result = matchViewerPath(pathName); + + expect(result).toBeTruthy(); + expect(result.params).toHaveProperty("customSlug"); + expect(result.params).toHaveProperty("pageId", "456"); + }); + + it("should match the deprecated viewer path", () => { + const pathName = "/applications/appId123/pages/456"; + const result = matchViewerPath(pathName); + + expect(result).toBeTruthy(); + expect(result.params).toHaveProperty("applicationId", "appId123"); + expect(result.params).toHaveProperty("pageId", "456"); + }); + + it("should not match when no pageId is present", () => { + const pathName = "/app/applicationSlug/pageSlug"; + const result = matchViewerPath(pathName); + + expect(result).toBeFalsy(); + }); + }); +}); diff --git a/app/client/yarn.lock b/app/client/yarn.lock index 2d6f4d78218..a6eaa5153f4 100644 --- a/app/client/yarn.lock +++ b/app/client/yarn.lock @@ -13102,6 +13102,7 @@ __metadata: appsmith-icons: "workspace:^" assert-never: ^1.2.1 astring: ^1.7.5 + async-mutex: ^0.5.0 axios: ^1.6.0 babel-plugin-lodash: ^3.3.4 babel-plugin-module-resolver: ^4.1.0 @@ -13666,6 +13667,15 @@ __metadata: languageName: node linkType: hard +"async-mutex@npm:^0.5.0": + version: 0.5.0 + resolution: "async-mutex@npm:0.5.0" + dependencies: + tslib: ^2.4.0 + checksum: be1587f4875f3bb15e34e9fcce82eac2966daef4432c8d0046e61947fb9a1b95405284601bc7ce4869319249bc07c75100880191db6af11d1498931ac2a2f9ea + languageName: node + linkType: hard + "async@npm:^3.2.0, async@npm:^3.2.3": version: 3.2.3 resolution: "async@npm:3.2.3"