Skip to content

Commit 6a43dd0

Browse files
authored
Add caching for Algolia calls (#77)
* Add caching for Algolia * Add namespace id for staging * Add tests for writing to and reading from KV * 'shell script' code blocks -> 'sh' * Readme copy tweaks * Extract bufToHex method * Add production KV namespace
1 parent 0d0319e commit 6a43dd0

File tree

5 files changed

+156
-13
lines changed

5 files changed

+156
-13
lines changed

README.md

+30-10
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,31 @@ Looking for the documentation on our API?
1616

1717
This project uses [Node.js](https://nodejs.org) for development, and is deployed as a
1818
[Cloudflare Worker](https://workers.cloudflare.com/). Please make sure you have a Node.js version
19-
installed that matches our defined requirement in the [.nvmrc](.nvmrc) file for this project.
19+
installed that matches our defined requirement in the [`.nvmrc`](.nvmrc) file for this project.
2020

2121
Included with this project is a dependency lock file. This is used to ensure that all installations
2222
of the project are using the same version of dependencies for consistency. You can install the
2323
dependencies following this lock file by running:
2424

25-
```shell script
25+
```sh
2626
npm ci
2727
```
2828

29-
Once the dependencies are installed, which includes the Wrangler CLI for Cloudflare Workers, the API
30-
server is ready to run in development mode. To start the server in development mode, run:
29+
Once the dependencies are installed, which includes the Wrangler CLI for Cloudflare Workers, you
30+
need to create the KV namespace for data caching before the API can be run. This command will ask
31+
you to authenticate with a Cloudflare account, so that the Workers KV namespace can be created:
3132

32-
```shell script
33+
```sh
34+
wrangler kv:namespace create CACHE --preview
35+
```
36+
37+
Copy the new `preview_id` returned by the command and replace the existing `preview_id` in
38+
[`wrangler.toml`](wrangler.toml).
39+
40+
With the KV namespace setup, the API server is now ready to run in development mode. To start the
41+
server in development mode, run:
42+
43+
```sh
3344
npm run dev
3445
```
3546

@@ -41,7 +52,7 @@ deployed in a development context to Cloudflare's Workers runtime.
4152
Our full set of tests (linting & a mocha+chai test suite using Miniflare to run the worker locally)
4253
can be run at any time with:
4354

44-
```shell script
55+
```sh
4556
npm test
4657
```
4758

@@ -54,20 +65,20 @@ API server.
5465
To help enforce this, we use both eslint and echint in our testing. To run eslint at any time, which
5566
checks the code style of any JavaScript, you can use:
5667

57-
```shell script
68+
```sh
5869
npm run test:eslint
5970
```
6071

6172
eslint also provides automatic fixing capabilities, these can be run against the codebase with:
6273

63-
```shell script
74+
```sh
6475
npm run test:eslint:fix
6576
```
6677

6778
The more generic rules defined in the [editorconfig file](.editorconfig) apply to all files in the
6879
repository and this is enforced by echint, which can be run at any time with:
6980

70-
```shell script
81+
```sh
7182
npm run test:echint
7283
```
7384

@@ -81,7 +92,7 @@ this is perfect, a human should always review changes!
8192
The mocha test suite can be run at any time with the following command (it will build the worker
8293
using Wrangler, and then run it with Miniflare during the Mocha+Chai test suite):
8394

84-
```shell script
95+
```sh
8596
npm run test:mocha
8697
```
8798

@@ -108,6 +119,15 @@ deploying to staging (api.cdnjs.dev) and production (api.cdnjs.com) based on com
108119
staging/production branches, automatically handling not only deploying the worker but also creating
109120
a Sentry release with full source maps.
110121

122+
Before deploying, ensure that you generate the required KV namespace for the environment you are
123+
deploying to and update [`wrangler.toml`](wrangler.toml) to use the correct ID:
124+
125+
```sh
126+
wrangler kv:namespace create CACHE --env=staging
127+
# or
128+
wrangler kv:namespace create CACHE --env=production
129+
```
130+
111131
To deploy to staging (assuming you have write access to this repository), run `make deploy-staging`.
112132
This will force-push your latest local commit to the staging branch, which will trigger GitHub
113133
Actions to run and deploy your worker to Cloudflare Workers.

src/routes/libraries.js

+26-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
/* global CACHE */
2+
13
import algolia from '../utils/algolia.js';
24
import cache from '../utils/cache.js';
35
import filter from '../utils/filter.js';
@@ -9,6 +11,14 @@ const validSearchFields = [ 'name', 'alternativeNames', 'github.repo', 'descript
911
'repositories.url', 'github.user', 'maintainers.name' ];
1012
const maxQueryLength = 512;
1113

14+
/**
15+
* Convert an ArrayBuffer to a hex string.
16+
*
17+
* @param {ArrayBuffer} buf Buffer to convert.
18+
* @return {string}
19+
*/
20+
const bufToHex = buf => Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
21+
1222
/**
1323
* Browse an Algolia index to get all objects matching a query.
1424
*
@@ -17,10 +27,22 @@ const maxQueryLength = 512;
1727
* @return {Promise<Object[]>}
1828
*/
1929
const browse = async (query, searchFields) => {
30+
// Normalize the search fields
31+
const fields = searchFields.filter(field => validSearchFields.includes(field)).sort();
32+
33+
// Check if there is a cached result for this query
34+
const cacheKey = await crypto.subtle.digest(
35+
{ name: 'SHA-512' },
36+
new TextEncoder().encode(`${query}:${fields.join(',')}`),
37+
).then(buf => `libraries:${bufToHex(buf)}`);
38+
const cached = await CACHE.get(cacheKey, { type: 'json' });
39+
if (cached) return cached;
40+
41+
// Fetch the results from Algolia
2042
const hits = [];
2143
await index.browseObjects({
2244
query,
23-
restrictSearchableAttributes: searchFields.filter(field => validSearchFields.includes(field)),
45+
restrictSearchableAttributes: fields,
2446
/**
2547
* Store an incoming batch of hits.
2648
*
@@ -32,6 +54,9 @@ const browse = async (query, searchFields) => {
3254
}).catch(err => {
3355
throw err instanceof Error ? err : new Error(`${err.name}: ${err.message}`);
3456
});
57+
58+
// Cache the results for 15 minutes
59+
await CACHE.put(cacheKey, JSON.stringify(hits), { expirationTtl: 60 * 15 });
3560
return hits;
3661
};
3762

src/routes/libraries.spec.js

+81
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { createHash } from 'crypto';
2+
13
import { expect } from 'chai';
24
import { describe, it, before } from 'mocha';
35

@@ -597,4 +599,83 @@ describe('/libraries', () => {
597599
});
598600
});
599601
});
602+
603+
describe('Caching Algolia data with KV', () => {
604+
it('writes the results from Algolia to KV', async () => {
605+
await request('/libraries', {}, undefined, async mf => {
606+
// Check that the results were stored under the `:` key (no query, no fields)
607+
const cache = await mf.getKVNamespace('CACHE');
608+
const key = `libraries:${createHash('sha512').update(':').digest('hex')}`;
609+
const { keys } = await cache.list();
610+
expect(keys).to.be.an('array');
611+
expect(keys).to.have.lengthOf(1);
612+
expect(keys[0].name).to.equal(key);
613+
expect(await cache.get(key, { type: 'json' })).to.be.an('array');
614+
});
615+
});
616+
617+
it('reuses existing Algolia values from KV', async () => {
618+
const response = await request('/libraries', {}, async mf => {
619+
// Create a stub set of Algolia results under the `:` key (no query, no fields)
620+
const cache = await mf.getKVNamespace('CACHE');
621+
const key = `libraries:${createHash('sha512').update(':').digest('hex')}`;
622+
await cache.put(key, JSON.stringify([ { name: 'testing' } ]));
623+
});
624+
625+
// Check the response was generated from KV
626+
expect(response).to.be.json;
627+
expect(response.body).to.have.property('results').that.is.an('array');
628+
expect(response.body.results).to.have.lengthOf(1);
629+
expect(response.body.results[0]).to.have.property('name').that.is.a('string');
630+
expect(response.body.results[0].name).to.equal('testing');
631+
});
632+
633+
it('reuses the same KV cache no matter the query parameter order', async () => {
634+
const key = `libraries:${createHash('sha512').update('test:name').digest('hex')}`;
635+
636+
// Run the request with the `name` parameter first, check consistent key was used
637+
await request('/libraries?search=test&search_fields=name', {}, undefined, async mf => {
638+
const cache = await mf.getKVNamespace('CACHE');
639+
const { keys } = await cache.list();
640+
expect(keys).to.be.an('array');
641+
expect(keys).to.have.lengthOf(1);
642+
expect(keys[0].name).to.equal(key);
643+
expect(await cache.get(key, { type: 'json' })).to.be.an('array');
644+
});
645+
646+
// Run the request with the `search_fields` parameter first, check consistent key was used
647+
await request('/libraries?search_fields=name&search=test', {}, undefined, async mf => {
648+
const cache = await mf.getKVNamespace('CACHE');
649+
const { keys } = await cache.list();
650+
expect(keys).to.be.an('array');
651+
expect(keys).to.have.lengthOf(1);
652+
expect(keys[0].name).to.equal(key);
653+
expect(await cache.get(key, { type: 'json' })).to.be.an('array');
654+
});
655+
});
656+
657+
it('reuses the same KV cache no matter the search fields order', async () => {
658+
const key = `libraries:${createHash('sha512').update('test:keywords,name').digest('hex')}`;
659+
660+
// Run the request with the `name` search field first, check consistent key was used
661+
await request('/libraries?search=test&search_fields=name,keywords', {}, undefined, async mf => {
662+
const cache = await mf.getKVNamespace('CACHE');
663+
const { keys } = await cache.list();
664+
expect(keys).to.be.an('array');
665+
expect(keys).to.have.lengthOf(1);
666+
expect(keys[0].name).to.equal(key);
667+
expect(await cache.get(key, { type: 'json' })).to.be.an('array');
668+
});
669+
670+
// Run the request with the `keywords` search field first, check consistent key was used
671+
await request('/libraries?search=test&search_fields=keywords,name', {}, undefined, async mf => {
672+
const cache = await mf.getKVNamespace('CACHE');
673+
const { keys } = await cache.list();
674+
expect(keys).to.be.an('array');
675+
expect(keys).to.have.lengthOf(1);
676+
expect(keys[0].name).to.equal(key);
677+
expect(await cache.get(key, { type: 'json' })).to.be.an('array');
678+
});
679+
});
680+
});
600681
});

src/utils/spec/request.js

+10-2
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@ import { Miniflare, Request } from 'miniflare';
1515
*
1616
* @param {string} route Route to request in API via Miniflare.
1717
* @param {RequestInit} [opts={}] Options to set for fetch request.
18-
* @return {ExtendedResponse}
18+
* @param {function(Miniflare, Request): (Promise<void>|void)} [preHook] Hook to run before the request is sent.
19+
* @param {function(Miniflare, Request, ExtendedResponse): (Promise<void>|void)} [postHook] Hook to run after the response is received.
20+
* @return {Promise<ExtendedResponse>}
1921
*/
20-
export default (route, opts = {}) => {
22+
export default async (route, opts = {}, preHook = undefined, postHook = undefined) => {
2123
// Create the Miniflare instance
2224
const mf = new Miniflare({
2325
scriptPath: fileURLToPath(new URL('../../../dist-worker/index.js', import.meta.url)),
@@ -29,6 +31,9 @@ export default (route, opts = {}) => {
2931
// Create a full request
3032
const req = new Request(`http://localhost:5050${route}`, opts);
3133

34+
// Run the pre-hook if defined
35+
if (typeof preHook === 'function') await preHook(mf, req);
36+
3237
// Send the request to Miniflare
3338
return mf.dispatchFetch(req).then(async resp => {
3439
// Patch in some extras for chai-http
@@ -55,6 +60,9 @@ export default (route, opts = {}) => {
5560
if (/[/+]json($|[^-\w])/i.test(resp.headers.get('content-type')?.toLowerCase()))
5661
Reflect.defineProperty(resp, 'body', { value: JSON.parse(text) });
5762

63+
// Run the post-hook if defined
64+
if (typeof postHook === 'function') await postHook(mf, req, resp);
65+
5866
return resp;
5967
});
6068
};

wrangler.toml

+9
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
name = "cdnjs-api-worker"
22
main = "src/index.js"
33
compatibility_date = "2022-05-20"
4+
kv_namespaces = [
5+
{ binding = "CACHE", id = "", preview_id = "845ae1599dcf4d75950b61201a951b73" }
6+
]
47

58
[vars]
69
DISABLE_CACHING = false
@@ -11,6 +14,9 @@ SENTRY_ENVIRONMENT = "development"
1114

1215
[env.staging]
1316
route = "api.cdnjs.dev/*"
17+
kv_namespaces = [
18+
{ binding = "CACHE", id = "34b159dab6f840ce9c17e4d0730ead12" }
19+
]
1420

1521
[env.staging.vars]
1622
DISABLE_CACHING = false
@@ -21,6 +27,9 @@ SENTRY_ENVIRONMENT = "staging"
2127

2228
[env.production]
2329
route = "api.cdnjs.com/*"
30+
kv_namespaces = [
31+
{ binding = "CACHE", id = "c2922fcf1af643658d6769859b913134" }
32+
]
2433

2534
[env.production.vars]
2635
DISABLE_CACHING = false

0 commit comments

Comments
 (0)