Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

@web5/agent Adding DwnServerInfo to RPC Clients #489

Merged
merged 12 commits into from
May 7, 2024
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 {
Copy link
Contributor

@thehenrytsai thehenrytsai May 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider turning this into generic, seems low hanging fruit.

Suggested change
export class DwnServerInfoCacheMemory implements DwnServerInfoCache {
export class MemoryCache<T> implements GenericCache<T> {

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The thing about this is we already have one called MemoryStore which is why his is more specific. Maybe MemoryStoreAsync? Unsure

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@thehenrytsai Yeah @shamilovtim called out something similar and I was considering this, but figured we could do a separate PR to add TTLCacheAsync or something of that sort to @web5/common because we are doing this same wrapper in a couple of places to conform with an async interface.

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(', ')}]`);
}
}
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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sanity: this would be the first precedence that it doesn't work in browser right? Have you tried passing number instead of string to tickAsync()?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's really strange that it doesn't work. I just tried a number instead of string and get the same behavior because I didn't think to try that before. But still fails in the browsers.

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
Loading