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 + diff --git a/packages/agent/.c8rc.json b/packages/agent/.c8rc.json index c44b67aba..e0b57f53e 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/clients/*-types.js" ], "reporter": [ "cobertura", 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/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/prototyping/clients/server-info-types.ts b/packages/agent/src/prototyping/clients/server-info-types.ts new file mode 100644 index 000000000..228832325 --- /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 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 25c479c1c..9edf16c53 100644 --- a/packages/agent/src/rpc-client.ts +++ b/packages/agent/src/rpc-client.ts @@ -2,6 +2,7 @@ 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'; @@ -39,7 +40,7 @@ export type RpcStatus = { message: string; }; -export interface Web5Rpc extends DwnRpc, DidRpc {} +export interface Web5Rpc extends DwnRpc, DidRpc, DwnServerInfoRpc {} /** * 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/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..d8c2b1733 --- /dev/null +++ b/packages/agent/tests/prototyping/clients/dwn-server-info-cache.spec.ts @@ -0,0 +1,119 @@ +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 { isNode } from '../../utils/runtimes.js'; + +describe('DwnServerInfoCache', () => { + + describe(`DwnServerInfoCacheMemory`, () => { + let cache: DwnServerInfoCache; + let clock: sinon.SinonFakeTimers; + + const exampleInfo:ServerInfo = { + maxFileSize : 100, + webSocketSupport : true, + registrationRequirements : [] + }; + + after(() => { + sinon.restore(); + }); + + beforeEach(() => { + clock = sinon.useFakeTimers(); + cache = new DwnServerInfoCacheMemory(); + }); + + 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; + }); + }); +}); \ 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..e26316c78 100644 --- a/packages/agent/tests/rpc-client.spec.ts +++ b/packages/agent/tests/rpc-client.spec.ts @@ -1,17 +1,65 @@ - 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(); + }); + + 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'); + + // 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; @@ -115,6 +163,79 @@ 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'); + }); + + 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); + }); + }); }); describe('HttpWeb5RpcClient', () => { @@ -252,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:]'); + } + }); + }); }); -}); \ No newline at end of file +});