mirror of
https://github.com/logos-messaging/js-waku.git
synced 2026-01-05 23:33:08 +00:00
209 lines
5.2 KiB
TypeScript
209 lines
5.2 KiB
TypeScript
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
|
|
import { Logger } from "@waku/utils";
|
|
|
|
import { ILocalHistory, MemLocalHistory } from "./mem_local_history.js";
|
|
import { ChannelId, ContentMessage, HistoryEntry } from "./message.js";
|
|
|
|
const log = new Logger("sds:persistent-history");
|
|
|
|
export interface HistoryStorage {
|
|
getItem(key: string): string | null;
|
|
setItem(key: string, value: string): void;
|
|
removeItem(key: string): void;
|
|
}
|
|
|
|
export interface PersistentHistoryOptions {
|
|
channelId: ChannelId;
|
|
storage?: HistoryStorage;
|
|
}
|
|
|
|
type StoredHistoryEntry = {
|
|
messageId: string;
|
|
retrievalHint?: string;
|
|
};
|
|
|
|
type StoredContentMessage = {
|
|
messageId: string;
|
|
channelId: string;
|
|
senderId: string;
|
|
lamportTimestamp: string;
|
|
causalHistory: StoredHistoryEntry[];
|
|
bloomFilter?: string;
|
|
content: string;
|
|
retrievalHint?: string;
|
|
};
|
|
|
|
const HISTORY_STORAGE_PREFIX = "waku:sds:history:";
|
|
const DEFAULT_HISTORY_STORAGE: HistoryStorage | undefined =
|
|
typeof localStorage !== "undefined" ? localStorage : undefined;
|
|
|
|
/**
|
|
* Persists the SDS local history in a browser/localStorage compatible backend.
|
|
*
|
|
* If no storage backend is available, this behaves like {@link MemLocalHistory}.
|
|
*/
|
|
export class PersistentHistory implements ILocalHistory {
|
|
private readonly storage?: HistoryStorage;
|
|
private readonly storageKey: string;
|
|
private readonly memory: MemLocalHistory;
|
|
|
|
public constructor(options: PersistentHistoryOptions) {
|
|
this.memory = new MemLocalHistory();
|
|
this.storage = options.storage ?? DEFAULT_HISTORY_STORAGE;
|
|
this.storageKey = `${HISTORY_STORAGE_PREFIX}${options.channelId}`;
|
|
|
|
if (!this.storage) {
|
|
log.warn(
|
|
"Storage backend unavailable (localStorage not found). Falling back to in-memory history. Messages will not persist across sessions."
|
|
);
|
|
}
|
|
|
|
this.load();
|
|
}
|
|
|
|
public get length(): number {
|
|
return this.memory.length;
|
|
}
|
|
|
|
public push(...items: ContentMessage[]): number {
|
|
const length = this.memory.push(...items);
|
|
this.save();
|
|
return length;
|
|
}
|
|
|
|
public some(
|
|
predicate: (
|
|
value: ContentMessage,
|
|
index: number,
|
|
array: ContentMessage[]
|
|
) => unknown,
|
|
thisArg?: any
|
|
): boolean {
|
|
return this.memory.some(predicate, thisArg);
|
|
}
|
|
|
|
public slice(start?: number, end?: number): ContentMessage[] {
|
|
return this.memory.slice(start, end);
|
|
}
|
|
|
|
public find(
|
|
predicate: (
|
|
value: ContentMessage,
|
|
index: number,
|
|
obj: ContentMessage[]
|
|
) => unknown,
|
|
thisArg?: any
|
|
): ContentMessage | undefined {
|
|
return this.memory.find(predicate, thisArg);
|
|
}
|
|
|
|
public findIndex(
|
|
predicate: (
|
|
value: ContentMessage,
|
|
index: number,
|
|
obj: ContentMessage[]
|
|
) => unknown,
|
|
thisArg?: any
|
|
): number {
|
|
return this.memory.findIndex(predicate, thisArg);
|
|
}
|
|
|
|
private save(): void {
|
|
if (!this.storage) {
|
|
return;
|
|
}
|
|
|
|
const payload = JSON.stringify(
|
|
this.memory.slice(0).map(serializeContentMessage)
|
|
);
|
|
this.storage.setItem(this.storageKey, payload);
|
|
}
|
|
|
|
private load(): void {
|
|
if (!this.storage) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const raw = this.storage.getItem(this.storageKey);
|
|
if (!raw) {
|
|
return;
|
|
}
|
|
|
|
const stored = JSON.parse(raw) as StoredContentMessage[];
|
|
const messages = stored
|
|
.map(deserializeContentMessage)
|
|
.filter((message): message is ContentMessage => Boolean(message));
|
|
if (messages.length) {
|
|
this.memory.push(...messages);
|
|
}
|
|
} catch {
|
|
this.storage.removeItem(this.storageKey);
|
|
}
|
|
}
|
|
}
|
|
|
|
const serializeHistoryEntry = (entry: HistoryEntry): StoredHistoryEntry => ({
|
|
messageId: entry.messageId,
|
|
retrievalHint: entry.retrievalHint
|
|
? bytesToHex(entry.retrievalHint)
|
|
: undefined
|
|
});
|
|
|
|
const deserializeHistoryEntry = (entry: StoredHistoryEntry): HistoryEntry => ({
|
|
messageId: entry.messageId,
|
|
retrievalHint: entry.retrievalHint
|
|
? hexToBytes(entry.retrievalHint)
|
|
: undefined
|
|
});
|
|
|
|
const serializeContentMessage = (
|
|
message: ContentMessage
|
|
): StoredContentMessage => ({
|
|
messageId: message.messageId,
|
|
channelId: message.channelId,
|
|
senderId: message.senderId,
|
|
lamportTimestamp: message.lamportTimestamp.toString(),
|
|
causalHistory: message.causalHistory.map(serializeHistoryEntry),
|
|
bloomFilter: toHex(message.bloomFilter),
|
|
content: bytesToHex(new Uint8Array(message.content)),
|
|
retrievalHint: toHex(message.retrievalHint)
|
|
});
|
|
|
|
const deserializeContentMessage = (
|
|
record: StoredContentMessage
|
|
): ContentMessage | undefined => {
|
|
try {
|
|
const content = hexToBytes(record.content);
|
|
return new ContentMessage(
|
|
record.messageId,
|
|
record.channelId,
|
|
record.senderId,
|
|
record.causalHistory.map(deserializeHistoryEntry),
|
|
BigInt(record.lamportTimestamp),
|
|
fromHex(record.bloomFilter),
|
|
content,
|
|
[],
|
|
fromHex(record.retrievalHint)
|
|
);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
const toHex = (
|
|
data?: Uint8Array | Uint8Array<ArrayBufferLike>
|
|
): string | undefined => {
|
|
if (!data || data.length === 0) {
|
|
return undefined;
|
|
}
|
|
return bytesToHex(data instanceof Uint8Array ? data : new Uint8Array(data));
|
|
};
|
|
|
|
const fromHex = (value?: string): Uint8Array | undefined => {
|
|
if (!value) {
|
|
return undefined;
|
|
}
|
|
return hexToBytes(value);
|
|
};
|