JavaScript agent
Overview
An agent is an API client used to interact with ICP. The JavaScript agent (agent-js
) is used to interact with the public ICP API endpoints and canisters deployed on ICP. agent-js
provides call
, query
, and readState
methods, along with other utilities, to an actor.
Calls made through the agent are intended to be structured through an actor and configured with a canister's interface that can be generated from a Candid interface. Raw calls are supported, but not recommended per best practices.
Installation
Prerequisites
To get started with agent-js
, it is recommended that your development environment include:
- IC SDK for canister creation and management.
- Node JS (12, 14, or 16).
- A canister you want to experiment with.
- Suggestions:
-
dfx new
default project. - An example from dfinity/examples.
To install the JavaScript agent, run the following command:
npm i --save @dfinity/agent
Authentication
agent-js
supports authentication through auth-client
, which uses the Internet Identity service.
First, install the auth-client
package:
npm i --save @dfinity/auth-client
Create an authentication client that will open an identity.ic0.app
window, save your delegation to indexedDb
, and provide you with an identity:
const authClient = await AuthClient.create();
The authClient can log in with
authClient.login({
// 7 days in nanoseconds
maxTimeToLive: BigInt(7 * 24 * 60 * 60 * 1000 * 1000 * 1000),
onSuccess: async () => {
handleAuthenticated(authClient);
},
});
Then, use that identity to make authenticated calls using the @dfinity/agent
actor:
const identity = await authClient.getIdentity();
const actor = Actor.createActor(idlFactory, {
agent: new HttpAgent({
identity,
}),
canisterId,
});
Storage and key management
By default, agent-js
uses a default IndexedDb storage interface and ECDSA keys.
You can use any storage structure that implements the AuthClientStorage interface.
export type StoredKey = string | CryptoKeyPair;
export interface AuthClientStorage {
get(key: string): Promise<StoredKey | null>;
set(key: string, value: StoredKey): Promise<void>;
remove(key: string): Promise<void>;
}
If you decide to use a custom storage implementation that only supports string data types, it is recommended to use the keyType
option to use an Ed25519
key instead of the default ECDSA key:
const authClient = await AuthClient.create({
storage: new LocalStorage(),
keyType: 'Ed25519',
});
Session management
There are two options supported by the AuthClient for secure session management:
The
maxTimeToLive
option built into the Internet Identity delegation determines in nanoseconds how long theDelegationIdentity
returned will be valid for.The Idle Manager, which monitors keyboard, mouse, and touchscreen activity. The Idle Manager will automatically log you out if the browser is not interacted with for a period of time.
- By default, this period is 10 minutes. After 10 minutes, the DelegationIdentity
will be removed from from localStorage
and window.location.reload()
is called.
- Alternatively, you can pass an onIdle
option that will replace the default window.location.reload()
behavior, or register callbacks with idleManager.registerCallback()
, which will also replace the default callback.
View the full set of options for the IdleManager.
Initializing an actor
Agents are most commonly used to create an actor using the Actor.createActor
constructor:
Actor.createActor(interfaceFactory: InterfaceFactory, configuration: ActorConfig): ActorSubclass<T>
The interfaceFactory
function returns a runtime interface that the actor uses to structure calls made to a canister. This function is generated by running the dfx generate
command in your project or using the didc
tool to generate interfaces for your project. Alternatively, this function can be written manually, though it is not recommended.
Actors in the context of the JavaScript agent are used to:
Poll for updates.
Build the Candid-encoded body of the agent's request.
Parse the response.
Provide type type safety for the canister's interface.
Making a call to the HttpAgent by itself without the use of an actor is an advanced use case that is unnecessary for most applications.
To set up an actor, you can import a createActor
utility from your canister's Candid declarations and canisterId alias
, which by default points to process.env<canister-id>_CANISTER_ID
.
You can pass the canister ID environment variable logic to your application in a number of ways, such as:
Edit it into the start of your
package.json
scripts (NFT_CANISTER_ID=... node....
).Install dotenv and configure it to read from a hidden
.env
file.
The following example uses local development, where you can read the canister ID from the local canister_ids.json
file, which can be found at .dfx/local/canister_ids.json
:
// src/node/index.js
import fetch from "isomorphic-fetch";
import { HttpAgent } from "@dfinity/agent";
import { createRequire } from "node:module";
import { canisterId, createActor } from "../declarations/agent_js_example/index.js";
import { identity } from "./identity.js";
// Require syntax is needed for JSON file imports
const require = createRequire(import.meta.url);
const localCanisterIds = require("../../.dfx/local/canister_ids.json");
// Use `process.env` if available, or fall back to local
const effectiveCanisterId =
canisterId?.toString() ?? localCanisterIds.agent_js_example.local;
const agent = new HttpAgent({
identity: await identity,
host: "http://127.0.0.1:4943",
fetch,
});
const actor = createActor(effectiveCanisterId, {
agent,
});
HTTP headers
An actor can be initialized to include the boundary node HTTP headers using the Actor.createActor
constructor:
Actor.createActorWithHttpDetails(interfaceFactory: InterfaceFactory, configuration: ActorConfig): ActorSubclass<ActorMethodMappedWithHttpDetails<T>>
Inspecting an actor's agent
To get the agent of an actor, use the Actor.agentOf
method:
const defaultAgent = Actor.agentOf(defaultActor);
This method can be used to replace or invalidate an identity used by an actor's agent. For example, the following method can be used to replace the current identity of an actor's agent with a newly authenticated identity from Internet Identity:
defaultAgent.replaceIdentity(await authClient.getIdentity());
Making calls
agent-js
supports the following methods for making calls to ICP:
call
: Used to make update calls.query
: Used to make query calls.readState
: Used to read state information from the ICP replica.
Raw calls can be made using the agent, but it is recommended to use the agent's generated calls instead.
Update calls
Update calls on ICP make a change to the canister's state. To make an update call with agent-js
, use the call
method:
call(canisterId: string | Principal, fields: CallOptions): Promise<SubmitResponse>
This method is defined in packages/agent/src/agent/api.ts:178.
Parameters for this method are:
canisterId
: The canister's principal ID in the formatstring
.fields
: The call's options. Possible options are defined in CallOptions.
The call
method returns Promise<[SubmitResponse](https://agent-js.icp.xyz/agent/interfaces/SubmitResponse.html)>
.
Query calls
Query calls on ICP do not change a canister's state and are only used to return information. To make an update call with agent-js
, use the query
method:
query(canisterId: string | Principal, options: QueryFields, identity?: Identity | Promise<Identity>): Promise<ApiQueryResponse>
This method is defined in packages/agent/src/agent/api.ts:200
Parameters for this method are:
canisterId
: The canister's principal ID in the formatstring
. Note that sending a query to the management canister is not supported, as it has no meaning from an agent.options
: Options used to create and send the query. Possible options are defined in QueryFields.identity
: Optional; identifies the sender principal to use when sending the query.
The query
method returns Promise<ApiQueryResponse>
. This is the response from the replica, and will be rejected if the communication failed. If the query itself failed but there were no protocol errors, the response will be of type QueryResponseRejected
.
Read state information
To read state information from the ICP replica, use the readState
method. This method makes a query call with a list of paths to return, and receives a certificate in response. If there are communication errors, the call will be rejected, and the certificate may contain less information than what was requested.
readState(effectiveCanisterId: string | Principal, options: ReadStateOptions, identity?: Identity, request?: any): Promise<ReadStateResponse>
This method is defined in packages/agent/src/agent/api.ts:170
Parameters for this method are:
effectiveCanisterId
: A canister's principal ID related to the call in the formatstring
.options
: The call's options. Possible options are defined in ReadStateOptions.identity
: Optional; identifies the sender principal to use when sending the query. If not specified, it uses the instance identity.request
: Optional; the request to send in case it has already been created.
This method returns Promise<ReadStateResponse>
.
Browser
When you are building apps that run in the browser, you can use the fetch
API as described below. Most apps will be able to determine whether they need to talk to https://icp0.io
or a local replica, depending on their URL.
Using fetch
agent-js
uses the web browser's fetch
API to make calls to ICP. When the agent isn't used in the browser, a fetch
implementation can be passed to the agent's constructor. This can be useful if you want to include custom fetch
implementation data, such as adding an authentication header to your request.
It is recommended to use the isomorphic-fetch
package to provide a consistent fetch
API across Node.js and the browser. You will need to provide a host option to the agent's constructor, since the agent will not be able to determine the host from the global context.
An example can be found below:
import fetch from 'isomorphic-fetch';
import { HttpAgent } from '@dfinity/agent';
const host = process.env.DFX_NETWORK === 'local' ? 'http://127.0.0.1:4943' : 'https://icp-api.io';
const agent = new HttpAgent({ fetch, host });
You can also pass fetchOptions
to the agent's constructor, which is then passed to the fetch
implementation. This is useful if you want to pass additional options to the fetch implementation. An example specifying a custom header can be found below:
import fetch from 'isomorphic-fetch';
import { HttpAgent } from '@dfinity/agent';
const host = process.env.DFX_NETWORK === 'local' ? 'http://127.0.0.1:4943' : 'https://ic0.app';
/**
* @type {RequestInit}
*/
const fetchOptions = {
headers: {
'X-Custom-Header': 'value',
},
};
const agent = new HttpAgent({ fetch, host, fetchOptions });
Performance
When developing an app that runs in the browser, it is important to take application performance into consideration. Updates to ICP may feel slow to your users, at around 2-4 seconds. When building your application, take that latency into consideration and consider following these best practices:
Avoid blocking UI interactions while you wait for the result of your update. Instead, allow users to continue to make other updates and interactions, and inform your users of success asynchronously.
Try to avoid making inter-canister calls. If the backend needs to talk to other canisters, the latency can add up quickly.
Use
Promise.all
to make multiple calls in a batch, instead of making them one by one.If you need to fetch assets or data, you can make direct
fetch
calls to theraw.icp0.io
endpoint of canisters.
Bundlers
It is recommended to use a bundler to assemble your code for convenience and simplified troubleshooting. Below is an example that provides a standard Webpack configuration, but you may also use other frameworks such as Rollup, Vite, Parcel, or others.
For this pattern, it is recommended to run a script to generate the .env.development
and .env.production
environment variable files for your project's canister IDs. This is a fairly standard approach for bundlers, and can be easily supported using dotenv. An example of this script can be found below:
// setupEnv.js
const fs = require("fs");
const path = require("path");
function initCanisterEnv() {
let localCanisters, prodCanisters;
try {
localCanisters = require(path.resolve(
".dfx",
"local",
"canister_ids.json"
));
} catch (error) {
console.log("No local canister_ids.json found");
}
try {
prodCanisters = require(path.resolve("canister_ids.json"));
} catch (error) {
console.log("No production canister_ids.json found");
}
const network =
process.env.DFX_NETWORK ||
(process.env.NODE_ENV === "production" ? "ic" : "local");
const canisterConfig = network === "local" ? localCanisters : prodCanisters;
const localMap = localCanisters
? Object.entries(localCanisters).reduce((prev, current) => {
const [canisterName, canisterDetails] = current;
prev[canisterName.toUpperCase() + "_CANISTER_ID"] =
canisterDetails[network];
return prev;
}, {})
: undefined;
const prodMap = prodCanisters
? Object.entries(prodCanisters).reduce((prev, current) => {
const [canisterName, canisterDetails] = current;
prev[canisterName.toUpperCase() + "_CANISTER_ID"] =
canisterDetails[network];
return prev;
}, {})
: undefined;
return [localMap, prodMap];
}
const [localCanisters, prodCanisters] = initCanisterEnv();
if (localCanisters) {
const localTemplate = Object.entries(localCanisters).reduce((start, next) => {
const [key, val] = next;
if (!start) return `${key}=${val}`;
return `${start ?? ""}
${key}=${val}`;
}, ``);
fs.writeFileSync(".env.development", localTemplate);
}
if (prodCanisters) {
const prodTemplate = Object.entries(prodCanisters).reduce((start, next) => {
const [key, val] = next;
if (!start) return `${key}=${val}`;
return `${start ?? ""}
${key}=${val}`;
}, ``);
fs.writeFileSync(".env", localTemplate);
}
Then, you can add "prestart"
and "prebuild"
commands of dfx generate; node setupEnv.js
. Follow the documentation for your preferred bundler on how to work with environment variables.
Example
Below is an example that creates an agent, fetches the root key of the replica, then creates an actor automatically through the Candid interface of the canister.
This example uses fetchRootKey. It is not recommended that dapps deployed on the mainnet call this function from agent-js, since using fetchRootKey on the mainnet poses severe security concerns for the dapp that's making the call. It is recommended to put it behind a condition so that it only runs locally.
This API call will fetch a root key for verification of update calls from a single replica, so it’s possible for that replica to respond with a malicious key. A verified mainnet root key is already embedded into agent-js, so this only needs to be called on your local replica, which will have a different key from mainnet that agent-js does not know ahead of time.
import { Actor, HttpAgent } from "@dfinity/agent";
// Imports and re-exports candid interface
import { idlFactory } from "./backend_canister.did.js";
export { idlFactory } from "./backend_canister.did.js";
/* CANISTER_ID is replaced by webpack based on node environment
* Note: canister environment variable will be standardized as
* process.env.CANISTER_ID_<CANISTER_NAME_UPPERCASE>
* beginning in dfx 0.15.0
*/
export const canisterId =
process.env.CANISTER_ID_BACKEND_CANISTER ||
process.env.BACKEND_CANISTER_CANISTER_ID;
export const createActor = (canisterId, options = {}) => {
const agent = options.agent || new HttpAgent({ ...options.agentOptions });
if (options.agent && options.agentOptions) {
console.warn(
"Detected both agent and agentOptions passed to createActor. Ignoring agentOptions and proceeding with the provided agent."
);
}
// Fetch root key for certificate validation during development
if (process.env.DFX_NETWORK !== "ic") {
agent.fetchRootKey().catch((err) => {
console.warn(
"Unable to fetch root key. Check to ensure that your local replica is running"
);
console.error(err);
});
}
// Creates an actor using the candid interface and the HttpAgent
return Actor.createActor(idlFactory, {
agent,
canisterId,
...options.actorOptions,
});
};
export const backend_canister = createActor(canisterId);