Skip to content

Commit

Permalink
@web5/agent Adding DwnServerInfo to RPC Clients (#489)
Browse files Browse the repository at this point in the history
- Added a `DwnServerInfo` HTTP client to get info from the `dwn-server`'s `/info` endpoint

```
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,
}
```

This is helpful for retrieving registration requirements and whether the server supports sockets.

It uses a `TTLCache` memory cache as the currently implemented and default, however additional caches can be easily added if necessary. 
  • Loading branch information
LiranCohen authored May 7, 2024
1 parent 6c57350 commit eabe5ca
Show file tree
Hide file tree
Showing 8 changed files with 434 additions and 8 deletions.
14 changes: 14 additions & 0 deletions .changeset/old-hotels-yawn.md
Original file line number Diff line number Diff line change
@@ -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

3 changes: 2 additions & 1 deletion packages/agent/.c8rc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, ServerInfo>;

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<ServerInfo| undefined> {
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<void> {
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<void> {
this.cache.delete(dwnUrl);
}

/**
* Clears all entries from the cache.
*
* @returns A promise that resolves when the operation is complete.
*/
public async clear(): Promise<void> {
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<void> {
// No-op since there is no underlying store to close.
}
}
40 changes: 40 additions & 0 deletions packages/agent/src/prototyping/clients/http-dwn-rpc-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DwnRpcResponse> {
Expand Down Expand Up @@ -65,4 +72,37 @@ export class HttpDwnRpcClient implements DwnRpc {

return reply as DwnRpcResponse;
}

async getServerInfo(dwnUrl: string): Promise<ServerInfo> {
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}`);
}
}
}
21 changes: 21 additions & 0 deletions packages/agent/src/prototyping/clients/server-info-types.ts
Original file line number Diff line number Diff line change
@@ -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<string, ServerInfo| undefined> {}

export interface DwnServerInfoRpc {
/** retrieves the DWN Sever info, used to detect features such as WebSocket Subscriptions */
getServerInfo(url: string): Promise<ServerInfo>;
}
22 changes: 21 additions & 1 deletion packages/agent/src/rpc-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -94,6 +95,21 @@ export class Web5RpcClient implements Web5Rpc {

return transportClient.sendDwnRequest(request);
}

async getServerInfo(dwnUrl: string): Promise<ServerInfo> {
// 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 {
Expand Down Expand Up @@ -139,4 +155,8 @@ export class WebSocketWeb5RpcClient extends WebSocketDwnRpcClient implements Web
async sendDidRequest(_request: DidRpcRequest): Promise<DidRpcResponse> {
throw new Error(`not implemented for transports [${this.transportProtocols.join(', ')}]`);
}

async getServerInfo(_dwnUrl: string): Promise<ServerInfo> {
throw new Error(`not implemented for transports [${this.transportProtocols.join(', ')}]`);
}
}
119 changes: 119 additions & 0 deletions packages/agent/tests/prototyping/clients/dwn-server-info-cache.spec.ts
Original file line number Diff line number Diff line change
@@ -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;
});
});
});
Loading

0 comments on commit eabe5ca

Please sign in to comment.