mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-18 00:25:07 +00:00
Experimental new cache backend powered by DO (#2479)
Co-authored-by: Johan Preynat <johan.preynat@gmail.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/cache-do': minor
|
||||
---
|
||||
|
||||
First version of a new cache backend powered by Cloudflare Durable Objects
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Use new cache backend, powered by Durable Objects, alongside the existing ones (KV, etc).
|
||||
@@ -35,3 +35,26 @@ jobs:
|
||||
# https://github.com/orgs/community/discussions/26875#discussioncomment-3253761
|
||||
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
release-preview:
|
||||
# For now it releases the cache-do to both preview and production
|
||||
# Once we changed to deploy the app only on release, we should change `release:preview` in `cache-do`
|
||||
name: Release Preview
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.1.18
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||
- name: Release preview packages
|
||||
run: bun run release:preview
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.wrangler
|
||||
worker-configuration.d.ts
|
||||
dist/
|
||||
@@ -0,0 +1,22 @@
|
||||
# `@gitbook/cache-do`
|
||||
|
||||
Cache backend, powered by Cloudflare Durable Objects. The cache is optimized for GitBook use-cases.
|
||||
|
||||
### Performances
|
||||
|
||||
The cache backend is optimized for performances by being distributed and accessible close to the worker locations that are reading it.
|
||||
|
||||
### Geo-distribution
|
||||
|
||||
To achieve a good balance between **performances** and **consistency**, cache objects are distributed over 7 locations, representing continents.
|
||||
|
||||
It makes it possible to purge all 7 locations in one go and achieve fast consistency.
|
||||
|
||||
### Concepts
|
||||
|
||||
**Cache tag**: unique tag in the cache environment. A cache tag groups multiple keys that should be purged together in one operation.
|
||||
Cache tags should not contain a large set of unique keys. Exceeding thousands could lead to performances or reliability issues.
|
||||
|
||||
**Cache key**: unique key in the cache environment. Each key should be assigned to a `tag`.
|
||||
|
||||
**Location**: cache is distributed over 7 unique locations, one for each continent.
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@gitbook/cache-do",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./api": {
|
||||
"types": "./dist/api.d.ts",
|
||||
"development": "./src/api.ts",
|
||||
"default": "./dist/api.js"
|
||||
}
|
||||
},
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@msgpack/msgpack": "^3.0.0-beta2",
|
||||
"lru_map": "^0.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3",
|
||||
"wrangler": "^3.78.7"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "wrangler types --experimental-include-runtime",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "tsc -w",
|
||||
"release": "wrangler deploy",
|
||||
"release:preview": "wrangler deploy && wrangler deploy --env preview"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"bin",
|
||||
"data",
|
||||
"README.md",
|
||||
"CHANGELOG.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { encode, decode } from '@msgpack/msgpack';
|
||||
import { DurableObject } from 'cloudflare:workers';
|
||||
import { LRUMap } from 'lru_map';
|
||||
|
||||
export interface CacheObjectDescriptor {
|
||||
get: <Value = unknown>(key: string) => Promise<Value | undefined>;
|
||||
set: <Value = unknown>(key: string, value: Value, expiresAt: number) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Value stored in a chunked binary msgpack format.
|
||||
* Stored under the key `prop.${key}.${index}`.
|
||||
*/
|
||||
interface CacheObjectProp<Value = unknown> {
|
||||
value: Value;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expiration clock stored under the key `exp.${expiresAt}.${key}`.
|
||||
*/
|
||||
interface CacheObjectExp {
|
||||
/** Key of the property */
|
||||
k: string;
|
||||
/** Number of chunks */
|
||||
c: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Durable Object class being deployed as a distributed cache.
|
||||
*/
|
||||
export class CacheObject extends DurableObject {
|
||||
private lru = new LRUMap<string, { match: CacheObjectProp | undefined }>(500);
|
||||
|
||||
/**
|
||||
* Open a descriptor to access the cache object.
|
||||
* The goal is to minimize the amount of RPC sessions between the client and the cache object.
|
||||
* One session is opened per request on the client side and used to perform multiple operations.
|
||||
* https://developers.cloudflare.com/workers/runtime-apis/rpc/#return-functions-from-rpc-methods
|
||||
*/
|
||||
public open(): CacheObjectDescriptor {
|
||||
return {
|
||||
get: async <Value = unknown>(key: string) => {
|
||||
return this.get<Value>(key);
|
||||
},
|
||||
set: async <Value = unknown>(key: string, value: Value, expiresAt: number) => {
|
||||
await this.set(key, value, expiresAt);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value of a property.
|
||||
*/
|
||||
public async get<Value = unknown>(key: string) {
|
||||
return timeFn(`get: ${key}`, async () => {
|
||||
// Try the memory state first.
|
||||
const memoryEntry = this.lru.get(key);
|
||||
if (memoryEntry) {
|
||||
console.log(`get: (memory, ${!!memoryEntry.match}) ${key}`);
|
||||
if (!memoryEntry.match) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (memoryEntry.match.expiresAt > Date.now()) {
|
||||
return memoryEntry.match.value as Value;
|
||||
}
|
||||
}
|
||||
|
||||
return await this.getFromStorage<Value>(key);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value of a property from the DO storage.
|
||||
*/
|
||||
public async getFromStorage<Value = unknown>(key: string) {
|
||||
const entries = await this.ctx.storage.list<Uint8Array>({
|
||||
prefix: getStoragePropKey(key),
|
||||
noCache: true,
|
||||
});
|
||||
if (entries.size) {
|
||||
const entry = decodeChunks<CacheObjectProp<Value>>(entries);
|
||||
if (entry && entry.expiresAt > Date.now()) {
|
||||
// Found
|
||||
this.lru.set(key, { match: entry });
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Not found
|
||||
this.lru.set(key, { match: undefined });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value in the cache object.
|
||||
*/
|
||||
public async set<Value = unknown>(key: string, value: Value, expiresAt: number) {
|
||||
return timeFn(`set: ${key}`, async () => {
|
||||
const prop: CacheObjectProp<Value> = {
|
||||
value,
|
||||
expiresAt,
|
||||
};
|
||||
|
||||
this.lru.set(key, { match: prop });
|
||||
await this.ctx.storage.transaction(async (tx) => {
|
||||
const entries = encodeChunks(key, prop);
|
||||
|
||||
const clockValue: CacheObjectExp = {
|
||||
k: key,
|
||||
c: Object.keys(entries).length,
|
||||
};
|
||||
|
||||
await tx.put(getGCClockKey(key, expiresAt), clockValue);
|
||||
await tx.put(entries);
|
||||
|
||||
const currentAlarm = await tx.getAlarm();
|
||||
if (!currentAlarm) {
|
||||
// Set an alarm to garbage collect all entries that have expired in 12h.
|
||||
await tx.setAlarm(Date.now() + 12 * 60 * 60 * 1000);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge all keys in the cache object.
|
||||
*/
|
||||
public async purge() {
|
||||
let result = new Set<string>();
|
||||
|
||||
try {
|
||||
// List all the keys in the cache object.
|
||||
const entries = await this.ctx.storage.list<CacheObjectExp>({
|
||||
prefix: 'exp.',
|
||||
noCache: true,
|
||||
});
|
||||
console.log(`purge: ${entries.size} entries`);
|
||||
entries.forEach((exp) => {
|
||||
result.add(exp.k);
|
||||
});
|
||||
} catch (error) {
|
||||
// If an error occurs, reset the cache object.
|
||||
// This is a safety mechanism to prevent the cache object from being stuck in a bad state.
|
||||
console.error('Error during purge, resetting the cache object', error);
|
||||
}
|
||||
|
||||
await this.reset();
|
||||
|
||||
console.log(`purge returns`, Array.from(result));
|
||||
return Array.from(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alarm to garbage collect all entries that have expired.
|
||||
*/
|
||||
async alarm() {
|
||||
try {
|
||||
const entries = await this.ctx.storage.list<CacheObjectExp>({
|
||||
prefix: 'exp.',
|
||||
noCache: true,
|
||||
});
|
||||
const toDeleteSet = new Set<string>();
|
||||
|
||||
for (const [key, exp] of entries) {
|
||||
const timestamp = parseInt(key.split('.')[1]);
|
||||
if (timestamp < Date.now()) {
|
||||
toDeleteSet.add(key);
|
||||
for (let i = 0; i < exp.c; i++) {
|
||||
toDeleteSet.add(getStoragePropChunkKey(exp.k, i));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the keys by batch of 128.
|
||||
const toDelete = Array.from(toDeleteSet);
|
||||
for (let i = 0; i < toDelete.length; i += 128) {
|
||||
await this.ctx.storage.delete(toDelete.slice(i, i + 128));
|
||||
}
|
||||
|
||||
// If there are still keys to delete, set an alarm to continue the deletion in 12h.
|
||||
if (toDelete.length) {
|
||||
await this.ctx.storage.setAlarm(Date.now() + 12 * 60 * 60 * 1000);
|
||||
}
|
||||
} catch (error) {
|
||||
// If an error occurs, reset the cache object.
|
||||
// This is a safety mechanism to prevent the cache object from being stuck in a bad state.
|
||||
console.error('Error during alarm, reset the cache object', error);
|
||||
await this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the cache object.
|
||||
*/
|
||||
async reset() {
|
||||
console.log('reset: clear all entries');
|
||||
this.lru.clear();
|
||||
await this.ctx.storage.deleteAll();
|
||||
}
|
||||
}
|
||||
|
||||
function getStoragePropKey(key: string): string {
|
||||
return `prop.${key}.`;
|
||||
}
|
||||
|
||||
function getStoragePropChunkKey(key: string, index: number): string {
|
||||
return `${getStoragePropKey(key)}${index}`;
|
||||
}
|
||||
|
||||
function getGCClockRootKey(timestamp: number): string {
|
||||
return `exp.${timestamp}.`;
|
||||
}
|
||||
|
||||
function getGCClockKey(key: string, expiresAt: number): string {
|
||||
return `${getGCClockRootKey(expiresAt)}${key}`;
|
||||
}
|
||||
|
||||
function encodeChunks<T>(key: string, value: T): Record<string, Uint8Array> {
|
||||
const buf = encode(value);
|
||||
const entries: Record<string, Uint8Array> = {};
|
||||
const chunks = chunkUint8Array(buf, 128 * 1024);
|
||||
|
||||
for (let index = 0; index < chunks.length; index++) {
|
||||
entries[getStoragePropChunkKey(key, index)] = chunks[index];
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function decodeChunks<T>(entries: Map<string, Uint8Array>): T | undefined {
|
||||
const chunks = Array.from(entries.entries())
|
||||
.map(([key, value]) => {
|
||||
const index = parseInt(key.split('.').pop()!);
|
||||
return [index, value] as const;
|
||||
})
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([, value]) => value);
|
||||
|
||||
if (chunks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const buf = mergeUint8Array(chunks);
|
||||
return decode(buf) as T;
|
||||
}
|
||||
|
||||
function chunkUint8Array(input: Uint8Array, chunkSize: number): Uint8Array[] {
|
||||
const chunks: Uint8Array[] = [];
|
||||
for (let i = 0; i < input.length; i += chunkSize) {
|
||||
chunks.push(input.slice(i, i + chunkSize));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function mergeUint8Array(chunks: Uint8Array[]): Uint8Array {
|
||||
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const result = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function timeFn<T>(message: string, fn: () => Promise<T>): Promise<T> {
|
||||
const start = performance.now();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
console.log(`${message} (${performance.now() - start}ms)`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { CacheObject, CacheObjectDescriptor } from './CacheObject';
|
||||
|
||||
export type CacheLocationId = ContinentCode;
|
||||
const allLocations: CacheLocationId[] = ['AF', 'AS', 'NA', 'SA', 'AN', 'EU', 'OC'];
|
||||
|
||||
/**
|
||||
* Location hint for the CacheObject durable object.
|
||||
*/
|
||||
const doLocationHints: {
|
||||
[key in CacheLocationId]: DurableObjectLocationHint;
|
||||
} = {
|
||||
AF: 'afr',
|
||||
AS: 'apac',
|
||||
NA: 'wnam',
|
||||
SA: 'sam',
|
||||
AN: 'oc',
|
||||
EU: 'weur',
|
||||
OC: 'oc',
|
||||
};
|
||||
|
||||
/**
|
||||
* Client to access a cache tag.
|
||||
*/
|
||||
export class CacheObjectStub {
|
||||
private opened: CacheObjectDescriptor | null = null;
|
||||
|
||||
constructor(
|
||||
/** Binding to the CacheObject durable object */
|
||||
private doNamespace: DurableObjectNamespace<CacheObject>,
|
||||
/** ID of the location to target */
|
||||
private locationId: CacheLocationId,
|
||||
/** Name of the tag */
|
||||
private tag: string,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Open the cache object.
|
||||
*/
|
||||
async open() {
|
||||
if (!this.opened) {
|
||||
const groupId = getCacheObjectIdName(this.locationId, this.tag);
|
||||
const cacheGroup = this.doNamespace.get(this.doNamespace.idFromName(groupId), {
|
||||
// Initialize the object with a locaiton hint,
|
||||
// as we might want to purge all locations before the object is created.
|
||||
// https://developers.cloudflare.com/durable-objects/reference/data-location/
|
||||
locationHint: doLocationHints[this.locationId],
|
||||
});
|
||||
this.opened = await cacheGroup.open();
|
||||
}
|
||||
|
||||
return this.opened;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value from the cache.
|
||||
*/
|
||||
async get<Value = unknown>(key: string) {
|
||||
const desc = await this.open();
|
||||
return await desc.get<Value>(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value in the cache.
|
||||
*/
|
||||
async set<Value = unknown>(key: string, value: Value, expiresAt: number) {
|
||||
// TODO: Should we write on all locations instead of just the current one?
|
||||
const desc = await this.open();
|
||||
return await desc.set<Value>(key, value, expiresAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge all keys in the cache tag.
|
||||
*/
|
||||
async purge() {
|
||||
const keys = new Set<string>();
|
||||
await Promise.all(
|
||||
allLocations.map(async (locationId) => {
|
||||
const groupId = getCacheObjectIdName(locationId, this.tag);
|
||||
const cacheGroup = this.doNamespace.get(this.doNamespace.idFromName(groupId));
|
||||
const locationkeys = await cacheGroup.purge();
|
||||
locationkeys.forEach((key) => keys.add(key));
|
||||
}),
|
||||
);
|
||||
|
||||
return keys;
|
||||
}
|
||||
}
|
||||
|
||||
function getCacheObjectIdName(locationId: CacheLocationId, tag: string): string {
|
||||
return `${locationId}:${tag}`;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './CacheObjectStub';
|
||||
@@ -0,0 +1,9 @@
|
||||
import { WorkerEntrypoint } from 'cloudflare:workers';
|
||||
|
||||
export * from './CacheObject';
|
||||
|
||||
export default class Worker extends WorkerEntrypoint {
|
||||
fetch() {
|
||||
return new Response('Hello, world!');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"incremental": true,
|
||||
"types": ["./.wrangler/types/runtime.d.ts"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
main = "./src/index.ts"
|
||||
name = "gitbook-open-cache"
|
||||
compatibility_date = "2024-09-02"
|
||||
|
||||
durable_objects.bindings = [
|
||||
{name = "CACHE", class_name = "CacheObject"}
|
||||
]
|
||||
|
||||
migrations = [
|
||||
{tag = "v1", new_classes = ["CacheObject"]}
|
||||
]
|
||||
|
||||
[env.preview]
|
||||
name = "gitbook-open-cache-preview"
|
||||
durable_objects.bindings = [
|
||||
{name = "CACHE", class_name = "CacheObject"}
|
||||
]
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
import type { CacheObject } from '@gitbook/cache-do';
|
||||
|
||||
declare global {
|
||||
interface CloudflareEnv {
|
||||
CACHE?: DurableObjectNamespace<CacheObject>;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@gitbook/react-contentkit": "workspace:*",
|
||||
"@gitbook/emoji-codepoints": "workspace:*",
|
||||
"@gitbook/cache-do": "workspace:*",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@sentry/nextjs": "^7.94.1",
|
||||
|
||||
@@ -29,7 +29,6 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
keys: result.keys,
|
||||
stats: result.stats,
|
||||
});
|
||||
}
|
||||
|
||||
+205
-178
@@ -72,12 +72,10 @@ export interface ContentTarget {
|
||||
const immutableCacheTtl_7days = {
|
||||
revalidateBefore: 24 * 60 * 60,
|
||||
ttl: 7 * 24 * 60 * 60,
|
||||
tags: [],
|
||||
};
|
||||
const immutableCacheTtl_1day = {
|
||||
revalidateBefore: 60 * 60,
|
||||
ttl: 24 * 60 * 60,
|
||||
tags: [],
|
||||
};
|
||||
|
||||
const apiSyncStorage = new AsyncLocalStorage<GitBookAPI>();
|
||||
@@ -143,9 +141,14 @@ export type PublishedContentWithCache =
|
||||
/**
|
||||
* Get a user by its ID.
|
||||
*/
|
||||
export const getUserById = cache(
|
||||
'api.getUserById',
|
||||
async (userId: string, options: CacheFunctionOptions) => {
|
||||
export const getUserById = cache({
|
||||
name: 'api.getUserById',
|
||||
tag: (userId) =>
|
||||
getAPICacheTag({
|
||||
tag: 'user',
|
||||
user: userId,
|
||||
}),
|
||||
get: async (userId: string, options: CacheFunctionOptions) => {
|
||||
try {
|
||||
const response = await api().users.getUserById(userId, {
|
||||
signal: options.signal,
|
||||
@@ -153,28 +156,31 @@ export const getUserById = cache(
|
||||
});
|
||||
return cacheResponse(response, {
|
||||
revalidateBefore: 60 * 60,
|
||||
tags: [],
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as GitBookAPIError).code === 404) {
|
||||
return {
|
||||
revalidateBefore: 60 * 60,
|
||||
data: null,
|
||||
tags: [],
|
||||
};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a synced block by its ref.
|
||||
*/
|
||||
export const getSyncedBlockContent = cache(
|
||||
'api.getSyncedBlockContent',
|
||||
async (
|
||||
export const getSyncedBlockContent = cache({
|
||||
name: 'api.getSyncedBlockContent',
|
||||
tag: (apiToken, organizationId, syncedBlockId) =>
|
||||
getAPICacheTag({
|
||||
tag: 'synced-block',
|
||||
syncedBlock: syncedBlockId,
|
||||
}),
|
||||
get: async (
|
||||
apiToken: string,
|
||||
organizationId: string,
|
||||
syncedBlockId: string,
|
||||
@@ -191,38 +197,36 @@ export const getSyncedBlockContent = cache(
|
||||
);
|
||||
return cacheResponse(response, {
|
||||
revalidateBefore: 60 * 60,
|
||||
tags: [
|
||||
getAPICacheTag({
|
||||
tag: 'synced-block',
|
||||
syncedBlock: syncedBlockId,
|
||||
}),
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as GitBookAPIError).code === 404) {
|
||||
return {
|
||||
revalidateBefore: 60 * 60,
|
||||
data: null,
|
||||
tags: [],
|
||||
};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
// We don't cache apiToken as it's not a stable key
|
||||
extractArgs: (args) => [args[1], args[2]],
|
||||
},
|
||||
);
|
||||
// We don't cache apiToken as it's not a stable key
|
||||
getKeyArgs: (args) => [args[1], args[2]],
|
||||
});
|
||||
/**
|
||||
* Resolve a URL to the content to render.
|
||||
*/
|
||||
export const getPublishedContentByUrl = cache(
|
||||
'api.getPublishedContentByUrl.v3',
|
||||
async (url: string, visitorAuthToken: string | undefined, options: CacheFunctionOptions) => {
|
||||
const parsedURL = new URL(url);
|
||||
|
||||
export const getPublishedContentByUrl = cache({
|
||||
name: 'api.getPublishedContentByUrl.v3',
|
||||
tag: (url) =>
|
||||
getAPICacheTag({
|
||||
tag: 'url',
|
||||
hostname: new URL(url).hostname,
|
||||
}),
|
||||
get: async (
|
||||
url: string,
|
||||
visitorAuthToken: string | undefined,
|
||||
options: CacheFunctionOptions,
|
||||
) => {
|
||||
try {
|
||||
const response = await api().urls.getPublishedContentByUrl(
|
||||
{
|
||||
@@ -243,12 +247,6 @@ export const getPublishedContentByUrl = cache(
|
||||
cacheTags: parsed.tags,
|
||||
};
|
||||
return {
|
||||
tags: [
|
||||
getAPICacheTag({
|
||||
tag: 'url',
|
||||
hostname: parsedURL.hostname,
|
||||
}),
|
||||
],
|
||||
ttl: parsed.ttl,
|
||||
data,
|
||||
};
|
||||
@@ -264,26 +262,21 @@ export const getPublishedContentByUrl = cache(
|
||||
// Cache errors for max 10 minutes in case the user is making changes to its content configuration
|
||||
// and to avoid caching too many entries when being spammed by botss
|
||||
ttl: 60 * 10,
|
||||
tags: [
|
||||
getAPICacheTag({
|
||||
tag: 'url',
|
||||
hostname: parsedURL.hostname,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a space by its ID.
|
||||
*/
|
||||
export const getSpace = cache(
|
||||
'api.getSpace',
|
||||
async (spaceId: string, shareKey: string | undefined, options: CacheFunctionOptions) => {
|
||||
export const getSpace = cache({
|
||||
name: 'api.getSpace',
|
||||
tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
|
||||
get: async (spaceId: string, shareKey: string | undefined, options: CacheFunctionOptions) => {
|
||||
const response = await api().spaces.getSpaceById(
|
||||
spaceId,
|
||||
{
|
||||
@@ -296,47 +289,47 @@ export const getSpace = cache(
|
||||
);
|
||||
return cacheResponse(response, {
|
||||
revalidateBefore: 60 * 60,
|
||||
tags: [getAPICacheTag({ tag: 'space', space: spaceId })],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a change request by its ID.
|
||||
*/
|
||||
export const getChangeRequest = cache(
|
||||
'api.getChangeRequest',
|
||||
async (spaceId: string, changeRequestId: string, options: CacheFunctionOptions) => {
|
||||
export const getChangeRequest = cache({
|
||||
name: 'api.getChangeRequest',
|
||||
tag: (spaceId, changeRequestId) =>
|
||||
getAPICacheTag({ tag: 'change-request', space: spaceId, changeRequest: changeRequestId }),
|
||||
get: async (spaceId: string, changeRequestId: string, options: CacheFunctionOptions) => {
|
||||
const response = await api().spaces.getChangeRequestById(spaceId, changeRequestId, {
|
||||
...noCacheFetchOptions,
|
||||
signal: options.signal,
|
||||
});
|
||||
return cacheResponse(response, {
|
||||
// We don't cache for long as we currently don't invalidate change-request cache
|
||||
// and it's only used for preview where perfs are not critical
|
||||
// and it's only used for preview where perfs are not critical
|
||||
ttl: 60 * 60,
|
||||
revalidateBefore: 10 * 60,
|
||||
tags: [],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* List the scripts to load for the space.
|
||||
*/
|
||||
export const getSpaceIntegrationScripts = cache(
|
||||
'api.getSpaceIntegrationScripts',
|
||||
async (spaceId: string, options: CacheFunctionOptions) => {
|
||||
export const getSpaceIntegrationScripts = cache({
|
||||
name: 'api.getSpaceIntegrationScripts',
|
||||
tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
|
||||
get: async (spaceId: string, options: CacheFunctionOptions) => {
|
||||
const response = await api().spaces.listSpaceIntegrationScripts(spaceId, {
|
||||
...noCacheFetchOptions,
|
||||
signal: options.signal,
|
||||
});
|
||||
return cacheResponse(response, {
|
||||
revalidateBefore: 60 * 60,
|
||||
tags: [getAPICacheTag({ tag: 'space', space: spaceId })],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
interface GetRevisionOptions {
|
||||
/**
|
||||
@@ -351,9 +344,11 @@ interface GetRevisionOptions {
|
||||
/**
|
||||
* Get a revision by its ID.
|
||||
*/
|
||||
export const getRevision = cache(
|
||||
'api.getRevision.v2',
|
||||
async (
|
||||
export const getRevision = cache({
|
||||
name: 'api.getRevision.v2',
|
||||
tag: (spaceId, revisionId) =>
|
||||
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
|
||||
get: async (
|
||||
spaceId: string,
|
||||
revisionId: string,
|
||||
fetchOptions: GetRevisionOptions,
|
||||
@@ -376,17 +371,17 @@ export const getRevision = cache(
|
||||
fetchOptions.metadata ? immutableCacheTtl_7days : immutableCacheTtl_1day,
|
||||
);
|
||||
},
|
||||
{
|
||||
extractArgs: (args) => [args[0], args[1]],
|
||||
},
|
||||
);
|
||||
getKeyArgs: (args) => [args[0], args[1]],
|
||||
});
|
||||
|
||||
/**
|
||||
* Get all the pages in a revision of a space.
|
||||
*/
|
||||
export const getRevisionPages = cache(
|
||||
'api.getRevisionPages.v4',
|
||||
async (
|
||||
export const getRevisionPages = cache({
|
||||
name: 'api.getRevisionPages.v4',
|
||||
tag: (spaceId, revisionId) =>
|
||||
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
|
||||
get: async (
|
||||
spaceId: string,
|
||||
revisionId: string,
|
||||
fetchOptions: GetRevisionOptions,
|
||||
@@ -409,17 +404,17 @@ export const getRevisionPages = cache(
|
||||
data: response.data.pages,
|
||||
});
|
||||
},
|
||||
{
|
||||
extractArgs: (args) => [args[0], args[1]],
|
||||
},
|
||||
);
|
||||
getKeyArgs: (args) => [args[0], args[1]],
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a revision page by its path
|
||||
*/
|
||||
export const getRevisionPageByPath = cache(
|
||||
'api.getRevisionPageByPath.v3',
|
||||
async (
|
||||
export const getRevisionPageByPath = cache({
|
||||
name: 'api.getRevisionPageByPath.v3',
|
||||
tag: (spaceId, revisionId) =>
|
||||
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
|
||||
get: async (
|
||||
spaceId: string,
|
||||
revisionId: string,
|
||||
pagePath: string,
|
||||
@@ -453,15 +448,22 @@ export const getRevisionPageByPath = cache(
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolve a file by its ID.
|
||||
* It should not be used directly, use `getRevisionFile` instead.
|
||||
*/
|
||||
const getRevisionFileById = cache(
|
||||
'api.getRevisionFile.v3',
|
||||
async (spaceId: string, revisionId: string, fileId: string, options: CacheFunctionOptions) => {
|
||||
const getRevisionFileById = cache({
|
||||
name: 'api.getRevisionFile.v3',
|
||||
tag: (spaceId, revisionId) =>
|
||||
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
|
||||
get: async (
|
||||
spaceId: string,
|
||||
revisionId: string,
|
||||
fileId: string,
|
||||
options: CacheFunctionOptions,
|
||||
) => {
|
||||
try {
|
||||
const response = await (async () => {
|
||||
return api().spaces.getFileInRevisionById(
|
||||
@@ -487,15 +489,17 @@ const getRevisionFileById = cache(
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get all the files in a revision of a space.
|
||||
* It should not be used directly, use `getRevisionFile` instead.
|
||||
*/
|
||||
const getRevisionAllFiles = cache(
|
||||
'api.getRevisionAllFiles.v2',
|
||||
async (spaceId: string, revisionId: string, options: CacheFunctionOptions) => {
|
||||
const getRevisionAllFiles = cache({
|
||||
name: 'api.getRevisionAllFiles.v2',
|
||||
tag: (spaceId, revisionId) =>
|
||||
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
|
||||
get: async (spaceId: string, revisionId: string, options: CacheFunctionOptions) => {
|
||||
const response = await getAll(
|
||||
(params) =>
|
||||
api().spaces.listFilesInRevisionById(
|
||||
@@ -522,10 +526,8 @@ const getRevisionAllFiles = cache(
|
||||
|
||||
return cacheResponse(response, { ...immutableCacheTtl_7days, data: files });
|
||||
},
|
||||
{
|
||||
timeout: 60 * 1000,
|
||||
},
|
||||
);
|
||||
timeout: 60 * 1000,
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolve a file by its ID.
|
||||
@@ -583,9 +585,11 @@ export const getRevisionFile = batch<[string, string, string], RevisionFile | nu
|
||||
/**
|
||||
* Get a document by its ID.
|
||||
*/
|
||||
export const getDocument = cache(
|
||||
'api.getDocument.v2',
|
||||
async (spaceId: string, documentId: string, options: CacheFunctionOptions) => {
|
||||
export const getDocument = cache({
|
||||
name: 'api.getDocument.v2',
|
||||
tag: (spaceId, documentId) =>
|
||||
getAPICacheTag({ tag: 'document', space: spaceId, document: documentId }),
|
||||
get: async (spaceId: string, documentId: string, options: CacheFunctionOptions) => {
|
||||
const response = await api().spaces.getDocumentById(
|
||||
spaceId,
|
||||
documentId,
|
||||
@@ -599,20 +603,19 @@ export const getDocument = cache(
|
||||
);
|
||||
return cacheResponse(response, immutableCacheTtl_7days);
|
||||
},
|
||||
{
|
||||
// Temporarily allow for a longer timeout than the default 10s
|
||||
// because GitBook's API currently re-normalizes all documents
|
||||
// and it can take more than 10s...
|
||||
timeout: 20 * 1000,
|
||||
},
|
||||
);
|
||||
// Temporarily allow for a longer timeout than the default 10s
|
||||
// because GitBook's API currently re-normalizes all documents
|
||||
// and it can take more than 10s...
|
||||
timeout: 20 * 1000,
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the customization settings for a site-space from the API.
|
||||
*/
|
||||
const getSiteSpaceCustomizationFromAPI = cache(
|
||||
'api.getSiteSpaceCustomizationById',
|
||||
async (
|
||||
const getSiteSpaceCustomizationFromAPI = cache({
|
||||
name: 'api.getSiteSpaceCustomizationById',
|
||||
tag: (organizationId, siteId, siteSpaceId) => getAPICacheTag({ tag: 'site', site: siteId }),
|
||||
get: async (
|
||||
organizationId: string,
|
||||
siteId: string,
|
||||
siteSpaceId: string,
|
||||
@@ -630,22 +633,17 @@ const getSiteSpaceCustomizationFromAPI = cache(
|
||||
);
|
||||
return cacheResponse(response, {
|
||||
revalidateBefore: 60 * 60,
|
||||
tags: [
|
||||
getAPICacheTag({
|
||||
tag: 'site',
|
||||
site: siteId,
|
||||
}),
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the customization settings for a site from the API.
|
||||
*/
|
||||
const getSiteCustomizationFromAPI = cache(
|
||||
'api.getSiteCustomizationById',
|
||||
async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
|
||||
const getSiteCustomizationFromAPI = cache({
|
||||
name: 'api.getSiteCustomizationById',
|
||||
tag: (organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }),
|
||||
get: async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
|
||||
const response = await api().orgs.getSiteCustomizationById(
|
||||
organizationId,
|
||||
siteId,
|
||||
@@ -657,15 +655,9 @@ const getSiteCustomizationFromAPI = cache(
|
||||
);
|
||||
return cacheResponse(response, {
|
||||
revalidateBefore: 60 * 60,
|
||||
tags: [
|
||||
getAPICacheTag({
|
||||
tag: 'site',
|
||||
site: siteId,
|
||||
}),
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the customization settings for a site space from the API.
|
||||
@@ -698,26 +690,27 @@ async function getSiteCustomization(args: {
|
||||
/**
|
||||
* Get the infos about a site by its ID.
|
||||
*/
|
||||
export const getSite = cache(
|
||||
'api.getSite',
|
||||
async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
|
||||
export const getSite = cache({
|
||||
name: 'api.getSite',
|
||||
tag: (organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }),
|
||||
get: async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
|
||||
const response = await api().orgs.getSiteById(organizationId, siteId, {
|
||||
...noCacheFetchOptions,
|
||||
signal: options.signal,
|
||||
});
|
||||
return cacheResponse(response, {
|
||||
revalidateBefore: 60 * 60,
|
||||
tags: [getAPICacheTag({ tag: 'site', site: siteId })],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* List all the site-spaces variants published in a site.
|
||||
*/
|
||||
export const getSiteSpaces = cache(
|
||||
'api.getSiteSpaces',
|
||||
async (
|
||||
export const getSiteSpaces = cache({
|
||||
name: 'api.getSiteSpaces',
|
||||
tag: ({ siteId }) => getAPICacheTag({ tag: 'site', site: siteId }),
|
||||
get: async (
|
||||
args: {
|
||||
organizationId: string;
|
||||
siteId: string;
|
||||
@@ -744,27 +737,26 @@ export const getSiteSpaces = cache(
|
||||
return cacheResponse(response, {
|
||||
revalidateBefore: 60 * 60,
|
||||
data: response.data.items.map((siteSpace) => siteSpace),
|
||||
tags: [getAPICacheTag({ tag: 'site', site: args.siteId })],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* List the scripts to load for the site.
|
||||
*/
|
||||
export const getSiteIntegrationScripts = cache(
|
||||
'api.getSiteIntegrationScripts',
|
||||
async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
|
||||
export const getSiteIntegrationScripts = cache({
|
||||
name: 'api.getSiteIntegrationScripts',
|
||||
tag: (organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }),
|
||||
get: async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
|
||||
const response = await api().orgs.listSiteIntegrationScripts(organizationId, siteId, {
|
||||
...noCacheFetchOptions,
|
||||
signal: options.signal,
|
||||
});
|
||||
return cacheResponse(response, {
|
||||
revalidateBefore: 60 * 60,
|
||||
tags: [getAPICacheTag({ tag: 'site', site: siteId })],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetch all the data to render the current site at once.
|
||||
@@ -835,19 +827,19 @@ export async function getCurrentSiteCustomization(args: {
|
||||
/**
|
||||
* Get the customization settings for a space from the API.
|
||||
*/
|
||||
export const getSpaceCustomizationFromAPI = cache(
|
||||
'api.getSpaceCustomization',
|
||||
async (spaceId: string, options: CacheFunctionOptions) => {
|
||||
export const getSpaceCustomizationFromAPI = cache({
|
||||
name: 'api.getSpaceCustomization',
|
||||
tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
|
||||
get: async (spaceId: string, options: CacheFunctionOptions) => {
|
||||
const response = await api().spaces.getSpacePublishingCustomizationById(spaceId, {
|
||||
signal: options.signal,
|
||||
...noCacheFetchOptions,
|
||||
});
|
||||
return cacheResponse(response, {
|
||||
revalidateBefore: 60 * 60,
|
||||
tags: [getAPICacheTag({ tag: 'space', space: spaceId })],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the customization settings for a space from the API.
|
||||
@@ -876,26 +868,27 @@ export async function getSpaceCustomization(spaceId: string): Promise<Customizat
|
||||
/**
|
||||
* Get the infos about a collection by its ID.
|
||||
*/
|
||||
export const getCollection = cache(
|
||||
'api.getCollection',
|
||||
async (collectionId: string, options: CacheFunctionOptions) => {
|
||||
export const getCollection = cache({
|
||||
name: 'api.getCollection',
|
||||
tag: (collectionId) => getAPICacheTag({ tag: 'collection', collection: collectionId }),
|
||||
get: async (collectionId: string, options: CacheFunctionOptions) => {
|
||||
const response = await api().collections.getCollectionById(collectionId, {
|
||||
...noCacheFetchOptions,
|
||||
signal: options.signal,
|
||||
});
|
||||
return cacheResponse(response, {
|
||||
revalidateBefore: 60 * 60,
|
||||
tags: [getAPICacheTag({ tag: 'collection', collection: collectionId })],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* List all the spaces variants published in a collection.
|
||||
*/
|
||||
export const getCollectionSpaces = cache(
|
||||
'api.getCollectionSpaces',
|
||||
async (collectionId: string, options: CacheFunctionOptions) => {
|
||||
export const getCollectionSpaces = cache({
|
||||
name: 'api.getCollectionSpaces',
|
||||
tag: (collectionId) => getAPICacheTag({ tag: 'collection', collection: collectionId }),
|
||||
get: async (collectionId: string, options: CacheFunctionOptions) => {
|
||||
const response = await getAll((params) =>
|
||||
api().collections.listSpacesInCollectionById(collectionId, params, {
|
||||
...noCacheFetchOptions,
|
||||
@@ -908,10 +901,9 @@ export const getCollectionSpaces = cache(
|
||||
data: response.data.items.filter(
|
||||
(space) => space.visibility === ContentVisibility.InCollection,
|
||||
),
|
||||
tags: [getAPICacheTag({ tag: 'collection', collection: collectionId })],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetch all the data to render a space at once.
|
||||
@@ -979,9 +971,10 @@ export async function getSpaceLayoutData(spaceId: string) {
|
||||
/**
|
||||
* Search content in a space.
|
||||
*/
|
||||
export const searchSpaceContent = cache(
|
||||
'api.searchSpaceContent',
|
||||
async (
|
||||
export const searchSpaceContent = cache({
|
||||
name: 'api.searchSpaceContent',
|
||||
tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
|
||||
get: async (
|
||||
spaceId: string,
|
||||
/** The revision ID is used as a cache bust key, to avoid revalidating lot of cache entries by tags */
|
||||
revisionId: string,
|
||||
@@ -996,18 +989,17 @@ export const searchSpaceContent = cache(
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return cacheResponse(response, {
|
||||
tags: [],
|
||||
});
|
||||
return cacheResponse(response);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Search content accross all spaces in a parent (site or collection).
|
||||
*/
|
||||
export const searchParentContent = cache(
|
||||
'api.searchParentContent',
|
||||
async (parentId: string, query: string, options: CacheFunctionOptions) => {
|
||||
export const searchParentContent = cache({
|
||||
name: 'api.searchParentContent',
|
||||
tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
|
||||
get: async (parentId: string, query: string, options: CacheFunctionOptions) => {
|
||||
const response = await api().search.searchContent(
|
||||
{ query },
|
||||
{
|
||||
@@ -1017,17 +1009,17 @@ export const searchParentContent = cache(
|
||||
);
|
||||
return cacheResponse(response, {
|
||||
ttl: 60 * 60,
|
||||
tags: [],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Search content in a Site or specific SiteSpaces.
|
||||
*/
|
||||
export const searchSiteContent = cache(
|
||||
'api.searchSiteContent',
|
||||
async (
|
||||
export const searchSiteContent = cache({
|
||||
name: 'api.searchSiteContent',
|
||||
tag: (organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }),
|
||||
get: async (
|
||||
organizationId: string,
|
||||
siteId: string,
|
||||
query: string,
|
||||
@@ -1052,33 +1044,32 @@ export const searchSiteContent = cache(
|
||||
|
||||
return cacheResponse(response, {
|
||||
ttl: 60 * 60,
|
||||
tags: [],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a list of recommended questions in a space.
|
||||
*/
|
||||
export const getRecommendedQuestionsInSpace = cache(
|
||||
'api.getRecommendedQuestionsInSpace',
|
||||
async (spaceId: string, options: CacheFunctionOptions) => {
|
||||
export const getRecommendedQuestionsInSpace = cache({
|
||||
name: 'api.getRecommendedQuestionsInSpace',
|
||||
tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
|
||||
get: async (spaceId: string, options: CacheFunctionOptions) => {
|
||||
const response = await api().spaces.getRecommendedQuestionsInSpace(spaceId, {
|
||||
...noCacheFetchOptions,
|
||||
signal: options.signal,
|
||||
});
|
||||
return cacheResponse(response, {
|
||||
tags: [],
|
||||
});
|
||||
return cacheResponse(response);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Render an integration contentkit UI
|
||||
*/
|
||||
export const renderIntegrationUi = cache(
|
||||
'api.renderIntegrationUi',
|
||||
async (
|
||||
export const renderIntegrationUi = cache({
|
||||
name: 'api.renderIntegrationUi',
|
||||
tag: (integrationName) => getAPICacheTag({ tag: 'integration', integration: integrationName }),
|
||||
get: async (
|
||||
integrationName: string,
|
||||
request: RequestRenderIntegrationUI,
|
||||
options: CacheFunctionOptions,
|
||||
@@ -1091,21 +1082,47 @@ export const renderIntegrationUi = cache(
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return cacheResponse(response, {
|
||||
tags: [],
|
||||
});
|
||||
return cacheResponse(response);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a cache tag for the API.
|
||||
*/
|
||||
export function getAPICacheTag(
|
||||
spec: // All data related to a space
|
||||
spec: // All data related to a user
|
||||
| {
|
||||
tag: 'user';
|
||||
user: string;
|
||||
}
|
||||
// All data related to a space
|
||||
| {
|
||||
tag: 'space';
|
||||
space: string;
|
||||
}
|
||||
// All data related to an integration
|
||||
| {
|
||||
tag: 'integration';
|
||||
integration: string;
|
||||
}
|
||||
// All data related to a change request
|
||||
| {
|
||||
tag: 'change-request';
|
||||
space: string;
|
||||
changeRequest: string;
|
||||
}
|
||||
// Immutable data related to a revision
|
||||
| {
|
||||
tag: 'revision';
|
||||
space: string;
|
||||
revision: string;
|
||||
}
|
||||
// Immutable data related to a document
|
||||
| {
|
||||
tag: 'document';
|
||||
space: string;
|
||||
document: string;
|
||||
}
|
||||
// All data related to the URL of a content
|
||||
| {
|
||||
tag: 'url';
|
||||
@@ -1128,16 +1145,26 @@ export function getAPICacheTag(
|
||||
},
|
||||
): string {
|
||||
switch (spec.tag) {
|
||||
case 'user':
|
||||
return `user:${spec.user}`;
|
||||
case 'url':
|
||||
return `url:${spec.hostname}`;
|
||||
case 'space':
|
||||
return `space:${spec.space}`;
|
||||
case 'change-request':
|
||||
return `space:${spec.space}:change-request:${spec.changeRequest}`;
|
||||
case 'revision':
|
||||
return `space:${spec.space}:revision:${spec.revision}`;
|
||||
case 'document':
|
||||
return `space:${spec.space}:document:${spec.document}`;
|
||||
case 'collection':
|
||||
return `collection:${spec.collection}`;
|
||||
case 'synced-block':
|
||||
return `synced-block:${spec.syncedBlock}`;
|
||||
case 'site':
|
||||
return `site:${spec.site}`;
|
||||
case 'integration':
|
||||
return `integration:${spec.integration}`;
|
||||
default:
|
||||
assertNever(spec);
|
||||
}
|
||||
|
||||
+2
-4
@@ -1,18 +1,16 @@
|
||||
import { cloudflareCache } from './cloudflare-cache';
|
||||
import { cloudflareDOCache } from './cloudflare-do';
|
||||
import { cloudflareKVCache } from './cloudflare-kv';
|
||||
import { memoryCache } from './memory';
|
||||
import { redisCache } from './redis';
|
||||
|
||||
export const cacheBackends = [
|
||||
// Cache local to the process
|
||||
// (can't be globally purged or shared between processes)
|
||||
memoryCache,
|
||||
// Global cache shared between all processes
|
||||
// with proper replication and invalidation
|
||||
// redisCache, // Disabled as we investigate high amount of fetch requests
|
||||
// Cache local to the datacenter
|
||||
// It can't be purged globally but it's faster
|
||||
cloudflareCache,
|
||||
// Cache global, but with slow replication
|
||||
cloudflareKVCache,
|
||||
cloudflareDOCache,
|
||||
];
|
||||
|
||||
+10
-6
@@ -15,12 +15,16 @@ describe('cache', () => {
|
||||
testId += 1;
|
||||
getTtl = () => 1000;
|
||||
|
||||
fn = cache(`cache-${testId}`, async (arg: string, options: CacheFunctionOptions) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
return {
|
||||
data: impl(arg),
|
||||
ttl: getTtl(),
|
||||
};
|
||||
fn = cache({
|
||||
name: `cache-${testId}`,
|
||||
tag: (arg) => 'test',
|
||||
get: async (arg: string, options: CacheFunctionOptions) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
return {
|
||||
data: impl(arg),
|
||||
ttl: getTtl(),
|
||||
};
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+43
-37
@@ -43,11 +43,26 @@ export interface CacheResult<Result> {
|
||||
* Time before ttl where the cache should be revalidated (in seconds).
|
||||
*/
|
||||
revalidateBefore?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tags to associate with the cache entry.
|
||||
*/
|
||||
tags?: string[];
|
||||
export interface CacheDefinition<Args extends any[], Result> {
|
||||
/** Unique name for the cache */
|
||||
name: string;
|
||||
|
||||
/** Tag to associate to the entry */
|
||||
tag?: (...args: Args) => string;
|
||||
|
||||
/** Filter the arguments that should be taken into consideration for the cache key */
|
||||
getKeyArgs?: (args: Args) => any[];
|
||||
|
||||
/** Default ttl (in seconds) */
|
||||
defaultTtl?: number;
|
||||
|
||||
/** When a request to the underlying resource will timeout. */
|
||||
timeout?: number;
|
||||
|
||||
/** Function to get the value */
|
||||
get: (...args: [...Args, CacheFunctionOptions]) => Promise<CacheResult<Result>>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,21 +70,11 @@ export interface CacheResult<Result> {
|
||||
* We don't use the next.js cache because it has a 2MB limit.
|
||||
*/
|
||||
export function cache<Args extends any[], Result>(
|
||||
cacheName: string,
|
||||
fn: (...args: [...Args, CacheFunctionOptions]) => Promise<CacheResult<Result>>,
|
||||
options: {
|
||||
/** Filter the arguments that should be taken into consideration for caching */
|
||||
extractArgs?: (args: Args) => any[];
|
||||
|
||||
/** Default ttl (in seconds) */
|
||||
defaultTtl?: number;
|
||||
|
||||
/** When a request to the underlying resource will timeout. */
|
||||
timeout?: number;
|
||||
} = {},
|
||||
cacheDef: CacheDefinition<Args, Result>,
|
||||
): CacheFunction<Args, Result> {
|
||||
// We stop everything after 10s to avoid pending requests
|
||||
const timeout = options.timeout ?? 1000 * 10;
|
||||
const timeout = cacheDef.timeout ?? 1000 * 10;
|
||||
const defaultTtl = cacheDef.defaultTtl ?? 60 * 60 * 24;
|
||||
|
||||
const revalidate = singletonMap(
|
||||
async (key: string, signal: AbortSignal | undefined, ...args: Args) => {
|
||||
@@ -80,19 +85,18 @@ export function cache<Args extends any[], Result>(
|
||||
},
|
||||
async (span) => {
|
||||
// Fetch upstream
|
||||
const result = await fn(...args, { signal });
|
||||
const result = await cacheDef.get(...args, { signal });
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const setAt = Date.now();
|
||||
const expiresAt =
|
||||
setAt + (result.ttl ?? options.defaultTtl ?? 60 * 60 * 24) * 1000;
|
||||
const expiresAt = setAt + (result.ttl ?? defaultTtl) * 1000;
|
||||
|
||||
const cacheEntry: CacheEntry = {
|
||||
data: result.data,
|
||||
meta: {
|
||||
key,
|
||||
cache: cacheName,
|
||||
tags: result.tags ?? [],
|
||||
cache: cacheDef.name,
|
||||
tag: cacheDef.tag?.(...args),
|
||||
setAt,
|
||||
expiresAt,
|
||||
revalidatesAt: result.revalidateBefore
|
||||
@@ -106,7 +110,7 @@ export function cache<Args extends any[], Result>(
|
||||
|
||||
// Write it to the cache
|
||||
if (result.ttl && result.ttl > 0) {
|
||||
await waitUntil(setCacheEntry(key, cacheEntry));
|
||||
await waitUntil(setCacheEntry(cacheEntry));
|
||||
}
|
||||
|
||||
return cacheEntry;
|
||||
@@ -122,9 +126,10 @@ export function cache<Args extends any[], Result>(
|
||||
let fetchDuration = 0;
|
||||
|
||||
let result: readonly [CacheEntry, string] | null = null;
|
||||
const tag = cacheDef.tag?.(...args);
|
||||
|
||||
// Try the memory backend, independently of the other backends as it doesn't have a network cost
|
||||
const memoryEntry = await memoryCache.get(key);
|
||||
const memoryEntry = await memoryCache.get({ key, tag });
|
||||
if (memoryEntry) {
|
||||
span.setAttribute('memory', true);
|
||||
result = [memoryEntry, 'memory'] as const;
|
||||
@@ -132,7 +137,7 @@ export function cache<Args extends any[], Result>(
|
||||
result = await race(
|
||||
cacheBackends,
|
||||
async (backend, { signal }) => {
|
||||
const entry = await backend.get(key, { signal });
|
||||
const entry = await backend.get({ key, tag }, { signal });
|
||||
return entry ? ([entry, backend.name] as const) : null;
|
||||
},
|
||||
{
|
||||
@@ -184,7 +189,7 @@ export function cache<Args extends any[], Result>(
|
||||
(backend) =>
|
||||
backend.name !== backendName && backend.replication === 'local',
|
||||
)
|
||||
.map((backend) => backend.set(key, savedEntry)),
|
||||
.map((backend) => backend.set(savedEntry)),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -214,8 +219,8 @@ export function cache<Args extends any[], Result>(
|
||||
const cacheFn = async (...rawArgs: Args | [...Args, CacheFunctionOptions]) => {
|
||||
const [args, { signal }] = extractCacheFunctionOptions<Args>(rawArgs);
|
||||
|
||||
const cacheArgs = options.extractArgs ? options.extractArgs(args) : args;
|
||||
const key = getCacheKey(cacheName, cacheArgs);
|
||||
const cacheArgs = cacheDef.getKeyArgs ? cacheDef.getKeyArgs(args) : args;
|
||||
const key = getCacheKey(cacheDef.name, cacheArgs);
|
||||
|
||||
return await trace(
|
||||
{
|
||||
@@ -231,17 +236,18 @@ export function cache<Args extends any[], Result>(
|
||||
|
||||
cacheFn.revalidate = async (...rawArgs: Args | [...Args, CacheFunctionOptions]) => {
|
||||
const [args, { signal }] = extractCacheFunctionOptions<Args>(rawArgs);
|
||||
const cacheArgs = options.extractArgs ? options.extractArgs(args) : args;
|
||||
const key = getCacheKey(cacheName, cacheArgs);
|
||||
const cacheArgs = cacheDef.getKeyArgs ? cacheDef.getKeyArgs(args) : args;
|
||||
const key = getCacheKey(cacheDef.name, cacheArgs);
|
||||
|
||||
await revalidate(key, signal, ...args);
|
||||
};
|
||||
|
||||
cacheFn.hasInMemory = async (...args: Args) => {
|
||||
const cacheArgs = options.extractArgs ? options.extractArgs(args) : args;
|
||||
const key = getCacheKey(cacheName, cacheArgs);
|
||||
const cacheArgs = cacheDef.getKeyArgs ? cacheDef.getKeyArgs(args) : args;
|
||||
const key = getCacheKey(cacheDef.name, cacheArgs);
|
||||
const tag = cacheDef.tag?.(...args);
|
||||
|
||||
const memoryEntry = await memoryCache.get(key);
|
||||
const memoryEntry = await memoryCache.get({ key, tag });
|
||||
if (memoryEntry) {
|
||||
return true;
|
||||
}
|
||||
@@ -250,7 +256,7 @@ export function cache<Args extends any[], Result>(
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
registeredCaches.set(cacheName, cacheFn);
|
||||
registeredCaches.set(cacheDef.name, cacheFn);
|
||||
|
||||
return cacheFn;
|
||||
}
|
||||
@@ -289,14 +295,14 @@ function hashValue(arg: any): string {
|
||||
return JSON.stringify(arg);
|
||||
}
|
||||
|
||||
async function setCacheEntry(key: string, entry: CacheEntry) {
|
||||
async function setCacheEntry(entry: CacheEntry) {
|
||||
return await trace(
|
||||
{
|
||||
operation: `cache.setCacheEntry`,
|
||||
name: key,
|
||||
name: entry.meta.key,
|
||||
},
|
||||
async () => {
|
||||
await Promise.all(cacheBackends.map((backend) => backend.set(key, entry)));
|
||||
await Promise.all(cacheBackends.map((backend) => backend.set(entry)));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+16
-14
@@ -19,7 +19,7 @@ const cacheVersion = 2;
|
||||
export const cloudflareCache: CacheBackend = {
|
||||
name: 'cloudflare',
|
||||
replication: 'local',
|
||||
async get(key, options) {
|
||||
async get(entry, options) {
|
||||
const cache = getCache();
|
||||
if (!cache) {
|
||||
return null;
|
||||
@@ -27,10 +27,10 @@ export const cloudflareCache: CacheBackend = {
|
||||
return trace(
|
||||
{
|
||||
operation: `cloudflareCache.get`,
|
||||
name: key,
|
||||
name: entry.key,
|
||||
},
|
||||
async (span) => {
|
||||
const cacheKey = await serializeKey(key);
|
||||
const cacheKey = await serializeKey(entry.key);
|
||||
const response = await cache.match(cacheKey);
|
||||
span.setAttribute('hit', !!response);
|
||||
|
||||
@@ -39,32 +39,32 @@ export const cloudflareCache: CacheBackend = {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = await deserializeEntry(response);
|
||||
return entry;
|
||||
const cacheEntry = await deserializeEntry(response);
|
||||
return cacheEntry;
|
||||
},
|
||||
);
|
||||
},
|
||||
async set(key, entry) {
|
||||
async set(entry) {
|
||||
const cache = getCache();
|
||||
if (cache) {
|
||||
await trace(
|
||||
{
|
||||
operation: `cloudflareCache.set`,
|
||||
name: key,
|
||||
name: entry.meta.key,
|
||||
},
|
||||
async () => {
|
||||
const cacheKey = await serializeKey(key);
|
||||
const cacheKey = await serializeKey(entry.meta.key);
|
||||
await cache.put(cacheKey, serializeEntry(entry));
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
async del(keys) {
|
||||
async del(entries) {
|
||||
const cache = getCache();
|
||||
if (cache) {
|
||||
await Promise.all(
|
||||
keys.map(async (key) => {
|
||||
const cacheKey = await serializeKey(key);
|
||||
entries.map(async (entry) => {
|
||||
const cacheKey = await serializeKey(entry.key);
|
||||
await cache.delete(cacheKey);
|
||||
}),
|
||||
);
|
||||
@@ -72,8 +72,7 @@ export const cloudflareCache: CacheBackend = {
|
||||
},
|
||||
async revalidateTags(tags) {
|
||||
return {
|
||||
keys: [],
|
||||
metas: [],
|
||||
entries: [],
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -103,7 +102,10 @@ async function serializeKey(key: string): Promise<string> {
|
||||
function serializeEntry(entry: CacheEntry): WorkerResponse {
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', 'application/json');
|
||||
const cacheTags = ['gitbook-open', ...entry.meta.tags];
|
||||
const cacheTags = ['gitbook-open'];
|
||||
if (entry.meta.tag) {
|
||||
cacheTags.push(entry.meta.tag);
|
||||
}
|
||||
|
||||
const maxAge = getCacheMaxAge(
|
||||
entry.meta,
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import { CacheObjectStub, CacheLocationId } from '@gitbook/cache-do/api';
|
||||
|
||||
import { CacheBackend, CacheEntry, CacheEntryLookup } from './types';
|
||||
import { trace } from '../tracing';
|
||||
|
||||
/**
|
||||
* Cache implementation using the custom Cloudflare Durable Object.
|
||||
*/
|
||||
export const cloudflareDOCache: CacheBackend = {
|
||||
name: 'cloudflare-do',
|
||||
replication: 'global',
|
||||
async get(entry, options) {
|
||||
const { key, tag } = entry;
|
||||
if (!tag) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trace(
|
||||
{
|
||||
operation: `cloudflareDO.get`,
|
||||
name: entry.key,
|
||||
},
|
||||
async (span) => {
|
||||
const stub = await getStub(tag);
|
||||
if (!stub) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await stub.get<CacheEntry>(key)) ?? null;
|
||||
},
|
||||
);
|
||||
},
|
||||
async set(entry) {
|
||||
const { key, tag } = entry.meta;
|
||||
if (!tag) {
|
||||
return;
|
||||
}
|
||||
|
||||
return trace(
|
||||
{
|
||||
operation: `cloudflareDO.set`,
|
||||
name: key,
|
||||
},
|
||||
async () => {
|
||||
const stub = await getStub(tag);
|
||||
if (!stub) {
|
||||
return;
|
||||
}
|
||||
|
||||
await stub.set<CacheEntry>(key, entry, entry.meta.expiresAt);
|
||||
},
|
||||
);
|
||||
},
|
||||
async del(entries) {
|
||||
// We don't need to directly delete entries from the Cloudflare DO cache.
|
||||
},
|
||||
async revalidateTags(tags) {
|
||||
const entries: CacheEntryLookup[] = [];
|
||||
|
||||
await Promise.all(
|
||||
tags.map(async (tag) => {
|
||||
const stub = await getStub(tag);
|
||||
if (!stub) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keys = await stub.purge();
|
||||
keys.forEach((key) => {
|
||||
entries.push({ key, tag });
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return { entries };
|
||||
},
|
||||
};
|
||||
|
||||
const globalStubs = new WeakMap<object, Map<string, CacheObjectStub>>();
|
||||
|
||||
async function getStub(tag: string): Promise<CacheObjectStub | null> {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// We lazy-load the next-on-pages package to avoid errors when running tests because of 'server-only'.
|
||||
const { getOptionalRequestContext } = await import('@cloudflare/next-on-pages');
|
||||
const cloudflare = getOptionalRequestContext();
|
||||
if (!cloudflare || !cloudflare.env.CACHE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requestStubs = globalStubs.get(cloudflare.cf) ?? new Map();
|
||||
globalStubs.set(cloudflare.cf, requestStubs);
|
||||
|
||||
const locationId: CacheLocationId = cloudflare.cf.continent ?? 'NA';
|
||||
const stub =
|
||||
requestStubs.get(tag) ?? new CacheObjectStub(cloudflare.env.CACHE, locationId, tag);
|
||||
requestStubs.set(tag, stub);
|
||||
|
||||
return stub;
|
||||
}
|
||||
+16
-26
@@ -17,7 +17,7 @@ interface KVTagMetadata {
|
||||
export const cloudflareKVCache: CacheBackend = {
|
||||
name: 'cloudflare-kv',
|
||||
replication: 'global',
|
||||
async get(key, options) {
|
||||
async get({ key }, options) {
|
||||
const kv = await getKVNamespace();
|
||||
if (!kv) {
|
||||
return null;
|
||||
@@ -42,7 +42,7 @@ export const cloudflareKVCache: CacheBackend = {
|
||||
},
|
||||
);
|
||||
},
|
||||
async set(key, entry) {
|
||||
async set(entry) {
|
||||
const kv = await getKVNamespace();
|
||||
if (!kv) {
|
||||
return;
|
||||
@@ -51,7 +51,7 @@ export const cloudflareKVCache: CacheBackend = {
|
||||
return trace(
|
||||
{
|
||||
operation: `cloudflareKV.set`,
|
||||
name: key,
|
||||
name: entry.meta.key,
|
||||
},
|
||||
async () => {
|
||||
const secondsFromNow = getCacheMaxAge(entry.meta, 0, 60 * 60 * 24);
|
||||
@@ -61,54 +61,45 @@ export const cloudflareKVCache: CacheBackend = {
|
||||
return;
|
||||
}
|
||||
|
||||
const kvKey = getValueKey(key);
|
||||
const kvKey = getValueKey(entry.meta.key);
|
||||
await kv.put(kvKey, JSON.stringify(entry), {
|
||||
expirationTtl: secondsFromNow,
|
||||
});
|
||||
|
||||
if (entry.meta.tags.length > 0) {
|
||||
if (entry.meta.tag) {
|
||||
const metadata: KVTagMetadata = {
|
||||
meta: entry.meta,
|
||||
};
|
||||
const jsonMetadata = JSON.stringify(metadata);
|
||||
const tagKey = getTagKey(entry.meta.tag, entry.meta.key);
|
||||
|
||||
// Write a key for each tag
|
||||
await Promise.all(
|
||||
entry.meta.tags.map(async (tag) => {
|
||||
const tagKey = getTagKey(tag, key);
|
||||
|
||||
await kv.put(tagKey, jsonMetadata, {
|
||||
metadata,
|
||||
expirationTtl: secondsFromNow,
|
||||
});
|
||||
}),
|
||||
);
|
||||
await kv.put(tagKey, jsonMetadata, {
|
||||
metadata,
|
||||
expirationTtl: secondsFromNow,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
async del(keys) {
|
||||
async del(entries) {
|
||||
const kv = await getKVNamespace();
|
||||
if (!kv) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
keys.map(async (key) => {
|
||||
entries.map(async ({ key }) => {
|
||||
const kvKey = getValueKey(key);
|
||||
await kv.delete(kvKey);
|
||||
}),
|
||||
);
|
||||
},
|
||||
async revalidateTags(tags) {
|
||||
const result: { keys: string[]; metas: CacheEntryMeta[] } = {
|
||||
keys: [],
|
||||
metas: [],
|
||||
};
|
||||
const result: CacheEntryMeta[] = [];
|
||||
|
||||
const kv = await getKVNamespace();
|
||||
if (!kv) {
|
||||
return result;
|
||||
return { entries: result };
|
||||
}
|
||||
|
||||
const pendingDeletions: Array<Promise<unknown>> = [];
|
||||
@@ -125,8 +116,7 @@ export const cloudflareKVCache: CacheBackend = {
|
||||
const metadata = entry.metadata;
|
||||
const key = metadata.meta.key;
|
||||
|
||||
result.metas.push(metadata.meta);
|
||||
result.keys.push(key);
|
||||
result.push(metadata.meta);
|
||||
|
||||
// Delete the tag key and the value key
|
||||
pendingDeletions.push(kv.delete(getValueKey(key)));
|
||||
@@ -147,7 +137,7 @@ export const cloudflareKVCache: CacheBackend = {
|
||||
|
||||
await Promise.all(pendingDeletions);
|
||||
|
||||
return result;
|
||||
return { entries: result };
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
-1
@@ -52,7 +52,6 @@ export function cacheResponse<Result, DefaultData = Result>(
|
||||
|
||||
return {
|
||||
ttl: defaultEntry.ttl ?? parsed.ttl,
|
||||
tags: [...(defaultEntry.tags ?? []), ...parsed.tags],
|
||||
revalidateBefore: defaultEntry.revalidateBefore,
|
||||
// @ts-ignore
|
||||
data: defaultEntry.data ?? response.data,
|
||||
|
||||
+12
-13
@@ -1,13 +1,13 @@
|
||||
import { CacheBackend, CacheEntry } from './types';
|
||||
import { CacheBackend, CacheEntry, CacheEntryLookup } from './types';
|
||||
import { NON_IMMUTABLE_LOCAL_CACHE_MAX_AGE_SECONDS, isCacheEntryImmutable } from './utils';
|
||||
import { getGlobalContext } from '../waitUntil';
|
||||
|
||||
export const memoryCache: CacheBackend = {
|
||||
name: 'memory',
|
||||
replication: 'local',
|
||||
async get(key) {
|
||||
async get(entry) {
|
||||
const memoryCache = await getMemoryCache();
|
||||
const memoryEntry = memoryCache.get(key);
|
||||
const memoryEntry = memoryCache.get(entry.key);
|
||||
|
||||
if (!memoryEntry) {
|
||||
return null;
|
||||
@@ -16,12 +16,12 @@ export const memoryCache: CacheBackend = {
|
||||
if (memoryEntry.meta.expiresAt > Date.now()) {
|
||||
return memoryEntry;
|
||||
} else {
|
||||
memoryCache.delete(key);
|
||||
memoryCache.delete(entry.key);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
async set(key, entry) {
|
||||
async set(entry) {
|
||||
const memoryCache = await getMemoryCache();
|
||||
// When the entry is immutable, we can cache it for the entire duration.
|
||||
// Else we cache it for a very short time.
|
||||
@@ -39,26 +39,25 @@ export const memoryCache: CacheBackend = {
|
||||
meta.expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
memoryCache.set(key, { ...entry, meta });
|
||||
memoryCache.set(entry.meta.key, { ...entry, meta });
|
||||
},
|
||||
async del(keys) {
|
||||
async del(entries) {
|
||||
const memoryCache = await getMemoryCache();
|
||||
keys.forEach((key) => memoryCache.delete(key));
|
||||
entries.forEach((entry) => memoryCache.delete(entry.key));
|
||||
},
|
||||
async revalidateTags(tags) {
|
||||
const memoryCache = await getMemoryCache();
|
||||
const keys: string[] = [];
|
||||
const entries: CacheEntryLookup[] = [];
|
||||
|
||||
memoryCache.forEach((entry, key) => {
|
||||
if (tags.some((tag) => entry.meta.tags.includes(tag))) {
|
||||
keys.push(key);
|
||||
if (entry.meta.tag && tags.includes(entry.meta.tag)) {
|
||||
entries.push({ key, tag: entry.meta.tag });
|
||||
memoryCache.delete(key);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
keys,
|
||||
metas: [],
|
||||
entries,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
-174
@@ -1,174 +0,0 @@
|
||||
import { Redis } from '@upstash/redis/cloudflare';
|
||||
|
||||
import { CacheBackend, CacheEntry, CacheEntryMeta } from './types';
|
||||
import { getCacheMaxAge } from './utils';
|
||||
import { trace } from '../tracing';
|
||||
import { filterOutNullable } from '../typescript';
|
||||
|
||||
const cacheNamespace = process.env.UPSTASH_REDIS_NAMESPACE ?? 'gitbook';
|
||||
const cacheVersion = 2;
|
||||
|
||||
export const redisCache: CacheBackend = {
|
||||
name: 'redis',
|
||||
replication: 'global',
|
||||
async get(key, options) {
|
||||
const redis = getRedis(options?.signal);
|
||||
if (!redis) {
|
||||
return null;
|
||||
}
|
||||
return trace(
|
||||
{
|
||||
operation: `redis.get`,
|
||||
name: key,
|
||||
},
|
||||
async (span) => {
|
||||
const valueKey = getCacheEntryKey(key, 'value');
|
||||
const redisEntry = await redis.get<CacheEntry>(valueKey);
|
||||
|
||||
span.setAttribute('hit', !!redisEntry);
|
||||
|
||||
if (!redisEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return redisEntry;
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async set(key, entry) {
|
||||
const redis = getRedis();
|
||||
if (!redis) {
|
||||
return;
|
||||
}
|
||||
|
||||
return trace(
|
||||
{
|
||||
operation: `redis.set`,
|
||||
name: key,
|
||||
},
|
||||
async () => {
|
||||
const expire = getCacheMaxAge(entry.meta);
|
||||
|
||||
// Don't cache for less than 10min, as it's not worth it
|
||||
if (expire <= 10 * 60) {
|
||||
return;
|
||||
}
|
||||
|
||||
const multi = redis.multi();
|
||||
const valueKey = getCacheEntryKey(key, 'value');
|
||||
const metaKey = getCacheEntryKey(key, 'meta');
|
||||
|
||||
entry.meta.tags.forEach((tag) => {
|
||||
const redisTagKey = getCacheTagKey(tag);
|
||||
|
||||
multi.sadd(redisTagKey, key);
|
||||
|
||||
// Set am expiration on the tag to be the maximum of the expiration of all keys
|
||||
multi.expire(redisTagKey, expire, 'GT');
|
||||
multi.expire(redisTagKey, expire, 'NX');
|
||||
});
|
||||
|
||||
multi.set(valueKey, entry);
|
||||
multi.set(metaKey, entry.meta);
|
||||
multi.expire(valueKey, expire);
|
||||
multi.expire(metaKey, expire);
|
||||
|
||||
await multi.exec();
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async del(keys) {
|
||||
const redis = getRedis();
|
||||
if (!redis) {
|
||||
return;
|
||||
}
|
||||
|
||||
const multi = redis.multi();
|
||||
keys.forEach((key) => {
|
||||
multi.del(getCacheEntryKey(key, 'value'));
|
||||
multi.del(getCacheEntryKey(key, 'meta'));
|
||||
});
|
||||
|
||||
await multi.exec();
|
||||
},
|
||||
|
||||
async revalidateTags(tags) {
|
||||
const redis = getRedis();
|
||||
if (!redis) {
|
||||
return { keys: [], metas: [] };
|
||||
}
|
||||
|
||||
const keys = new Set(
|
||||
(await Promise.all(tags.map((tag) => redis.smembers(getCacheTagKey(tag))))).flat(),
|
||||
);
|
||||
|
||||
const pipeline = redis.pipeline();
|
||||
let metas: Array<CacheEntryMeta | null> = [];
|
||||
|
||||
if (keys.size > 0) {
|
||||
// Read the meta
|
||||
metas = (
|
||||
await redis.mget<Array<CacheEntryMeta | null>>(
|
||||
// Hard limit to avoid fetching a massive list of data
|
||||
// Starts with the smallest keys.
|
||||
Array.from(keys)
|
||||
.sort((a, b) => a.length - b.length)
|
||||
.slice(0, 50)
|
||||
.map((key) => getCacheEntryKey(key, 'meta')),
|
||||
)
|
||||
)
|
||||
.flat()
|
||||
.filter(filterOutNullable);
|
||||
|
||||
// Delete all keys
|
||||
keys.forEach((key) => {
|
||||
pipeline.del(getCacheEntryKey(key, 'value'));
|
||||
});
|
||||
}
|
||||
|
||||
// And delete the tags
|
||||
tags.forEach((tag) => {
|
||||
pipeline.del(getCacheTagKey(tag));
|
||||
});
|
||||
|
||||
await pipeline.exec();
|
||||
return { keys: Array.from(keys), metas: metas.filter(filterOutNullable) };
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the redis client.
|
||||
*/
|
||||
export function getRedis(signal?: AbortSignal) {
|
||||
return process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN
|
||||
? new Redis({
|
||||
url: process.env.UPSTASH_REDIS_REST_URL,
|
||||
token: process.env.UPSTASH_REDIS_REST_TOKEN,
|
||||
signal,
|
||||
automaticDeserialization: true,
|
||||
})
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the key for a tag.
|
||||
*/
|
||||
function getCacheTagKey(tag: string) {
|
||||
return getRedisKey(`tags.${tag}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the key for an entry.
|
||||
*/
|
||||
function getCacheEntryKey(key: string, type: 'meta' | 'value') {
|
||||
return getRedisKey(`entry.${key}.${type}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a redis key for a cache entry.
|
||||
*/
|
||||
function getRedisKey(key: string) {
|
||||
return `${cacheNamespace}.${cacheVersion}.${key}`;
|
||||
}
|
||||
+25
-37
@@ -1,83 +1,71 @@
|
||||
import { cacheBackends } from './backends';
|
||||
import { getCacheKey } from './cache';
|
||||
import { CacheEntryMeta } from './types';
|
||||
import { CacheEntryLookup } from './types';
|
||||
|
||||
interface RevalidateTagsStats {
|
||||
[key: string]: {
|
||||
/**
|
||||
* Tag associated with the key.
|
||||
*/
|
||||
tag?: string;
|
||||
|
||||
/**
|
||||
* Backends that have the key.
|
||||
*/
|
||||
[backend: string]: { set: boolean; setAt?: number; expiresAt?: number };
|
||||
backends: Record<string, { set: boolean }>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Revalidate all values associated with tags.
|
||||
* It clears the values from the caches, but also start a background task to revalidate them.
|
||||
* Purge all cache entries associated with the given tags.
|
||||
* TODO: Implement background revalidation.
|
||||
*/
|
||||
export async function revalidateTags(tags: string[]): Promise<{
|
||||
keys: string[];
|
||||
stats: RevalidateTagsStats;
|
||||
}> {
|
||||
if (tags.length === 0) {
|
||||
return { keys: [], stats: {} };
|
||||
return { stats: {} };
|
||||
}
|
||||
|
||||
const stats: RevalidateTagsStats = {};
|
||||
|
||||
const processed = new Set<string>();
|
||||
|
||||
const keysByBackend = new Map<number, string[]>();
|
||||
const keys = new Set<string>();
|
||||
const metas: CacheEntryMeta[] = [];
|
||||
const entries = new Map<string, CacheEntryLookup>();
|
||||
|
||||
await Promise.all(
|
||||
cacheBackends.map(async (backend, backendIndex) => {
|
||||
const { keys: addedKeys, metas: addedMetas } = await backend.revalidateTags(tags);
|
||||
const { entries: addedEntries } = await backend.revalidateTags(tags);
|
||||
|
||||
console.log('revalidateTags', backend.name, addedKeys);
|
||||
addedEntries.forEach(({ key, tag }) => {
|
||||
stats[key] = stats[key] ?? {
|
||||
tag,
|
||||
backends: {},
|
||||
};
|
||||
stats[key].backends[backend.name] = { set: true };
|
||||
|
||||
addedKeys.forEach((key) => {
|
||||
stats[key] = stats[key] ?? {};
|
||||
stats[key][backend.name] = { set: true };
|
||||
|
||||
keys.add(key);
|
||||
entries.set(key, { tag, key });
|
||||
keysByBackend.set(backendIndex, [...(keysByBackend.get(backendIndex) ?? []), key]);
|
||||
});
|
||||
addedMetas.forEach((meta) => {
|
||||
const key = meta.key ?? getCacheKey(meta.cache, meta.args);
|
||||
stats[key] = stats[key] ?? {};
|
||||
stats[key][backend.name] = { set: true };
|
||||
stats[key][backend.name].setAt = meta.setAt;
|
||||
stats[key][backend.name].expiresAt = meta.expiresAt;
|
||||
|
||||
if (!processed.has(key)) {
|
||||
metas.push(meta);
|
||||
processed.add(key);
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Clear the keys on the backends that didn't return them
|
||||
await Promise.all(
|
||||
cacheBackends.map(async (backend, backendIndex) => {
|
||||
const unclearedKeys = Array.from(keys).filter(
|
||||
(key) => !keysByBackend.get(backendIndex)?.includes(key),
|
||||
const unclearedEntries = Array.from(entries.values()).filter(
|
||||
(entry) => !keysByBackend.get(backendIndex)?.includes(entry.key),
|
||||
);
|
||||
|
||||
if (unclearedKeys.length > 0) {
|
||||
unclearedKeys.forEach((key) => {
|
||||
stats[key][backend.name] = { set: false };
|
||||
if (unclearedEntries.length > 0) {
|
||||
unclearedEntries.forEach((entry) => {
|
||||
stats[entry.key].backends[backend.name] = { set: false };
|
||||
});
|
||||
|
||||
await backend.del(unclearedKeys);
|
||||
await backend.del(unclearedEntries);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
keys: Array.from(keys.keys()),
|
||||
stats,
|
||||
};
|
||||
}
|
||||
|
||||
+8
-11
@@ -1,6 +1,9 @@
|
||||
export interface CacheEntryMeta {
|
||||
export interface CacheEntryLookup {
|
||||
key: string;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
export interface CacheEntryMeta extends CacheEntryLookup {
|
||||
/**
|
||||
* Timestamp when the entry was created.
|
||||
*/
|
||||
@@ -21,12 +24,6 @@ export interface CacheEntryMeta {
|
||||
*/
|
||||
revalidatesAt?: number;
|
||||
|
||||
/**
|
||||
* Tags associated with the entry, used for revalidation.
|
||||
* If no tags is present, the entry is considered immutable.
|
||||
*/
|
||||
tags: string[];
|
||||
|
||||
/**
|
||||
* Arguments that were passed to the function.
|
||||
*/
|
||||
@@ -51,21 +48,21 @@ export interface CacheBackend {
|
||||
/**
|
||||
* Get a value from the cache.
|
||||
*/
|
||||
get(key: string, options?: { signal?: AbortSignal }): Promise<CacheEntry | null>;
|
||||
get(entry: CacheEntryLookup, options?: { signal?: AbortSignal }): Promise<CacheEntry | null>;
|
||||
|
||||
/**
|
||||
* Set a value in the cache.
|
||||
*/
|
||||
set(key: string, entry: CacheEntry): Promise<void>;
|
||||
set(entry: CacheEntry): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete a value from the cache.
|
||||
*/
|
||||
del(keys: string[]): Promise<void>;
|
||||
del(keys: CacheEntryLookup[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* Revalidate all keys associated with tags.
|
||||
* It should return the meta of all entries that were revalidated.
|
||||
*/
|
||||
revalidateTags(tags: string[]): Promise<{ keys: string[]; metas: CacheEntryMeta[] }>;
|
||||
revalidateTags(tags: string[]): Promise<{ entries: Array<CacheEntryLookup | CacheEntryMeta> }>;
|
||||
}
|
||||
|
||||
+1
-1
@@ -23,5 +23,5 @@ export function getCacheMaxAge(meta: CacheEntryMeta, min?: number, max?: number)
|
||||
* Return true if a cache entry can be considered immutable.
|
||||
*/
|
||||
export function isCacheEntryImmutable(meta: CacheEntryMeta): boolean {
|
||||
return !meta.tags || meta.tags.length === 0;
|
||||
return !meta.tag;
|
||||
}
|
||||
|
||||
@@ -48,27 +48,30 @@ export async function fetchOpenAPIBlock(
|
||||
}
|
||||
|
||||
const fetcher: OpenAPIFetcher = {
|
||||
fetch: cache('openapi.fetch', async (url: string, options: CacheFunctionOptions) => {
|
||||
// Wrap the raw string to prevent invalid URLs from being passed to fetch.
|
||||
// This can happen if the URL has whitespace, which is currently handled differently by Cloudflare's implementation of fetch:
|
||||
// https://github.com/cloudflare/workerd/issues/1957
|
||||
const response = await fetch(new URL(url), {
|
||||
...noCacheFetchOptions,
|
||||
signal: options.signal,
|
||||
});
|
||||
fetch: cache({
|
||||
name: 'openapi.fetch',
|
||||
get: async (url: string, options: CacheFunctionOptions) => {
|
||||
// Wrap the raw string to prevent invalid URLs from being passed to fetch.
|
||||
// This can happen if the URL has whitespace, which is currently handled differently by Cloudflare's implementation of fetch:
|
||||
// https://github.com/cloudflare/workerd/issues/1957
|
||||
const response = await fetch(new URL(url), {
|
||||
...noCacheFetchOptions,
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch OpenAPI file: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch OpenAPI file: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const data = await parseOpenAPIV3(url, text);
|
||||
return {
|
||||
...parseCacheResponse(response),
|
||||
data,
|
||||
};
|
||||
const text = await response.text();
|
||||
const data = await parseOpenAPIV3(url, text);
|
||||
return {
|
||||
...parseCacheResponse(response),
|
||||
data,
|
||||
};
|
||||
},
|
||||
}),
|
||||
parseMarkdown,
|
||||
};
|
||||
|
||||
@@ -25,6 +25,6 @@
|
||||
"bun-types" // add Bun global
|
||||
]
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"include": ["next-env.d.ts", "cf-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules", "packages/react-openapi", "packages/react-math"]
|
||||
}
|
||||
|
||||
+7
-1
@@ -46,7 +46,13 @@
|
||||
},
|
||||
// Script run when the package is deployed
|
||||
"release": {
|
||||
"dependsOn": ["^release", "build"]
|
||||
"dependsOn": ["^release", "build"],
|
||||
"env": ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN"]
|
||||
},
|
||||
// Script to run when deploying on main to preview/staging
|
||||
"release:preview": {
|
||||
"dependsOn": ["^release:preview", "build"],
|
||||
"env": ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN"]
|
||||
},
|
||||
// Clean up the package
|
||||
"clean": {
|
||||
|
||||
Reference in New Issue
Block a user