UNPKG

tinybase

Version:

A reactive data store and sync engine.

301 lines (291 loc) 9.25 kB
const getTypeOf = (thing) => typeof thing; const EMPTY_STRING = ''; const STRING = getTypeOf(EMPTY_STRING); const T = 't'; const V = 'v'; const strStartsWith = (str, prefix) => str.startsWith(prefix); const promise = Promise; const isInstanceOf = (thing, cls) => thing instanceof cls; const isUndefined = (thing) => thing == void 0; const ifNotUndefined = (value, then, otherwise) => isUndefined(value) ? otherwise?.() : then(value); const isString = (thing) => getTypeOf(thing) == STRING; const slice = (arrayOrString, start, end) => arrayOrString.slice(start, end); const size = (arrayOrString) => arrayOrString.length; const promiseAll = async (promises) => promise.all(promises); const arrayEvery = (array, cb) => array.every(cb); const arrayMap = (array, cb) => array.map(cb); const arrayIsEmpty = (array) => size(array) == 0; const arrayPush = (array, ...values) => array.push(...values); const arrayUnshift = (array, ...values) => array.unshift(...values); const object = Object; const objEntries = object.entries; const objNew = (entries = []) => object.fromEntries(entries); const objHas = (obj, id) => id in obj; const objToArray = (obj, cb) => arrayMap(objEntries(obj), ([id, value]) => cb(value, id)); const objEnsure = (obj, id, getDefaultValue) => { if (!objHas(obj, id)) { obj[id] = getDefaultValue(); } return obj[id]; }; const jsonString = JSON.stringify; const jsonParse = JSON.parse; const jsonStringWithMap = (obj) => jsonString(obj, (_key, value) => isInstanceOf(value, Map) ? object.fromEntries([...value]) : value, ); const collForEach = (coll, cb) => coll?.forEach(cb); const mapForEach = (map, cb) => collForEach(map, (value, key) => cb(key, value)); const SET_CHANGES = 's'; const STORE_PATH = '/store'; const PUT = 'PUT'; const construct = (prefix, type, payload) => prefix + type + (isString(payload) ? payload : jsonStringWithMap(payload)); const deconstruct = (prefix, message, stringified) => { const prefixSize = size(prefix); return strStartsWith(message, prefix) ? [ message[prefixSize], (stringified ? jsonParse : String)(slice(message, prefixSize + 1)), ] : void 0; }; const HAS_STORE = 'hasStore'; const RESPONSE_HEADERS = objNew( arrayMap(['Origin', 'Methods', 'Headers'], (suffix) => [ 'Access-Control-Allow-' + suffix, '*', ]), ); const hasStoreInStorage = async (storage, storagePrefix = EMPTY_STRING) => !!(await storage.get(storagePrefix + HAS_STORE)); const loadStoreFromStorage = async (storage, storagePrefix = EMPTY_STRING) => { const tables = {}; const values = {}; mapForEach(await storage.list(), (key, cellOrValue) => ifNotUndefined(deconstruct(storagePrefix, key), ([type, ids]) => { if (type == T) { const [tableId, rowId, cellId] = jsonParse('[' + ids + ']'); objEnsure(objEnsure(tables, tableId, objNew), rowId, objNew)[cellId] = cellOrValue; } else if (type == V) { values[ids] = cellOrValue; } }), ); return [tables, values]; }; const broadcastChanges = async (server, changes, without) => server.party.broadcast( construct( server.config.messagePrefix ?? EMPTY_STRING, SET_CHANGES, changes, ), without, ); const saveStore = async (that, changes, initialSave, requestOrConnection) => { const storage = that.party.storage; const storagePrefix = that.config.storagePrefix ?? EMPTY_STRING; const keysToSet = { [storagePrefix + HAS_STORE]: 1, }; const keysToDel = []; const keyPrefixesToDel = []; await promiseAll( objToArray(changes[0], async (table, tableId) => isUndefined(table) ? !initialSave && (await that.canDelTable(tableId, requestOrConnection)) && arrayUnshift( keyPrefixesToDel, constructStorageKey(storagePrefix, T, tableId), ) : (await that.canSetTable(tableId, initialSave, requestOrConnection)) && (await promiseAll( objToArray(table, async (row, rowId) => isUndefined(row) ? !initialSave && (await that.canDelRow(tableId, rowId, requestOrConnection)) && arrayPush( keyPrefixesToDel, constructStorageKey(storagePrefix, T, tableId, rowId), ) : (await that.canSetRow( tableId, rowId, initialSave, requestOrConnection, )) && (await promiseAll( objToArray(row, async (cell, cellId) => { const ids = [tableId, rowId, cellId]; const key = constructStorageKey(storagePrefix, T, ...ids); if (isUndefined(cell)) { if ( !initialSave && (await that.canDelCell(...ids, requestOrConnection)) ) { arrayPush(keysToDel, key); } } else if ( await that.canSetCell( ...ids, cell, initialSave, requestOrConnection, await storage.get(key), ) ) { keysToSet[key] = cell; } }), )), ), )), ), ); await promiseAll( objToArray(changes[1], async (value, valueId) => { const key = storagePrefix + V + valueId; if (isUndefined(value)) { if ( !initialSave && (await that.canDelValue(valueId, requestOrConnection)) ) { arrayPush(keysToDel, key); } } else if ( await that.canSetValue( valueId, value, initialSave, requestOrConnection, await storage.get(key), ) ) { keysToSet[key] = value; } }), ); if (!arrayIsEmpty(keyPrefixesToDel)) { mapForEach(await storage.list(), (key) => arrayEvery( keyPrefixesToDel, (keyPrefixToDelete) => !strStartsWith(key, keyPrefixToDelete) || (arrayPush(keysToDel, key) && 0), ), ); } await storage.delete(keysToDel); await storage.put(keysToSet); }; const constructStorageKey = (storagePrefix, type, ...ids) => construct(storagePrefix, type, slice(jsonStringWithMap(ids), 1, -1)); const createResponse = async (that, status, body = null) => new Response(body, { status, headers: that.config.responseHeaders, }); class TinyBasePartyKitServer { constructor(party) { this.party = party; this.config.storePath ??= STORE_PATH; this.config.messagePrefix ??= EMPTY_STRING; this.config.storagePrefix ??= EMPTY_STRING; this.config.responseHeaders ??= RESPONSE_HEADERS; } config = {}; async onRequest(request) { const { party: {storage}, config: {storePath = STORE_PATH, storagePrefix}, } = this; if (new URL(request.url).pathname.endsWith(storePath)) { const hasExistingStore = await hasStoreInStorage(storage, storagePrefix); const text = await request.text(); if (request.method == PUT) { if (hasExistingStore) { return createResponse(this, 205); } await saveStore(this, jsonParse(text), true, request); return createResponse(this, 201); } return createResponse( this, 200, hasExistingStore ? jsonStringWithMap( await loadStoreFromStorage(storage, storagePrefix), ) : EMPTY_STRING, ); } return createResponse(this, 404); } async onMessage(message, connection) { const { config: {messagePrefix = EMPTY_STRING, storagePrefix}, } = this; await ifNotUndefined( deconstruct(messagePrefix, message, 1), async ([type, payload]) => { if ( type == SET_CHANGES && (await hasStoreInStorage(this.party.storage, storagePrefix)) ) { await saveStore(this, payload, false, connection); broadcastChanges(this, payload, [connection.id]); } }, ); } async canSetTable(_tableId, _initialSave, _requestOrConnection) { return true; } async canDelTable(_tableId, _connection) { return true; } async canSetRow(_tableId, _rowId, _initialSave, _requestOrConnection) { return true; } async canDelRow(_tableId, _rowId, _connection) { return true; } async canSetCell( _tableId, _rowId, _cellId, _cell, _initialSave, _requestOrConnection, _oldCell, ) { return true; } async canDelCell(_tableId, _rowId, _cellId, _connection) { return true; } async canSetValue( _valueId, _value, _initialSave, _requestOrConnection, _oldValue, ) { return true; } async canDelValue(_valueId, _connection) { return true; } } export { TinyBasePartyKitServer, broadcastChanges, hasStoreInStorage, loadStoreFromStorage, };