From 29957c2a0189f2c047dd9a64011cc24bca644ccd Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Wed, 10 Apr 2024 18:57:58 -0400 Subject: [PATCH 01/12] dwn-server-info client and types --- .../clients/dwn-server-info-cache-level.ts | 159 +++++++++++++++++ .../clients/dwn-server-info-cache-memory.ts | 79 +++++++++ .../clients/dwn-server-info-cache-no-op.ts | 17 ++ .../prototyping/clients/server-info-types.ts | 21 +++ .../clients/dwn-server-info-cache.spec.ts | 164 ++++++++++++++++++ 5 files changed, 440 insertions(+) create mode 100644 packages/agent/src/prototyping/clients/dwn-server-info-cache-level.ts create mode 100644 packages/agent/src/prototyping/clients/dwn-server-info-cache-memory.ts create mode 100644 packages/agent/src/prototyping/clients/dwn-server-info-cache-no-op.ts create mode 100644 packages/agent/src/prototyping/clients/server-info-types.ts create mode 100644 packages/agent/tests/prototyping/clients/dwn-server-info-cache.spec.ts diff --git a/packages/agent/src/prototyping/clients/dwn-server-info-cache-level.ts b/packages/agent/src/prototyping/clients/dwn-server-info-cache-level.ts new file mode 100644 index 000000000..6cc0551d4 --- /dev/null +++ b/packages/agent/src/prototyping/clients/dwn-server-info-cache-level.ts @@ -0,0 +1,159 @@ +import type { AbstractLevel } from 'abstract-level'; + +import ms from 'ms'; +import { Level } from 'level'; +import { DwnServerInfoCache, ServerInfo } from './server-info-types.js'; + +/** + * Configuration parameters for creating a LevelDB-based cache for DWN Server Info results. + * + * Allows customization of the underlying database instance, storage location, and cache + * time-to-live (TTL) settings. + */ +export type DwnServerCacheLevelParams = { + /** + * Optional. An instance of `AbstractLevel` to use as the database. If not provided, a new + * LevelDB instance will be created at the specified `location`. + */ + db?: AbstractLevel; + + /** + * Optional. The file system path or IndexedDB name where the LevelDB store will be created. + * Defaults to 'DATA/DWN_SERVERINFOCACHE' if not specified. + */ + location?: string; + + /** + * Optional. The time-to-live for cache entries, expressed as a string (e.g., '1h', '15m'). + * Determines how long a cache entry should remain valid before being considered expired. Defaults + * to '15m' if not specified. + */ + ttl?: string; +} + +/** + * Encapsulates a ServerInfo result along with its expiration information for caching purposes. + * + * This type is used internally by the `DwnServerInfoCacheLevel` to store DWN ServerInfo results + * with an associated time-to-live (TTL) value. The TTL is represented in milliseconds and + * determines when the cached entry is considered expired and eligible for removal. + */ +type CacheWrapper = { + /** + * The expiration time of the cache entry in milliseconds since the Unix epoch. + * + * This value is used to calculate whether the cached entry is still valid or has expired. + */ + ttlMillis: number; + + /** + * The DWN ServerInfo entry being cached. + */ + value: ServerInfo; +} + +/** + * A Level-based cache implementation for storing and retrieving DWN ServerInfo results. + * + * This cache uses LevelDB for storage, allowing data persistence across process restarts or + * browser refreshes. It's suitable for both Node.js and browser environments. + * + * @remarks + * The LevelDB cache keeps data in memory for fast access and also writes to the filesystem in + * Node.js or indexedDB in browsers. Time-to-live (TTL) for cache entries is configurable. + * + * @example + * ``` + * const cache = new DwnServerInfoCacheLevel({ ttl: '15m' }); + * ``` + */ +export class DwnServerInfoCacheLevel implements DwnServerInfoCache { + /** The underlying LevelDB store used for caching. */ + private cache: AbstractLevel; + + /** The time-to-live for cache entries in milliseconds. */ + private ttl: number; + + constructor({ + db, + location = 'DATA/DWN_SERVERINFOCACHE', + ttl = '15m' + }: DwnServerCacheLevelParams = {}) { + this.cache = db ?? new Level(location); + this.ttl = ms(ttl); + } + + /** + * Retrieves a DWN ServerInfo entry from the cache. + * + * If the cached item has exceeded its TTL, it's scheduled for deletion and undefined is returned. + * + * @param dwnUrl - The DWN URL endpoint string used as the key for retrieving the cached result. + * @returns The cached ServerInfo entry or undefined if not found or expired. + */ + async get(dwnUrl: string): Promise { + try { + const str = await this.cache.get(dwnUrl); + const cacheWrapper: CacheWrapper = JSON.parse(str); + + if (Date.now() >= cacheWrapper.ttlMillis) { + // defer deletion to be called in the next tick of the js event loop + this.cache.nextTick(() => this.cache.del(dwnUrl)); + + return; + } else { + return cacheWrapper.value; + } + + } catch(error: any) { + // Don't throw when a key wasn't found. + if (error.notFound) { + return; + } + + throw error; + } + } + + /** + * Stores a DWN ServerInfo entry in the cache with a TTL. + * + * @param dwnUrl - The DWN URL endpoint string used as the key for storing the result. + * @param value - The DWN ServerInfo entry to be cached. + * @returns A promise that resolves when the operation is complete. + */ + set(dwnUrl: string, value: ServerInfo): Promise { + const cacheWrapper: CacheWrapper = { ttlMillis: Date.now() + this.ttl, value }; + const str = JSON.stringify(cacheWrapper); + + return this.cache.put(dwnUrl, str); + } + + /** + * Deletes a DWN ServerInfo entry from the cache. + * + * @param dwnUrl - The DWN URL endpoint string used as the key deletion. + * @returns A promise that resolves when the operation is complete. + */ + delete(dwnUrl: string): Promise { + return this.cache.del(dwnUrl); + } + + /** + * Clears all entries from the cache. + * + * @returns A promise that resolves when the operation is complete. + */ + clear(): Promise { + return this.cache.clear(); + } + + /** + * Closes the underlying LevelDB store. + * + * @returns A promise that resolves when the store is closed. + */ + close(): Promise { + return this.cache.close(); + } +} \ No newline at end of file diff --git a/packages/agent/src/prototyping/clients/dwn-server-info-cache-memory.ts b/packages/agent/src/prototyping/clients/dwn-server-info-cache-memory.ts new file mode 100644 index 000000000..65179b945 --- /dev/null +++ b/packages/agent/src/prototyping/clients/dwn-server-info-cache-memory.ts @@ -0,0 +1,79 @@ + +import ms from 'ms'; +import { TtlCache } from '@web5/common'; +import { DwnServerInfoCache, ServerInfo } from './server-info-types.js'; + +/** + * Configuration parameters for creating an in-memory cache for DWN ServerInfo entries. + * + * Allows customization of the cache time-to-live (TTL) setting. + */ +export type DwnServerInfoCacheMemoryParams = { + /** + * Optional. The time-to-live for cache entries, expressed as a string (e.g., '1h', '15m'). + * Determines how long a cache entry should remain valid before being considered expired. + * + * Defaults to '15m' if not specified. + */ + ttl?: string; +} + +export class DwnServerInfoCacheMemory implements DwnServerInfoCache { + private cache: TtlCache; + + constructor({ ttl = '15m' }: DwnServerInfoCacheMemoryParams= {}) { + this.cache = new TtlCache({ ttl: ms(ttl) }); + } + + /** + * Retrieves a DWN ServerInfo entry from the cache. + * + * If the cached item has exceeded its TTL, it's scheduled for deletion and undefined is returned. + * + * @param dwnUrl - The DWN URL endpoint string used as the key for getting the entry. + * @returns The cached DWN ServerInfo entry or undefined if not found or expired. + */ + public async get(dwnUrl: string): Promise { + return this.cache.get(dwnUrl); + } + + /** + * Stores a DWN ServerInfo entry in the cache with a TTL. + * + * @param dwnUrl - The DWN URL endpoint string used as the key for storing the entry. + * @param value - The DWN ServerInfo entry to be cached. + * @returns A promise that resolves when the operation is complete. + */ + public async set(dwnUrl: string, value: ServerInfo): Promise { + this.cache.set(dwnUrl, value); + } + + /** + * Deletes a DWN ServerInfo entry from the cache. + * + * @param dwnUrl - The DWN URL endpoint string used as the key for deletion. + * @returns A promise that resolves when the operation is complete. + */ + public async delete(dwnUrl: string): Promise { + this.cache.delete(dwnUrl); + } + + /** + * Clears all entries from the cache. + * + * @returns A promise that resolves when the operation is complete. + */ + public async clear(): Promise { + this.cache.clear(); + } + + /** + * This method is a no-op but exists to be consistent with other DWN ServerInfo Cache + * implementations. + * + * @returns A promise that resolves immediately. + */ + public async close(): Promise { + // No-op since there is no underlying store to close. + } +} \ No newline at end of file diff --git a/packages/agent/src/prototyping/clients/dwn-server-info-cache-no-op.ts b/packages/agent/src/prototyping/clients/dwn-server-info-cache-no-op.ts new file mode 100644 index 000000000..3705f1646 --- /dev/null +++ b/packages/agent/src/prototyping/clients/dwn-server-info-cache-no-op.ts @@ -0,0 +1,17 @@ + +import { DwnServerInfoCache, ServerInfo } from './server-info-types.js'; + +export class DwnServerInfoCacheNoOp implements DwnServerInfoCache { + + public async get(_dwnUrl: string): Promise { + return; + } + + public async set(_dwnUrl: string, _value: ServerInfo): Promise {} + + public async delete(_dwnUrl: string): Promise {} + + public async clear(): Promise {} + + public async close(): Promise {} +} \ No newline at end of file diff --git a/packages/agent/src/prototyping/clients/server-info-types.ts b/packages/agent/src/prototyping/clients/server-info-types.ts new file mode 100644 index 000000000..ecbad6684 --- /dev/null +++ b/packages/agent/src/prototyping/clients/server-info-types.ts @@ -0,0 +1,21 @@ +import { KeyValueStore } from "@web5/common"; + +export type ServerInfo = { + /** the maximum file size the user can request to store */ + maxFileSize: number, + /** + * an array of strings representing the server's registration requirements. + * + * ie. ['proof-of-work-sha256-v0', 'terms-of-service'] + * */ + registrationRequirements: string[], + /** whether web socket support is enabled on this server */ + webSocketSupport: boolean, +} + +export interface DwnServerInfoCache extends KeyValueStore {} + +export interface DwnServerInfo { + /** retrieves the DWN Sever info, used to detect features such as WebSocket Subscriptions */ + getServerInfo(url: string): Promise; +} \ No newline at end of file diff --git a/packages/agent/tests/prototyping/clients/dwn-server-info-cache.spec.ts b/packages/agent/tests/prototyping/clients/dwn-server-info-cache.spec.ts new file mode 100644 index 000000000..aea57abdd --- /dev/null +++ b/packages/agent/tests/prototyping/clients/dwn-server-info-cache.spec.ts @@ -0,0 +1,164 @@ +import sinon from 'sinon'; + +import { expect } from 'chai'; + +import { DwnServerInfoCache, ServerInfo } from '../../../src/prototyping/clients/server-info-types.js'; +import { DwnServerInfoCacheMemory } from '../../../src/prototyping/clients/dwn-server-info-cache-memory.js'; +import { DwnServerInfoCacheLevel } from '../../../src/prototyping/clients/dwn-server-info-cache-level.js'; +import { DwnServerInfoCacheNoOp } from '../../../src/prototyping/clients/dwn-server-info-cache-no-op.js'; +import { AbstractLevel } from 'abstract-level'; +import { isNode } from '../../utils/runtimes.js'; + +describe('DwnServerInfoCache', () => { + + const cacheImplementations = [ DwnServerInfoCacheMemory, DwnServerInfoCacheLevel ]; + + // basic cache tests for all caching interface implementations + for (const Cache of cacheImplementations) { + describe(`interface ${Cache.name}`, () => { + let cache: DwnServerInfoCache; + let clock: sinon.SinonFakeTimers; + + const exampleInfo:ServerInfo = { + maxFileSize : 100, + webSocketSupport : true, + registrationRequirements : [] + }; + + after(() => { + sinon.restore(); + }); + + beforeEach(() => { + clock = sinon.useFakeTimers(); + cache = new Cache(); + }); + + afterEach(async () => { + await cache.clear(); + await cache.close(); + clock.restore(); + }); + + it('sets server info in cache', async () => { + const key1 = 'some-key1'; + const key2 = 'some-key2'; + await cache.set(key1, { ...exampleInfo }); + await cache.set(key2, { ...exampleInfo, webSocketSupport: false }); // set to false + + const result1 = await cache.get(key1); + expect(result1!.webSocketSupport).to.deep.equal(true); + expect(result1).to.deep.equal(exampleInfo); + + const result2 = await cache.get(key2); + expect(result2!.webSocketSupport).to.deep.equal(false); + }); + + it('deletes from cache', async () => { + const key1 = 'some-key1'; + const key2 = 'some-key2'; + await cache.set(key1, { ...exampleInfo }); + await cache.set(key2, { ...exampleInfo, webSocketSupport: false }); // set to false + + const result1 = await cache.get(key1); + expect(result1!.webSocketSupport).to.deep.equal(true); + expect(result1).to.deep.equal(exampleInfo); + + const result2 = await cache.get(key2); + expect(result2!.webSocketSupport).to.deep.equal(false); + + // delete one of the keys + await cache.delete(key1); + + // check results after delete + const resultAfterDelete = await cache.get(key1); + expect(resultAfterDelete).to.equal(undefined); + + // key 2 still exists + const result2AfterDelete = await cache.get(key2); + expect(result2AfterDelete!.webSocketSupport).to.equal(false); + }); + + it('clears cache', async () => { + const key1 = 'some-key1'; + const key2 = 'some-key2'; + await cache.set(key1, { ...exampleInfo }); + await cache.set(key2, { ...exampleInfo, webSocketSupport: false }); // set to false + + const result1 = await cache.get(key1); + expect(result1!.webSocketSupport).to.deep.equal(true); + expect(result1).to.deep.equal(exampleInfo); + + const result2 = await cache.get(key2); + expect(result2!.webSocketSupport).to.deep.equal(false); + + // delete one of the keys + await cache.clear(); + + // check results after delete + const resultAfterDelete = await cache.get(key1); + expect(resultAfterDelete).to.equal(undefined); + const result2AfterDelete = await cache.get(key2); + expect(result2AfterDelete).to.equal(undefined); + }); + + it('returns undefined after ttl', async function () { + // skip this test in the browser, sinon fake timers don't seem to work here + // with a an await setTimeout in the test, it passes. + if (!isNode) { + this.skip(); + } + + const key = 'some-key1'; + await cache.set(key, { ...exampleInfo }); + + const result = await cache.get(key); + expect(result!.webSocketSupport).to.deep.equal(true); + expect(result).to.deep.equal(exampleInfo); + + // wait until 15m default ttl is up + await clock.tickAsync('15:01'); + + const resultAfter = await cache.get(key); + expect(resultAfter).to.be.undefined; + }); + }); + } + + describe('DwnServerInfoCacheLevel', () => { + it('should throw on unknown level error', async () => { + const mockLevel = sinon.createStubInstance(AbstractLevel); + mockLevel.get.throws('test error'); + const cache = new DwnServerInfoCacheLevel({ db: mockLevel }); + + try { + await cache.get('key'); + expect.fail('Expected an error to be thrown'); + } catch(error: any) { + expect(error.message).to.contain('test error'); + } + }); + }); + + describe('DwnServerInfoCacheNoOp', () => { + // for test coverage + const cache = new DwnServerInfoCacheNoOp(); + + it('sets', async () => { + await cache.set('test', { + webSocketSupport : true, + maxFileSize : 100, + registrationRequirements : [] + }); + }); + it('gets', async () => { + await cache.get('test'); + }); + it('delete', async () => { + await cache.delete('test'); + }); + it('clear', async () => { + await cache.clear(); + }); + }); +}); \ No newline at end of file From 5a2788cfdc822ff3c9b1393928e56f7cbfb11172 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Wed, 10 Apr 2024 21:19:09 -0400 Subject: [PATCH 02/12] add server info to rpc clients --- .../clients/http-dwn-rpc-client.ts | 40 +++++++++++++ packages/agent/src/rpc-client.ts | 22 ++++++- packages/agent/tests/rpc-client.spec.ts | 59 ++++++++++++++++++- 3 files changed, 119 insertions(+), 2 deletions(-) diff --git a/packages/agent/src/prototyping/clients/http-dwn-rpc-client.ts b/packages/agent/src/prototyping/clients/http-dwn-rpc-client.ts index b4b36a955..7e31df907 100644 --- a/packages/agent/src/prototyping/clients/http-dwn-rpc-client.ts +++ b/packages/agent/src/prototyping/clients/http-dwn-rpc-client.ts @@ -3,11 +3,18 @@ import type { DwnRpc, DwnRpcRequest, DwnRpcResponse } from './dwn-rpc-types.js'; import { createJsonRpcRequest, parseJson } from './json-rpc.js'; import { utils as cryptoUtils } from '@web5/crypto'; +import { DwnServerInfoCache, ServerInfo } from './server-info-types.js'; +import { DwnServerInfoCacheMemory } from './dwn-server-info-cache-memory.js'; /** * HTTP client that can be used to communicate with Dwn Servers */ export class HttpDwnRpcClient implements DwnRpc { + private serverInfoCache: DwnServerInfoCache; + constructor(serverInfoCache?: DwnServerInfoCache) { + this.serverInfoCache = serverInfoCache ?? new DwnServerInfoCacheMemory(); + } + get transportProtocols() { return ['http:', 'https:']; } async sendDwnRequest(request: DwnRpcRequest): Promise { @@ -65,4 +72,37 @@ export class HttpDwnRpcClient implements DwnRpc { return reply as DwnRpcResponse; } + + async getServerInfo(dwnUrl: string): Promise { + const serverInfo = await this.serverInfoCache.get(dwnUrl); + if (serverInfo) { + return serverInfo; + } + + const url = new URL(dwnUrl); + + // add `/info` to the dwn server url path + url.pathname.endsWith('/') ? url.pathname += 'info' : url.pathname += '/info'; + + try { + const response = await fetch(url.toString()); + if(response.ok) { + const results = await response.json() as ServerInfo; + + // explicitly return and cache only the desired properties. + const serverInfo = { + registrationRequirements : results.registrationRequirements, + maxFileSize : results.maxFileSize, + webSocketSupport : results.webSocketSupport, + }; + this.serverInfoCache.set(dwnUrl, serverInfo); + + return serverInfo; + } else { + throw new Error(`HTTP (${response.status}) - ${response.statusText}`); + } + } catch(error: any) { + throw new Error(`Error encountered while processing response from ${url.toString()}: ${error.message}`); + } + } } diff --git a/packages/agent/src/rpc-client.ts b/packages/agent/src/rpc-client.ts index 25c479c1c..9e97f7b31 100644 --- a/packages/agent/src/rpc-client.ts +++ b/packages/agent/src/rpc-client.ts @@ -7,6 +7,7 @@ import type { JsonRpcResponse } from './prototyping/clients/json-rpc.js'; import { createJsonRpcRequest } from './prototyping/clients/json-rpc.js'; import { HttpDwnRpcClient } from './prototyping/clients/http-dwn-rpc-client.js'; import { WebSocketDwnRpcClient } from './prototyping/clients/web-socket-clients.js'; +import { DwnServerInfo, ServerInfo } from './prototyping/clients/server-info-types.js'; /** * Interface that can be implemented to communicate with {@link Web5Agent | Web5 Agent} @@ -39,7 +40,7 @@ export type RpcStatus = { message: string; }; -export interface Web5Rpc extends DwnRpc, DidRpc {} +export interface Web5Rpc extends DwnRpc, DidRpc, DwnServerInfo {} /** * Client used to communicate with Dwn Servers @@ -94,6 +95,21 @@ export class Web5RpcClient implements Web5Rpc { return transportClient.sendDwnRequest(request); } + + async getServerInfo(dwnUrl: string): Promise { + // will throw if url is invalid + const url = new URL(dwnUrl); + + const transportClient = this.transportClients.get(url.protocol); + if(!transportClient) { + const error = new Error(`no ${url.protocol} transport client available`); + error.name = 'NO_TRANSPORT_CLIENT'; + + throw error; + } + + return transportClient.getServerInfo(dwnUrl); + } } export class HttpWeb5RpcClient extends HttpDwnRpcClient implements Web5Rpc { @@ -139,4 +155,8 @@ export class WebSocketWeb5RpcClient extends WebSocketDwnRpcClient implements Web async sendDidRequest(_request: DidRpcRequest): Promise { throw new Error(`not implemented for transports [${this.transportProtocols.join(', ')}]`); } + + async getServerInfo(_dwnUrl: string): Promise { + throw new Error(`not implemented for transports [${this.transportProtocols.join(', ')}]`); + } } \ No newline at end of file diff --git a/packages/agent/tests/rpc-client.spec.ts b/packages/agent/tests/rpc-client.spec.ts index 11cc6c809..e597161cc 100644 --- a/packages/agent/tests/rpc-client.spec.ts +++ b/packages/agent/tests/rpc-client.spec.ts @@ -115,6 +115,63 @@ describe('RPC Clients', () => { expect(stubHttpClient.sendDwnRequest.callCount).to.equal(0); }); }); + + describe('getServerInfo',() => { + let client: Web5RpcClient; + + after(() => { + sinon.restore(); + }); + + beforeEach(async () => { + sinon.restore(); + client = new Web5RpcClient(); + }); + + it('is able to get server info', async () => { + const serverInfo = await client.getServerInfo(testDwnUrl); + expect(serverInfo.registrationRequirements).to.not.be.undefined; + expect(serverInfo.maxFileSize).to.not.be.undefined; + expect(serverInfo.webSocketSupport).to.not.be.undefined; + }); + + it('throws for an invalid response', async () => { + const mockResponse = new Response(JSON.stringify({}), { status: 500 }); + sinon.stub(globalThis, 'fetch').resolves(mockResponse); + + try { + await client.getServerInfo(testDwnUrl); + expect.fail('Expected an error to be thrown'); + } catch(error: any) { + expect(error.message).to.contain('HTTP (500)'); + } + }); + + it('should append url with info path accounting for trailing slash', async () => { + const fetchStub = sinon.stub(globalThis, 'fetch').resolves(new Response(JSON.stringify({ + registrationRequirements : [], + maxFileSize : 123, + webSocketSupport : false, + }))); + + await client.getServerInfo('http://some-domain.com/dwn'); // without trailing slash + let fetchUrl = fetchStub.args[0][0]; + expect(fetchUrl).to.equal('http://some-domain.com/dwn/info'); + + // we reset the fetch stub and initiate a new response + // this wa the response body stream won't be attempt to be read twice and fail on the 2nd attempt. + fetchStub.reset(); + fetchStub.resolves(new Response(JSON.stringify({ + registrationRequirements : [], + maxFileSize : 123, + webSocketSupport : false, + }))); + + await client.getServerInfo('http://some-other-domain.com/dwn/'); // with trailing slash + fetchUrl = fetchStub.args[0][0]; + expect(fetchUrl).to.equal('http://some-other-domain.com/dwn/info'); + }); + }); }); describe('HttpWeb5RpcClient', () => { @@ -253,4 +310,4 @@ describe('RPC Clients', () => { }); }); }); -}); \ No newline at end of file +}); From 14f0c469355c1e4c72abdbe3d81c3f05f474e148 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Wed, 10 Apr 2024 22:23:56 -0400 Subject: [PATCH 03/12] rename server info rpc interface --- packages/agent/src/prototyping/clients/server-info-types.ts | 2 +- packages/agent/src/rpc-client.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/agent/src/prototyping/clients/server-info-types.ts b/packages/agent/src/prototyping/clients/server-info-types.ts index ecbad6684..c96d5d829 100644 --- a/packages/agent/src/prototyping/clients/server-info-types.ts +++ b/packages/agent/src/prototyping/clients/server-info-types.ts @@ -15,7 +15,7 @@ export type ServerInfo = { export interface DwnServerInfoCache extends KeyValueStore {} -export interface DwnServerInfo { +export interface DwnServerInfoRpc { /** retrieves the DWN Sever info, used to detect features such as WebSocket Subscriptions */ getServerInfo(url: string): Promise; } \ No newline at end of file diff --git a/packages/agent/src/rpc-client.ts b/packages/agent/src/rpc-client.ts index 9e97f7b31..9edf16c53 100644 --- a/packages/agent/src/rpc-client.ts +++ b/packages/agent/src/rpc-client.ts @@ -2,12 +2,12 @@ import { utils as cryptoUtils } from '@web5/crypto'; import type { DwnRpc, DwnRpcRequest, DwnRpcResponse } from './prototyping/clients/dwn-rpc-types.js'; +import type { DwnServerInfoRpc, ServerInfo } from './prototyping/clients/server-info-types.js'; import type { JsonRpcResponse } from './prototyping/clients/json-rpc.js'; import { createJsonRpcRequest } from './prototyping/clients/json-rpc.js'; import { HttpDwnRpcClient } from './prototyping/clients/http-dwn-rpc-client.js'; import { WebSocketDwnRpcClient } from './prototyping/clients/web-socket-clients.js'; -import { DwnServerInfo, ServerInfo } from './prototyping/clients/server-info-types.js'; /** * Interface that can be implemented to communicate with {@link Web5Agent | Web5 Agent} @@ -40,7 +40,7 @@ export type RpcStatus = { message: string; }; -export interface Web5Rpc extends DwnRpc, DidRpc, DwnServerInfo {} +export interface Web5Rpc extends DwnRpc, DidRpc, DwnServerInfoRpc {} /** * Client used to communicate with Dwn Servers From 01f2a82f58addde78aa43cc22205c466380073dd Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Wed, 10 Apr 2024 22:24:26 -0400 Subject: [PATCH 04/12] add changeset --- .changeset/old-hotels-yawn.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/old-hotels-yawn.md diff --git a/.changeset/old-hotels-yawn.md b/.changeset/old-hotels-yawn.md new file mode 100644 index 000000000..4c00a3c49 --- /dev/null +++ b/.changeset/old-hotels-yawn.md @@ -0,0 +1,14 @@ +--- +"@web5/agent": patch +"@web5/identity-agent": patch +"@web5/proxy-agent": patch +"@web5/user-agent": patch +--- + +Add `DwnServerInfoRpc` to `Web5Rpc` for retrieving server specific info. + +Server Info includes: + - maxFileSize + - registrationRequirements + - webSocketSupport + From e8392c079492ccc5e42f2e13aabfab2797295ab2 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Thu, 11 Apr 2024 15:45:25 -0400 Subject: [PATCH 05/12] linting fixes --- .../prototyping/clients/server-info-types.ts | 2 +- packages/agent/tests/rpc-client.spec.ts | 56 +++++++++++++++++-- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/packages/agent/src/prototyping/clients/server-info-types.ts b/packages/agent/src/prototyping/clients/server-info-types.ts index c96d5d829..228832325 100644 --- a/packages/agent/src/prototyping/clients/server-info-types.ts +++ b/packages/agent/src/prototyping/clients/server-info-types.ts @@ -1,4 +1,4 @@ -import { KeyValueStore } from "@web5/common"; +import { KeyValueStore } from '@web5/common'; export type ServerInfo = { /** the maximum file size the user can request to store */ diff --git a/packages/agent/tests/rpc-client.spec.ts b/packages/agent/tests/rpc-client.spec.ts index e597161cc..321591040 100644 --- a/packages/agent/tests/rpc-client.spec.ts +++ b/packages/agent/tests/rpc-client.spec.ts @@ -1,19 +1,63 @@ - import sinon from 'sinon'; - import { expect } from 'chai'; - -import { utils as cryptoUtils } from '@web5/crypto'; - import { testDwnUrl } from './utils/test-config.js'; +import { utils as cryptoUtils } from '@web5/crypto'; import { DidRpcMethod, HttpWeb5RpcClient, Web5RpcClient, WebSocketWeb5RpcClient } from '../src/rpc-client.js'; +import { DwnServerInfoCacheMemory } from '../src/prototyping/clients/dwn-server-info-cache-memory.js'; +import { HttpDwnRpcClient } from '../src/prototyping/clients/http-dwn-rpc-client.js'; import { Persona, TestDataGenerator } from '@tbd54566975/dwn-sdk-js'; import { JsonRpcErrorCodes, createJsonRpcErrorResponse, createJsonRpcSuccessResponse } from '../src/prototyping/clients/json-rpc.js'; describe('RPC Clients', () => { + describe('HttpDwnRpcClient', () => { + let client: HttpDwnRpcClient; + + beforeEach(async () => { + sinon.restore(); + client = new HttpDwnRpcClient(); + }); + + it('should retrieve subsequent result from cache', async () => { + // we spy on fetch to see how many times it is called + const fetchSpy = sinon.spy(globalThis, 'fetch'); + + // fetch info first, currently not in cache should call fetch + const serverInfo = await client.getServerInfo(testDwnUrl); + expect(fetchSpy.callCount).to.equal(1); + + // confirm it exists in cache + const cachedResult = await client['serverInfoCache'].get(testDwnUrl); + expect(cachedResult).to.equal(serverInfo); + + // make another call and confirm that fetch ahs not been called again + const serverInfo2 = await client.getServerInfo(testDwnUrl); + expect(fetchSpy.callCount).to.equal(1); // should still equal only 1 + expect(cachedResult).to.equal(serverInfo2); + + // delete the cache entry to force a fetch call + await client['serverInfoCache'].delete(testDwnUrl); + const noResult = await client['serverInfoCache'].get(testDwnUrl); + expect(noResult).to.equal(undefined); + + // make a third call and confirm that a new fetch request was made and data is in the cache + const serverInfo3 = await client.getServerInfo(testDwnUrl); + expect(fetchSpy.callCount).to.equal(2); // another fetch call was made + const cachedResult2 = await client['serverInfoCache'].get(testDwnUrl); + expect(cachedResult2).to.equal(serverInfo3); + }); + + it('should accept an override server info cache', async () => { + const serverInfoCacheStub = sinon.createStubInstance(DwnServerInfoCacheMemory); + const client = new HttpDwnRpcClient(serverInfoCacheStub); + await client.getServerInfo(testDwnUrl); + + expect(serverInfoCacheStub.get.callCount).to.equal(1); + }); + }); + describe('Web5RpcClient', () => { - let alice: Persona; + let alice: Persona beforeEach(async () => { sinon.restore(); From f0d538e12affe7fc760217534fc7c56a0a26421f Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Fri, 12 Apr 2024 10:23:16 -0400 Subject: [PATCH 06/12] clean up sinon after tests --- packages/agent/tests/rpc-client.spec.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/agent/tests/rpc-client.spec.ts b/packages/agent/tests/rpc-client.spec.ts index 321591040..f24f8eee3 100644 --- a/packages/agent/tests/rpc-client.spec.ts +++ b/packages/agent/tests/rpc-client.spec.ts @@ -18,6 +18,10 @@ describe('RPC Clients', () => { client = new HttpDwnRpcClient(); }); + after(() => { + sinon.restore(); + }); + it('should retrieve subsequent result from cache', async () => { // we spy on fetch to see how many times it is called const fetchSpy = sinon.spy(globalThis, 'fetch'); @@ -59,6 +63,10 @@ describe('RPC Clients', () => { describe('Web5RpcClient', () => { let alice: Persona + after(() => { + sinon.restore(); + }); + beforeEach(async () => { sinon.restore(); From 03b78fa3b83f78e555bd9b75da1356c13f818706 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Wed, 1 May 2024 11:23:06 -0400 Subject: [PATCH 07/12] remove unecessary sinon restore --- packages/agent/tests/rpc-client.spec.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/agent/tests/rpc-client.spec.ts b/packages/agent/tests/rpc-client.spec.ts index f24f8eee3..6b2a16164 100644 --- a/packages/agent/tests/rpc-client.spec.ts +++ b/packages/agent/tests/rpc-client.spec.ts @@ -61,11 +61,7 @@ describe('RPC Clients', () => { }); describe('Web5RpcClient', () => { - let alice: Persona - - after(() => { - sinon.restore(); - }); + let alice: Persona; beforeEach(async () => { sinon.restore(); From b38ddc72931f476287fcd44d3c231f200edfb257 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Wed, 1 May 2024 11:48:02 -0400 Subject: [PATCH 08/12] ignore server info types --- packages/agent/.c8rc.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/agent/.c8rc.json b/packages/agent/.c8rc.json index c44b67aba..2062e7981 100644 --- a/packages/agent/.c8rc.json +++ b/packages/agent/.c8rc.json @@ -10,7 +10,8 @@ "exclude": [ "tests/compiled/**/src/index.js", "tests/compiled/**/src/types.js", - "tests/compiled/**/src/types/**" + "tests/compiled/**/src/types/**", + "tests/compiled/**/src/prototyping/*-types.js" ], "reporter": [ "cobertura", From 04734bb8cae513683a0889cc3c75f97da4d1d610 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Wed, 1 May 2024 11:52:14 -0400 Subject: [PATCH 09/12] ignore prototype clients server-info-types from test coverage --- packages/agent/.c8rc.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent/.c8rc.json b/packages/agent/.c8rc.json index 2062e7981..e0b57f53e 100644 --- a/packages/agent/.c8rc.json +++ b/packages/agent/.c8rc.json @@ -11,7 +11,7 @@ "tests/compiled/**/src/index.js", "tests/compiled/**/src/types.js", "tests/compiled/**/src/types/**", - "tests/compiled/**/src/prototyping/*-types.js" + "tests/compiled/**/src/prototyping/clients/*-types.js" ], "reporter": [ "cobertura", From 0e9734c08d456b078d7a10031b6db8c863d6ab5d Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Wed, 1 May 2024 12:22:31 -0400 Subject: [PATCH 10/12] increase coverage for server info requests --- packages/agent/tests/rpc-client.spec.ts | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/agent/tests/rpc-client.spec.ts b/packages/agent/tests/rpc-client.spec.ts index 6b2a16164..e097c26bf 100644 --- a/packages/agent/tests/rpc-client.spec.ts +++ b/packages/agent/tests/rpc-client.spec.ts @@ -219,6 +219,22 @@ describe('RPC Clients', () => { fetchUrl = fetchStub.args[0][0]; expect(fetchUrl).to.equal('http://some-other-domain.com/dwn/info'); }); + + it('should throw if transport client is not found', async () => { + const stubHttpClient = sinon.createStubInstance(HttpWeb5RpcClient); + const httpOnlyClient = new Web5RpcClient([ stubHttpClient ]); + + // request with http + try { + await httpOnlyClient.getServerInfo('ws://127.0.0.1') + expect.fail('Expected error to be thrown'); + } catch (error: any) { + expect(error.message).to.equal('no ws: transport client available'); + } + + // confirm http transport was not called + expect(stubHttpClient.sendDidRequest.callCount).to.equal(0); + }); }); }); @@ -357,5 +373,16 @@ describe('RPC Clients', () => { } }); }); + + describe('getServerInfo', () => { + it('server info requests are not supported over sockets', async () => { + try { + await client.getServerInfo(socketDwnUrl); + expect.fail('Expected error to be thrown'); + } catch (error: any) { + expect(error.message).to.equal('not implemented for transports [ws:, wss:]'); + } + }); + }); }); }); From 95ca04e8d81a6befefd31ef3a042cf07e4a7e3d0 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Wed, 1 May 2024 12:26:58 -0400 Subject: [PATCH 11/12] fix linting --- packages/agent/tests/rpc-client.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent/tests/rpc-client.spec.ts b/packages/agent/tests/rpc-client.spec.ts index e097c26bf..e26316c78 100644 --- a/packages/agent/tests/rpc-client.spec.ts +++ b/packages/agent/tests/rpc-client.spec.ts @@ -226,7 +226,7 @@ describe('RPC Clients', () => { // request with http try { - await httpOnlyClient.getServerInfo('ws://127.0.0.1') + await httpOnlyClient.getServerInfo('ws://127.0.0.1'); expect.fail('Expected error to be thrown'); } catch (error: any) { expect(error.message).to.equal('no ws: transport client available'); From 1fbb6b5025971d235f7d2821adda124d001b3930 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Thu, 2 May 2024 17:20:32 -0400 Subject: [PATCH 12/12] removing unecessary cache implementations --- .../clients/dwn-server-info-cache-level.ts | 159 ------------ .../clients/dwn-server-info-cache-no-op.ts | 17 -- .../clients/dwn-server-info-cache.spec.ts | 243 +++++++----------- 3 files changed, 99 insertions(+), 320 deletions(-) delete mode 100644 packages/agent/src/prototyping/clients/dwn-server-info-cache-level.ts delete mode 100644 packages/agent/src/prototyping/clients/dwn-server-info-cache-no-op.ts diff --git a/packages/agent/src/prototyping/clients/dwn-server-info-cache-level.ts b/packages/agent/src/prototyping/clients/dwn-server-info-cache-level.ts deleted file mode 100644 index 6cc0551d4..000000000 --- a/packages/agent/src/prototyping/clients/dwn-server-info-cache-level.ts +++ /dev/null @@ -1,159 +0,0 @@ -import type { AbstractLevel } from 'abstract-level'; - -import ms from 'ms'; -import { Level } from 'level'; -import { DwnServerInfoCache, ServerInfo } from './server-info-types.js'; - -/** - * Configuration parameters for creating a LevelDB-based cache for DWN Server Info results. - * - * Allows customization of the underlying database instance, storage location, and cache - * time-to-live (TTL) settings. - */ -export type DwnServerCacheLevelParams = { - /** - * Optional. An instance of `AbstractLevel` to use as the database. If not provided, a new - * LevelDB instance will be created at the specified `location`. - */ - db?: AbstractLevel; - - /** - * Optional. The file system path or IndexedDB name where the LevelDB store will be created. - * Defaults to 'DATA/DWN_SERVERINFOCACHE' if not specified. - */ - location?: string; - - /** - * Optional. The time-to-live for cache entries, expressed as a string (e.g., '1h', '15m'). - * Determines how long a cache entry should remain valid before being considered expired. Defaults - * to '15m' if not specified. - */ - ttl?: string; -} - -/** - * Encapsulates a ServerInfo result along with its expiration information for caching purposes. - * - * This type is used internally by the `DwnServerInfoCacheLevel` to store DWN ServerInfo results - * with an associated time-to-live (TTL) value. The TTL is represented in milliseconds and - * determines when the cached entry is considered expired and eligible for removal. - */ -type CacheWrapper = { - /** - * The expiration time of the cache entry in milliseconds since the Unix epoch. - * - * This value is used to calculate whether the cached entry is still valid or has expired. - */ - ttlMillis: number; - - /** - * The DWN ServerInfo entry being cached. - */ - value: ServerInfo; -} - -/** - * A Level-based cache implementation for storing and retrieving DWN ServerInfo results. - * - * This cache uses LevelDB for storage, allowing data persistence across process restarts or - * browser refreshes. It's suitable for both Node.js and browser environments. - * - * @remarks - * The LevelDB cache keeps data in memory for fast access and also writes to the filesystem in - * Node.js or indexedDB in browsers. Time-to-live (TTL) for cache entries is configurable. - * - * @example - * ``` - * const cache = new DwnServerInfoCacheLevel({ ttl: '15m' }); - * ``` - */ -export class DwnServerInfoCacheLevel implements DwnServerInfoCache { - /** The underlying LevelDB store used for caching. */ - private cache: AbstractLevel; - - /** The time-to-live for cache entries in milliseconds. */ - private ttl: number; - - constructor({ - db, - location = 'DATA/DWN_SERVERINFOCACHE', - ttl = '15m' - }: DwnServerCacheLevelParams = {}) { - this.cache = db ?? new Level(location); - this.ttl = ms(ttl); - } - - /** - * Retrieves a DWN ServerInfo entry from the cache. - * - * If the cached item has exceeded its TTL, it's scheduled for deletion and undefined is returned. - * - * @param dwnUrl - The DWN URL endpoint string used as the key for retrieving the cached result. - * @returns The cached ServerInfo entry or undefined if not found or expired. - */ - async get(dwnUrl: string): Promise { - try { - const str = await this.cache.get(dwnUrl); - const cacheWrapper: CacheWrapper = JSON.parse(str); - - if (Date.now() >= cacheWrapper.ttlMillis) { - // defer deletion to be called in the next tick of the js event loop - this.cache.nextTick(() => this.cache.del(dwnUrl)); - - return; - } else { - return cacheWrapper.value; - } - - } catch(error: any) { - // Don't throw when a key wasn't found. - if (error.notFound) { - return; - } - - throw error; - } - } - - /** - * Stores a DWN ServerInfo entry in the cache with a TTL. - * - * @param dwnUrl - The DWN URL endpoint string used as the key for storing the result. - * @param value - The DWN ServerInfo entry to be cached. - * @returns A promise that resolves when the operation is complete. - */ - set(dwnUrl: string, value: ServerInfo): Promise { - const cacheWrapper: CacheWrapper = { ttlMillis: Date.now() + this.ttl, value }; - const str = JSON.stringify(cacheWrapper); - - return this.cache.put(dwnUrl, str); - } - - /** - * Deletes a DWN ServerInfo entry from the cache. - * - * @param dwnUrl - The DWN URL endpoint string used as the key deletion. - * @returns A promise that resolves when the operation is complete. - */ - delete(dwnUrl: string): Promise { - return this.cache.del(dwnUrl); - } - - /** - * Clears all entries from the cache. - * - * @returns A promise that resolves when the operation is complete. - */ - clear(): Promise { - return this.cache.clear(); - } - - /** - * Closes the underlying LevelDB store. - * - * @returns A promise that resolves when the store is closed. - */ - close(): Promise { - return this.cache.close(); - } -} \ No newline at end of file diff --git a/packages/agent/src/prototyping/clients/dwn-server-info-cache-no-op.ts b/packages/agent/src/prototyping/clients/dwn-server-info-cache-no-op.ts deleted file mode 100644 index 3705f1646..000000000 --- a/packages/agent/src/prototyping/clients/dwn-server-info-cache-no-op.ts +++ /dev/null @@ -1,17 +0,0 @@ - -import { DwnServerInfoCache, ServerInfo } from './server-info-types.js'; - -export class DwnServerInfoCacheNoOp implements DwnServerInfoCache { - - public async get(_dwnUrl: string): Promise { - return; - } - - public async set(_dwnUrl: string, _value: ServerInfo): Promise {} - - public async delete(_dwnUrl: string): Promise {} - - public async clear(): Promise {} - - public async close(): Promise {} -} \ No newline at end of file diff --git a/packages/agent/tests/prototyping/clients/dwn-server-info-cache.spec.ts b/packages/agent/tests/prototyping/clients/dwn-server-info-cache.spec.ts index aea57abdd..d8c2b1733 100644 --- a/packages/agent/tests/prototyping/clients/dwn-server-info-cache.spec.ts +++ b/packages/agent/tests/prototyping/clients/dwn-server-info-cache.spec.ts @@ -4,161 +4,116 @@ import { expect } from 'chai'; import { DwnServerInfoCache, ServerInfo } from '../../../src/prototyping/clients/server-info-types.js'; import { DwnServerInfoCacheMemory } from '../../../src/prototyping/clients/dwn-server-info-cache-memory.js'; -import { DwnServerInfoCacheLevel } from '../../../src/prototyping/clients/dwn-server-info-cache-level.js'; -import { DwnServerInfoCacheNoOp } from '../../../src/prototyping/clients/dwn-server-info-cache-no-op.js'; -import { AbstractLevel } from 'abstract-level'; import { isNode } from '../../utils/runtimes.js'; describe('DwnServerInfoCache', () => { - const cacheImplementations = [ DwnServerInfoCacheMemory, DwnServerInfoCacheLevel ]; - - // basic cache tests for all caching interface implementations - for (const Cache of cacheImplementations) { - describe(`interface ${Cache.name}`, () => { - let cache: DwnServerInfoCache; - let clock: sinon.SinonFakeTimers; - - const exampleInfo:ServerInfo = { - maxFileSize : 100, - webSocketSupport : true, - registrationRequirements : [] - }; - - after(() => { - sinon.restore(); - }); - - beforeEach(() => { - clock = sinon.useFakeTimers(); - cache = new Cache(); - }); - - afterEach(async () => { - await cache.clear(); - await cache.close(); - clock.restore(); - }); - - it('sets server info in cache', async () => { - const key1 = 'some-key1'; - const key2 = 'some-key2'; - await cache.set(key1, { ...exampleInfo }); - await cache.set(key2, { ...exampleInfo, webSocketSupport: false }); // set to false - - const result1 = await cache.get(key1); - expect(result1!.webSocketSupport).to.deep.equal(true); - expect(result1).to.deep.equal(exampleInfo); - - const result2 = await cache.get(key2); - expect(result2!.webSocketSupport).to.deep.equal(false); - }); - - it('deletes from cache', async () => { - const key1 = 'some-key1'; - const key2 = 'some-key2'; - await cache.set(key1, { ...exampleInfo }); - await cache.set(key2, { ...exampleInfo, webSocketSupport: false }); // set to false - - const result1 = await cache.get(key1); - expect(result1!.webSocketSupport).to.deep.equal(true); - expect(result1).to.deep.equal(exampleInfo); - - const result2 = await cache.get(key2); - expect(result2!.webSocketSupport).to.deep.equal(false); - - // delete one of the keys - await cache.delete(key1); - - // check results after delete - const resultAfterDelete = await cache.get(key1); - expect(resultAfterDelete).to.equal(undefined); - - // key 2 still exists - const result2AfterDelete = await cache.get(key2); - expect(result2AfterDelete!.webSocketSupport).to.equal(false); - }); - - it('clears cache', async () => { - const key1 = 'some-key1'; - const key2 = 'some-key2'; - await cache.set(key1, { ...exampleInfo }); - await cache.set(key2, { ...exampleInfo, webSocketSupport: false }); // set to false - - const result1 = await cache.get(key1); - expect(result1!.webSocketSupport).to.deep.equal(true); - expect(result1).to.deep.equal(exampleInfo); - - const result2 = await cache.get(key2); - expect(result2!.webSocketSupport).to.deep.equal(false); - - // delete one of the keys - await cache.clear(); - - // check results after delete - const resultAfterDelete = await cache.get(key1); - expect(resultAfterDelete).to.equal(undefined); - const result2AfterDelete = await cache.get(key2); - expect(result2AfterDelete).to.equal(undefined); - }); - - it('returns undefined after ttl', async function () { - // skip this test in the browser, sinon fake timers don't seem to work here - // with a an await setTimeout in the test, it passes. - if (!isNode) { - this.skip(); - } - - const key = 'some-key1'; - await cache.set(key, { ...exampleInfo }); - - const result = await cache.get(key); - expect(result!.webSocketSupport).to.deep.equal(true); - expect(result).to.deep.equal(exampleInfo); - - // wait until 15m default ttl is up - await clock.tickAsync('15:01'); - - const resultAfter = await cache.get(key); - expect(resultAfter).to.be.undefined; - }); - }); - } - - describe('DwnServerInfoCacheLevel', () => { - it('should throw on unknown level error', async () => { - const mockLevel = sinon.createStubInstance(AbstractLevel); - mockLevel.get.throws('test error'); - const cache = new DwnServerInfoCacheLevel({ db: mockLevel }); - - try { - await cache.get('key'); - expect.fail('Expected an error to be thrown'); - } catch(error: any) { - expect(error.message).to.contain('test error'); - } + describe(`DwnServerInfoCacheMemory`, () => { + let cache: DwnServerInfoCache; + let clock: sinon.SinonFakeTimers; + + const exampleInfo:ServerInfo = { + maxFileSize : 100, + webSocketSupport : true, + registrationRequirements : [] + }; + + after(() => { + sinon.restore(); }); - }); - describe('DwnServerInfoCacheNoOp', () => { - // for test coverage - const cache = new DwnServerInfoCacheNoOp(); + beforeEach(() => { + clock = sinon.useFakeTimers(); + cache = new DwnServerInfoCacheMemory(); + }); - it('sets', async () => { - await cache.set('test', { - webSocketSupport : true, - maxFileSize : 100, - registrationRequirements : [] - }); + afterEach(async () => { + await cache.clear(); + await cache.close(); + clock.restore(); }); - it('gets', async () => { - await cache.get('test'); + + it('sets server info in cache', async () => { + const key1 = 'some-key1'; + const key2 = 'some-key2'; + await cache.set(key1, { ...exampleInfo }); + await cache.set(key2, { ...exampleInfo, webSocketSupport: false }); // set to false + + const result1 = await cache.get(key1); + expect(result1!.webSocketSupport).to.deep.equal(true); + expect(result1).to.deep.equal(exampleInfo); + + const result2 = await cache.get(key2); + expect(result2!.webSocketSupport).to.deep.equal(false); }); - it('delete', async () => { - await cache.delete('test'); + + it('deletes from cache', async () => { + const key1 = 'some-key1'; + const key2 = 'some-key2'; + await cache.set(key1, { ...exampleInfo }); + await cache.set(key2, { ...exampleInfo, webSocketSupport: false }); // set to false + + const result1 = await cache.get(key1); + expect(result1!.webSocketSupport).to.deep.equal(true); + expect(result1).to.deep.equal(exampleInfo); + + const result2 = await cache.get(key2); + expect(result2!.webSocketSupport).to.deep.equal(false); + + // delete one of the keys + await cache.delete(key1); + + // check results after delete + const resultAfterDelete = await cache.get(key1); + expect(resultAfterDelete).to.equal(undefined); + + // key 2 still exists + const result2AfterDelete = await cache.get(key2); + expect(result2AfterDelete!.webSocketSupport).to.equal(false); }); - it('clear', async () => { + + it('clears cache', async () => { + const key1 = 'some-key1'; + const key2 = 'some-key2'; + await cache.set(key1, { ...exampleInfo }); + await cache.set(key2, { ...exampleInfo, webSocketSupport: false }); // set to false + + const result1 = await cache.get(key1); + expect(result1!.webSocketSupport).to.deep.equal(true); + expect(result1).to.deep.equal(exampleInfo); + + const result2 = await cache.get(key2); + expect(result2!.webSocketSupport).to.deep.equal(false); + + // delete one of the keys await cache.clear(); + + // check results after delete + const resultAfterDelete = await cache.get(key1); + expect(resultAfterDelete).to.equal(undefined); + const result2AfterDelete = await cache.get(key2); + expect(result2AfterDelete).to.equal(undefined); + }); + + it('returns undefined after ttl', async function () { + // skip this test in the browser, sinon fake timers don't seem to work here + // with a an await setTimeout in the test, it passes. + if (!isNode) { + this.skip(); + } + + const key = 'some-key1'; + await cache.set(key, { ...exampleInfo }); + + const result = await cache.get(key); + expect(result!.webSocketSupport).to.deep.equal(true); + expect(result).to.deep.equal(exampleInfo); + + // wait until 15m default ttl is up + await clock.tickAsync('15:01'); + + const resultAfter = await cache.get(key); + expect(resultAfter).to.be.undefined; }); }); }); \ No newline at end of file