feat: caches GraphQL queries for Cloudflare workers (#6929)

* feat: add token and nft injection

* feat: basic tests

* fix: get jest configured properly

* fix: change timeout

* fix: uninstall port ready

* fix: readd port ready

* fix: local tests work

* Update yarn.lock

* add lint disable for setup files

* fix: update dependencies

* fix: basic test suite for nfts/tokens

* feat: collection data

* fix: make tests more comprehensive

* fix: change matches to contains

* fix: tests for twitter alt image tag

* fix: image gen

* fix: add patch-package

* fix: update yarn install

* feat: basic image gen for nfts and collections

* fix: remove vibrant attempt

* use watermark asset

* dynamically grab color

* modularize code and prototype for token preview

* refactor code

* finalize css

* fix color grabber

* update tests

* fix up css

* refactor code a bit more

* remove console logs

* tests

* update tests

* update images based on design feedback

* network logos

* update lint

* slight refactoring

* more refactoring

* fix packages

* Update yarn.lock

* remove dynamically generated image stuff

* cleanup return values

* Create README.md

* Revert "Create README.md"

This reverts commit 7a91c98d384995fba914c9bf9a2fb3072793621f.

* First round of feedback

* comments

* feat: cache

* Update test.yml

* Update test.yml

* Update test.yml

* feedback round 2

* final feedback

* final final feedback

* add coverage and other options

* Update test.yml

* start typecheck

* update cache

* update snapshots?

* Update jest.config.json

* Update jest.config.json

* give timeout some buffer

* update import

* upgrade ts

* fix typing for apollo deps

* finalize typechecks

* downgrade typescript to original version

* add cache directory to jest

* remove coverage

* remove google analytics from tests

* review changes

* try cache setup

* Update cache.test.ts

* make cache helper function

* cache test

* remove unneeded test causing issues

* feat: parallelize cache (#6930)

* feat: parallelize cache?

* remove graph query from concurrency await

* most of feedback

* move tests

* update token tests

* singleton cache

* restructuring res and cache promise

* abstract away repeated graph logic

* final feedback

* Update yarn.lock

* final final feedback

* final final final feedback!

* final final final final feedback?
This commit is contained in:
Brendan Wong 2023-08-04 14:12:20 -04:00 committed by GitHub
parent dbb2f7f6a2
commit f845695f6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 188 additions and 48 deletions

@ -1,20 +1,15 @@
/* eslint-disable import/no-unused-modules */ /* eslint-disable import/no-unused-modules */
import { MetaTagInjector } from '../../components/metaTagInjector'
import getAsset from '../../utils/getAsset' import getAsset from '../../utils/getAsset'
import getRequest from '../../utils/getRequest'
export const onRequest: PagesFunction = async ({ params, request, next }) => { export const onRequest: PagesFunction = async ({ params, request, next }) => {
const { index } = params const res = next()
const collectionAddress = index[0]?.toString()
const tokenId = index[1]?.toString()
const assetPromise = getAsset(collectionAddress, tokenId, request.url)
const resPromise = next()
try { try {
const [data, res] = await Promise.all([assetPromise, resPromise]) const { index } = params
if (!data) { const collectionAddress = index[0]?.toString()
return resPromise const tokenId = index[1]?.toString()
} return getRequest(res, request.url, () => getAsset(collectionAddress, tokenId, request.url))
return new HTMLRewriter().on('head', new MetaTagInjector(data)).transform(res)
} catch (e) { } catch (e) {
return resPromise return res
} }
} }

@ -1,19 +1,14 @@
/* eslint-disable import/no-unused-modules */ /* eslint-disable import/no-unused-modules */
import { MetaTagInjector } from '../../components/metaTagInjector'
import getCollection from '../../utils/getCollection' import getCollection from '../../utils/getCollection'
import getRequest from '../../utils/getRequest'
export const onRequest: PagesFunction = async ({ params, request, next }) => { export const onRequest: PagesFunction = async ({ params, request, next }) => {
const { index } = params const res = next()
const collectionAddress = index?.toString()
const collectionPromise = getCollection(collectionAddress, request.url)
const resPromise = next()
try { try {
const [data, res] = await Promise.all([collectionPromise, resPromise]) const { index } = params
if (!data) { const collectionAddress = index?.toString()
return resPromise return getRequest(res, request.url, () => getCollection(collectionAddress, request.url))
}
return new HTMLRewriter().on('head', new MetaTagInjector(data)).transform(res)
} catch (e) { } catch (e) {
return resPromise return res
} }
} }

@ -1,6 +1,6 @@
/* eslint-disable import/no-unused-modules */ /* eslint-disable import/no-unused-modules */
import { Chain } from '../../src/graphql/data/__generated__/types-and-hooks' 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' import getToken from '../utils/getToken'
const convertTokenAddress = (tokenAddress: string, networkName: string) => { const convertTokenAddress = (tokenAddress: string, networkName: string) => {
@ -18,22 +18,17 @@ const convertTokenAddress = (tokenAddress: string, networkName: string) => {
} }
export const onRequest: PagesFunction = async ({ params, request, next }) => { export const onRequest: PagesFunction = async ({ params, request, next }) => {
const { index } = params const res = next()
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()
try { try {
const [data, res] = await Promise.all([tokenPromise, resPromise]) const { index } = params
if (!data) { const networkName = index[0]?.toString().toUpperCase()
return resPromise 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) { } catch (e) {
return resPromise return res
} }
} }

@ -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()
})

28
functions/utils/cache.ts Normal file

@ -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<Data | undefined> {
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()

@ -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()
})

@ -0,0 +1,31 @@
import { MetaTagInjector } from '../components/metaTagInjector'
import Cache from './cache'
export default async function getRequest(
res: Promise<Response>,
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
}
}

