diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 96e145aa..f46eb57d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -30,6 +30,9 @@ - docs: adds instructions on how to run unit and e2e tests to the README - chore: adds required `npm audit` check to PRs - new `HttpAgent` option: `backoffStrategy` - allows you to set a custom delay strategy for retries. The default is a newly exported `exponentialBackoff`, but you can pass your own function to customize the delay between retries. +- feat!: deprecate `HttpAgent` constructor in favor of new `create` and `createSync` methods. + - `create` is async and returns a promise. It will sync time with the replica and fetch the root key if the host is not `https://icp-api.io` + - Replaces `source` option with a `from` and `fromSync` methods, similar to `Principal.from` ### Changed @@ -42,7 +45,7 @@ - feat: make `IdbStorage` `get/set` methods generic - chore: add context to errors thrown when failing to decode CBOR values. -- chore: replaces globle npm install with setup-node for size-limit action +- chore: replaces global npm install with setup-node for size-limit action ## [1.2.0] - 2024-03-25 diff --git a/packages/agent/src/agent/http/http.test.ts b/packages/agent/src/agent/http/http.test.ts index a9d3f7f4..09d9cb67 100644 --- a/packages/agent/src/agent/http/http.test.ts +++ b/packages/agent/src/agent/http/http.test.ts @@ -228,7 +228,7 @@ test('readState should not call transformers if request is passed', async () => test('redirect avoid', async () => { function checkUrl(base: string, result: string) { const httpAgent = new HttpAgent({ host: base }); - expect(httpAgent['_host'].hostname).toBe(result); + expect(httpAgent.host.hostname).toBe(result); } checkUrl('https://ic0.app', 'ic0.app'); @@ -714,12 +714,12 @@ test('should fetch with given call options and fetch options', async () => { describe('default host', () => { it('should use a default host of icp-api.io', () => { const agent = new HttpAgent({ fetch: jest.fn() }); - expect((agent as any)._host.hostname).toBe('icp-api.io'); + expect((agent as any).host.hostname).toBe('icp-api.io'); }); it('should use a default of icp-api.io if location is not available', () => { delete (global as any).window; const agent = new HttpAgent({ fetch: jest.fn() }); - expect((agent as any)._host.hostname).toBe('icp-api.io'); + expect((agent as any).host.hostname).toBe('icp-api.io'); }); it('should use the existing host if the agent is used on a known hostname', () => { const knownHosts = ['ic0.app', 'icp0.io', '127.0.0.1', 'localhost']; @@ -729,8 +729,8 @@ describe('default host', () => { hostname: host, protocol: 'https:', } as any; - const agent = new HttpAgent({ fetch: jest.fn(), host }); - expect((agent as any)._host.hostname).toBe(host); + const agent = HttpAgent.createSync({ fetch: jest.fn(), host }); + expect((agent as any).host.hostname).toBe(host); } }); it('should correctly handle subdomains on known hosts', () => { @@ -743,10 +743,10 @@ describe('default host', () => { protocol: 'https:', } as any; const agent = new HttpAgent({ fetch: jest.fn() }); - expect((agent as any)._host.hostname).toBe(host); + expect(agent.host.hostname).toBe(host); } }); - it('should correctly handle subdomains on remote hosts', () => { + it('should correctly handle subdomains on remote hosts', async () => { const remoteHosts = [ '000.gitpod.io', '000.github.dev', @@ -760,11 +760,11 @@ describe('default host', () => { hostname: host, protocol: 'https:', } as any; - const agent = new HttpAgent({ fetch: jest.fn() }); - expect((agent as any)._host.hostname).toBe(host); + const agent = await HttpAgent.createSync({ fetch: jest.fn() }); + expect(agent.host.toString()).toBe(`https://${host}/`); } }); - it('should handle port numbers for 127.0.0.1 and localhost', () => { + it('should handle port numbers for 127.0.0.1 and localhost', async () => { const knownHosts = ['127.0.0.1', 'localhost']; for (const host of knownHosts) { delete (window as any).location; @@ -775,8 +775,8 @@ describe('default host', () => { protocol: 'http:', port: '4943', } as any; - const agent = new HttpAgent({ fetch: jest.fn() }); - expect((agent as any)._host.hostname).toBe(host); + const agent = await HttpAgent.createSync({ fetch: jest.fn() }); + expect(agent.host.toString()).toBe(`http://${host}:4943/`); } }); }); diff --git a/packages/agent/src/agent/http/index.ts b/packages/agent/src/agent/http/index.ts index 16c3df43..8c105797 100644 --- a/packages/agent/src/agent/http/index.ts +++ b/packages/agent/src/agent/http/index.ts @@ -88,10 +88,6 @@ export class IdentityInvalidError extends AgentError { // HttpAgent options that can be used at construction. export interface HttpAgentOptions { - // Another HttpAgent to inherit configuration (pipeline and fetch) of. This - // is only used at construction. - source?: HttpAgent; - // A surrogate to the global fetch function. Useful for testing. fetch?: typeof fetch; @@ -179,6 +175,53 @@ function getDefaultFetch(): typeof fetch { ); } +function determineHost(configuredHost: string | undefined): string { + let host: URL; + if (configuredHost !== undefined) { + if (!configuredHost.match(/^[a-z]+:/) && typeof window !== 'undefined') { + host = new URL(window.location.protocol + '//' + configuredHost); + } else { + host = new URL(configuredHost); + } + } else { + // Mainnet, local, and remote environments will have the api route available + const knownHosts = ['ic0.app', 'icp0.io', '127.0.0.1', 'localhost']; + const remoteHosts = ['.github.dev', '.gitpod.io']; + const location = typeof window !== 'undefined' ? window.location : undefined; + const hostname = location?.hostname; + let knownHost; + if (hostname && typeof hostname === 'string') { + if (remoteHosts.some(host => hostname.endsWith(host))) { + knownHost = hostname; + } else { + knownHost = knownHosts.find(host => hostname.endsWith(host)); + } + } + + if (location && knownHost) { + // If the user is on a boundary-node provided host, we can use the same host for the agent + host = new URL( + `${location.protocol}//${knownHost}${location.port ? ':' + location.port : ''}`, + ); + } else { + host = new URL('https://icp-api.io'); + } + } + return host.toString(); +} + +interface V1HttpAgentInterface { + _identity: Promise | null; + readonly _fetch: typeof fetch; + readonly _fetchOptions?: Record; + readonly _callOptions?: Record; + + readonly _host: URL; + readonly _credentials: string | undefined; + readonly _retryTimes: number; // Retry requests N times before erroring by default + _isAgent: true; +} + // A HTTP agent allows users to interact with a client of the internet computer // using the available methods. It exposes an API that closely follows the // public view of the internet computer, and is not intended to be exposed @@ -190,17 +233,20 @@ function getDefaultFetch(): typeof fetch { // allowing extensions. export class HttpAgent implements Agent { public rootKey = fromHex(IC_ROOT_KEY); - private _identity: Promise | null; - private readonly _fetch: typeof fetch; - private readonly _fetchOptions?: Record; - private readonly _callOptions?: Record; - private _timeDiffMsecs = 0; - private readonly _host: URL; - private readonly _credentials: string | undefined; - private _rootKeyFetched = false; - #retryTimes; // Retry requests N times before erroring by default + #identity: Promise | null; + readonly #fetch: typeof fetch; + readonly #fetchOptions?: Record; + readonly #callOptions?: Record; + #timeDiffMsecs = 0; + readonly host: URL; + readonly #credentials: string | undefined; + #rootKeyFetched = false; + readonly #retryTimes; // Retry requests N times before erroring by default #backoffStrategy: BackoffStrategyFactory; + + // Public signature to help with type checking. public readonly _isAgent = true; + public config: HttpAgentOptions = {}; // The UTC time in milliseconds when the latest request was made #waterMark = 0; @@ -219,62 +265,19 @@ export class HttpAgent implements Agent { }); #verifyQuerySignatures = true; + /** + * @param options - Options for the HttpAgent + * @deprecated Use `HttpAgent.create` or `HttpAgent.createSync` instead + */ constructor(options: HttpAgentOptions = {}) { - if (options.source) { - if (!(options.source instanceof HttpAgent)) { - throw new Error("An Agent's source can only be another HttpAgent"); - } - this._identity = options.source._identity; - this._fetch = options.source._fetch; - this._host = options.source._host; - this._credentials = options.source._credentials; - } else { - this._fetch = options.fetch || getDefaultFetch() || fetch.bind(global); - this._fetchOptions = options.fetchOptions; - this._callOptions = options.callOptions; - } - if (options.host !== undefined) { - if (!options.host.match(/^[a-z]+:/) && typeof window !== 'undefined') { - this._host = new URL(window.location.protocol + '//' + options.host); - } else { - this._host = new URL(options.host); - } - } else if (options.source !== undefined) { - // Safe to ignore here. - this._host = options.source._host; - } else { - const location = typeof window !== 'undefined' ? window.location : undefined; - if (!location) { - this._host = new URL('https://icp-api.io'); - this.log.warn( - 'Could not infer host from window.location, defaulting to mainnet gateway of https://icp-api.io. Please provide a host to the HttpAgent constructor to avoid this warning.', - ); - } - // Mainnet, local, and remote environments will have the api route available - const knownHosts = ['ic0.app', 'icp0.io', '127.0.0.1', 'localhost']; - const remoteHosts = ['.github.dev', '.gitpod.io']; - const hostname = location?.hostname; - let knownHost; - if (hostname && typeof hostname === 'string') { - if (remoteHosts.some(host => hostname.endsWith(host))) { - knownHost = hostname; - } else { - knownHost = knownHosts.find(host => hostname.endsWith(host)); - } - } + this.config = options; + this.#fetch = options.fetch || getDefaultFetch() || fetch.bind(global); + this.#fetchOptions = options.fetchOptions; + this.#callOptions = options.callOptions; + + const host = determineHost(options.host); + this.host = new URL(host); - if (location && knownHost) { - // If the user is on a boundary-node provided host, we can use the same host for the agent - this._host = new URL( - `${location.protocol}//${knownHost}${location.port ? ':' + location.port : ''}`, - ); - } else { - this._host = new URL('https://icp-api.io'); - this.log.warn( - 'Could not infer host from window.location, defaulting to mainnet gateway of https://icp-api.io. Please provide a host to the HttpAgent constructor to avoid this warning.', - ); - } - } if (options.verifyQuerySignatures !== undefined) { this.#verifyQuerySignatures = options.verifyQuerySignatures; } @@ -287,19 +290,19 @@ export class HttpAgent implements Agent { }); this.#backoffStrategy = options.backoffStrategy || defaultBackoffFactory; // Rewrite to avoid redirects - if (this._host.hostname.endsWith(IC0_SUB_DOMAIN)) { - this._host.hostname = IC0_DOMAIN; - } else if (this._host.hostname.endsWith(ICP0_SUB_DOMAIN)) { - this._host.hostname = ICP0_DOMAIN; - } else if (this._host.hostname.endsWith(ICP_API_SUB_DOMAIN)) { - this._host.hostname = ICP_API_DOMAIN; + if (this.host.hostname.endsWith(IC0_SUB_DOMAIN)) { + this.host.hostname = IC0_DOMAIN; + } else if (this.host.hostname.endsWith(ICP0_SUB_DOMAIN)) { + this.host.hostname = ICP0_DOMAIN; + } else if (this.host.hostname.endsWith(ICP_API_SUB_DOMAIN)) { + this.host.hostname = ICP_API_DOMAIN; } if (options.credentials) { const { name, password } = options.credentials; - this._credentials = `${name}${password ? ':' + password : ''}`; + this.#credentials = `${name}${password ? ':' + password : ''}`; } - this._identity = Promise.resolve(options.identity || new AnonymousIdentity()); + this.#identity = Promise.resolve(options.identity || new AnonymousIdentity()); // Add a nonce transform to ensure calls are unique this.addTransform('update', makeNonceTransform(makeNonce)); @@ -319,8 +322,45 @@ export class HttpAgent implements Agent { } } + public static createSync(options: HttpAgentOptions = {}): HttpAgent { + return new this({ ...options }); + } + + public static async create( + options: HttpAgentOptions & { shouldFetchRootKey?: boolean } = { + shouldFetchRootKey: false, + }, + ): Promise { + const agent = HttpAgent.createSync(options); + const initPromises: Promise[] = [agent.syncTime()]; + if (agent.host.toString() !== 'https://icp-api.io' && options.shouldFetchRootKey) { + initPromises.push(agent.fetchRootKey()); + } + await Promise.all(initPromises); + return agent; + } + + public static async from( + agent: Pick | V1HttpAgentInterface, + ): Promise { + try { + if ('config' in agent) { + return await HttpAgent.create(agent.config); + } + return await HttpAgent.create({ + fetch: agent._fetch, + fetchOptions: agent._fetchOptions, + callOptions: agent._callOptions, + host: agent._host.toString(), + identity: agent._identity ?? undefined, + }); + } catch (error) { + throw new AgentError('Failed to create agent from provided agent'); + } + } + public isLocal(): boolean { - const hostname = this._host.hostname; + const hostname = this.host.hostname; return hostname === '127.0.0.1' || hostname.endsWith('127.0.0.1'); } @@ -349,12 +389,12 @@ export class HttpAgent implements Agent { } public async getPrincipal(): Promise { - if (!this._identity) { + if (!this.#identity) { throw new IdentityInvalidError( "This identity has expired due this application's security policy. Please refresh your authentication.", ); } - return (await this._identity).getPrincipal(); + return (await this.#identity).getPrincipal(); } public async call( @@ -366,7 +406,7 @@ export class HttpAgent implements Agent { }, identity?: Identity | Promise, ): Promise { - const id = await (identity !== undefined ? await identity : await this._identity); + const id = await (identity !== undefined ? await identity : await this.#identity); if (!id) { throw new IdentityInvalidError( "This identity has expired due this application's security policy. Please refresh your authentication.", @@ -382,8 +422,8 @@ export class HttpAgent implements Agent { let ingress_expiry = new Expiry(DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS); // If the value is off by more than 30 seconds, reconcile system time with the network - if (Math.abs(this._timeDiffMsecs) > 1_000 * 30) { - ingress_expiry = new Expiry(DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS + this._timeDiffMsecs); + if (Math.abs(this.#timeDiffMsecs) > 1_000 * 30) { + ingress_expiry = new Expiry(DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS + this.#timeDiffMsecs); } const submit: CallRequest = { @@ -402,7 +442,7 @@ export class HttpAgent implements Agent { method: 'POST', headers: { 'Content-Type': 'application/cbor', - ...(this._credentials ? { Authorization: 'Basic ' + btoa(this._credentials) } : {}), + ...(this.#credentials ? { Authorization: 'Basic ' + btoa(this.#credentials) } : {}), }, }, endpoint: Endpoint.Call, @@ -434,8 +474,8 @@ export class HttpAgent implements Agent { const backoff = this.#backoffStrategy(); const request = this.#requestAndRetry({ request: () => - this._fetch('' + new URL(`/api/v2/canister/${ecid.toText()}/call`, this._host), { - ...this._callOptions, + this.#fetch('' + new URL(`/api/v2/canister/${ecid.toText()}/call`, this.host), { + ...this.#callOptions, ...transformedRequest.request, body, }), @@ -499,10 +539,10 @@ export class HttpAgent implements Agent { `fetching "/api/v2/canister/${ecid.toString()}/query" with request:`, transformedRequest, ); - const fetchResponse = await this._fetch( - '' + new URL(`/api/v2/canister/${ecid.toString()}/query`, this._host), + const fetchResponse = await this.#fetch( + '' + new URL(`/api/v2/canister/${ecid.toString()}/query`, this.host), { - ...this._fetchOptions, + ...this.#fetchOptions, ...transformedRequest.request, body, }, @@ -657,7 +697,7 @@ export class HttpAgent implements Agent { this.log.print(`ecid ${ecid.toString()}`); this.log.print(`canisterId ${canisterId.toString()}`); const makeQuery = async () => { - const id = await (identity !== undefined ? await identity : await this._identity); + const id = await (identity !== undefined ? await identity : await this.#identity); if (!id) { throw new IdentityInvalidError( "This identity has expired due this application's security policy. Please refresh your authentication.", @@ -685,7 +725,7 @@ export class HttpAgent implements Agent { method: 'POST', headers: { 'Content-Type': 'application/cbor', - ...(this._credentials ? { Authorization: 'Basic ' + btoa(this._credentials) } : {}), + ...(this.#credentials ? { Authorization: 'Basic ' + btoa(this.#credentials) } : {}), }, }, endpoint: Endpoint.Query, @@ -837,7 +877,7 @@ export class HttpAgent implements Agent { identity?: Identity | Promise, // eslint-disable-next-line @typescript-eslint/no-explicit-any ): Promise { - const id = await (identity !== undefined ? await identity : await this._identity); + const id = await (identity !== undefined ? await identity : await this.#identity); if (!id) { throw new IdentityInvalidError( "This identity has expired due this application's security policy. Please refresh your authentication.", @@ -852,7 +892,7 @@ export class HttpAgent implements Agent { method: 'POST', headers: { 'Content-Type': 'application/cbor', - ...(this._credentials ? { Authorization: 'Basic ' + btoa(this._credentials) } : {}), + ...(this.#credentials ? { Authorization: 'Basic ' + btoa(this.#credentials) } : {}), }, }, endpoint: Endpoint.ReadState, @@ -889,14 +929,11 @@ export class HttpAgent implements Agent { const response = await this.#requestAndRetry({ request: () => - this._fetch( - '' + new URL(`/api/v2/canister/${canister.toString()}/read_state`, this._host), - { - ...this._fetchOptions, - ...transformedRequest.request, - body, - }, - ), + this.#fetch('' + new URL(`/api/v2/canister/${canister.toString()}/read_state`, this.host), { + ...this.#fetchOptions, + ...transformedRequest.request, + body, + }), backoff, tries: 0, }); @@ -969,7 +1006,7 @@ export class HttpAgent implements Agent { const replicaTime = status.get('time'); if (replicaTime) { - this._timeDiffMsecs = Number(replicaTime as bigint) - Number(callTime); + this.#timeDiffMsecs = Number(replicaTime as bigint) - Number(callTime); } } catch (error) { this.log.error('Caught exception while attempting to sync time', error as AgentError); @@ -977,9 +1014,9 @@ export class HttpAgent implements Agent { } public async status(): Promise { - const headers: Record = this._credentials + const headers: Record = this.#credentials ? { - Authorization: 'Basic ' + btoa(this._credentials), + Authorization: 'Basic ' + btoa(this.#credentials), } : {}; @@ -988,27 +1025,27 @@ export class HttpAgent implements Agent { const response = await this.#requestAndRetry({ backoff, request: () => - this._fetch('' + new URL(`/api/v2/status`, this._host), { headers, ...this._fetchOptions }), + this.#fetch('' + new URL(`/api/v2/status`, this.host), { headers, ...this.#fetchOptions }), tries: 0, }); return cbor.decode(await response.arrayBuffer()); } public async fetchRootKey(): Promise { - if (!this._rootKeyFetched) { + if (!this.#rootKeyFetched) { // Hex-encoded version of the replica root key this.rootKey = ((await this.status()) as JsonObject & { root_key: ArrayBuffer }).root_key; - this._rootKeyFetched = true; + this.#rootKeyFetched = true; } return this.rootKey; } public invalidateIdentity(): void { - this._identity = null; + this.#identity = null; } public replaceIdentity(identity: Identity): void { - this._identity = Promise.resolve(identity); + this.#identity = Promise.resolve(identity); } public async fetchSubnetKeys(canisterId: Principal | string) { diff --git a/packages/agent/src/fetch_candid.ts b/packages/agent/src/fetch_candid.ts index 50d34b0a..821a635c 100644 --- a/packages/agent/src/fetch_candid.ts +++ b/packages/agent/src/fetch_candid.ts @@ -14,10 +14,7 @@ import { Actor, ActorSubclass } from './actor'; export async function fetchCandid(canisterId: string, agent?: HttpAgent): Promise { if (!agent) { // Create an anonymous `HttpAgent` (adapted from Candid UI) - agent = new HttpAgent(); - if (agent.isLocal()) { - agent.fetchRootKey(); - } + agent = await HttpAgent.create(); } // Attempt to use canister metadata