Skip to content

Instantly share code, notes, and snippets.

@domosedov
Created May 8, 2025 10:38
Show Gist options
  • Save domosedov/030882614876f4a28ddd9d0979dc5c1d to your computer and use it in GitHub Desktop.
Save domosedov/030882614876f4a28ddd9d0979dc5c1d to your computer and use it in GitHub Desktop.
Farfetched Extends
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 });
}
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,
});
}
}
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