@ -112,6 +112,7 @@
"@walletconnect/types": "^2.8.6", "@walletconnect/types": "^2.8.6",
"babel-jest": "^29.6.1", "babel-jest": "^29.6.1",
"babel-plugin-istanbul": "^6.1.1", "babel-plugin-istanbul": "^6.1.1",
"browser-cache-mock": "^0.1.7",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"concurrently": "^8.0.1", "concurrently": "^8.0.1",
"cypress": "12.12.0", "cypress": "12.12.0",
@ -123,6 +124,7 @@
"hardhat": "^2.14.0", "hardhat": "^2.14.0",
"jest": "^29.6.1", "jest": "^29.6.1",
"jest-dev-server": "^9.0.0", "jest-dev-server": "^9.0.0",
"jest-extended": "^4.0.1",
"jest-fail-on-console": "^3.1.1", "jest-fail-on-console": "^3.1.1",
"jest-fetch-mock": "^3.0.3", "jest-fetch-mock": "^3.0.3",
"jest-styled-components": "^7.0.8", "jest-styled-components": "^7.0.8",

@ -8446,6 +8446,11 @@ brorand@^1.1.0:
resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f"
integrity sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8= 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: browser-level@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/browser-level/-/browser-level-1.0.1.tgz#36e8c3183d0fe1c405239792faaab5f315871011" 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" jest-get-type "^27.5.1"
pretty-format "^27.5.1" pretty-format "^27.5.1"
jest-diff@^29.6.1: jest-diff@^29.0.0, jest-diff@^29.6.1:
version "29.6.1" version "29.6.2"
resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.6.1.tgz#13df6db0a89ee6ad93c747c75c85c70ba941e545" resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.6.2.tgz#c36001e5543e82a0805051d3ceac32e6825c1c46"
integrity sha512-FsNCvinvl8oVxpNLttNQX7FAq7vR+gMDGj90tiP7siWw1UdakWUGqrylpsYrpvj908IYckm5Y0Q7azNAozU1Kg== integrity sha512-t+ST7CB9GX5F2xKwhwCf0TAR17uNDiaPTZnVymP9lw0lssa9vG+AFyDZoeIHStU3WowFFwT+ky+er0WVl2yGhA==
dependencies: dependencies:
chalk "^4.0.0" chalk "^4.0.0"
diff-sequences "^29.4.3" diff-sequences "^29.4.3"
jest-get-type "^29.4.3" jest-get-type "^29.4.3"
pretty-format "^29.6.1" pretty-format "^29.6.2"
jest-docblock@^27.5.1: jest-docblock@^27.5.1:
version "27.5.1" version "27.5.1"
@ -13945,6 +13950,14 @@ jest-environment-node@^29.6.1:
jest-mock "^29.6.1" jest-mock "^29.6.1"
jest-util "^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: jest-fail-on-console@^3.1.1:
version "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" 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" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-27.5.1.tgz#3cd613c507b0f7ace013df407a1c1cd578bcb4f1"
integrity sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw== 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" version "29.4.3"
resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.4.3.tgz#1ab7a5207c995161100b5187159ca82dd48b3dd5" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.4.3.tgz#1ab7a5207c995161100b5187159ca82dd48b3dd5"
integrity sha512-J5Xez4nRRMjk8emnTpWrlkyb9pfRQQanDrvWHhsR1+VUfbwxi30eVcZFlcdGInRibU4G5LwHXpI7IRHU0CY+gg== integrity sha512-J5Xez4nRRMjk8emnTpWrlkyb9pfRQQanDrvWHhsR1+VUfbwxi30eVcZFlcdGInRibU4G5LwHXpI7IRHU0CY+gg==
@ -17320,10 +17333,10 @@ pretty-format@^28.1.3:
ansi-styles "^5.0.0" ansi-styles "^5.0.0"
react-is "^18.0.0" react-is "^18.0.0"
pretty-format@^29.6.1: pretty-format@^29.6.1, pretty-format@^29.6.2:
version "29.6.1" version "29.6.2"
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.6.1.tgz#ec838c288850b7c4f9090b867c2d4f4edbfb0f3e" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.6.2.tgz#3d5829261a8a4d89d8b9769064b29c50ed486a47"
integrity sha512-7jRj+yXO0W7e4/tSJKoR7HRIHLPPjtNaUGG2xxKQnGvPNRkgWcQ0AZX6P4KBRJN4FcTBWb3sa7DVUJmocYuoog== integrity sha512-1q0oC8eRveTg5nnBEWMXAU2qpv65Gnuf2eCQzSjxpWFkPaPARwqZZDGuNE0zPAZfTCHzIk3A8dIjwlQKKLphyg==
dependencies: dependencies:
"@jest/schemas" "^29.6.0" "@jest/schemas" "^29.6.0"
ansi-styles "^5.0.0" ansi-styles "^5.0.0"