refactor(editor): add cache extension for link preview service (#12196)

Closes: [BS-2578](https://linear.app/affine-design/issue/BS-2578/优化-footnote-预览的逻辑:支持缓存结果,避免重复-loading)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Introduced a link preview caching mechanism, enabling faster and more efficient reuse of link preview data across the app.
  - Added a feature flag for enabling or disabling link preview cache, configurable through workspace experimental settings.
  - Enhanced localization with new entries describing the link preview cache feature.

- **Improvements**
  - Updated link preview service architecture for better extensibility and maintainability.
  - Improved integration of feature flags throughout chat and rendering components.

- **Bug Fixes**
  - Fixed tooltip formatting for footnote URLs.

- **Chores**
  - Updated dependencies and localization completeness tracking.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
donteatfriedrice
2025-05-13 05:11:33 +00:00
parent cfe7b7cf29
commit 0d518adc5b
42 changed files with 830 additions and 169 deletions
@@ -22,6 +22,7 @@ export interface BlockSuiteFlags {
enable_embed_doc_with_alias: boolean;
enable_turbo_renderer: boolean;
enable_citation: boolean;
enable_link_preview_cache: boolean;
}
export class FeatureFlagService extends StoreExtension {
@@ -48,6 +49,7 @@ export class FeatureFlagService extends StoreExtension {
enable_embed_doc_with_alias: false,
enable_turbo_renderer: false,
enable_citation: false,
enable_link_preview_cache: false,
});
setFlag(key: keyof BlockSuiteFlags, value: boolean) {
@@ -11,7 +11,7 @@ export * from './feature-flag-service';
export * from './file-size-limit-service';
export * from './font-loader';
export * from './generate-url-service';
export * from './link-previewer-service';
export * from './link-preview-service';
export * from './native-clipboard-service';
export * from './notification-service';
export * from './open-doc-config';
@@ -0,0 +1,3 @@
export * from './link-preview-cache';
export * from './link-preview-service';
export * from './link-preview-storage';
@@ -0,0 +1,240 @@
import type { LinkPreviewData } from '@blocksuite/affine-model';
import { type Container, createIdentifier } from '@blocksuite/global/di';
import { Extension, type ExtensionType } from '@blocksuite/store';
import debounce from 'lodash-es/debounce';
import QuickLRU from 'quick-lru';
import { z } from 'zod';
import { LinkPreviewStorage } from './link-preview-storage';
export const LinkPreviewCacheConfigSchema = z.object({
/**
* The maximum number of items in the cache
*/
cacheSize: z.number(),
/**
* The time to live for the memory cache
*/
memoryTTL: z.number(),
/**
* The time to live for the local storage cache
*/
localStorageTTL: z.number(),
});
export type LinkPreviewCacheConfig = z.infer<
typeof LinkPreviewCacheConfigSchema
>;
const DEFAULT_LINK_PREVIEW_CACHE_CONFIG: LinkPreviewCacheConfig = {
cacheSize: 50,
memoryTTL: 1000 * 60 * 60, // 60 minutes
localStorageTTL: 1000 * 60 * 60 * 6, // 6 hours
};
/**
* It's used to debounce the save to local storage to avoid frequent writes
*/
const DEBOUNCE_TIME = 1000;
/**
* The interface for the link preview cache provider
*/
export interface LinkPreviewCacheProvider {
/**
* Get the link preview data for a given URL
* @param url The URL to get the link preview data for
* @returns The link preview data for the given URL
*/
get(url: string): Partial<LinkPreviewData> | undefined;
/**
* Set the link preview data for a given URL
* @param url The URL to set the link preview data for
* @param data The link preview data to set
*/
set(url: string, data: Partial<LinkPreviewData>): void;
/**
* Get the pending request for a given URL
* @param url The URL to get the pending request for
* @returns The pending request for the given URL
*/
getPendingRequest(url: string): Promise<Partial<LinkPreviewData>> | undefined;
/**
* Set the pending request for a given URL
* @param url The URL to set the pending request for
* @param promise The promise to set for the given URL
*/
setPendingRequest(
url: string,
promise: Promise<Partial<LinkPreviewData>>
): void;
/**
* Delete the pending request for a given URL
* @param url The URL to delete the pending request for
*/
deletePendingRequest(url: string): void;
/**
* Clear the cache
*/
clear(): void;
}
export const LinkPreviewCacheIdentifier =
createIdentifier<LinkPreviewCacheProvider>('AffineLinkPreviewCache');
/**
* The link preview cache, it will cache the link preview data in the memory and local storage
*/
export class LinkPreviewCache
extends Extension
implements LinkPreviewCacheProvider
{
/**
* The singleton instance of the link preview cache
*/
private static instance: LinkPreviewCache | null = null;
/**
* The memory cache for the link preview
*/
private readonly memoryCache: QuickLRU<string, Partial<LinkPreviewData>>;
/**
* The pending requests for the link preview
* The promise will be resolved when the data is fetched
*/
private readonly pendingRequests: Map<
string,
Promise<Partial<LinkPreviewData>>
>;
/**
* The local storage manager for the link preview
*/
private readonly storage: LinkPreviewStorage;
constructor(
private readonly config: LinkPreviewCacheConfig = DEFAULT_LINK_PREVIEW_CACHE_CONFIG
) {
super();
this.storage = new LinkPreviewStorage();
this.memoryCache = new QuickLRU({
maxSize: this.config.cacheSize,
maxAge: this.config.memoryTTL,
onEviction: key => {
this._clearItemFromStorage(key);
},
});
this.pendingRequests = new Map();
this._loadFromStorage();
}
static getInstance(config?: LinkPreviewCacheConfig): LinkPreviewCache {
if (!LinkPreviewCache.instance) {
LinkPreviewCache.instance = new LinkPreviewCache(config);
}
return LinkPreviewCache.instance;
}
get(url: string): Partial<LinkPreviewData> | undefined {
return this.memoryCache.get(url);
}
set(url: string, data: Partial<LinkPreviewData>): void {
this.memoryCache.set(url, data);
this._saveToStorage();
}
getPendingRequest(
url: string
): Promise<Partial<LinkPreviewData>> | undefined {
return this.pendingRequests.get(url);
}
setPendingRequest(
url: string,
promise: Promise<Partial<LinkPreviewData>>
): void {
this.pendingRequests.set(url, promise);
}
deletePendingRequest(url: string): void {
this.pendingRequests.delete(url);
}
/**
* Load the cache from local storage
*/
private readonly _loadFromStorage = (): void => {
const data = this.storage.load();
// Check if the data is expired
const localDataExpires = data.expires;
// If the data is expired, clear the data
if (localDataExpires && localDataExpires < Date.now()) {
this.storage.clear();
return;
}
// load the data to the memory cache
Object.entries(data.data).forEach(([url, item]) => {
this.memoryCache.set(url, item);
});
};
/**
* Save the cache to local storage
* Debounce the save to local storage to avoid frequent writes
*/
private readonly _saveToStorage = debounce(() => {
const entries = Array.from(this.memoryCache.entriesDescending());
const linkPreviewData = Object.fromEntries(
entries.slice(0, this.memoryCache.size).map(([url, data]) => [url, data])
);
const data = {
data: linkPreviewData,
expires: Date.now() + this.config.localStorageTTL,
};
this.storage.save(data);
}, DEBOUNCE_TIME);
/**
* Clear a link preview record from local storage with specific URL
* Called when the item is evicted from the memory cache
* @param {string} url The URL key to remove from storage
* @returns {boolean} Whether the item was successfully removed
*/
private readonly _clearItemFromStorage = (url: string): void => {
this.storage.clearItem(url);
};
clear(): void {
this.memoryCache.clear();
this.pendingRequests.clear();
}
clearLocalStorage(): void {
this.storage.clear();
}
static override setup(di: Container) {
di.addImpl(LinkPreviewCacheIdentifier, () =>
LinkPreviewCache.getInstance()
);
}
}
/**
* The extension for the link preview cache, it will override the link preview cache instance
* @param config - The configuration for the link preview cache
* @returns The extension for the link preview cache
*/
export const LinkPreviewCacheExtension = (
config?: LinkPreviewCacheConfig
): ExtensionType => {
return {
setup: (di: Container) => {
di.override(LinkPreviewCacheIdentifier, () =>
LinkPreviewCache.getInstance(config)
);
},
};
};
@@ -0,0 +1,225 @@
import { type LinkPreviewData } from '@blocksuite/affine-model';
import { type Container, createIdentifier } from '@blocksuite/global/di';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { type BlockStdScope, StdIdentifier } from '@blocksuite/std';
import { Extension } from '@blocksuite/store';
import { DEFAULT_LINK_PREVIEW_ENDPOINT } from '../../consts';
import { isAbortError } from '../../utils/is-abort-error';
import { FeatureFlagService } from '../feature-flag-service';
import {
LinkPreviewCacheIdentifier,
type LinkPreviewCacheProvider,
} from './link-preview-cache';
export type LinkPreviewResponseData = {
url: string;
title?: string;
siteName?: string;
description?: string;
images?: string[];
mediaType?: string;
contentType?: string;
charset?: string;
videos?: string[];
favicons?: string[];
};
export interface LinkPreviewProvider {
/**
* Query link preview data for a given URL
*/
query: (
url: string,
signal?: AbortSignal
) => Promise<Partial<LinkPreviewData>>;
/**
* Set the endpoint for link preview
*/
setEndpoint: (endpoint: string) => void;
/**
* Get the endpoint for link preview
*/
endpoint: string;
}
export const LinkPreviewServiceIdentifier =
createIdentifier<LinkPreviewProvider>('AffineLinkPreviewService');
export class LinkPreviewService
extends Extension
implements LinkPreviewProvider
{
static override setup(di: Container) {
di.addImpl(LinkPreviewServiceIdentifier, LinkPreviewService, [
StdIdentifier,
LinkPreviewCacheIdentifier,
]);
}
private _endpoint: string = DEFAULT_LINK_PREVIEW_ENDPOINT;
constructor(
private readonly _std: BlockStdScope,
private readonly _cache: LinkPreviewCacheProvider
) {
super();
}
get endpoint() {
return this._endpoint;
}
setEndpoint = (endpoint: string) => {
this._endpoint = endpoint;
};
private readonly _fetchTwitterPreview = async (
url: string,
signal?: AbortSignal
): Promise<Partial<LinkPreviewData>> => {
try {
const match = /\/status\/(\d+)/.exec(url);
if (!match) {
throw new BlockSuiteError(
ErrorCode.DefaultRuntimeError,
`Invalid tweet URL: ${url}`
);
}
const apiUrl = `https://api.fxtwitter.com/status/${match[1]}`;
const response = await fetch(apiUrl, { signal }).then(res => res.json());
const tweet = response?.tweet;
if (!tweet) {
throw new BlockSuiteError(
ErrorCode.DefaultRuntimeError,
`Invalid tweet response: ${url}`
);
}
return {
title: tweet.author?.name ?? null,
icon: tweet.author?.avatar_url ?? null,
description: tweet.text ?? null,
image:
tweet.media?.photos?.[0]?.url || tweet.author?.banner_url || null,
};
} catch (e) {
console.error(`Failed to fetch tweet: ${url}`);
console.error(e);
return {};
}
};
private readonly _fetchStandardPreview = async (
url: string,
signal?: AbortSignal
): Promise<Partial<LinkPreviewData>> => {
const response = await fetch(this.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ url }),
signal,
})
.then(r => {
if (!r || !r.ok) {
throw new BlockSuiteError(
ErrorCode.DefaultRuntimeError,
`Failed to fetch link preview: ${url}`
);
}
return r;
})
.catch(err => {
if (isAbortError(err)) return null;
console.error(`Failed to fetch link preview: ${url}`);
console.error(err);
return null;
});
if (!response) return {};
const data: LinkPreviewResponseData = await response.json();
return {
title: data.title ?? null,
description: data.description ?? null,
icon: data.favicons?.[0],
image: data.images?.[0],
};
};
private readonly _isTwitterUrl = (url: string): boolean => {
const twitterDomains = [
'https://x.com/',
'https://www.x.com/',
'https://www.twitter.com/',
'https://twitter.com/',
];
return (
twitterDomains.some(domain => url.startsWith(domain)) &&
url.includes('/status/')
);
};
private readonly _fetchPreview = async (
url: string,
signal?: AbortSignal
): Promise<Partial<LinkPreviewData>> => {
if (this._isTwitterUrl(url)) {
return this._fetchTwitterPreview(url, signal);
}
return this._fetchStandardPreview(url, signal);
};
/**
* Fetch link preview data for a given URL
*/
query = async (
url: string,
signal?: AbortSignal
): Promise<Partial<LinkPreviewData>> => {
const featureFlagService = this._std.store.get(FeatureFlagService);
const cacheEnabled = featureFlagService.getFlag(
'enable_link_preview_cache'
);
// If the cache is not enabled, fetch the preview directly
if (!cacheEnabled) {
return this._fetchPreview(url, signal);
}
// Check memory cache, if hit, return the cached data
const cached = this._cache.get(url);
if (cached) {
return cached;
}
// Check pending requests, if there is a pending request, return the promise
const pendingRequest = this._cache.getPendingRequest(url);
if (pendingRequest) {
return pendingRequest;
}
// Fetch new data
const promise = (async () => {
try {
// Fetch new data
const data = await this._fetchPreview(url, signal);
// If the data is not empty, set the data to the cache
if (data && Object.keys(data).length > 0) {
this._cache.set(url, data);
}
return data;
} finally {
// Delete the pending request regardless of success or failure
this._cache.deletePendingRequest(url);
}
})();
// Set the promise to the cache
this._cache.setPendingRequest(url, promise);
return promise;
};
}
@@ -0,0 +1,81 @@
import { LinkPreviewDataSchema } from '@blocksuite/affine-model';
import { z } from 'zod';
const _StorageSchema = z.object({
data: z.record(LinkPreviewDataSchema.partial()),
expires: z.number().optional(),
});
type StorageData = z.infer<typeof _StorageSchema>;
/**
* The local storage manager for the link preview cache data
*/
export class LinkPreviewStorage {
/**
* The storage key for the link preview
*/
storageKey = 'blocksuite:link-preview-cache';
/**
* Load the cache from local storage
* @returns StorageData
*/
load(): StorageData {
try {
const stored = localStorage.getItem(this.storageKey);
if (stored) {
const parsed = JSON.parse(stored);
const safe = _StorageSchema.safeParse(parsed);
if (safe.success) {
return safe.data;
}
// if the data is invalid, clear the data
this.clear();
}
} catch (e) {
console.error('Failed to load cache from storage:', e);
}
return { data: {} };
}
/**
* Save the cache to local storage
* @param {StorageData} data
*/
save(data: StorageData): void {
try {
const serialized = JSON.stringify(data);
localStorage.setItem(this.storageKey, serialized);
} catch (e) {
console.error('Failed to save cache to storage:', e);
}
}
/**
* Clear a link preview record from local storage with specific URL
* @param {string} url The URL key to remove from storage
* @returns {boolean} Whether the item was successfully removed
*/
clearItem(url: string): boolean {
try {
const data = this.load();
if (!(url in data.data)) {
return false;
}
delete data.data[url];
this.save(data);
return true;
} catch (e) {
console.error('Failed to clear item from storage:', e);
return false;
}
}
/**
* Clear all records from local storage
*/
clear(): void {
localStorage.removeItem(this.storageKey);
}
}
@@ -1,99 +0,0 @@
import type { LinkPreviewData } from '@blocksuite/affine-model';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { StoreExtension } from '@blocksuite/store';
import { DEFAULT_LINK_PREVIEW_ENDPOINT } from '../consts';
import { isAbortError } from '../utils/is-abort-error';
export type LinkPreviewResponseData = {
url: string;
title?: string;
siteName?: string;
description?: string;
images?: string[];
mediaType?: string;
contentType?: string;
charset?: string;
videos?: string[];
favicons?: string[];
};
export class LinkPreviewerService extends StoreExtension {
static override key = 'link-previewer';
private _endpoint = DEFAULT_LINK_PREVIEW_ENDPOINT;
query = async (
url: string,
signal?: AbortSignal
): Promise<Partial<LinkPreviewData>> => {
if (
(url.startsWith('https://x.com/') ||
url.startsWith('https://www.x.com/') ||
url.startsWith('https://www.twitter.com/') ||
url.startsWith('https://twitter.com/')) &&
url.includes('/status/')
) {
// use api.fxtwitter.com
url =
'https://api.fxtwitter.com/status/' + /\/status\/(.*)/.exec(url)?.[1];
try {
const { tweet } = await fetch(url, { signal }).then(res => res.json());
return {
title: tweet.author.name,
icon: tweet.author.avatar_url,
description: tweet.text,
image: tweet.media?.photos?.[0].url || tweet.author.banner_url,
};
} catch (e) {
console.error(`Failed to fetch tweet: ${url}`);
console.error(e);
return {};
}
} else {
const response = await fetch(this._endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
url,
}),
signal,
})
.then(r => {
if (!r || !r.ok) {
throw new BlockSuiteError(
ErrorCode.DefaultRuntimeError,
`Failed to fetch link preview: ${url}`
);
}
return r;
})
.catch(err => {
if (isAbortError(err)) return null;
console.error(`Failed to fetch link preview: ${url}`);
console.error(err);
return null;
});
if (!response) return {};
const data: LinkPreviewResponseData = await response.json();
return {
title: data.title ?? null,
description: data.description ?? null,
icon: data.favicons?.[0],
image: data.images?.[0],
};
}
};
get endpoint() {
return this._endpoint;
}
setEndpoint = (endpoint: string) => {
this._endpoint = endpoint;
};
}