mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-14 22:55:25 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2ed5c8c80 | |||
| c0ee60ec13 | |||
| f58b904ba6 | |||
| d5992f1a5c |
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"gitbook-v2": patch
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Adds Columns layout block to GBO
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"gitbook-v2": patch
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
encode customization header
|
||||
@@ -152,7 +152,7 @@
|
||||
"dependencies": {
|
||||
"@gitbook/api": "^0.119.0",
|
||||
"@gitbook/cache-tags": "workspace:*",
|
||||
"@opennextjs/cloudflare": "1.1.0",
|
||||
"@opennextjs/cloudflare": "1.2.1",
|
||||
"@sindresorhus/fnv1a": "^3.1.0",
|
||||
"assert-never": "^1.2.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
@@ -798,9 +798,9 @@
|
||||
|
||||
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
||||
|
||||
"@opennextjs/aws": ["@opennextjs/aws@3.6.4", "", { "dependencies": { "@ast-grep/napi": "^0.35.0", "@aws-sdk/client-cloudfront": "3.398.0", "@aws-sdk/client-dynamodb": "^3.398.0", "@aws-sdk/client-lambda": "^3.398.0", "@aws-sdk/client-s3": "^3.398.0", "@aws-sdk/client-sqs": "^3.398.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.1", "aws4fetch": "^1.0.18", "chalk": "^5.3.0", "esbuild": "0.25.4", "express": "5.0.1", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.0.0", "yaml": "^2.7.0" }, "bin": { "open-next": "dist/index.js" } }, "sha512-/bn9N/6dVu9+sC7AptaGJylKUzyDDgqe3yKfvUxXOpy7whIq/+3Bw+q2/bilyQg6FcskbCWUt9nLvNf1hAVmfA=="],
|
||||
"@opennextjs/aws": ["@opennextjs/aws@3.6.5", "", { "dependencies": { "@ast-grep/napi": "^0.35.0", "@aws-sdk/client-cloudfront": "3.398.0", "@aws-sdk/client-dynamodb": "^3.398.0", "@aws-sdk/client-lambda": "^3.398.0", "@aws-sdk/client-s3": "^3.398.0", "@aws-sdk/client-sqs": "^3.398.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.1", "aws4fetch": "^1.0.18", "chalk": "^5.3.0", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "5.0.1", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.0.0", "yaml": "^2.7.0" }, "bin": { "open-next": "dist/index.js" } }, "sha512-wni+CWlRCyWfhNfekQBBPPkrDDnaGdZLN9hMybKI0wKOKTO+zhPOqR65Eh3V0pzWAi84Sureb5mdMuLwCxAAcw=="],
|
||||
|
||||
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.1.0", "", { "dependencies": { "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.6.4", "enquirer": "^2.4.1", "glob": "^11.0.0", "ts-tqdm": "^0.8.6" }, "peerDependencies": { "wrangler": "^4.14.0" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-8DOzpLiewivA1pwRNGVPDXbrF79bzJiHscY+M1tekwvKqEqM26CRYafhrA5IDCeyteNaL9AZD6X4DLuCn/eeYQ=="],
|
||||
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.2.1", "", { "dependencies": { "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.6.5", "enquirer": "^2.4.1", "glob": "^11.0.0", "ts-tqdm": "^0.8.6" }, "peerDependencies": { "wrangler": "^4.19.1" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-cOco+nHwlo/PLB1bThF8IIvaS8PgAT9MEI5ZttFO/qt6spgvr2lUaPkpjgSIQmI3sBIEG2cLUykvQ2nbbZEcVw=="],
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
@@ -3666,6 +3666,8 @@
|
||||
|
||||
"@node-minify/core/mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
|
||||
|
||||
"@opennextjs/aws/cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
|
||||
|
||||
"@opennextjs/aws/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="],
|
||||
|
||||
"@radix-ui/react-collection/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw=="],
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
"bucket_name": "gitbook-open-v2-cache-preview"
|
||||
}
|
||||
],
|
||||
"services": [
|
||||
{
|
||||
"binding": "WORKER_SELF_REFERENCE",
|
||||
"service": "gitbook-open-v2-preview"
|
||||
}
|
||||
],
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{
|
||||
@@ -61,6 +67,12 @@
|
||||
"service": "gitbook-x-staging-tail"
|
||||
}
|
||||
],
|
||||
"services": [
|
||||
{
|
||||
"binding": "WORKER_SELF_REFERENCE",
|
||||
"service": "gitbook-open-v2-staging"
|
||||
}
|
||||
],
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{
|
||||
@@ -100,6 +112,12 @@
|
||||
"service": "gitbook-x-prod-tail"
|
||||
}
|
||||
],
|
||||
"services": [
|
||||
{
|
||||
"binding": "WORKER_SELF_REFERENCE",
|
||||
"service": "gitbook-open-v2-production"
|
||||
}
|
||||
],
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{
|
||||
|
||||
@@ -1,46 +1,101 @@
|
||||
import { trace } from '@/lib/tracing';
|
||||
import type { DurableObjectLocationHint } from '@cloudflare/workers-types';
|
||||
import { generateShardId } from '@opennextjs/aws/core/routing/queue.js';
|
||||
import type { NextModeTagCache } from '@opennextjs/aws/types/overrides.js';
|
||||
import doShardedTagCache from '@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache';
|
||||
import { getCloudflareContext } from '@opennextjs/cloudflare';
|
||||
import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter';
|
||||
|
||||
const originalTagCache = doShardedTagCache({
|
||||
baseShardSize: 12,
|
||||
regionalCache: true,
|
||||
// We can probably increase this value even further
|
||||
regionalCacheTtlSec: 60,
|
||||
shardReplication: {
|
||||
numberOfSoftReplicas: 2,
|
||||
numberOfHardReplicas: 1,
|
||||
regionalReplication: {
|
||||
defaultRegion: 'enam',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const DEFAULT_REGION = 'enam';
|
||||
//TODO: We may not need all these regions.
|
||||
export const AVAILABLE_REGIONS = [
|
||||
'enam',
|
||||
'weur',
|
||||
'apac',
|
||||
'sam',
|
||||
'afr',
|
||||
'oc',
|
||||
] as DurableObjectLocationHint[];
|
||||
|
||||
export default {
|
||||
name: 'GitbookTagCache',
|
||||
mode: 'nextMode',
|
||||
getLastRevalidated: async (tags: string[]) => {
|
||||
const tagsToCheck = tags.filter(softTagFilter);
|
||||
if (tagsToCheck.length === 0) {
|
||||
// If we reach here, it probably means that there is an issue that we'll need to address.
|
||||
console.warn(
|
||||
'getLastRevalidated - No valid tags to check for last revalidation, original tags:',
|
||||
tags
|
||||
);
|
||||
return 0; // If no tags to check, return 0
|
||||
/**
|
||||
* - `change-request` are only used by preview urls. One DO per region and space
|
||||
* - `internal` are used for the release tag and integrations which does not depend on the user
|
||||
* - `global` are used for url and site tag
|
||||
* - `space` are only used the space tag (i.e. when a CR is merged)
|
||||
* - `organization` are used for translation and openAPI. One DO per region and organization
|
||||
*/
|
||||
type TagType = 'change-request' | 'internal' | 'global' | 'space' | 'organization';
|
||||
|
||||
interface DOIdOptions {
|
||||
baseShardId: string;
|
||||
numberOfReplicas: number;
|
||||
shardType: TagType;
|
||||
replicaId?: number;
|
||||
region?: DurableObjectLocationHint;
|
||||
tag: string;
|
||||
}
|
||||
|
||||
interface CacheTagKeyOptions {
|
||||
doId: DOId;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export class DOId {
|
||||
options;
|
||||
shardId;
|
||||
replicaId;
|
||||
region;
|
||||
constructor(options: DOIdOptions) {
|
||||
this.options = options;
|
||||
const { baseShardId, shardType, numberOfReplicas, replicaId, region } = options;
|
||||
this.shardId = `tag-${shardType};${baseShardId}`;
|
||||
this.replicaId = replicaId ?? this.generateRandomNumberBetween(1, numberOfReplicas);
|
||||
this.region = region;
|
||||
}
|
||||
generateRandomNumberBetween(min: number, max: number) {
|
||||
return Math.floor(Math.random() * (max - min + 1) + min);
|
||||
}
|
||||
get key() {
|
||||
// For those 2 type of tags, we likely don't need any kind of replication or even sharding
|
||||
// We may reconsider it for `change-request` at some point
|
||||
if (this.options.shardType === 'organization') {
|
||||
const organization = this.options.tag.split(':')[1];
|
||||
return `${this.shardId};region-${this.region};org:${organization}`;
|
||||
}
|
||||
return trace(
|
||||
{
|
||||
operation: 'gitbookTagCacheGetLastRevalidated',
|
||||
name: tagsToCheck.join(', '),
|
||||
},
|
||||
async () => {
|
||||
return await originalTagCache.getLastRevalidated(tagsToCheck);
|
||||
}
|
||||
);
|
||||
},
|
||||
hasBeenRevalidated: async (tags: string[], lastModified?: number) => {
|
||||
if (this.options.shardType === 'change-request') {
|
||||
const space = this.options.tag.split(':')[1];
|
||||
return `${this.shardId};region-${this.region};space:${space}`;
|
||||
}
|
||||
return `${this.shardId};replica-${this.replicaId}${this.region ? `;region-${this.region}` : ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
class GitbookTagCache implements NextModeTagCache {
|
||||
localCache: Cache | undefined;
|
||||
maxWriteRetries = 3;
|
||||
constructor(
|
||||
private config: {
|
||||
numberOfShards: Record<TagType, number>;
|
||||
numberOfReplicas: Record<TagType, number>;
|
||||
ttl: Record<TagType, number>;
|
||||
}
|
||||
) {}
|
||||
|
||||
name = 'GitbookTagCache';
|
||||
mode = 'nextMode' as const;
|
||||
|
||||
// This one is only used for soft tags, no need to do anything for us
|
||||
async getLastRevalidated(_tags: string[]) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
getDurableObjectStub(doId: DOId) {
|
||||
const durableObject = getCloudflareContext().env.NEXT_TAG_CACHE_DO_SHARDED;
|
||||
if (!durableObject) throw new Error('No durable object binding for cache revalidation');
|
||||
const id = durableObject.idFromName(doId.key);
|
||||
return durableObject.get(id, { locationHint: doId.region });
|
||||
}
|
||||
|
||||
async hasBeenRevalidated(tags: string[], lastModified?: number) {
|
||||
const tagsToCheck = tags.filter(softTagFilter);
|
||||
if (tagsToCheck.length === 0) {
|
||||
// If we reach here, it probably means that there is an issue that we'll need to address.
|
||||
@@ -56,12 +111,38 @@ export default {
|
||||
name: tagsToCheck.join(', '),
|
||||
},
|
||||
async () => {
|
||||
const result = await originalTagCache.hasBeenRevalidated(tagsToCheck, lastModified);
|
||||
return result;
|
||||
const shardedTagGroups = this.groupTagsByDO({ tags });
|
||||
const shardedTagRevalidationOutcomes = await Promise.all(
|
||||
shardedTagGroups.map(async ({ doId, tags }) => {
|
||||
const cachedValue = await this.getFromRegionalCache({
|
||||
doId,
|
||||
tags,
|
||||
});
|
||||
if (cachedValue) {
|
||||
return (await cachedValue.text()) === 'true';
|
||||
}
|
||||
const stub = this.getDurableObjectStub(doId);
|
||||
const _hasBeenRevalidated = await stub.hasBeenRevalidated(
|
||||
tags,
|
||||
lastModified
|
||||
);
|
||||
//TODO: Do we want to cache the result if it has been revalidated ?
|
||||
// If we do so, we risk causing cache MISS even though it has been revalidated elsewhere
|
||||
// On the other hand revalidating a tag that is used in a lot of places will cause a lot of requests
|
||||
if (!_hasBeenRevalidated) {
|
||||
getCloudflareContext().ctx.waitUntil(
|
||||
this.putToRegionalCache({ doId, tags }, _hasBeenRevalidated)
|
||||
);
|
||||
}
|
||||
return _hasBeenRevalidated;
|
||||
})
|
||||
);
|
||||
return shardedTagRevalidationOutcomes.some((result) => result);
|
||||
}
|
||||
);
|
||||
},
|
||||
writeTags: async (tags: string[]) => {
|
||||
}
|
||||
|
||||
async writeTags(tags: string[]) {
|
||||
return trace(
|
||||
{
|
||||
operation: 'gitbookTagCacheWriteTags',
|
||||
@@ -74,8 +155,250 @@ export default {
|
||||
return; // If no tags to write, exit early
|
||||
}
|
||||
// Write only the filtered tags
|
||||
await originalTagCache.writeTags(tagsToWrite);
|
||||
const shardedTagGroups = this.groupTagsByDO({
|
||||
tags: tagsToWrite,
|
||||
});
|
||||
// We want to use the same revalidation time for all tags
|
||||
const currentTime = Date.now();
|
||||
await Promise.all(
|
||||
shardedTagGroups.map(async ({ doId, tags }) => {
|
||||
await this.performWriteTagsWithRetry(doId, tags, currentTime);
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async performWriteTagsWithRetry(
|
||||
doId: DOId,
|
||||
tags: string[],
|
||||
lastModified: number,
|
||||
retryNumber = 0
|
||||
) {
|
||||
try {
|
||||
const stub = this.getDurableObjectStub(doId);
|
||||
await stub.writeTags(tags, lastModified);
|
||||
// Depending on the shards and the tags, deleting from the regional cache will not work for every tag
|
||||
// We also need to delete both cache
|
||||
await Promise.all([this.deleteRegionalCache({ doId, tags })]);
|
||||
} catch (e) {
|
||||
if (retryNumber >= this.maxWriteRetries) {
|
||||
console.error('Error while writing tags, too many retries', e);
|
||||
// Do we want to throw an error here ?
|
||||
await getCloudflareContext().env.NEXT_TAG_CACHE_DO_SHARDED_DLQ?.send({
|
||||
failingShardId: doId.key,
|
||||
failingTags: tags,
|
||||
lastModified,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.performWriteTagsWithRetry(doId, tags, lastModified, retryNumber + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Same tags are guaranteed to be in the same shard
|
||||
* @param tags
|
||||
* @returns An array of DO ids and tags
|
||||
*/
|
||||
groupTagsByDO({ tags }: { tags: string[] }) {
|
||||
const crTags = this.generateDOIdArray({ tags, shardType: 'change-request' });
|
||||
const spaceTags = this.generateDOIdArray({ tags, shardType: 'space' });
|
||||
const internalTags = this.generateDOIdArray({ tags, shardType: 'internal' });
|
||||
const globalTags = this.generateDOIdArray({ tags, shardType: 'global' });
|
||||
const orgTags = this.generateDOIdArray({ tags, shardType: 'organization' });
|
||||
const tagIdCollection = [
|
||||
...crTags,
|
||||
...spaceTags,
|
||||
...internalTags,
|
||||
...globalTags,
|
||||
...orgTags,
|
||||
];
|
||||
// We then group the tags by DO id
|
||||
const tagsByDOId = new Map<string, { doId: DOId; tags: string[] }>();
|
||||
for (const { doId, tag } of tagIdCollection) {
|
||||
const doIdString = doId.key;
|
||||
const tagsArray = tagsByDOId.get(doIdString)?.tags ?? [];
|
||||
tagsArray.push(tag);
|
||||
tagsByDOId.set(doIdString, {
|
||||
// We override the doId here, but it should be the same for all tags
|
||||
doId,
|
||||
tags: tagsArray,
|
||||
});
|
||||
}
|
||||
const result = Array.from(tagsByDOId.values());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a list of DO ids for the shards and replicas
|
||||
* @param tags The tags to generate shards for
|
||||
* @param shardType The type of the shard (change-request, internal, global, space)
|
||||
* @returns An array of TagCacheDOId and tag
|
||||
*/
|
||||
generateDOIdArray({
|
||||
tags,
|
||||
shardType,
|
||||
}: {
|
||||
tags: string[];
|
||||
shardType: TagType;
|
||||
}) {
|
||||
const numReplicas = this.config.numberOfReplicas[shardType];
|
||||
const replicaIndexes = Array.from({ length: numReplicas }, (_, i) => i + 1);
|
||||
|
||||
const regionalReplicas = replicaIndexes.flatMap((replicaId) => {
|
||||
return tags
|
||||
.filter((tag) => this.getTagType(tag) === shardType)
|
||||
.map((tag) => {
|
||||
return {
|
||||
doId: new DOId({
|
||||
baseShardId: generateShardId(
|
||||
tag,
|
||||
this.config.numberOfShards[shardType],
|
||||
'shard'
|
||||
),
|
||||
numberOfReplicas: numReplicas,
|
||||
shardType,
|
||||
replicaId,
|
||||
tag,
|
||||
}),
|
||||
tag,
|
||||
};
|
||||
});
|
||||
});
|
||||
// If we have regional replication enabled, we need to further duplicate the shards in all the regions
|
||||
const regionalReplicasInAllRegions = regionalReplicas.flatMap(({ doId, tag }) => {
|
||||
return AVAILABLE_REGIONS.map((region) => {
|
||||
return {
|
||||
doId: new DOId({
|
||||
baseShardId: doId.options.baseShardId,
|
||||
numberOfReplicas: numReplicas,
|
||||
shardType,
|
||||
replicaId: doId.replicaId,
|
||||
region,
|
||||
tag,
|
||||
}),
|
||||
tag,
|
||||
};
|
||||
});
|
||||
});
|
||||
return regionalReplicasInAllRegions;
|
||||
}
|
||||
|
||||
getTagType(tag: string): TagType {
|
||||
if (tag.includes('change-request:')) {
|
||||
return 'change-request';
|
||||
}
|
||||
if (tag.startsWith('release:') || tag.startsWith('integration:')) {
|
||||
// Maybe use platform instead of internal ??
|
||||
return 'internal';
|
||||
}
|
||||
if (tag.startsWith('site:') || tag.startsWith('url:')) {
|
||||
// TODO: find a better name here.
|
||||
// BTW do we want to append something like the organization to these cache tag
|
||||
return 'global';
|
||||
}
|
||||
if (tag.startsWith('organization:')) {
|
||||
return 'organization';
|
||||
}
|
||||
return 'space';
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns The closest region to the user, if not found we default to "enam"
|
||||
*/
|
||||
getClosestRegion() {
|
||||
const continent = getCloudflareContext().cf?.continent;
|
||||
if (!continent) return 'enam';
|
||||
switch (continent) {
|
||||
case 'AF':
|
||||
return 'afr';
|
||||
case 'AS':
|
||||
return 'apac';
|
||||
case 'EU':
|
||||
return 'weur';
|
||||
case 'NA':
|
||||
return 'enam';
|
||||
case 'OC':
|
||||
return 'oc';
|
||||
case 'SA':
|
||||
return 'sam';
|
||||
default:
|
||||
return 'enam';
|
||||
}
|
||||
}
|
||||
|
||||
// Cache API
|
||||
async getCacheInstance() {
|
||||
if (!this.localCache) {
|
||||
this.localCache = await caches.open('sharded-do-tag-cache');
|
||||
}
|
||||
return this.localCache;
|
||||
}
|
||||
getCacheUrlKey(opts: CacheTagKeyOptions) {
|
||||
const { doId, tags } = opts;
|
||||
return `http://local.cache/shard/${doId.shardId}?tags=${encodeURIComponent(tags.join(';'))}`;
|
||||
}
|
||||
async getFromRegionalCache(opts: CacheTagKeyOptions) {
|
||||
try {
|
||||
const cache = await this.getCacheInstance();
|
||||
if (!cache) return;
|
||||
return cache.match(this.getCacheUrlKey(opts));
|
||||
} catch (e) {
|
||||
console.error('Error while fetching from regional cache', e);
|
||||
}
|
||||
}
|
||||
async putToRegionalCache(optsKey: CacheTagKeyOptions, value: boolean) {
|
||||
const cache = await this.getCacheInstance();
|
||||
if (!cache) return;
|
||||
const tags = optsKey.tags;
|
||||
await cache.put(
|
||||
this.getCacheUrlKey(optsKey),
|
||||
new Response(`${value}`, {
|
||||
headers: {
|
||||
'cache-control': `max-age=${this.config.ttl[optsKey.doId.options.shardType] ?? 5}`,
|
||||
...(tags.length > 0
|
||||
? {
|
||||
'cache-tag': tags.join(','),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
async deleteRegionalCache(optsKey: CacheTagKeyOptions) {
|
||||
// We never want to crash because of the cache
|
||||
try {
|
||||
const cache = await this.getCacheInstance();
|
||||
if (!cache) return;
|
||||
await cache.delete(this.getCacheUrlKey(optsKey));
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new GitbookTagCache({
|
||||
numberOfShards: {
|
||||
'change-request': 1,
|
||||
global: 10,
|
||||
internal: 10,
|
||||
space: 10,
|
||||
organization: 1,
|
||||
},
|
||||
} satisfies NextModeTagCache;
|
||||
numberOfReplicas: {
|
||||
'change-request': 1,
|
||||
global: 2,
|
||||
internal: 2,
|
||||
space: 1,
|
||||
organization: 1,
|
||||
},
|
||||
ttl: {
|
||||
'change-request': 60,
|
||||
global: 3600,
|
||||
internal: 86400,
|
||||
space: 3600,
|
||||
organization: 86400,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"dependencies": {
|
||||
"@gitbook/api": "^0.119.0",
|
||||
"@gitbook/cache-tags": "workspace:*",
|
||||
"@opennextjs/cloudflare": "1.1.0",
|
||||
"@opennextjs/cloudflare": "1.2.1",
|
||||
"@sindresorhus/fnv1a": "^3.1.0",
|
||||
"assert-never": "^1.2.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
|
||||
@@ -226,7 +226,8 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
const customization = siteRequestURL.searchParams.get('customization');
|
||||
if (customization && validateSerializedCustomization(customization)) {
|
||||
routeType = 'dynamic';
|
||||
requestHeaders.set(MiddlewareHeaders.Customization, customization);
|
||||
// We need to encode the customization headers, otherwise it will fail for some customization values containing non ASCII chars on vercel.
|
||||
requestHeaders.set(MiddlewareHeaders.Customization, encodeURIComponent(customization));
|
||||
}
|
||||
const theme = siteRequestURL.searchParams.get('theme');
|
||||
if (theme === CustomizationThemeMode.Dark || theme === CustomizationThemeMode.Light) {
|
||||
|
||||
@@ -666,6 +666,7 @@ const testCases: TestsCase[] = [
|
||||
name: 'Stepper',
|
||||
url: 'blocks/stepper',
|
||||
},
|
||||
{ name: 'Columns', url: 'blocks/columns' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { ClassValue } from '@/lib/tailwind';
|
||||
|
||||
import { BlockContentRef } from './BlockContentRef';
|
||||
import { CodeBlock } from './CodeBlock';
|
||||
import { Columns } from './Columns';
|
||||
import { Divider } from './Divider';
|
||||
import type { DocumentContextProps } from './DocumentView';
|
||||
import { Drawing } from './Drawing';
|
||||
@@ -68,6 +69,8 @@ export function Block<T extends DocumentBlock>(props: BlockProps<T>) {
|
||||
return <List {...props} block={block} />;
|
||||
case 'list-item':
|
||||
return <ListItem {...props} block={block} />;
|
||||
case 'columns':
|
||||
return <Columns {...props} block={block} />;
|
||||
case 'code':
|
||||
return <CodeBlock {...props} block={block} />;
|
||||
case 'hint':
|
||||
@@ -112,10 +115,8 @@ export function Block<T extends DocumentBlock>(props: BlockProps<T>) {
|
||||
case 'image':
|
||||
case 'code-line':
|
||||
case 'tabs-item':
|
||||
throw new Error(`Blocks (${block.type}) should be directly rendered by parent`);
|
||||
case 'columns':
|
||||
case 'column':
|
||||
return null;
|
||||
throw new Error(`Blocks (${block.type}) should be directly rendered by parent`);
|
||||
default:
|
||||
return nullIfNever(block);
|
||||
}
|
||||
@@ -171,6 +172,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
|
||||
case 'integration':
|
||||
case 'stepper':
|
||||
case 'reusable-content':
|
||||
case 'columns':
|
||||
return <SkeletonCard id={id} style={style} />;
|
||||
case 'embed':
|
||||
case 'images':
|
||||
@@ -179,10 +181,8 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
|
||||
case 'image':
|
||||
case 'code-line':
|
||||
case 'tabs-item':
|
||||
throw new Error(`Blocks (${block.type}) should be directly rendered by parent`);
|
||||
case 'columns':
|
||||
case 'column':
|
||||
return null;
|
||||
throw new Error(`Blocks (${block.type}) should be directly rendered by parent`);
|
||||
default:
|
||||
return nullIfNever(block);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
import type { DocumentBlockColumns, Length } from '@gitbook/api';
|
||||
import type { BlockProps } from '../Block';
|
||||
import { Blocks } from '../Blocks';
|
||||
|
||||
export function Columns(props: BlockProps<DocumentBlockColumns>) {
|
||||
const { block, style, ancestorBlocks, document, context } = props;
|
||||
return (
|
||||
<div className={tcls('flex flex-col gap-x-8 md:flex-row', style)}>
|
||||
{block.nodes.map((columnBlock) => {
|
||||
const width = columnBlock.data.width;
|
||||
const { className, style } = transformLengthToCSS(width);
|
||||
return (
|
||||
<Column key={columnBlock.key} className={className} style={style}>
|
||||
<Blocks
|
||||
key={columnBlock.key}
|
||||
nodes={columnBlock.nodes}
|
||||
document={document}
|
||||
ancestorBlocks={[...ancestorBlocks, block, columnBlock]}
|
||||
context={context}
|
||||
blockStyle="flip-heading-hash"
|
||||
style="w-full space-y-4"
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Column(props: {
|
||||
children?: React.ReactNode;
|
||||
className?: ClassValue;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<div className={tcls('flex-col', props.className)} style={props.style}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function transformLengthToCSS(length: Length | undefined) {
|
||||
if (!length) {
|
||||
return { className: ['md:w-full'] }; // default to full width if no length is specified
|
||||
}
|
||||
|
||||
if (typeof length === 'number') {
|
||||
return { style: undefined }; // not implemented yet with non-percentage lengths
|
||||
}
|
||||
|
||||
if (length.unit === '%') {
|
||||
return {
|
||||
className: [
|
||||
'md:flex-shrink-0',
|
||||
COLUMN_WIDTHS[Math.round(length.value * 0.01 * (COLUMN_WIDTHS.length - 1))],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return { style: undefined }; // not implemented yet with non-percentage lengths
|
||||
}
|
||||
|
||||
// Tailwind CSS classes for column widths.
|
||||
// The index of the array corresponds to the percentage width of the column.
|
||||
const COLUMN_WIDTHS = [
|
||||
'md:w-0',
|
||||
'md:w-1/12',
|
||||
'md:w-2/12',
|
||||
'md:w-3/12',
|
||||
'md:w-4/12',
|
||||
'md:w-5/12',
|
||||
'md:w-6/12',
|
||||
'md:w-7/12',
|
||||
'md:w-8/12',
|
||||
'md:w-9/12',
|
||||
'md:w-10/12',
|
||||
'md:w-11/12',
|
||||
'md:w-full',
|
||||
];
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Columns';
|
||||
@@ -12,9 +12,12 @@ export async function getDynamicCustomizationSettings(
|
||||
): Promise<SiteCustomizationSettings> {
|
||||
const headersList = await headers();
|
||||
const extend = headersList.get(MiddlewareHeaders.Customization);
|
||||
|
||||
if (extend) {
|
||||
try {
|
||||
const parsedSettings = rison.decode_object<SiteCustomizationSettings>(extend);
|
||||
// We need to decode it first as it is URL encoded, then decode the Rison object
|
||||
const unencoded = decodeURIComponent(extend);
|
||||
const parsedSettings = rison.decode_object<SiteCustomizationSettings>(unencoded);
|
||||
|
||||
return parsedSettings;
|
||||
} catch (_error) {}
|
||||
|
||||
@@ -201,7 +201,9 @@ export async function middleware(request: NextRequest) {
|
||||
|
||||
const customization = url.searchParams.get('customization');
|
||||
if (customization && validateSerializedCustomization(customization)) {
|
||||
headers.set(MiddlewareHeaders.Customization, customization);
|
||||
// We need to encode the customization to ensure it is properly encoded in vercel.
|
||||
// We do it here as well so that we have a single method to decode it later.
|
||||
headers.set(MiddlewareHeaders.Customization, encodeURIComponent(customization));
|
||||
}
|
||||
|
||||
const theme = url.searchParams.get('theme');
|
||||
|
||||
Reference in New Issue
Block a user