@socketsupply/socket
Version:
A Cross-Platform, Native Runtime for Desktop and Mobile Apps — Create apps using HTML, CSS, and JavaScript. Written from the ground up to be small and maintainable.
872 lines (751 loc) • 18.7 kB
JavaScript
/* global ErrorEvent */
import { defineBuiltin } from './builtins.js'
import { Deferred } from '../async/deferred.js'
import serialize from '../internal/serialize.js'
import database from '../internal/database.js'
import gc from '../gc.js'
/**
* @typedef {{
* types?: object,
* loader?: import('./loader.js').Loader
* }} CacheOptions
*/
export const CACHE_CHANNEL_MESSAGE_ID = 'id'
export const CACHE_CHANNEL_MESSAGE_REPLICATE = 'replicate'
/**
* @typedef {{
* name: string
* }} StorageOptions
*/
/**
* An storage context object with persistence and durability
* for service worker storages.
*/
export class Storage extends EventTarget {
/**
* Maximum entries that will be restored from storage into the context object.
* @type {number}
*/
static MAX_CONTEXT_ENTRIES = 16 * 1024
/**
* A mapping of known `Storage` instances.
* @type {Map<string, Storage>}
*/
static instances = new Map()
/**
* Opens an storage for a particular name.
* @param {StorageOptions} options
* @return {Promise<Storage>}
*/
static async open (options) {
if (Storage.instances.has(options?.name)) {
const storage = Storage.instances.get(options.name)
await storage.ready
return storage
}
const storage = new this(options)
Storage.instances.set(storage.name, storage)
await storage.open()
return storage
}
#database = null
#opening = false
#context = {}
#proxy = null
#ready = null
#name = null
/**
* `Storage` class constructor
* @ignore
* @param {StorageOptions} options
*/
constructor (options) {
super()
this.#name = options.name
this.#proxy = new Proxy(this.#context, {
get: (_, property) => {
return this.#context[property]
},
set: (_, property, value) => {
this.#context[property] = value
if (this.database && this.database.opened) {
this.forwardRequest(this.database.put(property, value, { durability: 'relaxed' }))
}
return true
},
deleteProperty: (_, property) => {
if (this.database && this.database.opened) {
this.forwardRequest(this.database.delete(property))
}
return Reflect.deleteProperty(this.#context, property)
},
getOwnPropertyDescriptor: (_, property) => {
if (property in this.#context) {
return {
configurable: true,
enumerable: true,
writable: true,
value: this.#context[property]
}
}
},
has: (_, property) => {
return Reflect.has(this.#context, property)
},
ownKeys: (_) => {
return Reflect.ownKeys(this.#context)
}
})
}
/**
* A reference to the currently opened storage database.
* @type {import('../internal/database.js').Database}
*/
get database () {
return this.#database
}
/**
* `true` if the storage is opened, otherwise `false`.
* @type {boolean}
*/
get opened () {
return this.#database?.opened === true
}
/**
* `true` if the storage is opening, otherwise `false`.
* @type {boolean}
*/
get opening () {
return this.#opening
}
/**
* A proxied object for reading and writing storage state.
* Values written to this object must be cloneable with respect to the
* structured clone algorithm.
* @see {https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm}
* @type {Proxy<object>}
*/
get context () {
return this.#proxy
}
/**
* The current storage name. This value is also used as the
* internal database name.
* @type {string}
*/
get name () {
return `socket.runtime.commonjs.cache.storage(${this.#name})`
}
/**
* A promise that resolves when the storage is opened.
* @type {Promise?}
*/
get ready () {
return this.#ready?.promise
}
/**
* @ignore
* @param {Promise} promise
*/
async forwardRequest (promise) {
try {
return await promise
} catch (error) {
this.dispatchEvent(new ErrorEvent('error', { error }))
}
}
/**
* Resets the current storage to an empty state.
*/
async reset () {
await this.close()
await database.drop(this.name)
this.#database = null
await this.open()
}
/**
* Synchronizes database entries into the storage context.
*/
async sync (options = null) {
const entries = await this.#database.get(options?.query ?? undefined, {
count: Storage.MAX_CONTEXT_ENTRIES
})
const promises = []
const snapshot = Object.keys(this.#context)
const delta = []
for (const [key, value] of entries) {
if (!snapshot.includes(key)) {
delta.push(key)
}
this.#context[key] = value
}
for (const key of delta) {
const value = this.#context[key]
promises.push(this.forwardRequest(this.database.put(key, value, { durability: 'relaxed' })))
}
await Promise.all(promises)
}
/**
* Opens the storage.
* @ignore
*/
async open (options = null) {
if (!this.opening && !this.#database) {
this.#opening = true
this.#ready = new Deferred()
this.#database = await database.open(this.name)
await this.sync(options?.sync ?? null)
this.#opening = false
this.#ready.resolve()
}
return this.#ready.promise
}
/**
* Closes the storage database, purging existing state.
* @ignore
*/
async close () {
await this.#database.close()
for (const key in this.#context) {
Reflect.deleteProperty(this.#context, key)
}
}
}
/**
* A container for `Snapshot` data storage.
*/
export class SnapshotData {
/**
* `SnapshotData` class constructor.
* @param {object=} [data]
*/
constructor (data = null) {
// make the `prototype` a `null` value
Object.setPrototypeOf(this, null)
if (data && typeof data === 'object') {
Object.assign(this, data)
}
this[Symbol.toStringTag] = 'Snapshot.Data'
this.toJSON = () => {
return { ...this }
}
}
}
/**
* A container for storing a snapshot of the cache data.
*/
export class Snapshot {
/**
* @type {typeof SnapshotData}
*/
static Data = SnapshotData
// @ts-ignore
#data = new Snapshot.Data()
/**
* `Snapshot` class constructor.
*/
constructor () {
for (const key in Cache.shared) {
// @ts-ignore
this.#data[key] = new Snapshot.Data(Object.fromEntries(Cache.shared[key].entries()))
}
}
/**
* A reference to the snapshot data.
* @type {Snapshot.Data}
*/
get data () {
return this.#data
}
/**
* @ignore
*/
[Symbol.for('socket.runtime.serialize')] () {
return { ...serialize(this.#data) }
}
/**
* @ignore
* @return {object}
*/
toJSON () {
return { ...serialize(this.#data) }
}
}
/**
* An interface for managing and performing operations on a collection
* of `Cache` objects.
*/
export class CacheCollection {
/**
* `CacheCollection` class constructor.
* @ignore
* @param {Cache[]|Record<string, Cache>=} [collection]
*/
constructor (collection = null) {
if (collection && typeof collection === 'object') {
if (Array.isArray(collection)) {
for (const value of collection) {
if (value instanceof Cache) {
this.add(value)
}
}
} else {
for (const key in collection) {
const value = collection[key]
this.add(key, value)
}
}
}
}
/**
* Adds a `Cache` instance to the collection.
* @param {string|Cache} name
* @param {Cache=} [cache]
* @param {boolean}
*/
add (name, cache = null) {
if (name instanceof Cache) {
return this.add(name.name, name)
}
if (typeof name === 'string' && cache instanceof Cache) {
if (name in Object.getPrototypeOf(this)) {
return false
}
Object.defineProperty(this, name, {
configurable: false,
enumerable: true,
writable: false,
value: cache
})
return true
}
return false
}
/**
* Calls a method on each `Cache` object in the collection.
* @param {string} method
* @param {...any} args
* @return {Promise<Record<string,any>>}
*/
async call (method, ...args) {
const results = {}
for (const key in this) {
const value = this[key]
if (value instanceof Cache) {
if (typeof value[method] === 'function') {
results[key] = await value[method](...args)
}
}
}
return results
}
async restore () {
return await this.call('restore')
}
async reset () {
return await this.call('reset')
}
async snapshot () {
return await this.call('snapshot')
}
async get (key) {
return await this.call('get', key)
}
async delete (key) {
return await this.call('delete', key)
}
async keys (key) {
return await this.call('keys', key)
}
async values (key) {
return await this.call('values', key)
}
async clear (key) {
return await this.call('clear', key)
}
}
/**
* A container for a shared cache that lives for the life time of
* application execution. Updates to this storage are replicated to other
* instances in the application context, including windows and workers.
*/
export class Cache {
/**
* A globally shared type mapping for the cache to use when
* derserializing a value.
* @type {Map<string, function>}
*/
static types = new Map()
/**
* A globally shared cache store keyed by cache name. This is useful so
* when multiple instances of a `Cache` are created, they can share the
* same data store, reducing duplications.
* @type {Record<string, Map<string, object>}
*/
static shared = Object.create(null)
/**
* A mapping of opened `Storage` instances.
* @type {Map<string, Storage>}
*/
static storages = Storage.instances
/**
* The `Cache.Snapshot` class.
* @type {typeof Snapshot}
*/
static Snapshot = Snapshot
/**
* The `Cache.Storage` class
* @type {typeof Storage}
*/
static Storage = Storage
/**
* Creates a snapshot of the current cache which can be serialized and
* stored in persistent storage.
* @return {Snapshot}
*/
static snapshot () {
return new Snapshot()
}
/**
* Restore caches from persistent storage.
* @param {string[]} names
* @return {Promise}
*/
static async restore (names) {
const promises = []
for (const name of names) {
promises.push(Storage.open({ name }).then((storage) => {
this.storages.set(name, storage)
Cache.shared[name] ||= new Map()
for (const key in storage.context) {
const value = storage.context[key]
Cache.shared[name].set(key, value)
}
}))
}
await Promise.all(promises)
}
#onmessage = null
#storage = null
#channel = null
#loader = null
#name = ''
#types = null
#data = null
#id = null
/**
* `Cache` class constructor.
* @param {string} name
* @param {CacheOptions=} [options]
*/
constructor (name, options) {
if (!name || typeof name !== 'string') {
throw new TypeError(`Expecting 'name' to be a string. Received: ${name}`)
}
if (!Cache.shared[name]) {
Cache.shared[name] = new Map()
}
this.#id = Math.random().toString(16).slice(2)
this.#name = name
this.#data = Cache.shared[name]
this.#types = new Map(Cache.types.entries())
this.#loader = options?.loader ?? null
this.#storage = Cache.storages.get(name) ?? new Storage({ name })
this.#channel = new BroadcastChannel(`socket.runtime.commonjs.cache.${name}`)
if (!Cache.storages.has(name)) {
Cache.storages.set(name, this.#storage)
}
if (options?.types && typeof options.types === 'object') {
for (const key in options.types) {
const value = options.types[key]
if (typeof value === 'function') {
this.#types.set(key, value)
}
}
}
this.#onmessage = (event) => {
const { data } = event
if (!data || typeof data !== 'object') {
return
}
if (data[CACHE_CHANNEL_MESSAGE_ID] === this.#id) {
return
}
// recv 'replicate'
if (Array.isArray(data[CACHE_CHANNEL_MESSAGE_REPLICATE])) {
for (const entry of data[CACHE_CHANNEL_MESSAGE_REPLICATE]) {
if (!this.has(...entry)) {
const [key, value] = entry
if (value?.__type__) {
this.#data.set(key, this.#types.get(value.__type__).from(value, {
loader: this.#loader
}))
} else {
this.#data.set(key, value)
}
}
}
}
}
this.#channel.addEventListener('message', this.#onmessage)
this.#storage.open()
gc.ref(this)
}
/**
* The unique ID for this cache.
* @type {string}
*/
get id () {
return this.#id
}
/**
* The loader associated with this cache.
* @type {import('./loader.js').Loader}
*/
get loader () {
return this.#loader
}
/**
* A reference to the persisted storage.
* @type {Storage}
*/
get storage () {
return this.#storage
}
/**
* The cache name
* @type {string}
*/
get name () {
return this.#name
}
/**
* The underlying cache data map.
* @type {Map}
*/
get data () {
return this.#data
}
/**
* The broadcast channel associated with this cach.
* @type {BroadcastChannel}
*/
get channel () {
return this.#channel
}
/**
* The size of the cache.
* @type {number}
*/
get size () {
return this.#data.size
}
/**
* @type {Map}
*/
get types () {
return this.#types
}
/**
* Resets the cache map and persisted storage.
*/
async reset () {
this.#data.clear()
await this.#storage.reset()
}
/**
* Restores cache data from storage.
*/
async restore () {
if (!this.#storage.opened) {
await this.#storage.open()
}
for (const key in this.#storage.context) {
const value = this.#storage.context[key]
if (value && !this.#data.has(key)) {
if (value?.__type__) {
this.#data.set(key, this.#types.get(value.__type__).from(value, {
loader: this.loader
}))
} else {
this.#data.set(key, value)
}
}
}
}
/**
* Creates a snapshot of the current cache which can be serialized and
* stored in persistent storage.
* @return {Snapshot.Data}
*/
snapshot () {
const snapshot = new Snapshot()
return snapshot.data[this.name]
}
/**
* Get a value at `key`.
* @param {string} key
* @return {object|undefined}
*/
get (key) {
const types = Array.from(this.types.values())
let value = null
if (this.#data.has(key)) {
value = this.#data.get(key)
} else if (key in this.#storage.context) {
value = this.#storage.context[key]
}
if (!value) {
return
}
// late init from type
if (value?.__type__ && this.#types.has(value.__type__)) {
value = this.#types.get(value.__type__).from(value, {
loader: this.#loader
})
} else if (types.length === 1) {
// if there is only 1 type in this cache types mapping, it most likely is the
// general type used for this cache, so try to use it
const [Type] = types
if (typeof Type === 'function' && !(value instanceof Type)) {
if (typeof Type.from === 'function') {
value = Type.from(value, {
loader: this.#loader
})
}
}
}
// reset the value
this.#data.set(key, value)
return value
}
/**
* Set `value` at `key`.
* @param {string} key
* @param {object} value
* @return {Cache}
*/
set (key, value) {
this.#data.set(key, value)
this.#storage.context[key] = serialize(value)
return this
}
/**
* Returns `true` if `key` is in cache, otherwise `false`.
* @param {string}
* @return {boolean}
*/
has (key) {
return this.#data.has(key)
}
/**
* Delete a value at `key`.
* This does not replicate to shared caches.
* @param {string} key
* @return {boolean}
*/
delete (key) {
delete this.#storage.context[key]
if (this.#data.delete(key)) {
return true
}
return false
}
/**
* Returns an iterator for all cache keys.
* @return {object}
*/
keys () {
return this.#data.keys()
}
/**
* Returns an iterator for all cache values.
* @return {object}
*/
values () {
return this.#data.values()
}
/**
* Returns an iterator for all cache entries.
* @return {object}
*/
entries () {
return this.#data.entries()
}
/**
* Clears all entries in the cache.
* This does not replicate to shared caches.
* @return {undefined}
*/
clear () {
this.#data.clear()
}
/**
* Enumerates entries in map calling `callback(value, key
* @param {function(object, string, Cache): any} callback
*/
forEach (callback) {
if (!callback || typeof callback !== 'function') {
throw new TypeError(`${callback} is not a function`)
}
this.#data.forEach((value, key) => {
callback(value, key, this)
})
}
/**
* Broadcasts a replication to other shared caches.
*/
replicate () {
const entries = Array.from(this.#data.entries())
if (entries.length === 0) {
return this
}
const message = {
[CACHE_CHANNEL_MESSAGE_ID]: this.#id,
[CACHE_CHANNEL_MESSAGE_REPLICATE]: entries
}
this.#channel.postMessage(message)
return this
}
/**
* Destroys the cache. This function stops the broadcast channel and removes
* and listeners
*/
destroy () {
this.#channel.removeEventListener('message', this.#onmessage)
this.#data.clear()
this.#data = null
this.#channel = null
gc.unref(this)
}
/**
* @ignore
*/
[Symbol.iterator] () {
return this.#data.entries()
}
/**
* Implements `gc.finalizer` for gc'd resource cleanup.
* @return {gc.Finalizer}
* @ignore
*/
[gc.finalizer] () {
return {
args: [this.#data, this.#channel, this.#onmessage],
async handle (data, channel, onmessage) {
data.clear()
channel.removeEventListener('message', onmessage)
}
}
}
}
export default Cache
defineBuiltin('commonjs/cache', {
CACHE_CHANNEL_MESSAGE_ID,
CACHE_CHANNEL_MESSAGE_REPLICATE,
CacheCollection,
SnapshotData,
Snapshot,
Storage,
Cache
})