@dfinity/agent
Version:
JavaScript and TypeScript library to interact with the Internet Computer
192 lines (172 loc) • 7.07 kB
text/typescript
/**
* @experimental
* @module canister-env/api
*/
import { hexToBytes } from '@noble/hashes/utils';
import {
InputError,
InvalidRootKeyErrorCode,
EmptyCookieErrorCode,
MissingRootKeyErrorCode,
MissingCookieErrorCode,
} from '../errors.ts';
const IC_ENV_COOKIE_NAME = 'ic_env';
const ENV_VAR_SEPARATOR = '&';
const ENV_VAR_ASSIGNMENT_SYMBOL = '=';
const IC_ROOT_KEY_VALUE_NAME = 'ic_root_key';
const IC_ROOT_KEY_ENV_NAME = 'IC_ROOT_KEY'; // the value must be the same as the `CanisterEnv.IC_ROOT_KEY` property name
const IC_ROOT_KEY_BYTES_LENGTH = 133;
/**
* The environment variables served by the asset canister that hosts the frontend.
* You can extend the `CanisterEnv` interface to add your own environment variables
* and have strong typing for them.
* @see The {@link https://js.icp.build/core/latest/canister-environment/ | Canister Environment Guide} for more details on how to use the canister environment in a frontend application
* @experimental
* @example
* Extend the global `CanisterEnv` interface to add your own environment variables:
* ```ts title="index.ts"
* // You can declare the interface to have strong typing
* // for your own environment variables across your codebase
* declare module '@icp-sdk/core/agent/canister-env' {
* interface CanisterEnv {
* readonly ['PUBLIC_CANISTER_ID:backend']: string;
* readonly PUBLIC_API_URL: string;
* }
* }
*
* const env = getCanisterEnv();
*
* console.log(env.IC_ROOT_KEY); // by default served by the asset canister
* console.log(env['PUBLIC_CANISTER_ID:backend']); // ✅ TS passes
* console.log(env.PUBLIC_API_URL); // ✅ TS passes
* console.log(env['PUBLIC_CANISTER_ID:another']); // ❌ TS will show an error
* ```
* @example
* Alternatively, use the generic parameter to specify additional properties:
* ```ts title="index.ts"
* const env = getCanisterEnv<{ readonly ['PUBLIC_CANISTER_ID:backend']: string }>();
*
* console.log(env.IC_ROOT_KEY); // by default served by the asset canister
* console.log(env['PUBLIC_CANISTER_ID:backend']); // ✅ from generic parameter, TS passes
* console.log(env['PUBLIC_CANISTER_ID:frontend']); // ❌ TS will show an error
* ```
*/
export interface CanisterEnv {
/**
* The root key of the IC network where the asset canister is deployed.
* Served by default by the asset canister that hosts the frontend.
*/
readonly IC_ROOT_KEY: Uint8Array; // the key must be the same as the `IC_ROOT_KEY_ENV_NAME` constant
}
/**
* Options for the {@link getCanisterEnv} function
* @experimental
*/
export type GetCanisterEnvOptions = {
/**
* The name of the cookie to get the environment variables from.
* @default 'ic_env'
*/
cookieName?: string;
};
/**
* Get the environment variables served by the asset canister via the cookie.
*
* The returned object always includes `IC_ROOT_KEY` property.
* You can extend the global `CanisterEnv` interface to add your own environment variables
* and have strong typing for them.
*
* In Node.js environment (or any other environment where `globalThis.document` is not available), this function will throw an error.
* Use {@link safeGetCanisterEnv} if you need a function that returns `undefined` instead of throwing errors.
* @param options The options for loading the canister environment variables
* @returns The environment variables for the asset canister, always including `IC_ROOT_KEY`
* @throws {TypeError} When `globalThis.document` is not available
* @throws {InputError} When the cookie is not found
* @throws {InputError} When the `IC_ROOT_KEY` is missing or has an invalid length
* @see The {@link https://js.icp.build/core/latest/canister-environment/ | Canister Environment Guide} for more details on how to use the canister environment in a frontend application
* @experimental
* @example
* ```ts
* type MyCanisterEnv = {
* readonly ['PUBLIC_CANISTER_ID:backend']: string;
* };
*
* const env = getCanisterEnv<MyCanisterEnv>();
*
* console.log(env.IC_ROOT_KEY); // always available (Uint8Array)
* console.log(env['PUBLIC_CANISTER_ID:backend']); // ✅ from generic parameter, TS passes
* console.log(env['PUBLIC_CANISTER_ID:frontend']); // ❌ TS will show an error
* ```
* Have a look at {@link CanisterEnv} for more details on how to extend the interface
*/
export function getCanisterEnv<T = Record<string, never>>(
options: GetCanisterEnvOptions = {},
): CanisterEnv & T {
const { cookieName = IC_ENV_COOKIE_NAME } = options;
const cookie = getCookie(cookieName);
if (!cookie) {
throw InputError.fromCode(new MissingCookieErrorCode(cookieName));
}
const cookieValue = cookie.split('=')[1].trim();
if (!cookieValue) {
throw InputError.fromCode(new EmptyCookieErrorCode(cookieName));
}
const decodedCookieValue = decodeURIComponent(cookieValue);
const envVars = parseEnvVars<T>(decodedCookieValue);
if (!envVars.IC_ROOT_KEY) {
throw InputError.fromCode(new MissingRootKeyErrorCode());
}
return envVars;
}
/**
* Safe version of {@link getCanisterEnv} that returns `undefined` instead of throwing errors.
* @param options The options for loading the asset canister environment variables
* @returns The environment variables for the asset canister, or `undefined` if any error occurs
* @see The {@link https://js.icp.build/core/latest/canister-environment/ | Canister Environment Guide} for more details on how to use the canister environment in a frontend application
* @experimental
* @example
* ```ts
* // in a browser environment with valid cookie
* const env = safeGetCanisterEnv();
* console.log(env); // { IC_ROOT_KEY: Uint8Array, ... }
*
* // in a Node.js environment
* const env = safeGetCanisterEnv();
* console.log(env); // undefined
*
* // in a browser without the environment cookie
* const env = safeGetCanisterEnv();
* console.log(env); // undefined
* ```
*/
export function safeGetCanisterEnv<T = Record<string, never>>(
options: GetCanisterEnvOptions = {},
): (CanisterEnv & T) | undefined {
try {
return getCanisterEnv<T>(options);
} catch {
return undefined;
}
}
function getCookie(cookieName: string): string | undefined {
return globalThis.document.cookie
.split(';')
.find(cookie => cookie.trim().startsWith(`${cookieName}=`));
}
function parseEnvVars<T = Record<string, never>>(decoded: string): CanisterEnv & T {
const entries = decoded.split(ENV_VAR_SEPARATOR).map(v => {
// we only want to split at the first occurrence of the assignment symbol
const symbolIndex = v.indexOf(ENV_VAR_ASSIGNMENT_SYMBOL);
const key = v.slice(0, symbolIndex);
const value = v.substring(symbolIndex + 1);
if (key === IC_ROOT_KEY_VALUE_NAME) {
const rootKey = hexToBytes(value);
if (rootKey.length !== IC_ROOT_KEY_BYTES_LENGTH) {
throw InputError.fromCode(new InvalidRootKeyErrorCode(rootKey, IC_ROOT_KEY_BYTES_LENGTH));
}
return [IC_ROOT_KEY_ENV_NAME, rootKey];
}
return [key, value];
});
return Object.fromEntries(entries);
}