Skip to content

Commit

Permalink
perf(api): add swr caching to metadata api (#801)
Browse files Browse the repository at this point in the history
* chore: upgrade wrangler deps

* feat: add swr caching to metadata

* Create lovely-fishes-design.md

* docs: update metadata api documentation

* docs: update hard rate limit value

* docs: fix unescaped html string

* docs: minor formatting fixes

* docs: update examples with actual links

* refactor: throw using statuserror
  • Loading branch information
ayuhito committed Sep 13, 2023
1 parent 0f4d2c7 commit 7fb5ee6
Show file tree
Hide file tree
Showing 23 changed files with 1,353 additions and 846 deletions.
6 changes: 6 additions & 0 deletions .changeset/lovely-fishes-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"download-api": patch
"api": patch
---

This adds SWR caching to the metadata API with 1-hour TTLs to the original source for faster performance and more up-to-date metadata.
34 changes: 34 additions & 0 deletions api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Fontsource API

This API uses Cloudflare Workers, KV and R2 to serve the API and CDN. We also use a custom proxy provided by [jsDelivr](https://www.jsdelivr.com/) to cache all R2 requests on the edge to also reduce costs.

Learn more about the API at [fontsource.org](https://fontsource.org/docs/api/introduction).

### Workers

- [download](./download) - An unbound worker that populates the R2 bucket with the latest fonts.
- [metadata](./metadata) - The API worker that serves the KV metadata for the fonts.

### Development

To run the API locally, you will need to install Node 18+ and `pnpm`.

```bash
pnpm install
```

Each directory represents a different worker that is deployed to Cloudflare. To run a worker locally, you can use the following command:

```bash
pnpm run dev
```

As different workers are binded to each other, they may connect to the live service. Thus you will need to run `pnpm run dev` in each relevant directory to use the local workers dev registry.

### Testing

To run the tests, you can use the following command in each directory:

```bash
pnpm run test
```
14 changes: 7 additions & 7 deletions api/download/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@
},
"devDependencies": {
"@ayuhito/eslint-config": "^0.4.1",
"@cloudflare/workers-types": "^4.20230518.0",
"eslint": "^8.42.0",
"itty-router": "^4.0.9",
"@cloudflare/workers-types": "^4.20230904.0",
"eslint": "^8.49.0",
"itty-router": "^4.0.23",
"jszip": "^3.10.1",
"p-queue": "^7.3.4",
"typescript": "^5.1.3",
"vitest-environment-miniflare": "^2.14.0",
"p-queue": "^7.4.1",
"typescript": "^5.2.2",
"vitest-environment-miniflare": "^2.14.1",
"woff2sfnt-sfnt2woff": "^1.0.0",
"wrangler": "^3.1.0"
"wrangler": "^3.7.0"
}
}
12 changes: 6 additions & 6 deletions api/metadata/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@
},
"devDependencies": {
"@ayuhito/eslint-config": "^0.4.1",
"@cloudflare/workers-types": "^4.20230518.0",
"eslint": "^8.42.0",
"itty-router": "^4.0.9",
"typescript": "^5.1.3",
"vitest-environment-miniflare": "^2.14.0",
"wrangler": "^3.1.0"
"@cloudflare/workers-types": "^4.20230904.0",
"eslint": "^8.49.0",
"itty-router": "^4.0.23",
"typescript": "^5.2.2",
"vitest-environment-miniflare": "^2.14.1",
"wrangler": "^3.7.0"
}
}
12 changes: 7 additions & 5 deletions api/metadata/src/download/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ interface FileGenerator {
// We need to make a POST request to the download worker
const createRequest = (
request: Request,
{ id, subsets, weights, styles }: FileGenerator
{ id, subsets, weights, styles }: FileGenerator,
) => {
const newRequestInit = {
method: 'POST',
Expand All @@ -24,15 +24,16 @@ const createRequest = (
const getOrUpdateZip = async (
request: Request,
data: FileGenerator,
env: Env
env: Env,
) => {
// Check if download.zip exists in bucket
const zip = await env.BUCKET.get(`${data.id}@latest/download.zip`);
if (!zip) {
// Try calling download worker
await env.DOWNLOAD.fetch(createRequest(request, data));
// Check again if download.zip exists in bucket
return await env.BUCKET.get(`${data.id}@latest/download.zip`);
const zip = await env.BUCKET.get(`${data.id}@latest/download.zip`);
return zip;
}
return zip;
};
Expand All @@ -41,15 +42,16 @@ const getOrUpdateFile = async (
request: Request,
data: FileGenerator,
file: string,
env: Env
env: Env,
) => {
// Check if file exists in bucket
const font = await env.BUCKET.get(`${data.id}@latest/${file}`);
if (!font) {
// Try calling download worker
await env.DOWNLOAD.fetch(createRequest(request, data));
// Check again if file exists in bucket
return await env.BUCKET.get(`${data.id}@latest/${file}`);
const zip = await env.BUCKET.get(`${data.id}@latest/${file}`);
return zip;
}
return font;
};
Expand Down
8 changes: 4 additions & 4 deletions api/metadata/src/download/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ interface DownloadRequest extends IRequestStrict {

const router = Router<DownloadRequest, CFRouterContext>();

router.get('/v1/download/:id', withParams, async (request, env, _ctx) => {
router.get('/v1/download/:id', withParams, async (request, env, ctx) => {
const id = request.id;

const data = await getOrUpdateId(id, env);
const data = await getOrUpdateId(id, env, ctx);
if (!data) {
return error(404, 'Not Found.');
}
Expand All @@ -35,8 +35,8 @@ router.get('/v1/download/:id', withParams, async (request, env, _ctx) => {
router.all('*', () =>
error(
404,
'Not Found. Please refer to the Fontsource API documentation: https://fontsource.org/docs/api'
)
'Not Found. Please refer to the Fontsource API documentation: https://fontsource.org/docs/api',
),
);

export default router;
36 changes: 30 additions & 6 deletions api/metadata/src/fontlist/get.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,51 @@
import type { FontsourceMetadata } from '../types';
import type { FontsourceMetadata, TTLMetadata } from '../types';
import type { Fontlist, FontlistQueries } from './types';
import { updateList, updateMetadata } from './update';

const getOrUpdateMetadata = async (env: Env): Promise<FontsourceMetadata> => {
const value = await env.FONTLIST.get<FontsourceMetadata>('metadata', {
const getOrUpdateMetadata = async (
env: Env,
ctx: ExecutionContext,
): Promise<FontsourceMetadata> => {
const { value, metadata } = await env.FONTLIST.getWithMetadata<
FontsourceMetadata,
TTLMetadata
>('metadata', {
type: 'json',
});

if (!value) {
return await updateMetadata(env);
}

// If the ttl is not set or the ttl is greater than the current time, then return old value
// while revalidating the cache
if (!metadata?.ttl || metadata.ttl > Date.now()) {
ctx.waitUntil(updateMetadata(env));
}

return value;
};

const getOrUpdateList = async (
key: FontlistQueries,
env: Env
env: Env,
ctx: ExecutionContext,
): Promise<Fontlist> => {
let value = await env.FONTLIST.get<Fontlist>(key, { type: 'json' });
const { value, metadata } = await env.FONTLIST.getWithMetadata<
Fontlist,
TTLMetadata
>(key, {
type: 'json',
});

if (!value) {
value = await updateList(key, env);
return await updateList(key, env);
}

// If the ttl is not set or the ttl is greater than the current time, then return old value
// while revalidating the cache
if (!metadata?.ttl || metadata.ttl > Date.now()) {
ctx.waitUntil(updateList(key, env));
}

return value;
Expand Down
18 changes: 13 additions & 5 deletions api/metadata/src/fontlist/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,15 @@ import { type Fontlist, isFontlistQuery } from './types';

const router = Router<IRequestStrict, CFRouterContext>();

router.get('/fontlist', async (request, env, _ctx) => {
router.get('/fontlist', async (request, env, ctx) => {
// Check cf cache
const cacheKey = new Request(request.url, request);
const cache = caches.default;

const response = await cache.match(cacheKey);
if (response) return response;

// Get query string
const url = new URL(request.url);
const queryString = url.searchParams.toString();

Expand All @@ -18,7 +26,7 @@ router.get('/fontlist', async (request, env, _ctx) => {
// If there is no query string, then return the type list
let list: Fontlist | undefined;
if (queryString.length === 0) {
list = await getOrUpdateList('type', env);
list = await getOrUpdateList('type', env, ctx);
}

// If there is a query string, then return the respective list
Expand All @@ -32,7 +40,7 @@ router.get('/fontlist', async (request, env, _ctx) => {
}

// Get or update the list
list = await getOrUpdateList(query, env);
list = await getOrUpdateList(query, env, ctx);
}

if (!list) {
Expand All @@ -46,8 +54,8 @@ router.get('/fontlist', async (request, env, _ctx) => {
router.all('*', () =>
error(
404,
'Not Found. Please refer to the Fontsource API documentation: https://fontsource.org/docs/api'
)
'Not Found. Please refer to the Fontsource API documentation: https://fontsource.org/docs/api',
),
);

export default router;
14 changes: 12 additions & 2 deletions api/metadata/src/fontlist/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ const updateMetadata = async (env: Env) => {
const data = await response.json<FontsourceMetadata>();

// Save entire metadata into KV first
await env.FONTLIST.put('metadata', JSON.stringify(data));
await env.FONTLIST.put('metadata', JSON.stringify(data), {
metadata: {
// We need to set a custom ttl for a stale-while-revalidate strategy
ttl: Date.now() + 1000 * 60 * 60, // 1 hour
},
});

return data;
};
Expand All @@ -20,12 +25,17 @@ const updateList = async (key: FontlistQueries, env: Env) => {
// Depending on key, generate a fontlist object with respective values
const list: Fontlist = {};

// Rewrite variable object to boolean state
for (const value of Object.values(data)) {
list[value.id] = key === 'variable' ? Boolean(value.variable) : value[key];
}

// Store the list in KV
await env.FONTLIST.put(key, JSON.stringify(list));
await env.FONTLIST.put(key, JSON.stringify(list), {
metadata: {
ttl: Date.now() + 1000 * 60 * 60, // 1 hour
},
});
return list;
};

Expand Down
29 changes: 23 additions & 6 deletions api/metadata/src/fonts/get.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,40 @@
import { type TTLMetadata } from '../types';
import type { ArrayMetadata, IDResponse } from './types';
import { updateArrayMetadata, updateId } from './update';

const getOrUpdateArrayMetadata = async (env: Env) => {
const value = await env.FONTLIST.get<ArrayMetadata>('metadata_arr', {
const getOrUpdateArrayMetadata = async (env: Env, ctx: ExecutionContext) => {
const { value, metadata } = await env.FONTLIST.getWithMetadata<
ArrayMetadata,
TTLMetadata
>('metadata_arr', {
type: 'json',
});

if (!value) {
return await updateArrayMetadata(env);
return await updateArrayMetadata(env, ctx);
}

// If the ttl is not set or the ttl is greater than the current time, then return old value
// while revalidating the cache
if (!metadata?.ttl || metadata.ttl > Date.now()) {
ctx.waitUntil(updateArrayMetadata(env, ctx));
}

return value;
};

const getOrUpdateId = async (id: string, env: Env) => {
const value = await env.FONTS.get<IDResponse>(id, { type: 'json' });
const getOrUpdateId = async (id: string, env: Env, ctx: ExecutionContext) => {
const { value, metadata } = await env.FONTS.getWithMetadata<
IDResponse,
TTLMetadata
>(id, { type: 'json' });

if (!value) {
return await updateId(id, env);
return await updateId(id, env, ctx);
}

if (!metadata?.ttl || metadata.ttl > Date.now()) {
ctx.waitUntil(updateId(id, env, ctx));
}

return value;
Expand Down

0 comments on commit 7fb5ee6

Please sign in to comment.