diff --git a/package-lock.json b/package-lock.json index 8cfcdfba..a1746ec7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "idb-keyval": "6.2.0", "js-sha3": "0.9.3", "jsonld": "8.3.1", + "mitt": "^2.1.0", "pubsub-js": "1.9.4", "uuid": "9.0.1" }, @@ -6307,6 +6308,12 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mitt": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-2.1.0.tgz", + "integrity": "sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg==", + "license": "MIT" + }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", diff --git a/package.json b/package.json index 611c1b1b..0b4be04f 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "idb-keyval": "6.2.0", "js-sha3": "0.9.3", "jsonld": "8.3.1", + "mitt": "^2.1.0", "pubsub-js": "1.9.4", "uuid": "9.0.1" }, diff --git a/src/storage/index.ts b/src/storage/index.ts index f8738d49..fc748901 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -7,3 +7,4 @@ export * from './local-storage'; export * from './indexed-db'; export * from './shared'; export * from './fs'; +export * from './session'; diff --git a/src/storage/session/index.ts b/src/storage/session/index.ts new file mode 100644 index 00000000..8d0cce1c --- /dev/null +++ b/src/storage/session/index.ts @@ -0,0 +1,2 @@ +export * from './keystore'; +export * from './utility'; \ No newline at end of file diff --git a/src/storage/session/keystore.ts b/src/storage/session/keystore.ts new file mode 100644 index 00000000..5627b989 --- /dev/null +++ b/src/storage/session/keystore.ts @@ -0,0 +1,235 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @cspell/spellchecker */ +import mitt, { Emitter } from 'mitt' +import { split, saveToWindowName, loadFromWindowName, join } from './utility' + +interface ExpirableKeyV0 { + readonly key: string + readonly expiresAt?: number // timestamp +} + +interface ExpirableKeyV1 { + v: 1 + readonly value: string + readonly expiresAt?: number // timestamp +} + +const isExpirableKeyV0 = (entry: any): entry is ExpirableKeyV0 => { + return entry.v === undefined && !!entry.key +} +const isExpirableKeyV1 = (entry: any): entry is ExpirableKeyV1 => { + return entry.v === 1 && !!entry.value +} + +const convertV0toV1 = (v0Entry: ExpirableKeyV0): ExpirableKeyV1 => ({ + v: 1, + value: v0Entry.key, + expiresAt: v0Entry.expiresAt +}) + +// -- + +export interface KeyEvent { + name: Keys +} + +export interface EventMap { + created: KeyEvent + read: KeyEvent + updated: KeyEvent + deleted: KeyEvent + expired: KeyEvent + persisting: KeyEvent +} + +export type EventTypes = keyof EventMap +export type EventPayload> = EventMap[T] +export type Callback> = ( + value: EventPayload +) => void + +export interface ConstructorOptions { + name?: string, + onChanged: (keyName: string) => void, + onExpired: () => void +} + +// -- + +export class SessionKeystore { + // Members + readonly name: string + readonly #storageKey: string + #emitter: Emitter + #store: Map + #timeouts: Map + readonly id: number + + // -- + + constructor(opts: ConstructorOptions = { + onChanged: function (keyName: string): void { + throw new Error(`Function not implemented. keyName: ${keyName}`) + }, + onExpired: function (): void { + throw new Error('Function not implemented.') + } + }) { + this.name = opts.name || 'default' + this.id = Math.floor(Math.random() * 1000000) + this.#storageKey = `session-keystore:${this.name}` + this.#emitter = mitt() + this.#store = new Map() + this.#timeouts = new Map() + /* istanbul ignore else */ + if (typeof window !== 'undefined') { + try { + this._load() + } catch { /* empty */ } + window.addEventListener('unload', this.persist.bind(this)) + } + } + + // Event Emitter -- + + // Returns an unsubscribe callback + on>(event: T, callback: Callback) { + this.#emitter.on(event, callback as any) + return () => this.#emitter.off(event, callback as any) + } + + off>(event: T, callback: Callback) { + this.#emitter.off(event, callback as any) + } + + // API -- + + set(key: Keys, value: string, expiresAt?: Date | number) { + console.log('key', key) + let d: number | undefined + if (expiresAt !== undefined) { + d = typeof expiresAt === 'number' ? expiresAt : expiresAt.valueOf() + } + const newItem: ExpirableKeyV1 = { + v: 1, + value, + expiresAt: d + } + console.log('newItem', newItem) + const oldItem = this.#store.get(key) + console.log('oldItem', oldItem) + this.#store.set(key, newItem) + if (this._setTimeout(key) === 'expired') { + return // Don't call created or updated + } + if (!oldItem) { + this.#emitter.emit('created', { name: key, value: newItem.value }) + } else if (oldItem.value !== newItem.value) { + this.#emitter.emit('updated', { name: key }) + } + } + + get(key: Keys, now = Date.now()) { + console.log('get', key) + const item = this.#store.get(key) + console.log('item', item) + if (!item) { + return null + } + if (item.expiresAt !== undefined && item.expiresAt <= now) { + this._expired(key) + return null + } + this.#emitter.emit('read', { name: key }) + return item.value + } + + delete(key: Keys) { + this._clearTimeout(key) + this.#store.delete(key) + this.#emitter.emit('deleted', { name: key }) + } + + clear() { + this.#store.forEach((_, key) => this.delete(key)) + } + + // -- + + persist() { + this.#emitter.emit('persisting', { name: this.name }); + /* istanbul ignore next */ + if (typeof window === 'undefined') { + throw new Error( + 'SessionKeystore.persist is only available in the browser.' + ) + } + const json = JSON.stringify(Array.from(this.#store.entries())) + const [a, b] = split(json) + saveToWindowName(this.#storageKey, a) + window.sessionStorage.setItem(this.#storageKey, b) + } + + private _load() { + const a = loadFromWindowName(this.#storageKey) + const b = window.sessionStorage.getItem(this.#storageKey) + window.sessionStorage.removeItem(this.#storageKey) + if (!a || !b) { + return + } + const json = join(a, b) + /* istanbul ignore next */ + if (!json) { + return + } + const entries: [Keys, ExpirableKeyV1][] = JSON.parse(json) + + this.#store = new Map( + entries.map(([key, item]) => { + if (isExpirableKeyV0(item)) { + return [key, convertV0toV1(item)] + } + if (isExpirableKeyV1(item)) { + return [key, item] + } + /* istanbul ignore next */ + return [key, item] + }) + ) + // Re-establish timeouts + this.#store.forEach((_, key) => { + this._setTimeout(key) + }) + } + + private _setTimeout(key: Keys): 'expired' | undefined { + this._clearTimeout(key) + const keyEntry = this.#store.get(key) + if (keyEntry?.expiresAt === undefined) { + return + } + const now = Date.now() + const timeout = keyEntry.expiresAt - now + if (timeout <= 0) { + this._expired(key) + return 'expired' + } + const t = setTimeout(() => { + this._expired(key) + }, timeout) + this.#timeouts.set(key, t) + return undefined + } + + private _clearTimeout(key: Keys) { + const timeoutId = this.#timeouts.get(key) + clearTimeout(timeoutId) + this.#timeouts.delete(key) + } + + private _expired(key: Keys) { + this._clearTimeout(key) + this.#store.delete(key) + this.#emitter.emit('expired', { name: key }) + } +} \ No newline at end of file diff --git a/src/storage/session/utility.ts b/src/storage/session/utility.ts new file mode 100644 index 00000000..3567c2a7 --- /dev/null +++ b/src/storage/session/utility.ts @@ -0,0 +1,67 @@ +import crypto from 'crypto' // Node.js crypto module +import { decodeBase64url, encodeBase64url, utf8Decoder, utf8Encoder } from '../../utils' +import { base64UrlToBytes, bytesToBase64url } from '../../utils/encoding'; + +const randomBytes = (length: number): Uint8Array => { + if (typeof window === 'undefined') { + return crypto.randomBytes(length) + } else { + return window.crypto.getRandomValues(new Uint8Array(length)) + } +} + +export const split = (secret: string): string[] => { + const buff = utf8Encoder(secret) + const rand1 = randomBytes(buff.length) + const rand2 = new Uint8Array(rand1) // Make a copy + for (let i = 0; i < buff.length; i++) { + rand2[i] = rand2[i] ^ buff[i] + } + return [encodeBase64url(bytesToBase64url(rand1)), encodeBase64url(bytesToBase64url(rand2))] +} + +export const join = (a: string, b: string) => { + if (a.length !== b.length) { + return null + } + const aBuff = base64UrlToBytes(decodeBase64url(a)) + const bBuff = base64UrlToBytes(decodeBase64url(b)) + const output = new Uint8Array(aBuff.length) + for (const i in output) { + output[i] = aBuff[i] ^ bBuff[i] + } + return utf8Decoder(output) +} + +// -- + +const loadObjectFromWindowName = (): { [key: string]: string } => { + if (!window.top || !window.top.name || window.top.name === '') { + return {} + } + try { + return JSON.parse(window.top.name) + } catch { /* empty */ } + return {} +} + +export const saveToWindowName = (name: string, data: string) => { + const obj = loadObjectFromWindowName() + obj[name] = data + if (window.top) { + window.top.name = JSON.stringify(obj) + } +} + +export const loadFromWindowName = (name: string) => { + const saved = loadObjectFromWindowName() + if (!(name in saved)) { + return null + } + const { [name]: out, ...safe } = saved + const json = JSON.stringify(safe) + if (window.top) { + window.top.name = json === '{}' ? '' : json + } + return out || null +} \ No newline at end of file diff --git a/src/utils/encoding.ts b/src/utils/encoding.ts index 64482f4b..7cc0ac1c 100644 --- a/src/utils/encoding.ts +++ b/src/utils/encoding.ts @@ -45,3 +45,12 @@ export function decodeBase64url(s: string, opts = { loose: true }): string { export function bytesToHex(b: Uint8Array): string { return Hex.encodeString(b); } + +export function utf8Decoder(b: Uint8Array): string { + return byteDecoder.decode(b); +} + +export function utf8Encoder(s: string): Uint8Array { + const buf = byteEncoder.encode(s); + return new Uint8Array(buf, 0, buf.length); +} \ No newline at end of file