Created
May 8, 2025 10:38
-
-
Save domosedov/030882614876f4a28ddd9d0979dc5c1d to your computer and use it in GitHub Desktop.
Farfetched Extends
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { | |
createEffect, | |
createEvent, | |
createStore, | |
sample, | |
scopeBind, | |
type Json, | |
} from "effector"; | |
import { | |
CacheAdapter, | |
CacheAdapterOptions, | |
createCacheAdapter, | |
} from "@farfetched/core"; | |
import { attachObservability } from "./observability"; | |
import { parseTime } from "./time"; | |
export const META_KEY = "__farfetched_meta__"; | |
export type SerializeConfig = { | |
serialize?: { | |
read: (value: Json) => unknown; | |
write: (value: unknown) => Json; | |
}; | |
}; | |
function browserStorageCache( | |
config: { | |
storage: () => Storage; | |
} & CacheAdapterOptions & | |
SerializeConfig | |
): CacheAdapter { | |
const { storage, observability, maxAge, maxEntries, serialize } = config; | |
function storageCache(): CacheAdapter { | |
const getSavedItemFx = createEffect(async (key: string) => { | |
const item = await getItemFx(key); | |
if (!item) return null; | |
try { | |
const parsed = JSON.parse(item); | |
return { | |
...parsed, | |
value: serialize?.read ? serialize.read(parsed.value) : parsed.value, | |
} as SavedItem; | |
} catch { | |
return null; | |
} | |
}); | |
const setSavedItemFx = createEffect( | |
async ({ key, value }: { key: string; value: unknown }) => { | |
const item = JSON.stringify({ | |
value: serialize?.write ? serialize.write(value) : value, | |
timestamp: Date.now(), | |
}); | |
metaStorage.addKey({ key }); | |
await setItemFx({ key, value: item }); | |
} | |
); | |
const removeSavedItemFx = createEffect(async (key: string) => { | |
metaStorage.removeKey({ key }); | |
await removeItemFx(key); | |
}); | |
const itemExpired = createEvent<{ key: string; value: unknown }>(); | |
const itemEvicted = createEvent<{ key: string }>(); | |
const purge = createEvent(); | |
const purgeFx = createEffect(async (keys: string[]) => | |
Promise.all(keys.map(removeSavedItemFx)) | |
); | |
sample({ | |
clock: purge, | |
source: metaStorage.$meta, | |
fn: (meta) => meta?.keys ?? [], | |
target: purgeFx, | |
}); | |
if (maxAge) { | |
const timeout = parseTime(maxAge); | |
setItemFx.done.watch((payload) => { | |
if (payload.params.key === META_KEY) return; | |
const boundItemExpired = scopeBind(itemExpired, { safe: true }); | |
setTimeout(() => boundItemExpired(get("params")(payload)), timeout); | |
}); | |
sample({ | |
clock: itemExpired, | |
fn: ({ key }) => key, | |
target: removeSavedItemFx, | |
}); | |
} | |
const adapter = { | |
get: createEffect< | |
{ key: string }, | |
{ value: unknown; cachedAt: number } | null | |
>(async ({ key }) => { | |
const saved = await getSavedItemFx(key); | |
if (!saved) return null; | |
if (maxAge) { | |
const expiredAt = saved?.timestamp + parseTime(maxAge); | |
if (Date.now() >= expiredAt) { | |
itemExpired({ key, value: saved.value }); | |
await removeSavedItemFx(key); | |
return null; | |
} | |
} | |
return { value: saved.value, cachedAt: saved.timestamp }; | |
}), | |
set: createEffect<{ key: string; value: unknown }, void>( | |
async ({ key, value }) => { | |
const meta = await getMetaFx(); | |
const keysAmount = meta?.keys?.length ?? 0; | |
if (maxEntries && keysAmount >= maxEntries) { | |
const forDelete = meta?.keys?.slice(0, keysAmount - maxEntries + 1); | |
for (const key of forDelete ?? []) { | |
itemEvicted({ key }); | |
await removeSavedItemFx(key); | |
} | |
} | |
await setSavedItemFx({ key, value }); | |
} | |
), | |
unset: createEffect<{ key: string }, void>(async ({ key }) => { | |
await removeSavedItemFx(key); | |
}), | |
purge, | |
}; | |
attachObservability({ | |
adapter, | |
options: observability, | |
events: { itemExpired, itemEvicted }, | |
}); | |
return createCacheAdapter(adapter); | |
} | |
interface SavedItem { | |
timestamp: number; | |
value: unknown; | |
} | |
// -- meta storage | |
const $meta = createStore<Meta | null>(null, { | |
serialize: "ignore", | |
name: "ff.browserStorage.$meta", | |
sid: "ff.browserStorage.$meta", | |
}); | |
const getMetaFx = createEffect(async () => { | |
const meta = await getItemFx(META_KEY); | |
if (!meta) return null; | |
try { | |
const parsed = JSON.parse(meta); | |
return parsed as Meta; | |
} catch { | |
return null; | |
} | |
}); | |
const setMetaFx = createEffect((meta: Meta) => | |
setItemFx({ key: META_KEY, value: JSON.stringify(meta) }) | |
); | |
const addKey = createEvent<{ key: string }>(); | |
const removeKey = createEvent<{ key: string }>(); | |
const metaStorage = { | |
$meta, | |
addKey, | |
removeKey, | |
}; | |
sample({ clock: getMetaFx.doneData, target: $meta }); | |
sample({ clock: $meta, filter: Boolean, target: setMetaFx }); | |
sample({ | |
clock: addKey, | |
source: $meta, | |
fn: (meta, { key }) => { | |
const knownKeys = meta?.keys ?? []; | |
if (knownKeys.includes(key)) { | |
return meta; | |
} | |
return { ...meta, keys: [...knownKeys, key] }; | |
}, | |
target: $meta, | |
}); | |
sample({ | |
clock: removeKey, | |
source: $meta, | |
fn: (meta, { key }) => ({ | |
...meta, | |
keys: meta?.keys?.filter((k) => k !== key) ?? [], | |
}), | |
target: $meta, | |
}); | |
interface Meta { | |
keys?: string[]; | |
} | |
// -- storage effects | |
const setItemFx = createEffect((params: { key: string; value: string }) => { | |
storage().setItem(params.key, params.value); | |
}); | |
const getItemFx = createEffect((key: string) => storage().getItem(key)); | |
const removeItemFx = createEffect((key: string) => storage().removeItem(key)); | |
return storageCache(); | |
} | |
function get<T extends Record<string, any>, P extends keyof T>( | |
path: P | |
): (obj: T) => T[P] { | |
return (obj) => obj[path]; | |
} | |
export function localStorageCache( | |
config?: CacheAdapterOptions & SerializeConfig | |
): CacheAdapter { | |
return browserStorageCache({ storage: () => localStorage, ...config }); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { CacheAdapter, CacheAdapterOptions } from "@farfetched/core"; | |
import { type Event, sample } from "effector"; | |
export function attachObservability({ | |
adapter, | |
options, | |
events, | |
}: { | |
adapter: Omit<CacheAdapter, "__">; | |
options?: CacheAdapterOptions["observability"]; | |
events?: { | |
itemExpired?: Event<{ key: string; value: unknown }>; | |
itemEvicted?: Event<{ key: string }>; | |
}; | |
}) { | |
if (options?.hit) { | |
sample({ | |
clock: adapter.get.done, | |
filter: ({ result }) => result !== null, | |
fn: ({ params }) => ({ key: params.key }), | |
target: options.hit, | |
}); | |
} | |
if (options?.miss) { | |
sample({ | |
clock: adapter.get.done, | |
filter: ({ result }) => result === null, | |
fn: ({ params }) => ({ key: params.key }), | |
target: options.miss, | |
}); | |
} | |
if (options?.expired && events?.itemExpired) { | |
sample({ | |
clock: events.itemExpired, | |
fn: ({ key }) => ({ key }), | |
target: options.expired, | |
}); | |
} | |
if (options?.evicted && events?.itemEvicted) { | |
sample({ | |
clock: events.itemEvicted, | |
target: options.evicted, | |
}); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
type MillisecondUnit = "ms" | "milli" | "millisecond" | "milliseconds"; | |
type Millisecond = `${number}${MillisecondUnit}`; | |
const millisecondUnits: MillisecondUnit[] = [ | |
"ms", | |
"milli", | |
"millisecond", | |
"milliseconds", | |
]; | |
type SecUnit = "s" | "sec" | "secs" | "second" | "seconds"; | |
type Sec = `${number}${SecUnit}`; | |
const secUnits: SecUnit[] = ["s", "sec", "secs", "second", "seconds"]; | |
type MinUnit = "m" | "min" | "mins" | "minute" | "minutes"; | |
type Min = `${number}${MinUnit}`; | |
const minUnits: MinUnit[] = ["m", "min", "mins", "minute", "minutes"]; | |
type HourUnit = "h" | "hr" | "hrs" | "hour" | "hours"; | |
type Hour = `${number}${HourUnit}`; | |
const hourUnits: HourUnit[] = ["h", "hr", "hrs", "hour", "hours"]; | |
export type Time = | |
// Without milliseconds | |
| `${Hour} ${Min} ${Sec}` | |
| `${Hour} ${Min}` | |
| `${Min} ${Sec}` | |
| `${Hour}` | |
| `${Min}` | |
| `${Sec}` | |
// With milliseconds | |
| `${Hour} ${Min} ${Sec} ${Millisecond}` | |
| `${Hour} ${Min} ${Millisecond}` | |
| `${Min} ${Sec} ${Millisecond}` | |
| `${Hour} ${Millisecond}` | |
| `${Min} ${Millisecond}` | |
| `${Sec} ${Millisecond}` | |
| `${Millisecond}` | |
// Only milliseconds | |
| number; | |
export function parseTime(time: Time): number { | |
if (typeof time === "number") { | |
return time; | |
} | |
let result = 0; | |
for (const part of time.split(" ")) { | |
switch (true) { | |
case hasEnding(part, millisecondUnits): | |
result += parseNumber(part); | |
break; | |
case hasEnding(part, secUnits): | |
result += parseNumber(part) * 1000; | |
break; | |
case hasEnding(part, minUnits): | |
result += parseNumber(part) * 60000; | |
break; | |
case hasEnding(part, hourUnits): | |
result += parseNumber(part) * 3600000; | |
break; | |
} | |
} | |
return result; | |
} | |
function hasEnding(value: string, allowedEndings: string[]): boolean { | |
return allowedEndings.includes(extractNonNumeric(value)); | |
} | |
function extractNonNumeric(value: string): string { | |
return value.replace(/[0-9.]/g, ""); | |
} | |
function parseNumber(value: string): number { | |
return value.includes(".") ? parseFloat(value) : parseInt(value); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment