Skip to content

Commit eabe5ca

Browse files
authored
@web5/agent Adding DwnServerInfo to RPC Clients (#489)
- 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. 
1 parent 6c57350 commit eabe5ca

File tree

8 files changed

+434
-8
lines changed

8 files changed

+434
-8
lines changed

.changeset/old-hotels-yawn.md

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@web5/agent": patch
3+
"@web5/identity-agent": patch
4+
"@web5/proxy-agent": patch
5+
"@web5/user-agent": patch
6+
---
7+
8+
Add `DwnServerInfoRpc` to `Web5Rpc` for retrieving server specific info.
9+
10+
Server Info includes:
11+
- maxFileSize
12+
- registrationRequirements
13+
- webSocketSupport
14+

packages/agent/.c8rc.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
"exclude": [
1111
"tests/compiled/**/src/index.js",
1212
"tests/compiled/**/src/types.js",
13-
"tests/compiled/**/src/types/**"
13+
"tests/compiled/**/src/types/**",
14+
"tests/compiled/**/src/prototyping/clients/*-types.js"
1415
],
1516
"reporter": [
1617
"cobertura",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
2+
import ms from 'ms';
3+
import { TtlCache } from '@web5/common';
4+
import { DwnServerInfoCache, ServerInfo } from './server-info-types.js';
5+
6+
/**
7+
* Configuration parameters for creating an in-memory cache for DWN ServerInfo entries.
8+
*
9+
* Allows customization of the cache time-to-live (TTL) setting.
10+
*/
11+
export type DwnServerInfoCacheMemoryParams = {
12+
/**
13+
* Optional. The time-to-live for cache entries, expressed as a string (e.g., '1h', '15m').
14+
* Determines how long a cache entry should remain valid before being considered expired.
15+
*
16+
* Defaults to '15m' if not specified.
17+
*/
18+
ttl?: string;
19+
}
20+
21+
export class DwnServerInfoCacheMemory implements DwnServerInfoCache {
22+
private cache: TtlCache<string, ServerInfo>;
23+
24+
constructor({ ttl = '15m' }: DwnServerInfoCacheMemoryParams= {}) {
25+
this.cache = new TtlCache({ ttl: ms(ttl) });
26+
}
27+
28+
/**
29+
* Retrieves a DWN ServerInfo entry from the cache.
30+
*
31+
* If the cached item has exceeded its TTL, it's scheduled for deletion and undefined is returned.
32+
*
33+
* @param dwnUrl - The DWN URL endpoint string used as the key for getting the entry.
34+
* @returns The cached DWN ServerInfo entry or undefined if not found or expired.
35+
*/
36+
public async get(dwnUrl: string): Promise<ServerInfo| undefined> {
37+
return this.cache.get(dwnUrl);
38+
}
39+
40+
/**
41+
* Stores a DWN ServerInfo entry in the cache with a TTL.
42+
*
43+
* @param dwnUrl - The DWN URL endpoint string used as the key for storing the entry.
44+
* @param value - The DWN ServerInfo entry to be cached.
45+
* @returns A promise that resolves when the operation is complete.
46+
*/
47+
public async set(dwnUrl: string, value: ServerInfo): Promise<void> {
48+
this.cache.set(dwnUrl, value);
49+
}
50+
51+
/**
52+
* Deletes a DWN ServerInfo entry from the cache.
53+
*
54+
* @param dwnUrl - The DWN URL endpoint string used as the key for deletion.
55+
* @returns A promise that resolves when the operation is complete.
56+
*/
57+
public async delete(dwnUrl: string): Promise<void> {
58+
this.cache.delete(dwnUrl);
59+
}
60+
61+
/**
62+
* Clears all entries from the cache.
63+
*
64+
* @returns A promise that resolves when the operation is complete.
65+
*/
66+
public async clear(): Promise<void> {
67+
this.cache.clear();
68+
}
69+
70+
/**
71+
* This method is a no-op but exists to be consistent with other DWN ServerInfo Cache
72+
* implementations.
73+
*
74+
* @returns A promise that resolves immediately.
75+
*/
76+
public async close(): Promise<void> {
77+
// No-op since there is no underlying store to close.
78+
}
79+
}

packages/agent/src/prototyping/clients/http-dwn-rpc-client.ts

+40
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,18 @@ import type { DwnRpc, DwnRpcRequest, DwnRpcResponse } from './dwn-rpc-types.js';
33

44
import { createJsonRpcRequest, parseJson } from './json-rpc.js';
55
import { utils as cryptoUtils } from '@web5/crypto';
6+
import { DwnServerInfoCache, ServerInfo } from './server-info-types.js';
7+
import { DwnServerInfoCacheMemory } from './dwn-server-info-cache-memory.js';
68

79
/**
810
* HTTP client that can be used to communicate with Dwn Servers
911
*/
1012
export class HttpDwnRpcClient implements DwnRpc {
13+
private serverInfoCache: DwnServerInfoCache;
14+
constructor(serverInfoCache?: DwnServerInfoCache) {
15+
this.serverInfoCache = serverInfoCache ?? new DwnServerInfoCacheMemory();
16+
}
17+
1118
get transportProtocols() { return ['http:', 'https:']; }
1219

1320
async sendDwnRequest(request: DwnRpcRequest): Promise<DwnRpcResponse> {
@@ -65,4 +72,37 @@ export class HttpDwnRpcClient implements DwnRpc {
6572

6673
return reply as DwnRpcResponse;
6774
}
75+
76+
async getServerInfo(dwnUrl: string): Promise<ServerInfo> {
77+
const serverInfo = await this.serverInfoCache.get(dwnUrl);
78+
if (serverInfo) {
79+
return serverInfo;
80+
}
81+
82+
const url = new URL(dwnUrl);
83+
84+
// add `/info` to the dwn server url path
85+
url.pathname.endsWith('/') ? url.pathname += 'info' : url.pathname += '/info';
86+
87+
try {
88+
const response = await fetch(url.toString());
89+
if(response.ok) {
90+
const results = await response.json() as ServerInfo;
91+
92+
// explicitly return and cache only the desired properties.
93+
const serverInfo = {
94+
registrationRequirements : results.registrationRequirements,
95+
maxFileSize : results.maxFileSize,
96+
webSocketSupport : results.webSocketSupport,
97+
};
98+
this.serverInfoCache.set(dwnUrl, serverInfo);
99+
100+
return serverInfo;
101+
} else {
102+
throw new Error(`HTTP (${response.status}) - ${response.statusText}`);
103+
}
104+
} catch(error: any) {
105+
throw new Error(`Error encountered while processing response from ${url.toString()}: ${error.message}`);
106+
}
107+
}
68108
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { KeyValueStore } from '@web5/common';
2+
3+
export type ServerInfo = {
4+
/** the maximum file size the user can request to store */
5+
maxFileSize: number,
6+
/**
7+
* an array of strings representing the server's registration requirements.
8+
*
9+
* ie. ['proof-of-work-sha256-v0', 'terms-of-service']
10+
* */
11+
registrationRequirements: string[],
12+
/** whether web socket support is enabled on this server */
13+
webSocketSupport: boolean,
14+
}
15+
16+
export interface DwnServerInfoCache extends KeyValueStore<string, ServerInfo| undefined> {}
17+
18+
export interface DwnServerInfoRpc {
19+
/** retrieves the DWN Sever info, used to detect features such as WebSocket Subscriptions */
20+
getServerInfo(url: string): Promise<ServerInfo>;
21+
}

packages/agent/src/rpc-client.ts

+21-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { utils as cryptoUtils } from '@web5/crypto';
22

33

44
import type { DwnRpc, DwnRpcRequest, DwnRpcResponse } from './prototyping/clients/dwn-rpc-types.js';
5+
import type { DwnServerInfoRpc, ServerInfo } from './prototyping/clients/server-info-types.js';
56
import type { JsonRpcResponse } from './prototyping/clients/json-rpc.js';
67

78
import { createJsonRpcRequest } from './prototyping/clients/json-rpc.js';
@@ -39,7 +40,7 @@ export type RpcStatus = {
3940
message: string;
4041
};
4142

42-
export interface Web5Rpc extends DwnRpc, DidRpc {}
43+
export interface Web5Rpc extends DwnRpc, DidRpc, DwnServerInfoRpc {}
4344

4445
/**
4546
* Client used to communicate with Dwn Servers
@@ -94,6 +95,21 @@ export class Web5RpcClient implements Web5Rpc {
9495

9596
return transportClient.sendDwnRequest(request);
9697
}
98+
99+
async getServerInfo(dwnUrl: string): Promise<ServerInfo> {
100+
// will throw if url is invalid
101+
const url = new URL(dwnUrl);
102+
103+
const transportClient = this.transportClients.get(url.protocol);
104+
if(!transportClient) {
105+
const error = new Error(`no ${url.protocol} transport client available`);
106+
error.name = 'NO_TRANSPORT_CLIENT';
107+
108+
throw error;
109+
}
110+
111+
return transportClient.getServerInfo(dwnUrl);
112+
}
97113
}
98114

99115
export class HttpWeb5RpcClient extends HttpDwnRpcClient implements Web5Rpc {
@@ -139,4 +155,8 @@ export class WebSocketWeb5RpcClient extends WebSocketDwnRpcClient implements Web
139155
async sendDidRequest(_request: DidRpcRequest): Promise<DidRpcResponse> {
140156
throw new Error(`not implemented for transports [${this.transportProtocols.join(', ')}]`);
141157
}
158+
159+
async getServerInfo(_dwnUrl: string): Promise<ServerInfo> {
160+
throw new Error(`not implemented for transports [${this.transportProtocols.join(', ')}]`);
161+
}
142162
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import sinon from 'sinon';
2+
3+
import { expect } from 'chai';
4+
5+
import { DwnServerInfoCache, ServerInfo } from '../../../src/prototyping/clients/server-info-types.js';
6+
import { DwnServerInfoCacheMemory } from '../../../src/prototyping/clients/dwn-server-info-cache-memory.js';
7+
import { isNode } from '../../utils/runtimes.js';
8+
9+
describe('DwnServerInfoCache', () => {
10+
11+
describe(`DwnServerInfoCacheMemory`, () => {
12+
let cache: DwnServerInfoCache;
13+
let clock: sinon.SinonFakeTimers;
14+
15+
const exampleInfo:ServerInfo = {
16+
maxFileSize : 100,
17+
webSocketSupport : true,
18+
registrationRequirements : []
19+
};
20+
21+
after(() => {
22+
sinon.restore();
23+
});
24+
25+
beforeEach(() => {
26+
clock = sinon.useFakeTimers();
27+
cache = new DwnServerInfoCacheMemory();
28+
});
29+
30+
afterEach(async () => {
31+
await cache.clear();
32+
await cache.close();
33+
clock.restore();
34+
});
35+
36+
it('sets server info in cache', async () => {
37+
const key1 = 'some-key1';
38+
const key2 = 'some-key2';
39+
await cache.set(key1, { ...exampleInfo });
40+
await cache.set(key2, { ...exampleInfo, webSocketSupport: false }); // set to false
41+
42+
const result1 = await cache.get(key1);
43+
expect(result1!.webSocketSupport).to.deep.equal(true);
44+
expect(result1).to.deep.equal(exampleInfo);
45+
46+
const result2 = await cache.get(key2);
47+
expect(result2!.webSocketSupport).to.deep.equal(false);
48+
});
49+
50+
it('deletes from cache', async () => {
51+
const key1 = 'some-key1';
52+
const key2 = 'some-key2';
53+
await cache.set(key1, { ...exampleInfo });
54+
await cache.set(key2, { ...exampleInfo, webSocketSupport: false }); // set to false
55+
56+
const result1 = await cache.get(key1);
57+
expect(result1!.webSocketSupport).to.deep.equal(true);
58+
expect(result1).to.deep.equal(exampleInfo);
59+
60+
const result2 = await cache.get(key2);
61+
expect(result2!.webSocketSupport).to.deep.equal(false);
62+
63+
// delete one of the keys
64+
await cache.delete(key1);
65+
66+
// check results after delete
67+
const resultAfterDelete = await cache.get(key1);
68+
expect(resultAfterDelete).to.equal(undefined);
69+
70+
// key 2 still exists
71+
const result2AfterDelete = await cache.get(key2);
72+
expect(result2AfterDelete!.webSocketSupport).to.equal(false);
73+
});
74+
75+
it('clears cache', async () => {
76+
const key1 = 'some-key1';
77+
const key2 = 'some-key2';
78+
await cache.set(key1, { ...exampleInfo });
79+
await cache.set(key2, { ...exampleInfo, webSocketSupport: false }); // set to false
80+
81+
const result1 = await cache.get(key1);
82+
expect(result1!.webSocketSupport).to.deep.equal(true);
83+
expect(result1).to.deep.equal(exampleInfo);
84+
85+
const result2 = await cache.get(key2);
86+
expect(result2!.webSocketSupport).to.deep.equal(false);
87+
88+
// delete one of the keys
89+
await cache.clear();
90+
91+
// check results after delete
92+
const resultAfterDelete = await cache.get(key1);
93+
expect(resultAfterDelete).to.equal(undefined);
94+
const result2AfterDelete = await cache.get(key2);
95+
expect(result2AfterDelete).to.equal(undefined);
96+
});
97+
98+
it('returns undefined after ttl', async function () {
99+
// skip this test in the browser, sinon fake timers don't seem to work here
100+
// with a an await setTimeout in the test, it passes.
101+
if (!isNode) {
102+
this.skip();
103+
}
104+
105+
const key = 'some-key1';
106+
await cache.set(key, { ...exampleInfo });
107+
108+
const result = await cache.get(key);
109+
expect(result!.webSocketSupport).to.deep.equal(true);
110+
expect(result).to.deep.equal(exampleInfo);
111+
112+
// wait until 15m default ttl is up
113+
await clock.tickAsync('15:01');
114+
115+
const resultAfter = await cache.get(key);
116+
expect(resultAfter).to.be.undefined;
117+
});
118+
});
119+
});

0 commit comments

Comments
 (0)