mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-18 08:35:10 +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
|
# https://github.com/orgs/community/discussions/26875#discussioncomment-3253761
|
||||||
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
|
||||||
NPM_TOKEN: ${{ secrets.NPM_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-openapi": "workspace:*",
|
||||||
"@gitbook/react-contentkit": "workspace:*",
|
"@gitbook/react-contentkit": "workspace:*",
|
||||||
"@gitbook/emoji-codepoints": "workspace:*",
|
"@gitbook/emoji-codepoints": "workspace:*",
|
||||||
|
"@gitbook/cache-do": "workspace:*",
|
||||||
"@radix-ui/react-checkbox": "^1.0.4",
|
"@radix-ui/react-checkbox": "^1.0.4",
|
||||||
"@radix-ui/react-popover": "^1.0.7",
|
"@radix-ui/react-popover": "^1.0.7",
|
||||||
"@sentry/nextjs": "^7.94.1",
|
"@sentry/nextjs": "^7.94.1",
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
keys: result.keys,
|
|
||||||
stats: result.stats,
|
stats: result.stats,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+205
-178
@@ -72,12 +72,10 @@ export interface ContentTarget {
|
|||||||
const immutableCacheTtl_7days = {
|
const immutableCacheTtl_7days = {
|
||||||
revalidateBefore: 24 * 60 * 60,
|
revalidateBefore: 24 * 60 * 60,
|
||||||
ttl: 7 * 24 * 60 * 60,
|
ttl: 7 * 24 * 60 * 60,
|
||||||
tags: [],
|
|
||||||
};
|
};
|
||||||
const immutableCacheTtl_1day = {
|
const immutableCacheTtl_1day = {
|
||||||
revalidateBefore: 60 * 60,
|
revalidateBefore: 60 * 60,
|
||||||
ttl: 24 * 60 * 60,
|
ttl: 24 * 60 * 60,
|
||||||
tags: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const apiSyncStorage = new AsyncLocalStorage<GitBookAPI>();
|
const apiSyncStorage = new AsyncLocalStorage<GitBookAPI>();
|
||||||
@@ -143,9 +141,14 @@ export type PublishedContentWithCache =
|
|||||||
/**
|
/**
|
||||||
* Get a user by its ID.
|
* Get a user by its ID.
|
||||||
*/
|
*/
|
||||||
export const getUserById = cache(
|
export const getUserById = cache({
|
||||||
'api.getUserById',
|
name: 'api.getUserById',
|
||||||
async (userId: string, options: CacheFunctionOptions) => {
|
tag: (userId) =>
|
||||||
|
getAPICacheTag({
|
||||||
|
tag: 'user',
|
||||||
|
user: userId,
|
||||||
|
}),
|
||||||
|
get: async (userId: string, options: CacheFunctionOptions) => {
|
||||||
try {
|
try {
|
||||||
const response = await api().users.getUserById(userId, {
|
const response = await api().users.getUserById(userId, {
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
@@ -153,28 +156,31 @@ export const getUserById = cache(
|
|||||||
});
|
});
|
||||||
return cacheResponse(response, {
|
return cacheResponse(response, {
|
||||||
revalidateBefore: 60 * 60,
|
revalidateBefore: 60 * 60,
|
||||||
tags: [],
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as GitBookAPIError).code === 404) {
|
if ((error as GitBookAPIError).code === 404) {
|
||||||
return {
|
return {
|
||||||
revalidateBefore: 60 * 60,
|
revalidateBefore: 60 * 60,
|
||||||
data: null,
|
data: null,
|
||||||
tags: [],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a synced block by its ref.
|
* Get a synced block by its ref.
|
||||||
*/
|
*/
|
||||||
export const getSyncedBlockContent = cache(
|
export const getSyncedBlockContent = cache({
|
||||||
'api.getSyncedBlockContent',
|
name: 'api.getSyncedBlockContent',
|
||||||
async (
|
tag: (apiToken, organizationId, syncedBlockId) =>
|
||||||
|
getAPICacheTag({
|
||||||
|
tag: 'synced-block',
|
||||||
|
syncedBlock: syncedBlockId,
|
||||||
|
}),
|
||||||
|
get: async (
|
||||||
apiToken: string,
|
apiToken: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
syncedBlockId: string,
|
syncedBlockId: string,
|
||||||
@@ -191,38 +197,36 @@ export const getSyncedBlockContent = cache(
|
|||||||
);
|
);
|
||||||
return cacheResponse(response, {
|
return cacheResponse(response, {
|
||||||
revalidateBefore: 60 * 60,
|
revalidateBefore: 60 * 60,
|
||||||
tags: [
|
|
||||||
getAPICacheTag({
|
|
||||||
tag: 'synced-block',
|
|
||||||
syncedBlock: syncedBlockId,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as GitBookAPIError).code === 404) {
|
if ((error as GitBookAPIError).code === 404) {
|
||||||
return {
|
return {
|
||||||
revalidateBefore: 60 * 60,
|
revalidateBefore: 60 * 60,
|
||||||
data: null,
|
data: null,
|
||||||
tags: [],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
// We don't cache apiToken as it's not a stable key
|
||||||
// We don't cache apiToken as it's not a stable key
|
getKeyArgs: (args) => [args[1], args[2]],
|
||||||
extractArgs: (args) => [args[1], args[2]],
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
/**
|
/**
|
||||||
* Resolve a URL to the content to render.
|
* Resolve a URL to the content to render.
|
||||||
*/
|
*/
|
||||||
export const getPublishedContentByUrl = cache(
|
export const getPublishedContentByUrl = cache({
|
||||||
'api.getPublishedContentByUrl.v3',
|
name: 'api.getPublishedContentByUrl.v3',
|
||||||
async (url: string, visitorAuthToken: string | undefined, options: CacheFunctionOptions) => {
|
tag: (url) =>
|
||||||
const parsedURL = new URL(url);
|
getAPICacheTag({
|
||||||
|
tag: 'url',
|
||||||
|
hostname: new URL(url).hostname,
|
||||||
|
}),
|
||||||
|
get: async (
|
||||||
|
url: string,
|
||||||
|
visitorAuthToken: string | undefined,
|
||||||
|
options: CacheFunctionOptions,
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const response = await api().urls.getPublishedContentByUrl(
|
const response = await api().urls.getPublishedContentByUrl(
|
||||||
{
|
{
|
||||||
@@ -243,12 +247,6 @@ export const getPublishedContentByUrl = cache(
|
|||||||
cacheTags: parsed.tags,
|
cacheTags: parsed.tags,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
tags: [
|
|
||||||
getAPICacheTag({
|
|
||||||
tag: 'url',
|
|
||||||
hostname: parsedURL.hostname,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
ttl: parsed.ttl,
|
ttl: parsed.ttl,
|
||||||
data,
|
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
|
// 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
|
// and to avoid caching too many entries when being spammed by botss
|
||||||
ttl: 60 * 10,
|
ttl: 60 * 10,
|
||||||
tags: [
|
|
||||||
getAPICacheTag({
|
|
||||||
tag: 'url',
|
|
||||||
hostname: parsedURL.hostname,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a space by its ID.
|
* Get a space by its ID.
|
||||||
*/
|
*/
|
||||||
export const getSpace = cache(
|
export const getSpace = cache({
|
||||||
'api.getSpace',
|
name: 'api.getSpace',
|
||||||
async (spaceId: string, shareKey: string | undefined, options: CacheFunctionOptions) => {
|
tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
|
||||||
|
get: async (spaceId: string, shareKey: string | undefined, options: CacheFunctionOptions) => {
|
||||||
const response = await api().spaces.getSpaceById(
|
const response = await api().spaces.getSpaceById(
|
||||||
spaceId,
|
spaceId,
|
||||||
{
|
{
|
||||||
@@ -296,47 +289,47 @@ export const getSpace = cache(
|
|||||||
);
|
);
|
||||||
return cacheResponse(response, {
|
return cacheResponse(response, {
|
||||||
revalidateBefore: 60 * 60,
|
revalidateBefore: 60 * 60,
|
||||||
tags: [getAPICacheTag({ tag: 'space', space: spaceId })],
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a change request by its ID.
|
* Get a change request by its ID.
|
||||||
*/
|
*/
|
||||||
export const getChangeRequest = cache(
|
export const getChangeRequest = cache({
|
||||||
'api.getChangeRequest',
|
name: 'api.getChangeRequest',
|
||||||
async (spaceId: string, changeRequestId: string, options: CacheFunctionOptions) => {
|
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, {
|
const response = await api().spaces.getChangeRequestById(spaceId, changeRequestId, {
|
||||||
...noCacheFetchOptions,
|
...noCacheFetchOptions,
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
});
|
});
|
||||||
return cacheResponse(response, {
|
return cacheResponse(response, {
|
||||||
// We don't cache for long as we currently don't invalidate change-request cache
|
// 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,
|
ttl: 60 * 60,
|
||||||
revalidateBefore: 10 * 60,
|
revalidateBefore: 10 * 60,
|
||||||
tags: [],
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List the scripts to load for the space.
|
* List the scripts to load for the space.
|
||||||
*/
|
*/
|
||||||
export const getSpaceIntegrationScripts = cache(
|
export const getSpaceIntegrationScripts = cache({
|
||||||
'api.getSpaceIntegrationScripts',
|
name: 'api.getSpaceIntegrationScripts',
|
||||||
async (spaceId: string, options: CacheFunctionOptions) => {
|
tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
|
||||||
|
get: async (spaceId: string, options: CacheFunctionOptions) => {
|
||||||
const response = await api().spaces.listSpaceIntegrationScripts(spaceId, {
|
const response = await api().spaces.listSpaceIntegrationScripts(spaceId, {
|
||||||
...noCacheFetchOptions,
|
...noCacheFetchOptions,
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
});
|
});
|
||||||
return cacheResponse(response, {
|
return cacheResponse(response, {
|
||||||
revalidateBefore: 60 * 60,
|
revalidateBefore: 60 * 60,
|
||||||
tags: [getAPICacheTag({ tag: 'space', space: spaceId })],
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
interface GetRevisionOptions {
|
interface GetRevisionOptions {
|
||||||
/**
|
/**
|
||||||
@@ -351,9 +344,11 @@ interface GetRevisionOptions {
|
|||||||
/**
|
/**
|
||||||
* Get a revision by its ID.
|
* Get a revision by its ID.
|
||||||
*/
|
*/
|
||||||
export const getRevision = cache(
|
export const getRevision = cache({
|
||||||
'api.getRevision.v2',
|
name: 'api.getRevision.v2',
|
||||||
async (
|
tag: (spaceId, revisionId) =>
|
||||||
|
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
|
||||||
|
get: async (
|
||||||
spaceId: string,
|
spaceId: string,
|
||||||
revisionId: string,
|
revisionId: string,
|
||||||
fetchOptions: GetRevisionOptions,
|
fetchOptions: GetRevisionOptions,
|
||||||
@@ -376,17 +371,17 @@ export const getRevision = cache(
|
|||||||
fetchOptions.metadata ? immutableCacheTtl_7days : immutableCacheTtl_1day,
|
fetchOptions.metadata ? immutableCacheTtl_7days : immutableCacheTtl_1day,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
{
|
getKeyArgs: (args) => [args[0], args[1]],
|
||||||
extractArgs: (args) => [args[0], args[1]],
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all the pages in a revision of a space.
|
* Get all the pages in a revision of a space.
|
||||||
*/
|
*/
|
||||||
export const getRevisionPages = cache(
|
export const getRevisionPages = cache({
|
||||||
'api.getRevisionPages.v4',
|
name: 'api.getRevisionPages.v4',
|
||||||
async (
|
tag: (spaceId, revisionId) =>
|
||||||
|
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
|
||||||
|
get: async (
|
||||||
spaceId: string,
|
spaceId: string,
|
||||||
revisionId: string,
|
revisionId: string,
|
||||||
fetchOptions: GetRevisionOptions,
|
fetchOptions: GetRevisionOptions,
|
||||||
@@ -409,17 +404,17 @@ export const getRevisionPages = cache(
|
|||||||
data: response.data.pages,
|
data: response.data.pages,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{
|
getKeyArgs: (args) => [args[0], args[1]],
|
||||||
extractArgs: (args) => [args[0], args[1]],
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a revision page by its path
|
* Get a revision page by its path
|
||||||
*/
|
*/
|
||||||
export const getRevisionPageByPath = cache(
|
export const getRevisionPageByPath = cache({
|
||||||
'api.getRevisionPageByPath.v3',
|
name: 'api.getRevisionPageByPath.v3',
|
||||||
async (
|
tag: (spaceId, revisionId) =>
|
||||||
|
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
|
||||||
|
get: async (
|
||||||
spaceId: string,
|
spaceId: string,
|
||||||
revisionId: string,
|
revisionId: string,
|
||||||
pagePath: string,
|
pagePath: string,
|
||||||
@@ -453,15 +448,22 @@ export const getRevisionPageByPath = cache(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a file by its ID.
|
* Resolve a file by its ID.
|
||||||
* It should not be used directly, use `getRevisionFile` instead.
|
* It should not be used directly, use `getRevisionFile` instead.
|
||||||
*/
|
*/
|
||||||
const getRevisionFileById = cache(
|
const getRevisionFileById = cache({
|
||||||
'api.getRevisionFile.v3',
|
name: 'api.getRevisionFile.v3',
|
||||||
async (spaceId: string, revisionId: string, fileId: string, options: CacheFunctionOptions) => {
|
tag: (spaceId, revisionId) =>
|
||||||
|
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
|
||||||
|
get: async (
|
||||||
|
spaceId: string,
|
||||||
|
revisionId: string,
|
||||||
|
fileId: string,
|
||||||
|
options: CacheFunctionOptions,
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const response = await (async () => {
|
const response = await (async () => {
|
||||||
return api().spaces.getFileInRevisionById(
|
return api().spaces.getFileInRevisionById(
|
||||||
@@ -487,15 +489,17 @@ const getRevisionFileById = cache(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all the files in a revision of a space.
|
* Get all the files in a revision of a space.
|
||||||
* It should not be used directly, use `getRevisionFile` instead.
|
* It should not be used directly, use `getRevisionFile` instead.
|
||||||
*/
|
*/
|
||||||
const getRevisionAllFiles = cache(
|
const getRevisionAllFiles = cache({
|
||||||
'api.getRevisionAllFiles.v2',
|
name: 'api.getRevisionAllFiles.v2',
|
||||||
async (spaceId: string, revisionId: string, options: CacheFunctionOptions) => {
|
tag: (spaceId, revisionId) =>
|
||||||
|
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
|
||||||
|
get: async (spaceId: string, revisionId: string, options: CacheFunctionOptions) => {
|
||||||
const response = await getAll(
|
const response = await getAll(
|
||||||
(params) =>
|
(params) =>
|
||||||
api().spaces.listFilesInRevisionById(
|
api().spaces.listFilesInRevisionById(
|
||||||
@@ -522,10 +526,8 @@ const getRevisionAllFiles = cache(
|
|||||||
|
|
||||||
return cacheResponse(response, { ...immutableCacheTtl_7days, data: files });
|
return cacheResponse(response, { ...immutableCacheTtl_7days, data: files });
|
||||||
},
|
},
|
||||||
{
|
timeout: 60 * 1000,
|
||||||
timeout: 60 * 1000,
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a file by its ID.
|
* 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.
|
* Get a document by its ID.
|
||||||
*/
|
*/
|
||||||
export const getDocument = cache(
|
export const getDocument = cache({
|
||||||
'api.getDocument.v2',
|
name: 'api.getDocument.v2',
|
||||||
async (spaceId: string, documentId: string, options: CacheFunctionOptions) => {
|
tag: (spaceId, documentId) =>
|
||||||
|
getAPICacheTag({ tag: 'document', space: spaceId, document: documentId }),
|
||||||
|
get: async (spaceId: string, documentId: string, options: CacheFunctionOptions) => {
|
||||||
const response = await api().spaces.getDocumentById(
|
const response = await api().spaces.getDocumentById(
|
||||||
spaceId,
|
spaceId,
|
||||||
documentId,
|
documentId,
|
||||||
@@ -599,20 +603,19 @@ export const getDocument = cache(
|
|||||||
);
|
);
|
||||||
return cacheResponse(response, immutableCacheTtl_7days);
|
return cacheResponse(response, immutableCacheTtl_7days);
|
||||||
},
|
},
|
||||||
{
|
// Temporarily allow for a longer timeout than the default 10s
|
||||||
// Temporarily allow for a longer timeout than the default 10s
|
// because GitBook's API currently re-normalizes all documents
|
||||||
// because GitBook's API currently re-normalizes all documents
|
// and it can take more than 10s...
|
||||||
// and it can take more than 10s...
|
timeout: 20 * 1000,
|
||||||
timeout: 20 * 1000,
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the customization settings for a site-space from the API.
|
* Get the customization settings for a site-space from the API.
|
||||||
*/
|
*/
|
||||||
const getSiteSpaceCustomizationFromAPI = cache(
|
const getSiteSpaceCustomizationFromAPI = cache({
|
||||||
'api.getSiteSpaceCustomizationById',
|
name: 'api.getSiteSpaceCustomizationById',
|
||||||
async (
|
tag: (organizationId, siteId, siteSpaceId) => getAPICacheTag({ tag: 'site', site: siteId }),
|
||||||
|
get: async (
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
siteId: string,
|
siteId: string,
|
||||||
siteSpaceId: string,
|
siteSpaceId: string,
|
||||||
@@ -630,22 +633,17 @@ const getSiteSpaceCustomizationFromAPI = cache(
|
|||||||
);
|
);
|
||||||
return cacheResponse(response, {
|
return cacheResponse(response, {
|
||||||
revalidateBefore: 60 * 60,
|
revalidateBefore: 60 * 60,
|
||||||
tags: [
|
|
||||||
getAPICacheTag({
|
|
||||||
tag: 'site',
|
|
||||||
site: siteId,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the customization settings for a site from the API.
|
* Get the customization settings for a site from the API.
|
||||||
*/
|
*/
|
||||||
const getSiteCustomizationFromAPI = cache(
|
const getSiteCustomizationFromAPI = cache({
|
||||||
'api.getSiteCustomizationById',
|
name: 'api.getSiteCustomizationById',
|
||||||
async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
|
tag: (organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }),
|
||||||
|
get: async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
|
||||||
const response = await api().orgs.getSiteCustomizationById(
|
const response = await api().orgs.getSiteCustomizationById(
|
||||||
organizationId,
|
organizationId,
|
||||||
siteId,
|
siteId,
|
||||||
@@ -657,15 +655,9 @@ const getSiteCustomizationFromAPI = cache(
|
|||||||
);
|
);
|
||||||
return cacheResponse(response, {
|
return cacheResponse(response, {
|
||||||
revalidateBefore: 60 * 60,
|
revalidateBefore: 60 * 60,
|
||||||
tags: [
|
|
||||||
getAPICacheTag({
|
|
||||||
tag: 'site',
|
|
||||||
site: siteId,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the customization settings for a site space from the API.
|
* 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.
|
* Get the infos about a site by its ID.
|
||||||
*/
|
*/
|
||||||
export const getSite = cache(
|
export const getSite = cache({
|
||||||
'api.getSite',
|
name: 'api.getSite',
|
||||||
async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
|
tag: (organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }),
|
||||||
|
get: async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
|
||||||
const response = await api().orgs.getSiteById(organizationId, siteId, {
|
const response = await api().orgs.getSiteById(organizationId, siteId, {
|
||||||
...noCacheFetchOptions,
|
...noCacheFetchOptions,
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
});
|
});
|
||||||
return cacheResponse(response, {
|
return cacheResponse(response, {
|
||||||
revalidateBefore: 60 * 60,
|
revalidateBefore: 60 * 60,
|
||||||
tags: [getAPICacheTag({ tag: 'site', site: siteId })],
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all the site-spaces variants published in a site.
|
* List all the site-spaces variants published in a site.
|
||||||
*/
|
*/
|
||||||
export const getSiteSpaces = cache(
|
export const getSiteSpaces = cache({
|
||||||
'api.getSiteSpaces',
|
name: 'api.getSiteSpaces',
|
||||||
async (
|
tag: ({ siteId }) => getAPICacheTag({ tag: 'site', site: siteId }),
|
||||||
|
get: async (
|
||||||
args: {
|
args: {
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
siteId: string;
|
siteId: string;
|
||||||
@@ -744,27 +737,26 @@ export const getSiteSpaces = cache(
|
|||||||
return cacheResponse(response, {
|
return cacheResponse(response, {
|
||||||
revalidateBefore: 60 * 60,
|
revalidateBefore: 60 * 60,
|
||||||
data: response.data.items.map((siteSpace) => siteSpace),
|
data: response.data.items.map((siteSpace) => siteSpace),
|
||||||
tags: [getAPICacheTag({ tag: 'site', site: args.siteId })],
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List the scripts to load for the site.
|
* List the scripts to load for the site.
|
||||||
*/
|
*/
|
||||||
export const getSiteIntegrationScripts = cache(
|
export const getSiteIntegrationScripts = cache({
|
||||||
'api.getSiteIntegrationScripts',
|
name: 'api.getSiteIntegrationScripts',
|
||||||
async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
|
tag: (organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }),
|
||||||
|
get: async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
|
||||||
const response = await api().orgs.listSiteIntegrationScripts(organizationId, siteId, {
|
const response = await api().orgs.listSiteIntegrationScripts(organizationId, siteId, {
|
||||||
...noCacheFetchOptions,
|
...noCacheFetchOptions,
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
});
|
});
|
||||||
return cacheResponse(response, {
|
return cacheResponse(response, {
|
||||||
revalidateBefore: 60 * 60,
|
revalidateBefore: 60 * 60,
|
||||||
tags: [getAPICacheTag({ tag: 'site', site: siteId })],
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch all the data to render the current site at once.
|
* 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.
|
* Get the customization settings for a space from the API.
|
||||||
*/
|
*/
|
||||||
export const getSpaceCustomizationFromAPI = cache(
|
export const getSpaceCustomizationFromAPI = cache({
|
||||||
'api.getSpaceCustomization',
|
name: 'api.getSpaceCustomization',
|
||||||
async (spaceId: string, options: CacheFunctionOptions) => {
|
tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
|
||||||
|
get: async (spaceId: string, options: CacheFunctionOptions) => {
|
||||||
const response = await api().spaces.getSpacePublishingCustomizationById(spaceId, {
|
const response = await api().spaces.getSpacePublishingCustomizationById(spaceId, {
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
...noCacheFetchOptions,
|
...noCacheFetchOptions,
|
||||||
});
|
});
|
||||||
return cacheResponse(response, {
|
return cacheResponse(response, {
|
||||||
revalidateBefore: 60 * 60,
|
revalidateBefore: 60 * 60,
|
||||||
tags: [getAPICacheTag({ tag: 'space', space: spaceId })],
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the customization settings for a space from the API.
|
* 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.
|
* Get the infos about a collection by its ID.
|
||||||
*/
|
*/
|
||||||
export const getCollection = cache(
|
export const getCollection = cache({
|
||||||
'api.getCollection',
|
name: 'api.getCollection',
|
||||||
async (collectionId: string, options: CacheFunctionOptions) => {
|
tag: (collectionId) => getAPICacheTag({ tag: 'collection', collection: collectionId }),
|
||||||
|
get: async (collectionId: string, options: CacheFunctionOptions) => {
|
||||||
const response = await api().collections.getCollectionById(collectionId, {
|
const response = await api().collections.getCollectionById(collectionId, {
|
||||||
...noCacheFetchOptions,
|
...noCacheFetchOptions,
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
});
|
});
|
||||||
return cacheResponse(response, {
|
return cacheResponse(response, {
|
||||||
revalidateBefore: 60 * 60,
|
revalidateBefore: 60 * 60,
|
||||||
tags: [getAPICacheTag({ tag: 'collection', collection: collectionId })],
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all the spaces variants published in a collection.
|
* List all the spaces variants published in a collection.
|
||||||
*/
|
*/
|
||||||
export const getCollectionSpaces = cache(
|
export const getCollectionSpaces = cache({
|
||||||
'api.getCollectionSpaces',
|
name: 'api.getCollectionSpaces',
|
||||||
async (collectionId: string, options: CacheFunctionOptions) => {
|
tag: (collectionId) => getAPICacheTag({ tag: 'collection', collection: collectionId }),
|
||||||
|
get: async (collectionId: string, options: CacheFunctionOptions) => {
|
||||||
const response = await getAll((params) =>
|
const response = await getAll((params) =>
|
||||||
api().collections.listSpacesInCollectionById(collectionId, params, {
|
api().collections.listSpacesInCollectionById(collectionId, params, {
|
||||||
...noCacheFetchOptions,
|
...noCacheFetchOptions,
|
||||||
@@ -908,10 +901,9 @@ export const getCollectionSpaces = cache(
|
|||||||
data: response.data.items.filter(
|
data: response.data.items.filter(
|
||||||
(space) => space.visibility === ContentVisibility.InCollection,
|
(space) => space.visibility === ContentVisibility.InCollection,
|
||||||
),
|
),
|
||||||
tags: [getAPICacheTag({ tag: 'collection', collection: collectionId })],
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch all the data to render a space at once.
|
* 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.
|
* Search content in a space.
|
||||||
*/
|
*/
|
||||||
export const searchSpaceContent = cache(
|
export const searchSpaceContent = cache({
|
||||||
'api.searchSpaceContent',
|
name: 'api.searchSpaceContent',
|
||||||
async (
|
tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
|
||||||
|
get: async (
|
||||||
spaceId: string,
|
spaceId: string,
|
||||||
/** The revision ID is used as a cache bust key, to avoid revalidating lot of cache entries by tags */
|
/** The revision ID is used as a cache bust key, to avoid revalidating lot of cache entries by tags */
|
||||||
revisionId: string,
|
revisionId: string,
|
||||||
@@ -996,18 +989,17 @@ export const searchSpaceContent = cache(
|
|||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return cacheResponse(response, {
|
return cacheResponse(response);
|
||||||
tags: [],
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search content accross all spaces in a parent (site or collection).
|
* Search content accross all spaces in a parent (site or collection).
|
||||||
*/
|
*/
|
||||||
export const searchParentContent = cache(
|
export const searchParentContent = cache({
|
||||||
'api.searchParentContent',
|
name: 'api.searchParentContent',
|
||||||
async (parentId: string, query: string, options: CacheFunctionOptions) => {
|
tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
|
||||||
|
get: async (parentId: string, query: string, options: CacheFunctionOptions) => {
|
||||||
const response = await api().search.searchContent(
|
const response = await api().search.searchContent(
|
||||||
{ query },
|
{ query },
|
||||||
{
|
{
|
||||||
@@ -1017,17 +1009,17 @@ export const searchParentContent = cache(
|
|||||||
);
|
);
|
||||||
return cacheResponse(response, {
|
return cacheResponse(response, {
|
||||||
ttl: 60 * 60,
|
ttl: 60 * 60,
|
||||||
tags: [],
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search content in a Site or specific SiteSpaces.
|
* Search content in a Site or specific SiteSpaces.
|
||||||
*/
|
*/
|
||||||
export const searchSiteContent = cache(
|
export const searchSiteContent = cache({
|
||||||
'api.searchSiteContent',
|
name: 'api.searchSiteContent',
|
||||||
async (
|
tag: (organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }),
|
||||||
|
get: async (
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
siteId: string,
|
siteId: string,
|
||||||
query: string,
|
query: string,
|
||||||
@@ -1052,33 +1044,32 @@ export const searchSiteContent = cache(
|
|||||||
|
|
||||||
return cacheResponse(response, {
|
return cacheResponse(response, {
|
||||||
ttl: 60 * 60,
|
ttl: 60 * 60,
|
||||||
tags: [],
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a list of recommended questions in a space.
|
* Get a list of recommended questions in a space.
|
||||||
*/
|
*/
|
||||||
export const getRecommendedQuestionsInSpace = cache(
|
export const getRecommendedQuestionsInSpace = cache({
|
||||||
'api.getRecommendedQuestionsInSpace',
|
name: 'api.getRecommendedQuestionsInSpace',
|
||||||
async (spaceId: string, options: CacheFunctionOptions) => {
|
tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
|
||||||
|
get: async (spaceId: string, options: CacheFunctionOptions) => {
|
||||||
const response = await api().spaces.getRecommendedQuestionsInSpace(spaceId, {
|
const response = await api().spaces.getRecommendedQuestionsInSpace(spaceId, {
|
||||||
...noCacheFetchOptions,
|
...noCacheFetchOptions,
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
});
|
});
|
||||||
return cacheResponse(response, {
|
return cacheResponse(response);
|
||||||
tags: [],
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render an integration contentkit UI
|
* Render an integration contentkit UI
|
||||||
*/
|
*/
|
||||||
export const renderIntegrationUi = cache(
|
export const renderIntegrationUi = cache({
|
||||||
'api.renderIntegrationUi',
|
name: 'api.renderIntegrationUi',
|
||||||
async (
|
tag: (integrationName) => getAPICacheTag({ tag: 'integration', integration: integrationName }),
|
||||||
|
get: async (
|
||||||
integrationName: string,
|
integrationName: string,
|
||||||
request: RequestRenderIntegrationUI,
|
request: RequestRenderIntegrationUI,
|
||||||
options: CacheFunctionOptions,
|
options: CacheFunctionOptions,
|
||||||
@@ -1091,21 +1082,47 @@ export const renderIntegrationUi = cache(
|
|||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return cacheResponse(response, {
|
return cacheResponse(response);
|
||||||
tags: [],
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a cache tag for the API.
|
* Create a cache tag for the API.
|
||||||
*/
|
*/
|
||||||
export function getAPICacheTag(
|
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';
|
tag: 'space';
|
||||||
space: string;
|
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
|
// All data related to the URL of a content
|
||||||
| {
|
| {
|
||||||
tag: 'url';
|
tag: 'url';
|
||||||
@@ -1128,16 +1145,26 @@ export function getAPICacheTag(
|
|||||||
},
|
},
|
||||||
): string {
|
): string {
|
||||||
switch (spec.tag) {
|
switch (spec.tag) {
|
||||||
|
case 'user':
|
||||||
|
return `user:${spec.user}`;
|
||||||
case 'url':
|
case 'url':
|
||||||
return `url:${spec.hostname}`;
|
return `url:${spec.hostname}`;
|
||||||
case 'space':
|
case 'space':
|
||||||
return `space:${spec.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':
|
case 'collection':
|
||||||
return `collection:${spec.collection}`;
|
return `collection:${spec.collection}`;
|
||||||
case 'synced-block':
|
case 'synced-block':
|
||||||
return `synced-block:${spec.syncedBlock}`;
|
return `synced-block:${spec.syncedBlock}`;
|
||||||
case 'site':
|
case 'site':
|
||||||
return `site:${spec.site}`;
|
return `site:${spec.site}`;
|
||||||
|
case 'integration':
|
||||||
|
return `integration:${spec.integration}`;
|
||||||
default:
|
default:
|
||||||
assertNever(spec);
|
assertNever(spec);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-4
@@ -1,18 +1,16 @@
|
|||||||
import { cloudflareCache } from './cloudflare-cache';
|
import { cloudflareCache } from './cloudflare-cache';
|
||||||
|
import { cloudflareDOCache } from './cloudflare-do';
|
||||||
import { cloudflareKVCache } from './cloudflare-kv';
|
import { cloudflareKVCache } from './cloudflare-kv';
|
||||||
import { memoryCache } from './memory';
|
import { memoryCache } from './memory';
|
||||||
import { redisCache } from './redis';
|
|
||||||
|
|
||||||
export const cacheBackends = [
|
export const cacheBackends = [
|
||||||
// Cache local to the process
|
// Cache local to the process
|
||||||
// (can't be globally purged or shared between processes)
|
// (can't be globally purged or shared between processes)
|
||||||
memoryCache,
|
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
|
// Cache local to the datacenter
|
||||||
// It can't be purged globally but it's faster
|
// It can't be purged globally but it's faster
|
||||||
cloudflareCache,
|
cloudflareCache,
|
||||||
// Cache global, but with slow replication
|
// Cache global, but with slow replication
|
||||||
cloudflareKVCache,
|
cloudflareKVCache,
|
||||||
|
cloudflareDOCache,
|
||||||
];
|
];
|
||||||
|
|||||||
+10
-6
@@ -15,12 +15,16 @@ describe('cache', () => {
|
|||||||
testId += 1;
|
testId += 1;
|
||||||
getTtl = () => 1000;
|
getTtl = () => 1000;
|
||||||
|
|
||||||
fn = cache(`cache-${testId}`, async (arg: string, options: CacheFunctionOptions) => {
|
fn = cache({
|
||||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
name: `cache-${testId}`,
|
||||||
return {
|
tag: (arg) => 'test',
|
||||||
data: impl(arg),
|
get: async (arg: string, options: CacheFunctionOptions) => {
|
||||||
ttl: getTtl(),
|
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).
|
* Time before ttl where the cache should be revalidated (in seconds).
|
||||||
*/
|
*/
|
||||||
revalidateBefore?: number;
|
revalidateBefore?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
export interface CacheDefinition<Args extends any[], Result> {
|
||||||
* Tags to associate with the cache entry.
|
/** Unique name for the cache */
|
||||||
*/
|
name: string;
|
||||||
tags?: 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.
|
* We don't use the next.js cache because it has a 2MB limit.
|
||||||
*/
|
*/
|
||||||
export function cache<Args extends any[], Result>(
|
export function cache<Args extends any[], Result>(
|
||||||
cacheName: string,
|
cacheDef: CacheDefinition<Args, Result>,
|
||||||
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;
|
|
||||||
} = {},
|
|
||||||
): CacheFunction<Args, Result> {
|
): CacheFunction<Args, Result> {
|
||||||
// We stop everything after 10s to avoid pending requests
|
// 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(
|
const revalidate = singletonMap(
|
||||||
async (key: string, signal: AbortSignal | undefined, ...args: Args) => {
|
async (key: string, signal: AbortSignal | undefined, ...args: Args) => {
|
||||||
@@ -80,19 +85,18 @@ export function cache<Args extends any[], Result>(
|
|||||||
},
|
},
|
||||||
async (span) => {
|
async (span) => {
|
||||||
// Fetch upstream
|
// Fetch upstream
|
||||||
const result = await fn(...args, { signal });
|
const result = await cacheDef.get(...args, { signal });
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
|
|
||||||
const setAt = Date.now();
|
const setAt = Date.now();
|
||||||
const expiresAt =
|
const expiresAt = setAt + (result.ttl ?? defaultTtl) * 1000;
|
||||||
setAt + (result.ttl ?? options.defaultTtl ?? 60 * 60 * 24) * 1000;
|
|
||||||
|
|
||||||
const cacheEntry: CacheEntry = {
|
const cacheEntry: CacheEntry = {
|
||||||
data: result.data,
|
data: result.data,
|
||||||
meta: {
|
meta: {
|
||||||
key,
|
key,
|
||||||
cache: cacheName,
|
cache: cacheDef.name,
|
||||||
tags: result.tags ?? [],
|
tag: cacheDef.tag?.(...args),
|
||||||
setAt,
|
setAt,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
revalidatesAt: result.revalidateBefore
|
revalidatesAt: result.revalidateBefore
|
||||||
@@ -106,7 +110,7 @@ export function cache<Args extends any[], Result>(
|
|||||||
|
|
||||||
// Write it to the cache
|
// Write it to the cache
|
||||||
if (result.ttl && result.ttl > 0) {
|
if (result.ttl && result.ttl > 0) {
|
||||||
await waitUntil(setCacheEntry(key, cacheEntry));
|
await waitUntil(setCacheEntry(cacheEntry));
|
||||||
}
|
}
|
||||||
|
|
||||||
return cacheEntry;
|
return cacheEntry;
|
||||||
@@ -122,9 +126,10 @@ export function cache<Args extends any[], Result>(
|
|||||||
let fetchDuration = 0;
|
let fetchDuration = 0;
|
||||||
|
|
||||||
let result: readonly [CacheEntry, string] | null = null;
|
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
|
// 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) {
|
if (memoryEntry) {
|
||||||
span.setAttribute('memory', true);
|
span.setAttribute('memory', true);
|
||||||
result = [memoryEntry, 'memory'] as const;
|
result = [memoryEntry, 'memory'] as const;
|
||||||
@@ -132,7 +137,7 @@ export function cache<Args extends any[], Result>(
|
|||||||
result = await race(
|
result = await race(
|
||||||
cacheBackends,
|
cacheBackends,
|
||||||
async (backend, { signal }) => {
|
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;
|
return entry ? ([entry, backend.name] as const) : null;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -184,7 +189,7 @@ export function cache<Args extends any[], Result>(
|
|||||||
(backend) =>
|
(backend) =>
|
||||||
backend.name !== backendName && backend.replication === 'local',
|
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 cacheFn = async (...rawArgs: Args | [...Args, CacheFunctionOptions]) => {
|
||||||
const [args, { signal }] = extractCacheFunctionOptions<Args>(rawArgs);
|
const [args, { signal }] = extractCacheFunctionOptions<Args>(rawArgs);
|
||||||
|
|
||||||
const cacheArgs = options.extractArgs ? options.extractArgs(args) : args;
|
const cacheArgs = cacheDef.getKeyArgs ? cacheDef.getKeyArgs(args) : args;
|
||||||
const key = getCacheKey(cacheName, cacheArgs);
|
const key = getCacheKey(cacheDef.name, cacheArgs);
|
||||||
|
|
||||||
return await trace(
|
return await trace(
|
||||||
{
|
{
|
||||||
@@ -231,17 +236,18 @@ export function cache<Args extends any[], Result>(
|
|||||||
|
|
||||||
cacheFn.revalidate = async (...rawArgs: Args | [...Args, CacheFunctionOptions]) => {
|
cacheFn.revalidate = async (...rawArgs: Args | [...Args, CacheFunctionOptions]) => {
|
||||||
const [args, { signal }] = extractCacheFunctionOptions<Args>(rawArgs);
|
const [args, { signal }] = extractCacheFunctionOptions<Args>(rawArgs);
|
||||||
const cacheArgs = options.extractArgs ? options.extractArgs(args) : args;
|
const cacheArgs = cacheDef.getKeyArgs ? cacheDef.getKeyArgs(args) : args;
|
||||||
const key = getCacheKey(cacheName, cacheArgs);
|
const key = getCacheKey(cacheDef.name, cacheArgs);
|
||||||
|
|
||||||
await revalidate(key, signal, ...args);
|
await revalidate(key, signal, ...args);
|
||||||
};
|
};
|
||||||
|
|
||||||
cacheFn.hasInMemory = async (...args: Args) => {
|
cacheFn.hasInMemory = async (...args: Args) => {
|
||||||
const cacheArgs = options.extractArgs ? options.extractArgs(args) : args;
|
const cacheArgs = cacheDef.getKeyArgs ? cacheDef.getKeyArgs(args) : args;
|
||||||
const key = getCacheKey(cacheName, cacheArgs);
|
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) {
|
if (memoryEntry) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -250,7 +256,7 @@ export function cache<Args extends any[], Result>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
registeredCaches.set(cacheName, cacheFn);
|
registeredCaches.set(cacheDef.name, cacheFn);
|
||||||
|
|
||||||
return cacheFn;
|
return cacheFn;
|
||||||
}
|
}
|
||||||
@@ -289,14 +295,14 @@ function hashValue(arg: any): string {
|
|||||||
return JSON.stringify(arg);
|
return JSON.stringify(arg);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setCacheEntry(key: string, entry: CacheEntry) {
|
async function setCacheEntry(entry: CacheEntry) {
|
||||||
return await trace(
|
return await trace(
|
||||||
{
|
{
|
||||||
operation: `cache.setCacheEntry`,
|
operation: `cache.setCacheEntry`,
|
||||||
name: key,
|
name: entry.meta.key,
|
||||||
},
|
},
|
||||||
async () => {
|
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 = {
|
export const cloudflareCache: CacheBackend = {
|
||||||
name: 'cloudflare',
|
name: 'cloudflare',
|
||||||
replication: 'local',
|
replication: 'local',
|
||||||
async get(key, options) {
|
async get(entry, options) {
|
||||||
const cache = getCache();
|
const cache = getCache();
|
||||||
if (!cache) {
|
if (!cache) {
|
||||||
return null;
|
return null;
|
||||||
@@ -27,10 +27,10 @@ export const cloudflareCache: CacheBackend = {
|
|||||||
return trace(
|
return trace(
|
||||||
{
|
{
|
||||||
operation: `cloudflareCache.get`,
|
operation: `cloudflareCache.get`,
|
||||||
name: key,
|
name: entry.key,
|
||||||
},
|
},
|
||||||
async (span) => {
|
async (span) => {
|
||||||
const cacheKey = await serializeKey(key);
|
const cacheKey = await serializeKey(entry.key);
|
||||||
const response = await cache.match(cacheKey);
|
const response = await cache.match(cacheKey);
|
||||||
span.setAttribute('hit', !!response);
|
span.setAttribute('hit', !!response);
|
||||||
|
|
||||||
@@ -39,32 +39,32 @@ export const cloudflareCache: CacheBackend = {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const entry = await deserializeEntry(response);
|
const cacheEntry = await deserializeEntry(response);
|
||||||
return entry;
|
return cacheEntry;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
async set(key, entry) {
|
async set(entry) {
|
||||||
const cache = getCache();
|
const cache = getCache();
|
||||||
if (cache) {
|
if (cache) {
|
||||||
await trace(
|
await trace(
|
||||||
{
|
{
|
||||||
operation: `cloudflareCache.set`,
|
operation: `cloudflareCache.set`,
|
||||||
name: key,
|
name: entry.meta.key,
|
||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
const cacheKey = await serializeKey(key);
|
const cacheKey = await serializeKey(entry.meta.key);
|
||||||
await cache.put(cacheKey, serializeEntry(entry));
|
await cache.put(cacheKey, serializeEntry(entry));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async del(keys) {
|
async del(entries) {
|
||||||
const cache = getCache();
|
const cache = getCache();
|
||||||
if (cache) {
|
if (cache) {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
keys.map(async (key) => {
|
entries.map(async (entry) => {
|
||||||
const cacheKey = await serializeKey(key);
|
const cacheKey = await serializeKey(entry.key);
|
||||||
await cache.delete(cacheKey);
|
await cache.delete(cacheKey);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -72,8 +72,7 @@ export const cloudflareCache: CacheBackend = {
|
|||||||
},
|
},
|
||||||
async revalidateTags(tags) {
|
async revalidateTags(tags) {
|
||||||
return {
|
return {
|
||||||
keys: [],
|
entries: [],
|
||||||
metas: [],
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -103,7 +102,10 @@ async function serializeKey(key: string): Promise<string> {
|
|||||||
function serializeEntry(entry: CacheEntry): WorkerResponse {
|
function serializeEntry(entry: CacheEntry): WorkerResponse {
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
headers.set('Content-Type', 'application/json');
|
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(
|
const maxAge = getCacheMaxAge(
|
||||||
entry.meta,
|
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 = {
|
export const cloudflareKVCache: CacheBackend = {
|
||||||
name: 'cloudflare-kv',
|
name: 'cloudflare-kv',
|
||||||
replication: 'global',
|
replication: 'global',
|
||||||
async get(key, options) {
|
async get({ key }, options) {
|
||||||
const kv = await getKVNamespace();
|
const kv = await getKVNamespace();
|
||||||
if (!kv) {
|
if (!kv) {
|
||||||
return null;
|
return null;
|
||||||
@@ -42,7 +42,7 @@ export const cloudflareKVCache: CacheBackend = {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
async set(key, entry) {
|
async set(entry) {
|
||||||
const kv = await getKVNamespace();
|
const kv = await getKVNamespace();
|
||||||
if (!kv) {
|
if (!kv) {
|
||||||
return;
|
return;
|
||||||
@@ -51,7 +51,7 @@ export const cloudflareKVCache: CacheBackend = {
|
|||||||
return trace(
|
return trace(
|
||||||
{
|
{
|
||||||
operation: `cloudflareKV.set`,
|
operation: `cloudflareKV.set`,
|
||||||
name: key,
|
name: entry.meta.key,
|
||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
const secondsFromNow = getCacheMaxAge(entry.meta, 0, 60 * 60 * 24);
|
const secondsFromNow = getCacheMaxAge(entry.meta, 0, 60 * 60 * 24);
|
||||||
@@ -61,54 +61,45 @@ export const cloudflareKVCache: CacheBackend = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const kvKey = getValueKey(key);
|
const kvKey = getValueKey(entry.meta.key);
|
||||||
await kv.put(kvKey, JSON.stringify(entry), {
|
await kv.put(kvKey, JSON.stringify(entry), {
|
||||||
expirationTtl: secondsFromNow,
|
expirationTtl: secondsFromNow,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (entry.meta.tags.length > 0) {
|
if (entry.meta.tag) {
|
||||||
const metadata: KVTagMetadata = {
|
const metadata: KVTagMetadata = {
|
||||||
meta: entry.meta,
|
meta: entry.meta,
|
||||||
};
|
};
|
||||||
const jsonMetadata = JSON.stringify(metadata);
|
const jsonMetadata = JSON.stringify(metadata);
|
||||||
|
const tagKey = getTagKey(entry.meta.tag, entry.meta.key);
|
||||||
|
|
||||||
// Write a key for each tag
|
await kv.put(tagKey, jsonMetadata, {
|
||||||
await Promise.all(
|
metadata,
|
||||||
entry.meta.tags.map(async (tag) => {
|
expirationTtl: secondsFromNow,
|
||||||
const tagKey = getTagKey(tag, key);
|
});
|
||||||
|
|
||||||
await kv.put(tagKey, jsonMetadata, {
|
|
||||||
metadata,
|
|
||||||
expirationTtl: secondsFromNow,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
async del(keys) {
|
async del(entries) {
|
||||||
const kv = await getKVNamespace();
|
const kv = await getKVNamespace();
|
||||||
if (!kv) {
|
if (!kv) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
keys.map(async (key) => {
|
entries.map(async ({ key }) => {
|
||||||
const kvKey = getValueKey(key);
|
const kvKey = getValueKey(key);
|
||||||
await kv.delete(kvKey);
|
await kv.delete(kvKey);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
async revalidateTags(tags) {
|
async revalidateTags(tags) {
|
||||||
const result: { keys: string[]; metas: CacheEntryMeta[] } = {
|
const result: CacheEntryMeta[] = [];
|
||||||
keys: [],
|
|
||||||
metas: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const kv = await getKVNamespace();
|
const kv = await getKVNamespace();
|
||||||
if (!kv) {
|
if (!kv) {
|
||||||
return result;
|
return { entries: result };
|
||||||
}
|
}
|
||||||
|
|
||||||
const pendingDeletions: Array<Promise<unknown>> = [];
|
const pendingDeletions: Array<Promise<unknown>> = [];
|
||||||
@@ -125,8 +116,7 @@ export const cloudflareKVCache: CacheBackend = {
|
|||||||
const metadata = entry.metadata;
|
const metadata = entry.metadata;
|
||||||
const key = metadata.meta.key;
|
const key = metadata.meta.key;
|
||||||
|
|
||||||
result.metas.push(metadata.meta);
|
result.push(metadata.meta);
|
||||||
result.keys.push(key);
|
|
||||||
|
|
||||||
// Delete the tag key and the value key
|
// Delete the tag key and the value key
|
||||||
pendingDeletions.push(kv.delete(getValueKey(key)));
|
pendingDeletions.push(kv.delete(getValueKey(key)));
|
||||||
@@ -147,7 +137,7 @@ export const cloudflareKVCache: CacheBackend = {
|
|||||||
|
|
||||||
await Promise.all(pendingDeletions);
|
await Promise.all(pendingDeletions);
|
||||||
|
|
||||||
return result;
|
return { entries: result };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
-1
@@ -52,7 +52,6 @@ export function cacheResponse<Result, DefaultData = Result>(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
ttl: defaultEntry.ttl ?? parsed.ttl,
|
ttl: defaultEntry.ttl ?? parsed.ttl,
|
||||||
tags: [...(defaultEntry.tags ?? []), ...parsed.tags],
|
|
||||||
revalidateBefore: defaultEntry.revalidateBefore,
|
revalidateBefore: defaultEntry.revalidateBefore,
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
data: defaultEntry.data ?? response.data,
|
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 { NON_IMMUTABLE_LOCAL_CACHE_MAX_AGE_SECONDS, isCacheEntryImmutable } from './utils';
|
||||||
import { getGlobalContext } from '../waitUntil';
|
import { getGlobalContext } from '../waitUntil';
|
||||||
|
|
||||||
export const memoryCache: CacheBackend = {
|
export const memoryCache: CacheBackend = {
|
||||||
name: 'memory',
|
name: 'memory',
|
||||||
replication: 'local',
|
replication: 'local',
|
||||||
async get(key) {
|
async get(entry) {
|
||||||
const memoryCache = await getMemoryCache();
|
const memoryCache = await getMemoryCache();
|
||||||
const memoryEntry = memoryCache.get(key);
|
const memoryEntry = memoryCache.get(entry.key);
|
||||||
|
|
||||||
if (!memoryEntry) {
|
if (!memoryEntry) {
|
||||||
return null;
|
return null;
|
||||||
@@ -16,12 +16,12 @@ export const memoryCache: CacheBackend = {
|
|||||||
if (memoryEntry.meta.expiresAt > Date.now()) {
|
if (memoryEntry.meta.expiresAt > Date.now()) {
|
||||||
return memoryEntry;
|
return memoryEntry;
|
||||||
} else {
|
} else {
|
||||||
memoryCache.delete(key);
|
memoryCache.delete(entry.key);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
async set(key, entry) {
|
async set(entry) {
|
||||||
const memoryCache = await getMemoryCache();
|
const memoryCache = await getMemoryCache();
|
||||||
// When the entry is immutable, we can cache it for the entire duration.
|
// When the entry is immutable, we can cache it for the entire duration.
|
||||||
// Else we cache it for a very short time.
|
// Else we cache it for a very short time.
|
||||||
@@ -39,26 +39,25 @@ export const memoryCache: CacheBackend = {
|
|||||||
meta.expiresAt = expiresAt;
|
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();
|
const memoryCache = await getMemoryCache();
|
||||||
keys.forEach((key) => memoryCache.delete(key));
|
entries.forEach((entry) => memoryCache.delete(entry.key));
|
||||||
},
|
},
|
||||||
async revalidateTags(tags) {
|
async revalidateTags(tags) {
|
||||||
const memoryCache = await getMemoryCache();
|
const memoryCache = await getMemoryCache();
|
||||||
const keys: string[] = [];
|
const entries: CacheEntryLookup[] = [];
|
||||||
|
|
||||||
memoryCache.forEach((entry, key) => {
|
memoryCache.forEach((entry, key) => {
|
||||||
if (tags.some((tag) => entry.meta.tags.includes(tag))) {
|
if (entry.meta.tag && tags.includes(entry.meta.tag)) {
|
||||||
keys.push(key);
|
entries.push({ key, tag: entry.meta.tag });
|
||||||
memoryCache.delete(key);
|
memoryCache.delete(key);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
keys,
|
entries,
|
||||||
metas: [],
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
-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 { cacheBackends } from './backends';
|
||||||
import { getCacheKey } from './cache';
|
import { CacheEntryLookup } from './types';
|
||||||
import { CacheEntryMeta } from './types';
|
|
||||||
|
|
||||||
interface RevalidateTagsStats {
|
interface RevalidateTagsStats {
|
||||||
[key: string]: {
|
[key: string]: {
|
||||||
|
/**
|
||||||
|
* Tag associated with the key.
|
||||||
|
*/
|
||||||
|
tag?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backends that have the key.
|
* Backends that have the key.
|
||||||
*/
|
*/
|
||||||
[backend: string]: { set: boolean; setAt?: number; expiresAt?: number };
|
backends: Record<string, { set: boolean }>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Revalidate all values associated with tags.
|
* Purge all cache entries associated with the given tags.
|
||||||
* It clears the values from the caches, but also start a background task to revalidate them.
|
* TODO: Implement background revalidation.
|
||||||
*/
|
*/
|
||||||
export async function revalidateTags(tags: string[]): Promise<{
|
export async function revalidateTags(tags: string[]): Promise<{
|
||||||
keys: string[];
|
|
||||||
stats: RevalidateTagsStats;
|
stats: RevalidateTagsStats;
|
||||||
}> {
|
}> {
|
||||||
if (tags.length === 0) {
|
if (tags.length === 0) {
|
||||||
return { keys: [], stats: {} };
|
return { stats: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats: RevalidateTagsStats = {};
|
const stats: RevalidateTagsStats = {};
|
||||||
|
|
||||||
const processed = new Set<string>();
|
|
||||||
|
|
||||||
const keysByBackend = new Map<number, string[]>();
|
const keysByBackend = new Map<number, string[]>();
|
||||||
const keys = new Set<string>();
|
const entries = new Map<string, CacheEntryLookup>();
|
||||||
const metas: CacheEntryMeta[] = [];
|
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
cacheBackends.map(async (backend, backendIndex) => {
|
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) => {
|
entries.set(key, { tag, key });
|
||||||
stats[key] = stats[key] ?? {};
|
|
||||||
stats[key][backend.name] = { set: true };
|
|
||||||
|
|
||||||
keys.add(key);
|
|
||||||
keysByBackend.set(backendIndex, [...(keysByBackend.get(backendIndex) ?? []), 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
|
// Clear the keys on the backends that didn't return them
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
cacheBackends.map(async (backend, backendIndex) => {
|
cacheBackends.map(async (backend, backendIndex) => {
|
||||||
const unclearedKeys = Array.from(keys).filter(
|
const unclearedEntries = Array.from(entries.values()).filter(
|
||||||
(key) => !keysByBackend.get(backendIndex)?.includes(key),
|
(entry) => !keysByBackend.get(backendIndex)?.includes(entry.key),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (unclearedKeys.length > 0) {
|
if (unclearedEntries.length > 0) {
|
||||||
unclearedKeys.forEach((key) => {
|
unclearedEntries.forEach((entry) => {
|
||||||
stats[key][backend.name] = { set: false };
|
stats[entry.key].backends[backend.name] = { set: false };
|
||||||
});
|
});
|
||||||
|
|
||||||
await backend.del(unclearedKeys);
|
await backend.del(unclearedEntries);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
keys: Array.from(keys.keys()),
|
|
||||||
stats,
|
stats,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-11
@@ -1,6 +1,9 @@
|
|||||||
export interface CacheEntryMeta {
|
export interface CacheEntryLookup {
|
||||||
key: string;
|
key: string;
|
||||||
|
tag?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CacheEntryMeta extends CacheEntryLookup {
|
||||||
/**
|
/**
|
||||||
* Timestamp when the entry was created.
|
* Timestamp when the entry was created.
|
||||||
*/
|
*/
|
||||||
@@ -21,12 +24,6 @@ export interface CacheEntryMeta {
|
|||||||
*/
|
*/
|
||||||
revalidatesAt?: number;
|
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.
|
* Arguments that were passed to the function.
|
||||||
*/
|
*/
|
||||||
@@ -51,21 +48,21 @@ export interface CacheBackend {
|
|||||||
/**
|
/**
|
||||||
* Get a value from the cache.
|
* 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 a value in the cache.
|
||||||
*/
|
*/
|
||||||
set(key: string, entry: CacheEntry): Promise<void>;
|
set(entry: CacheEntry): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a value from the cache.
|
* Delete a value from the cache.
|
||||||
*/
|
*/
|
||||||
del(keys: string[]): Promise<void>;
|
del(keys: CacheEntryLookup[]): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Revalidate all keys associated with tags.
|
* Revalidate all keys associated with tags.
|
||||||
* It should return the meta of all entries that were revalidated.
|
* 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.
|
* Return true if a cache entry can be considered immutable.
|
||||||
*/
|
*/
|
||||||
export function isCacheEntryImmutable(meta: CacheEntryMeta): boolean {
|
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 = {
|
const fetcher: OpenAPIFetcher = {
|
||||||
fetch: cache('openapi.fetch', async (url: string, options: CacheFunctionOptions) => {
|
fetch: cache({
|
||||||
// Wrap the raw string to prevent invalid URLs from being passed to fetch.
|
name: 'openapi.fetch',
|
||||||
// This can happen if the URL has whitespace, which is currently handled differently by Cloudflare's implementation of fetch:
|
get: async (url: string, options: CacheFunctionOptions) => {
|
||||||
// https://github.com/cloudflare/workerd/issues/1957
|
// Wrap the raw string to prevent invalid URLs from being passed to fetch.
|
||||||
const response = await fetch(new URL(url), {
|
// This can happen if the URL has whitespace, which is currently handled differently by Cloudflare's implementation of fetch:
|
||||||
...noCacheFetchOptions,
|
// https://github.com/cloudflare/workerd/issues/1957
|
||||||
signal: options.signal,
|
const response = await fetch(new URL(url), {
|
||||||
});
|
...noCacheFetchOptions,
|
||||||
|
signal: options.signal,
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to fetch OpenAPI file: ${response.status} ${response.statusText}`,
|
`Failed to fetch OpenAPI file: ${response.status} ${response.statusText}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
const data = await parseOpenAPIV3(url, text);
|
const data = await parseOpenAPIV3(url, text);
|
||||||
return {
|
return {
|
||||||
...parseCacheResponse(response),
|
...parseCacheResponse(response),
|
||||||
data,
|
data,
|
||||||
};
|
};
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
parseMarkdown,
|
parseMarkdown,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,6 +25,6 @@
|
|||||||
"bun-types" // add Bun global
|
"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"]
|
"exclude": ["node_modules", "packages/react-openapi", "packages/react-math"]
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -46,7 +46,13 @@
|
|||||||
},
|
},
|
||||||
// Script run when the package is deployed
|
// Script run when the package is deployed
|
||||||
"release": {
|
"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 up the package
|
||||||
"clean": {
|
"clean": {
|
||||||
|
|||||||
Reference in New Issue
Block a user