Skip to content

Commit 2096b66

Browse files
committed
feat: add prefetch and cache api
1 parent 7f67062 commit 2096b66

File tree

16 files changed

+524
-85
lines changed

16 files changed

+524
-85
lines changed
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
1+
import { cache } from '@module-federation/modern-js/react';
2+
13
export type Data = {
24
data: string;
35
};
46

5-
export const fetchData = async (): Promise<Data> => {
7+
export const fetchData = cache(async (): Promise<Data> => {
68
return new Promise((resolve) => {
79
setTimeout(() => {
810
resolve({
911
data: `[ provider - server ] fetched data: ${new Date()}`,
1012
});
1113
}, 1000);
1214
});
13-
};
15+
});

packages/bridge/bridge-react/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,8 @@
9999
"dependencies": {
100100
"@module-federation/bridge-shared": "workspace:*",
101101
"@module-federation/sdk": "workspace:*",
102-
"react-error-boundary": "^4.1.2"
102+
"react-error-boundary": "^4.1.2",
103+
"lru-cache": "^10.4.3"
103104
},
104105
"peerDependencies": {
105106
"react": ">=16.9.0",

packages/bridge/bridge-react/src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ export {
1515
callDataFetch,
1616
setSSREnv,
1717
autoFetchDataPlugin,
18+
CacheSize,
19+
CacheTime,
20+
configureCache,
21+
generateKey,
22+
cache,
23+
revalidateTag,
24+
clearStore,
25+
prefetch,
1826
} from './lazy';
1927

2028
export type { CreateRootOptions, Root } from './provider/versions/legacy';
@@ -30,4 +38,6 @@ export type {
3038
NoSSRRemoteInfo,
3139
CollectSSRAssetsOptions,
3240
CreateLazyComponentOptions,
41+
CacheStatus,
42+
CacheStatsInfo,
3343
} from './lazy';

packages/bridge/bridge-react/src/lazy/createLazyComponent.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,9 +194,7 @@ export function createLazyComponent<T, E extends keyof T>(
194194
) {
195195
const { instance } = options;
196196
if (!instance) {
197-
throw new Error(
198-
'instance is required if used in "@module-federation/bridge-react"!',
199-
);
197+
throw new Error('instance is required for createLazyComponent!');
200198
}
201199
type ComponentType = T[E] extends (...args: any) => any
202200
? Parameters<T[E]>[0] extends undefined
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
import { LRUCache } from 'lru-cache';
2+
import { getDataFetchCache } from '../utils';
3+
4+
import type {
5+
CacheConfig,
6+
CacheItem,
7+
DataFetch,
8+
DataFetchParams,
9+
} from '../types';
10+
11+
export const CacheSize = {
12+
KB: 1024,
13+
MB: 1024 * 1024,
14+
GB: 1024 * 1024 * 1024,
15+
} as const;
16+
17+
export const CacheTime = {
18+
SECOND: 1000,
19+
MINUTE: 60 * 1000,
20+
HOUR: 60 * 60 * 1000,
21+
DAY: 24 * 60 * 60 * 1000,
22+
WEEK: 7 * 24 * 60 * 60 * 1000,
23+
MONTH: 30 * 24 * 60 * 60 * 1000,
24+
} as const;
25+
26+
export type CacheStatus = 'hit' | 'stale' | 'miss';
27+
28+
export interface CacheStatsInfo {
29+
status: CacheStatus;
30+
key: string | symbol;
31+
params: DataFetchParams;
32+
result: any;
33+
}
34+
35+
interface CacheOptions {
36+
tag?: string | string[];
37+
maxAge?: number;
38+
revalidate?: number;
39+
getKey?: <Args extends any[]>(...args: Args) => string;
40+
customKey?: <Args extends any[]>(options: {
41+
params: Args;
42+
fn: (...args: Args) => any;
43+
generatedKey: string;
44+
}) => string | symbol;
45+
onCache?: (info: CacheStatsInfo) => boolean;
46+
}
47+
48+
function getTagKeyMap() {
49+
const dataFetchCache = getDataFetchCache();
50+
if (!dataFetchCache || !dataFetchCache.tagKeyMap) {
51+
const tagKeyMap = new Map<string, Set<string>>();
52+
globalThis.__MF_DATA_FETCH_CACHE__ ||= {};
53+
globalThis.__MF_DATA_FETCH_CACHE__.tagKeyMap = tagKeyMap;
54+
return tagKeyMap;
55+
}
56+
return dataFetchCache.tagKeyMap;
57+
}
58+
59+
function addTagKeyRelation(tag: string, key: string) {
60+
const tagKeyMap = getTagKeyMap();
61+
let keys = tagKeyMap.get(tag);
62+
if (!keys) {
63+
keys = new Set();
64+
tagKeyMap.set(tag, keys);
65+
}
66+
keys.add(key);
67+
}
68+
69+
function getCacheConfig() {
70+
const dataFetchCache = getDataFetchCache();
71+
if (!dataFetchCache || !dataFetchCache.cacheConfig) {
72+
const cacheConfig: CacheConfig = {
73+
maxSize: CacheSize.GB,
74+
};
75+
globalThis.__MF_DATA_FETCH_CACHE__ ||= {};
76+
globalThis.__MF_DATA_FETCH_CACHE__.cacheConfig = cacheConfig;
77+
return cacheConfig;
78+
}
79+
return dataFetchCache.cacheConfig;
80+
}
81+
82+
export function configureCache(config: CacheConfig): void {
83+
const cacheConfig = getCacheConfig();
84+
Object.assign(cacheConfig, config);
85+
}
86+
87+
function getLRUCache() {
88+
const dataFetchCache = getDataFetchCache();
89+
const cacheConfig = getCacheConfig();
90+
91+
if (!dataFetchCache || !dataFetchCache.cacheStore) {
92+
const cacheStore = new LRUCache<string, Map<string, CacheItem<any>>>({
93+
maxSize: cacheConfig.maxSize ?? CacheSize.GB,
94+
sizeCalculation: (value: Map<string, CacheItem<any>>): number => {
95+
if (!value.size) {
96+
return 1;
97+
}
98+
99+
let size = 0;
100+
for (const [k, item] of value.entries()) {
101+
size += k.length * 2;
102+
size += estimateObjectSize(item.data);
103+
size += 8;
104+
}
105+
return size;
106+
},
107+
updateAgeOnGet: true,
108+
updateAgeOnHas: true,
109+
});
110+
globalThis.__MF_DATA_FETCH_CACHE__ ||= {};
111+
globalThis.__MF_DATA_FETCH_CACHE__.cacheStore = cacheStore;
112+
return cacheStore;
113+
}
114+
115+
return dataFetchCache.cacheStore;
116+
}
117+
118+
function estimateObjectSize(data: unknown): number {
119+
const type = typeof data;
120+
121+
if (type === 'number') return 8;
122+
if (type === 'boolean') return 4;
123+
if (type === 'string') return Math.max((data as string).length * 2, 1);
124+
if (data === null || data === undefined) return 1;
125+
126+
if (ArrayBuffer.isView(data)) {
127+
return Math.max(data.byteLength, 1);
128+
}
129+
130+
if (Array.isArray(data)) {
131+
return Math.max(
132+
data.reduce((acc, item) => acc + estimateObjectSize(item), 0),
133+
1,
134+
);
135+
}
136+
137+
if (data instanceof Map || data instanceof Set) {
138+
return 1024;
139+
}
140+
141+
if (data instanceof Date) {
142+
return 8;
143+
}
144+
145+
if (type === 'object') {
146+
return Math.max(
147+
Object.entries(data).reduce(
148+
(acc, [key, value]) => acc + key.length * 2 + estimateObjectSize(value),
149+
0,
150+
),
151+
1,
152+
);
153+
}
154+
155+
return 1;
156+
}
157+
158+
export function generateKey(dataFetchOptions: DataFetchParams): string {
159+
return JSON.stringify(dataFetchOptions, (_, value) => {
160+
if (value && typeof value === 'object' && !Array.isArray(value)) {
161+
return Object.keys(value)
162+
.sort()
163+
.reduce((result: Record<string, unknown>, key) => {
164+
result[key] = value[key];
165+
return result;
166+
}, {});
167+
}
168+
return value;
169+
});
170+
}
171+
172+
export function cache<T>(
173+
fn: DataFetch<T>,
174+
options?: CacheOptions,
175+
): DataFetch<T> {
176+
const {
177+
tag = 'default',
178+
maxAge = CacheTime.MINUTE * 5,
179+
revalidate = 0,
180+
onCache,
181+
getKey,
182+
} = options || {};
183+
184+
const tags = Array.isArray(tag) ? tag : [tag];
185+
186+
return async (dataFetchOptions) => {
187+
// if downgrade, skip cache
188+
if (dataFetchOptions.isDowngrade || !dataFetchOptions._id) {
189+
return fn(dataFetchOptions);
190+
}
191+
const store = getLRUCache();
192+
193+
const now = Date.now();
194+
const storeKey = dataFetchOptions._id;
195+
const cacheKey = getKey
196+
? getKey(dataFetchOptions)
197+
: generateKey(dataFetchOptions);
198+
199+
tags.forEach((t) => addTagKeyRelation(t, cacheKey));
200+
201+
let cacheStore = store.get(cacheKey);
202+
if (!cacheStore) {
203+
cacheStore = new Map();
204+
}
205+
206+
const cached = cacheStore.get(storeKey);
207+
if (cached) {
208+
const age = now - cached.timestamp;
209+
210+
if (age < maxAge) {
211+
if (onCache) {
212+
const useCache = onCache({
213+
status: 'hit',
214+
key: cacheKey,
215+
params: dataFetchOptions,
216+
result: cached.data,
217+
});
218+
if (!useCache) {
219+
return fn(dataFetchOptions);
220+
}
221+
}
222+
return cached.data;
223+
}
224+
225+
if (revalidate > 0 && age < maxAge + revalidate) {
226+
if (onCache) {
227+
onCache({
228+
status: 'stale',
229+
key: cacheKey,
230+
params: dataFetchOptions,
231+
result: cached.data,
232+
});
233+
}
234+
235+
if (!cached.isRevalidating) {
236+
cached.isRevalidating = true;
237+
Promise.resolve().then(async () => {
238+
try {
239+
const newData = await fn(dataFetchOptions);
240+
cacheStore!.set(storeKey, {
241+
data: newData,
242+
timestamp: Date.now(),
243+
isRevalidating: false,
244+
});
245+
246+
store.set(cacheKey, cacheStore!);
247+
} catch (error) {
248+
cached.isRevalidating = false;
249+
console.error('Background revalidation failed:', error);
250+
}
251+
});
252+
}
253+
return cached.data;
254+
}
255+
}
256+
257+
const data = await fn(dataFetchOptions);
258+
cacheStore.set(storeKey, {
259+
data,
260+
timestamp: now,
261+
isRevalidating: false,
262+
});
263+
store.set(cacheKey, cacheStore);
264+
265+
if (onCache) {
266+
onCache({
267+
status: 'miss',
268+
key: cacheKey,
269+
params: dataFetchOptions,
270+
result: data,
271+
});
272+
}
273+
274+
return data;
275+
};
276+
}
277+
278+
export function revalidateTag(tag: string): void {
279+
const tagKeyMap = getTagKeyMap();
280+
const keys = tagKeyMap.get(tag);
281+
const lruCache = getLRUCache();
282+
if (keys) {
283+
keys.forEach((key) => {
284+
lruCache?.delete(key);
285+
});
286+
}
287+
}
288+
289+
export function clearStore(): void {
290+
const lruCache = getLRUCache();
291+
const tagKeyMap = getTagKeyMap();
292+
293+
lruCache?.clear();
294+
delete globalThis.__MF_DATA_FETCH_CACHE__?.cacheStore;
295+
tagKeyMap.clear();
296+
}

packages/bridge/bridge-react/src/lazy/data-fetch/data-fetch-server-middleware.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ const dataFetchServerMiddleware: MiddlewareHandler = async (ctx, next) => {
159159
const callFetchDataPromise = fetchData(dataFetchId, {
160160
...params,
161161
isDowngrade: !remoteInfo,
162+
_id: dataFetchId,
162163
});
163164
const wrappedPromise = wrapSetTimeout(
164165
callFetchDataPromise,
@@ -178,7 +179,11 @@ const dataFetchServerMiddleware: MiddlewareHandler = async (ctx, next) => {
178179
throw new Error('host instance not found!');
179180
}
180181
const dataFetchFn = await loadDataFetchModule(hostInstance, remoteId);
181-
const data = await dataFetchFn({ ...params, isDowngrade: !remoteInfo });
182+
const data = await dataFetchFn({
183+
...params,
184+
isDowngrade: !remoteInfo,
185+
_id: dataFetchId,
186+
});
182187
logger.log('fetch data from server, loadDataFetchModule res: ', data);
183188
return ctx.json(data);
184189
} catch (e) {
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,15 @@
11
export { callDataFetch } from './call-data-fetch';
22
export { injectDataFetch } from './inject-data-fetch';
3+
export {
4+
CacheSize,
5+
CacheTime,
6+
configureCache,
7+
generateKey,
8+
cache,
9+
revalidateTag,
10+
clearStore,
11+
} from './cache';
12+
13+
export type { CacheStatus, CacheStatsInfo } from './cache';
14+
15+
export { prefetch } from './prefetch';

0 commit comments

Comments
 (0)