diff --git a/functions/nfts/asset/[[index]].ts b/functions/nfts/asset/[[index]].ts index f33c1aacf8..e9c71ff5a5 100644 --- a/functions/nfts/asset/[[index]].ts +++ b/functions/nfts/asset/[[index]].ts @@ -1,20 +1,15 @@ /* eslint-disable import/no-unused-modules */ -import { MetaTagInjector } from '../../components/metaTagInjector' import getAsset from '../../utils/getAsset' +import getRequest from '../../utils/getRequest' export const onRequest: PagesFunction = async ({ params, request, next }) => { - const { index } = params - const collectionAddress = index[0]?.toString() - const tokenId = index[1]?.toString() - const assetPromise = getAsset(collectionAddress, tokenId, request.url) - const resPromise = next() + const res = next() try { - const [data, res] = await Promise.all([assetPromise, resPromise]) - if (!data) { - return resPromise - } - return new HTMLRewriter().on('head', new MetaTagInjector(data)).transform(res) + const { index } = params + const collectionAddress = index[0]?.toString() + const tokenId = index[1]?.toString() + return getRequest(res, request.url, () => getAsset(collectionAddress, tokenId, request.url)) } catch (e) { - return resPromise + return res } } diff --git a/functions/__snapshots__/nft.test.ts.snap b/functions/nfts/asset/__snapshots__/nft.test.ts.snap similarity index 100% rename from functions/__snapshots__/nft.test.ts.snap rename to functions/nfts/asset/__snapshots__/nft.test.ts.snap diff --git a/functions/nft.test.ts b/functions/nfts/asset/nft.test.ts similarity index 100% rename from functions/nft.test.ts rename to functions/nfts/asset/nft.test.ts diff --git a/functions/nfts/collection/[index].ts b/functions/nfts/collection/[index].ts index 5721415610..c5ba593f62 100644 --- a/functions/nfts/collection/[index].ts +++ b/functions/nfts/collection/[index].ts @@ -1,19 +1,14 @@ /* eslint-disable import/no-unused-modules */ -import { MetaTagInjector } from '../../components/metaTagInjector' import getCollection from '../../utils/getCollection' +import getRequest from '../../utils/getRequest' export const onRequest: PagesFunction = async ({ params, request, next }) => { - const { index } = params - const collectionAddress = index?.toString() - const collectionPromise = getCollection(collectionAddress, request.url) - const resPromise = next() + const res = next() try { - const [data, res] = await Promise.all([collectionPromise, resPromise]) - if (!data) { - return resPromise - } - return new HTMLRewriter().on('head', new MetaTagInjector(data)).transform(res) + const { index } = params + const collectionAddress = index?.toString() + return getRequest(res, request.url, () => getCollection(collectionAddress, request.url)) } catch (e) { - return resPromise + return res } } diff --git a/functions/__snapshots__/collection.test.ts.snap b/functions/nfts/collection/__snapshots__/collection.test.ts.snap similarity index 100% rename from functions/__snapshots__/collection.test.ts.snap rename to functions/nfts/collection/__snapshots__/collection.test.ts.snap diff --git a/functions/collection.test.ts b/functions/nfts/collection/collection.test.ts similarity index 100% rename from functions/collection.test.ts rename to functions/nfts/collection/collection.test.ts diff --git a/functions/tokens/[[index]].ts b/functions/tokens/[[index]].ts index 13a6390d3b..84cbc4c045 100644 --- a/functions/tokens/[[index]].ts +++ b/functions/tokens/[[index]].ts @@ -1,6 +1,6 @@ /* eslint-disable import/no-unused-modules */ import { Chain } from '../../src/graphql/data/__generated__/types-and-hooks' -import { MetaTagInjector } from '../components/metaTagInjector' +import getRequest from '../utils/getRequest' import getToken from '../utils/getToken' const convertTokenAddress = (tokenAddress: string, networkName: string) => { @@ -18,22 +18,17 @@ const convertTokenAddress = (tokenAddress: string, networkName: string) => { } export const onRequest: PagesFunction = async ({ params, request, next }) => { - const { index } = params - const networkName = index[0]?.toString().toUpperCase() - const tokenString = index[1]?.toString() - if (!tokenString) { - return next() - } - const tokenAddress = convertTokenAddress(tokenString, networkName) - const tokenPromise = getToken(networkName, tokenAddress, request.url) - const resPromise = next() + const res = next() try { - const [data, res] = await Promise.all([tokenPromise, resPromise]) - if (!data) { - return resPromise + const { index } = params + const networkName = index[0]?.toString().toUpperCase() + const tokenString = index[1]?.toString() + if (!tokenString) { + return res } - return new HTMLRewriter().on('head', new MetaTagInjector(data)).transform(res) + const tokenAddress = convertTokenAddress(tokenString, networkName) + return getRequest(res, request.url, () => getToken(networkName, tokenAddress, request.url)) } catch (e) { - return resPromise + return res } } diff --git a/functions/__snapshots__/token.test.ts.snap b/functions/tokens/__snapshots__/token.test.ts.snap similarity index 100% rename from functions/__snapshots__/token.test.ts.snap rename to functions/tokens/__snapshots__/token.test.ts.snap diff --git a/functions/token.test.ts b/functions/tokens/token.test.ts similarity index 100% rename from functions/token.test.ts rename to functions/tokens/token.test.ts diff --git a/functions/utils/cache.test.ts b/functions/utils/cache.test.ts new file mode 100644 index 0000000000..97355f216f --- /dev/null +++ b/functions/utils/cache.test.ts @@ -0,0 +1,43 @@ +import CacheMock from 'browser-cache-mock' + +import { mocked } from '../../src/test-utils/mocked' +import Cache from './cache' + +const cacheMock = new CacheMock() + +const data = { + title: 'test', + image: 'testImage', + url: 'testUrl', +} + +beforeAll(() => { + const globalAny: any = global + globalAny.caches = { + open: async () => cacheMock, + ...cacheMock, + } +}) + +test('Should put cache properly', async () => { + jest.spyOn(cacheMock, 'put') + await Cache.put(data, 'https://example.com') + expect(cacheMock.put).toHaveBeenCalledWith('https://example.com', expect.anything()) + const call = mocked(cacheMock.put).mock.calls[0] + const response = JSON.parse(await (call[1] as Response).clone().text()) + expect(response).toStrictEqual(data) + + await expect(Cache.match('https://example.com')).resolves.toStrictEqual(data) +}) + +test('Should match cache properly', async () => { + jest.spyOn(cacheMock, 'match').mockResolvedValueOnce(new Response(JSON.stringify(data))) + const response = await Cache.match('https://example.com') + expect(response).toStrictEqual(data) +}) + +test('Should return undefined if not all data is present', async () => { + jest.spyOn(cacheMock, 'match').mockResolvedValueOnce(new Response(JSON.stringify({ ...data, title: undefined }))) + const response = await Cache.match('https://example.com') + expect(response).toBeUndefined() +}) diff --git a/functions/utils/cache.ts b/functions/utils/cache.ts new file mode 100644 index 0000000000..2eb51476f0 --- /dev/null +++ b/functions/utils/cache.ts @@ -0,0 +1,28 @@ +interface Data { + title: string + image: string + url: string +} + +const CACHE_NAME = 'functions-cache' as const + +class Cache { + async match(request: string): Promise { + const cache = await caches.open(CACHE_NAME) + const response = await cache.match(request) + if (!response) return undefined + const data: Data = JSON.parse(await response.text()) + if (!data.title || !data.image || !data.url) return undefined + return data + } + + async put(data: Data, request: string) { + // Set max age to 1 week + const response = new Response(JSON.stringify(data)) + response.headers.set('Cache-Control', 'max-age=604800') + const cache = await caches.open(CACHE_NAME) + await cache.put(request, response) + } +} + +export default new Cache() diff --git a/functions/utils/getRequest.test.ts b/functions/utils/getRequest.test.ts new file mode 100644 index 0000000000..8fce759f65 --- /dev/null +++ b/functions/utils/getRequest.test.ts @@ -0,0 +1,38 @@ +import * as matchers from 'jest-extended' +expect.extend(matchers) + +import { mocked } from '../../src/test-utils/mocked' +import Cache from './cache' +import getRequest from './getRequest' + +jest.mock('./cache', () => ({ + match: jest.fn(), + put: jest.fn(), +})) + +test('should call Cache.match before calling getData when request is not cached', async () => { + const url = 'https://example.com' + const getData = jest.fn().mockResolvedValueOnce({ + title: 'test', + image: 'testImage', + url: 'testUrl', + }) + await getRequest(Promise.resolve(new Response()), url, getData) + expect(Cache.match).toHaveBeenCalledWith(url) + expect(getData).toHaveBeenCalled() + expect(Cache.match).toHaveBeenCalledBefore(getData) + expect(Cache.put).toHaveBeenCalledAfter(getData) +}) + +test('getData should not be called when request is cached', async () => { + const url = 'https://example.com' + mocked(Cache.match).mockResolvedValueOnce({ + title: 'test', + image: 'testImage', + url: 'testUrl', + }) + const getData = jest.fn() + await getRequest(Promise.resolve(new Response()), url, getData) + expect(Cache.match).toHaveBeenCalledWith(url) + expect(getData).not.toHaveBeenCalled() +}) diff --git a/functions/utils/getRequest.ts b/functions/utils/getRequest.ts new file mode 100644 index 0000000000..488a1cbb74 --- /dev/null +++ b/functions/utils/getRequest.ts @@ -0,0 +1,31 @@ +import { MetaTagInjector } from '../components/metaTagInjector' +import Cache from './cache' + +export default async function getRequest( + res: Promise, + url: string, + getData: () => Promise< + | { + title: string + image: string + url: string + } + | undefined + > +) { + try { + const cachedData = await Cache.match(url) + if (cachedData) { + return new HTMLRewriter().on('head', new MetaTagInjector(cachedData)).transform(await res) + } else { + const data = await getData() + if (!data) { + return res + } + await Cache.put(data, url) + return new HTMLRewriter().on('head', new MetaTagInjector(data)).transform(await res) + } + } catch (e) { + return res + } +} diff --git a/package.json b/package.json index 2a44a415bb..937238ae56 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ "@walletconnect/types": "^2.8.6", "babel-jest": "^29.6.1", "babel-plugin-istanbul": "^6.1.1", + "browser-cache-mock": "^0.1.7", "buffer": "^6.0.3", "concurrently": "^8.0.1", "cypress": "12.12.0", @@ -123,6 +124,7 @@ "hardhat": "^2.14.0", "jest": "^29.6.1", "jest-dev-server": "^9.0.0", + "jest-extended": "^4.0.1", "jest-fail-on-console": "^3.1.1", "jest-fetch-mock": "^3.0.3", "jest-styled-components": "^7.0.8", diff --git a/yarn.lock b/yarn.lock index 73f94ab778..0848e86b3e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8446,6 +8446,11 @@ brorand@^1.1.0: resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" integrity sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8= +browser-cache-mock@^0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/browser-cache-mock/-/browser-cache-mock-0.1.7.tgz#4bfa4aa1022b1ce642e656e3cc8158deaaf30e12" + integrity sha512-+0bM5cjnj8Z8caAYBe1A9AA30juciI8fBHhw+aPBSSGL89UHPUcBA/E6CrQAecC635MEfx8qadTnaocEwE2q8Q== + browser-level@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/browser-level/-/browser-level-1.0.1.tgz#36e8c3183d0fe1c405239792faaab5f315871011" @@ -13862,15 +13867,15 @@ jest-diff@^27.5.1: jest-get-type "^27.5.1" pretty-format "^27.5.1" -jest-diff@^29.6.1: - version "29.6.1" - resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.6.1.tgz#13df6db0a89ee6ad93c747c75c85c70ba941e545" - integrity sha512-FsNCvinvl8oVxpNLttNQX7FAq7vR+gMDGj90tiP7siWw1UdakWUGqrylpsYrpvj908IYckm5Y0Q7azNAozU1Kg== +jest-diff@^29.0.0, jest-diff@^29.6.1: + version "29.6.2" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.6.2.tgz#c36001e5543e82a0805051d3ceac32e6825c1c46" + integrity sha512-t+ST7CB9GX5F2xKwhwCf0TAR17uNDiaPTZnVymP9lw0lssa9vG+AFyDZoeIHStU3WowFFwT+ky+er0WVl2yGhA== dependencies: chalk "^4.0.0" diff-sequences "^29.4.3" jest-get-type "^29.4.3" - pretty-format "^29.6.1" + pretty-format "^29.6.2" jest-docblock@^27.5.1: version "27.5.1" @@ -13945,6 +13950,14 @@ jest-environment-node@^29.6.1: jest-mock "^29.6.1" jest-util "^29.6.1" +jest-extended@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/jest-extended/-/jest-extended-4.0.1.tgz#2315cb5914fc132e5acd07945bfaa01aac3947c2" + integrity sha512-KM6dwuBUAgy6QONuR19CGubZB9Hkjqvl/d5Yc/FXsdB8+gsGxB2VQ+NEdOrr95J4GMPeLnDoPOKyi6+mKCCnZQ== + dependencies: + jest-diff "^29.0.0" + jest-get-type "^29.0.0" + jest-fail-on-console@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/jest-fail-on-console/-/jest-fail-on-console-3.1.1.tgz#4ca0d0cc8f11675e8e9f52159a37a6602a7e6c09" @@ -13975,7 +13988,7 @@ jest-get-type@^27.5.1: resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-27.5.1.tgz#3cd613c507b0f7ace013df407a1c1cd578bcb4f1" integrity sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw== -jest-get-type@^29.4.3: +jest-get-type@^29.0.0, jest-get-type@^29.4.3: version "29.4.3" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.4.3.tgz#1ab7a5207c995161100b5187159ca82dd48b3dd5" integrity sha512-J5Xez4nRRMjk8emnTpWrlkyb9pfRQQanDrvWHhsR1+VUfbwxi30eVcZFlcdGInRibU4G5LwHXpI7IRHU0CY+gg== @@ -17320,10 +17333,10 @@ pretty-format@^28.1.3: ansi-styles "^5.0.0" react-is "^18.0.0" -pretty-format@^29.6.1: - version "29.6.1" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.6.1.tgz#ec838c288850b7c4f9090b867c2d4f4edbfb0f3e" - integrity sha512-7jRj+yXO0W7e4/tSJKoR7HRIHLPPjtNaUGG2xxKQnGvPNRkgWcQ0AZX6P4KBRJN4FcTBWb3sa7DVUJmocYuoog== +pretty-format@^29.6.1, pretty-format@^29.6.2: + version "29.6.2" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.6.2.tgz#3d5829261a8a4d89d8b9769064b29c50ed486a47" + integrity sha512-1q0oC8eRveTg5nnBEWMXAU2qpv65Gnuf2eCQzSjxpWFkPaPARwqZZDGuNE0zPAZfTCHzIk3A8dIjwlQKKLphyg== dependencies: "@jest/schemas" "^29.6.0" ansi-styles "^5.0.0"