diff --git a/.claude/docs-guidelines.md b/.claude/docs-guidelines.md index 3c2b98e0..09747ab8 100644 --- a/.claude/docs-guidelines.md +++ b/.claude/docs-guidelines.md @@ -14,7 +14,7 @@ Documentation follows the Diátaxis framework: - Schema is generated in `crates/schema-gen/` - Referenced in `icp.yaml` files via `# yaml-language-server: $schema=...` -- Regenerate when manifest types change: `./scripts/generate-config-schema.sh` +- Regenerate when manifest types change: `./scripts/generate-config-schemas.sh` ## CLI Docs Generation @@ -29,3 +29,37 @@ Documentation follows the Diátaxis framework: - Consistent ordering: npm (in Quick Install), then Homebrew, then Shell Script (in Alternative Methods) - When referencing alternatives in other docs, maintain this order: "Homebrew, shell script, ..." (e.g., "See the Installation Guide for Homebrew, shell script, or other options") - Both `icp-cli` and `ic-wasm` are available as official Homebrew formulas: `brew install icp-cli` and `brew install ic-wasm` + +## Writing Guidelines + +- Use "canister environment variables" (not just "environment variables") when referring to runtime variables stored in canister settings — this distinguishes them from shell/build environment variables +- Verify code examples and CLI commands work before committing; explain non-obvious flags +- Link to anchors on other pages rather than duplicating content (e.g., `[Custom Variables](../reference/environment-variables.md#custom-variables)`) +- Link to external tools rather than duplicating their documentation + +## Link Formatting + +Source documentation in `docs/` must work in two contexts: + +1. **GitHub**: Renders Markdown directly with `.md` extensions +2. **Starlight docs site**: `scripts/prepare-docs.sh` transforms links to clean URLs + +**Link format rules:** + +- Always use relative paths with `.md` extensions: `[Link](../concepts/file.md)` +- Anchors go after the extension: `[Link](../concepts/file.md#section-name)` +- Never use absolute paths or URLs for internal docs links + +**Cross-reference examples:** + +```markdown +# From docs/guides/local-development.md: +[Canister Discovery](../concepts/canister-discovery.md) +[Custom Variables](../reference/environment-variables.md#custom-variables) + +# From docs/concepts/canister-discovery.md: +[same-directory link](binding-generation.md) +[root-level link](../tutorial.md) +``` + +The `prepare-docs.sh` script handles the transformation to Starlight's URL structure. If you add new link patterns, verify they work by building the docs site locally with `cd docs-site && npm run build`. diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index e14c36c2..1c17e2f9 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -82,6 +82,8 @@ export default defineConfig({ { label: 'Project Model', slug: 'concepts/project-model' }, { label: 'Build, Deploy, Sync', slug: 'concepts/build-deploy-sync' }, { label: 'Environments and Networks', slug: 'concepts/environments' }, + { label: 'Canister Discovery', slug: 'concepts/canister-discovery' }, + { label: 'Binding Generation', slug: 'concepts/binding-generation' }, { label: 'Recipes', slug: 'concepts/recipes' }, ], }, diff --git a/docs/concepts/binding-generation.md b/docs/concepts/binding-generation.md new file mode 100644 index 00000000..0a2de75e --- /dev/null +++ b/docs/concepts/binding-generation.md @@ -0,0 +1,48 @@ +# Binding Generation + +Understanding and using type-safe client code for calling canisters. + +## What Are Bindings? + +Bindings are generated code that provides type-safe access to canister methods. They're created from Candid interface files (`.did`), which define a canister's public API. + +## Candid Interface Files + +Candid is the interface description language for the Internet Computer. A `.did` file defines the public methods and types a canister exposes — it's the contract between a canister and its callers. + +`.did` files can be: +- **Manually authored** — Recommended for stable APIs where backward compatibility matters +- **Generated from code** — Convenient during development, but review before publishing + +For Candid syntax and best practices, see the [Candid specification](https://github.com/dfinity/candid/blob/master/spec/Candid.md). + +## Generating Client Bindings + +icp-cli focuses on deployment — use these dedicated tools to generate bindings: + +| Language | Tool | Documentation | +|----------|------|---------------| +| TypeScript/JavaScript | `@icp-sdk/bindgen` | [js.icp.build/bindgen](https://js.icp.build/bindgen) | +| Rust | `candid` crate | [docs.rs/candid](https://docs.rs/candid) | +| Other languages | `didc` CLI | [github.com/dfinity/candid](https://github.com/dfinity/candid) | + +> **Note:** Generated bindings typically hardcode a canister ID or require one at initialization. With icp-cli, canister IDs differ between environments. You can look up IDs with `icp canister status -i`, or read them from canister environment variables at runtime. See [Canister Discovery](canister-discovery.md) for details. + +### TypeScript/JavaScript + +Use `@icp-sdk/bindgen` to generate TypeScript bindings from Candid files. See the [@icp-sdk/bindgen documentation](https://js.icp.build/bindgen) for usage and build tool integration. + +### Rust + +The `candid` crate provides Candid serialization and code generation macros. See the [candid crate documentation](https://docs.rs/candid). + +### Other Languages + +The `didc` CLI generates bindings for various languages. See the [Candid repository](https://github.com/dfinity/candid) for available targets. + +## See Also + +- [Canister Discovery](canister-discovery.md) — How canisters find each other's IDs +- [Local Development](../guides/local-development.md) — Development workflow + +[Browse all documentation →](../index.md) diff --git a/docs/concepts/build-deploy-sync.md b/docs/concepts/build-deploy-sync.md index 45c349db..f1519299 100644 --- a/docs/concepts/build-deploy-sync.md +++ b/docs/concepts/build-deploy-sync.md @@ -136,7 +136,13 @@ The `icp deploy` command is a composite command that executes multiple steps in 1. **Build** — Compile all target canisters to WASM (always runs) 2. **Create** — Create canisters on the network (only for canisters that don't exist yet) -3. **Update Canister Environment Variables** — Apply the updated Canister Environment Variables. These include variables used by bindings allowing canister interactions. +3. **Update Canister Environment Variables** — For each canister being deployed: + - Collects IDs of all canisters in the environment + - Creates `PUBLIC_CANISTER_ID:` variables for each canister + - Merges with any custom `environment_variables` from settings + - Updates canister settings via the Management Canister + + This step enables canisters to discover each other without hardcoding IDs. See [Canister Discovery](canister-discovery.md) for details. 4. **Update Settings** — Apply canister settings (controllers, memory allocation, compute allocation, etc.) 5. **Install** — Install WASM code into canisters (always runs) 6. **Sync** — Run post-deployment steps like asset uploads (only if sync steps are configured) @@ -151,7 +157,7 @@ The `icp deploy` command is a composite command that executes multiple steps in **Subsequent deployments:** - Skip the canister creation -- Settings and Environment Variables are applied if they've changed. +- Settings and Canister Environment Variables are applied if they've changed. - WASM code is upgraded, preserving canister state Unlike `icp canister create` (which prints "already exists" and exits), `icp deploy` silently skips creation for existing canisters and continues with the remaining steps. @@ -209,5 +215,6 @@ icp sync ## Next Steps - [Local Development](../guides/local-development.md) — Apply this in practice +- [Canister Discovery](canister-discovery.md) — How canisters discover each other [Browse all documentation →](../index.md) diff --git a/docs/concepts/canister-discovery.md b/docs/concepts/canister-discovery.md new file mode 100644 index 00000000..4f12dc28 --- /dev/null +++ b/docs/concepts/canister-discovery.md @@ -0,0 +1,171 @@ +# Canister Discovery + +How icp-cli enables canisters to discover each other through automatic ID injection. + +## The Discovery Problem + +Canister IDs are assigned at deployment time and differ between environments: + +| Environment | Backend ID | +|-------------|-----------| +| local | `bkyz2-fmaaa-aaaaa-qaaaq-cai` | +| staging | `rrkah-fqaaa-aaaaa-aaaaq-cai` | +| ic (mainnet) | `xxxxx-xxxxx-xxxxx-xxxxx-cai` | + +Hardcoding IDs creates problems: + +- Deploying to a new environment requires code changes +- Recreating a canister invalidates hardcoded references +- Sharing code with others fails because IDs don't match + +## Automatic Canister ID Injection + +icp-cli solves this by automatically injecting canister IDs as [canister environment variables](../reference/environment-variables.md#canister-runtime-environment-variables) during deployment. + +### How It Works + +During `icp deploy`, icp-cli automatically: + +1. Collects all canister IDs in the current environment +2. Creates a variable for each: `PUBLIC_CANISTER_ID:` → `` +3. Injects **all** these variables into **every** canister in the environment + +This means each canister receives the IDs of all other canisters, enabling any canister to call any other canister without hardcoding IDs. + +> **Note:** Variables are only updated for the canisters being deployed. If you deploy a single canister (`icp deploy backend`), only that canister receives updated variables. When adding new canisters to an existing project, run `icp deploy` without arguments to update all canisters with the complete set of IDs. + +### Variable Format + +For an environment with `backend`, `frontend`, and `worker` canisters: + +``` +PUBLIC_CANISTER_ID:backend → bkyz2-fmaaa-aaaaa-qaaaq-cai +PUBLIC_CANISTER_ID:frontend → bd3sg-teaaa-aaaaa-qaaba-cai +PUBLIC_CANISTER_ID:worker → b77ix-eeaaa-aaaaa-qaada-cai +``` + +These variables are stored in canister settings, not baked into the WASM. The same WASM can run in different environments with different canister IDs. + +### Deployment Order + +When deploying multiple canisters: + +1. `icp deploy` creates all canisters first (getting their IDs) +2. Then injects `PUBLIC_CANISTER_ID:*` variables into all canisters +3. Then installs WASM code + +All canisters can reference each other's IDs regardless of declaration order in `icp.yaml`. + +## Frontend to Backend Communication + +When your frontend is deployed to an asset canister: + +1. The asset canister receives `PUBLIC_CANISTER_ID:*` variables +2. It exposes them via a cookie named `ic_env`, along with the network's root key (`IC_ROOT_KEY`) +3. Your frontend JavaScript reads the cookie to get canister IDs and root key + +This mechanism works identically on local networks and mainnet — your frontend code doesn't need to change between environments. + +### Working Examples + +- **hello-world template** — The template from `icp new` demonstrates this pattern. Look at the frontend source code to see how it reads the backend canister ID. +- **[frontend-environment-variables example](https://github.com/dfinity/icp-cli/tree/main/examples/icp-frontend-environment-variables)** — A detailed example showing dev server configuration with Vite. + +### Implementation + +Use [@icp-sdk/core](https://www.npmjs.com/package/@icp-sdk/core) to read the cookie: + +```typescript +import { getCanisterEnv } from "@icp-sdk/core/agent/canister-env"; + +interface CanisterEnv { + "PUBLIC_CANISTER_ID:backend": string; + IC_ROOT_KEY: Uint8Array; // Parsed from hex by the library +} + +const env = getCanisterEnv(); +``` + +For local development with a dev server, see the [Local Development Guide](../guides/local-development.md#frontend-development). + +## Backend to Backend Communication + +Since all canisters receive `PUBLIC_CANISTER_ID:*` variables for every canister in the environment, backend canisters can discover each other's IDs at runtime. + +### Reading Canister Environment Variables + +**Rust** canisters can read the injected canister IDs using [`ic_cdk::api::env_var_value`](https://docs.rs/ic-cdk/latest/ic_cdk/api/fn.env_var_value.html): + +```rust +use candid::Principal; + +let backend_id = Principal::from_text( + &ic_cdk::api::env_var_value("PUBLIC_CANISTER_ID:backend") +).unwrap(); +``` + +**Motoko** canisters can read canister environment variables using `Prim.envVar` (since Motoko 0.16.2): + +```motoko +import Prim "mo:⛔"; +import Principal "mo:core/Principal"; + +let ?backendIdText = Prim.envVar("PUBLIC_CANISTER_ID:backend") else { + return #err("backend canister ID not set"); +}; +let backendId = Principal.fromText(backendIdText); +``` + +> **Note:** `Prim` is an internal module not intended for general use. This functionality will be available in the Motoko core package in a future release. + +### Making Inter-Canister Calls + +Once you have the target canister ID, make calls using your language's CDK: + +- **Rust**: [`ic_cdk::call`](https://docs.rs/ic-cdk/latest/ic_cdk/call/index.html) API +- **Motoko**: [Inter-canister calls](https://docs.internetcomputer.org/motoko/fundamentals/actors/messaging#inter-canister-calls) + +### Alternative Patterns + +If you prefer not to use canister environment variables: + +1. **Init arguments** — Pass canister IDs as initialization parameters +2. **Configuration** — Store IDs in canister state during setup + +## Custom Canister Environment Variables + +Beyond automatic `PUBLIC_CANISTER_ID:*` variables, you can define custom canister environment variables in `icp.yaml`. See the [Environment Variables Reference](../reference/environment-variables.md#custom-variables) for configuration syntax. + +## Troubleshooting + +### "Canister not found" errors + +Ensure the target canister is deployed: + +```bash +icp canister list # Check what's deployed +icp deploy # Deploy all canisters +``` + +### Canister environment variables not available + +Canister environment variables are set automatically during `icp deploy`. If you're using `icp canister install` directly, variables won't be set. Use `icp deploy` instead. + +### Wrong canister ID in different environment + +Check which environment you're targeting: + +```bash +icp canister list -e local # Local environment +icp canister list -e production # Production environment +``` + +## See Also + +- [Binding Generation](binding-generation.md) — Type-safe canister interfaces +- [Environment Variables Reference](../reference/environment-variables.md) — Complete variable documentation +- [Canister Settings Reference](../reference/canister-settings.md) — Settings configuration +- [Build, Deploy, Sync](build-deploy-sync.md) — Deployment lifecycle details +- [Local Development](../guides/local-development.md) — Frontend local dev setup + +[Browse all documentation →](../index.md) diff --git a/docs/concepts/index.md b/docs/concepts/index.md index fb7f29d3..957fa9d6 100644 --- a/docs/concepts/index.md +++ b/docs/concepts/index.md @@ -8,6 +8,8 @@ Understanding how icp-cli organizes and manages your project. - [Build, Deploy, Sync](build-deploy-sync.md) — The three phases of the deployment lifecycle - [Environments and Networks](environments.md) — Deployment targets and how they relate - [Recipes](recipes.md) — Templated, reusable build configurations +- [Canister Discovery](canister-discovery.md) — How canisters discover each other +- [Binding Generation](binding-generation.md) — Type-safe canister interfaces ## Quick Reference diff --git a/docs/guides/local-development.md b/docs/guides/local-development.md index a97b38d9..7db4540b 100644 --- a/docs/guides/local-development.md +++ b/docs/guides/local-development.md @@ -90,6 +90,110 @@ icp build # Build all icp build frontend # Build specific canister ``` +## Frontend Development + +### Asset Canisters + +Web frontends on the Internet Computer are served by **asset canisters** — pre-built canisters maintained by DFINITY that serve static files (HTML, JS, CSS, images) over HTTP. + +The `@dfinity/asset-canister` recipe deploys this pre-built canister and syncs your frontend files to it: + +```yaml +canisters: + - name: frontend + recipe: + type: "@dfinity/asset-canister" + configuration: + dir: dist # Your built frontend files +``` + +Deploy and access your frontend: + +```bash +icp network start -d +icp deploy +``` + +Open your browser to `http://.localhost:8000/` (the canister ID is shown in the deploy output). + +### Calling Backend Canisters + +This section applies when your frontend needs to call backend canisters. If your frontend is purely static, you can skip this. + +When a frontend calls a backend canister, it needs two things: + +1. **The backend's canister ID** — to know which canister to call +2. **The network's root key** — to verify response signatures + +Asset canisters solve this automatically via a cookie named `ic_env`: + +1. During `icp deploy`, canister IDs are injected as `PUBLIC_CANISTER_ID:*` canister environment variables +2. The asset canister serves these variables plus the network's root key via the `ic_env` cookie +3. Your frontend reads the cookie using `@icp-sdk/core` to get canister IDs and root key + +This works identically on local networks and mainnet — your frontend code doesn't need to change between environments. + +See [Canister Discovery](../concepts/canister-discovery.md) for implementation details. + +### Development Approaches + +When developing a frontend that calls backend canisters, you have two options: + +| Approach | Best for | Trade-offs | +|----------|----------|------------| +| **Deploy and access asset canister** | Testing production-like behavior | No hot reload; must redeploy on every change | +| **Use a local dev server** | Fast iteration during development | Requires manual configuration | + +#### Option 1: Deploy and access the asset canister + +Deploy all canisters and access the frontend through the asset canister: + +```bash +icp deploy +``` + +Open `http://.localhost:8000/` + +The asset canister automatically sets the `ic_env` cookie with canister IDs and the network's root key. + +**Limitation:** No hot module replacement. You must run `icp deploy frontend` after every frontend change. + +#### Option 2: Use a local dev server + +For hot reloading, run a dev server (Vite, webpack, etc.) that serves your frontend locally. Since your dev server isn't the asset canister, you need to configure it to provide the `ic_env` cookie. + +**Key insight:** You only need to deploy the backend canister — the frontend canister isn't needed since your dev server serves the frontend. + +```bash +icp deploy backend # Only deploy backend +npm run dev # Start your dev server +``` + +### Configuring a Dev Server + +When using a dev server, configure it to: + +1. **Fetch canister IDs and root key** from the CLI at startup +2. **Set the `ic_env` cookie** with these values (mimics what asset canisters do) +3. **Proxy `/api` requests** to the target network + +See the [frontend-environment-variables example](https://github.com/dfinity/icp-cli/tree/main/examples/icp-frontend-environment-variables) for a complete Vite configuration. + +**Workflow:** + +```bash +icp network start -d # Start local network +icp deploy backend # Deploy backend canister +npm run dev # Start dev server (fetches IDs automatically) +``` + +**Important:** After `icp network stop` and restart, the dev server will automatically fetch new canister IDs on next startup. + +### Example Projects + +- **hello-world template** — The template from `icp new` shows the complete pattern for reading the `ic_env` cookie. This is the simplest starting point. +- **[frontend-environment-variables example](https://github.com/dfinity/icp-cli/tree/main/examples/icp-frontend-environment-variables)** — A detailed Vite setup showing dev server configuration: fetching canister IDs and root key via CLI, setting the `ic_env` cookie, and using `@icp-sdk/core` to parse environment variables. + ## Resetting State To start fresh with a clean network: @@ -157,8 +261,20 @@ icp network start -d 1. Verify the build succeeded: `icp build` 2. Check network health: `icp network ping` +**Frontend can't find canister IDs** + +If using a dev server, ensure you've deployed the backend before starting: + +```bash +icp deploy backend +npm run dev # Start after deploy +``` + +If accessing the asset canister directly, check that you're using the correct URL format: `http://.localhost:8000/` + ## Next Steps +- [Canister Discovery](../concepts/canister-discovery.md) — How canisters find each other - [Deploying to Mainnet](deploying-to-mainnet.md) — Go live with your canisters [Browse all documentation →](../index.md) diff --git a/docs/index.md b/docs/index.md index 2be5c332..fc62f7ec 100644 --- a/docs/index.md +++ b/docs/index.md @@ -29,6 +29,8 @@ Understand how icp-cli works: - [Build, Deploy, Sync](concepts/build-deploy-sync.md) — The deployment lifecycle - [Environments and Networks](concepts/environments.md) — Deployment targets explained - [Recipes](concepts/recipes.md) — Templated build configurations +- [Canister Discovery](concepts/canister-discovery.md) — How canisters discover each other +- [Binding Generation](concepts/binding-generation.md) — Type-safe canister interfaces ## Reference diff --git a/docs/reference/environment-variables.md b/docs/reference/environment-variables.md index d8314f61..9cb45c5b 100644 --- a/docs/reference/environment-variables.md +++ b/docs/reference/environment-variables.md @@ -113,7 +113,98 @@ $env:ICP_CLI_BASH_PATH = "C:\Program Files\Git\bin\bash.exe" - Git Bash: `C:\Program Files\Git\bin\bash.exe` - MSYS2: `C:\msys64\usr\bin\bash.exe` +## Canister Runtime Environment Variables + +These variables are stored in canister settings and accessible to canister code at runtime. They are distinct from the build-time and CLI configuration variables above. + +### Automatic Variables + +#### `PUBLIC_CANISTER_ID:` + +During deployment, icp-cli automatically creates canister environment variables containing the canister IDs of all canisters in the current environment. + +| Property | Value | +|----------|-------| +| Format | `PUBLIC_CANISTER_ID:` | +| Value | Canister principal (text) | +| When Set | Automatically during `icp deploy` | + +**Example:** + +For an environment with canisters `backend` and `frontend`: + +``` +PUBLIC_CANISTER_ID:backend = bkyz2-fmaaa-aaaaa-qaaaq-cai +PUBLIC_CANISTER_ID:frontend = bd3sg-teaaa-aaaaa-qaaba-cai +``` + +**Purpose:** Enables canisters to discover other canisters in the same environment without hardcoding IDs. This is especially important for: + +- Frontend canisters calling backend canisters +- Multi-canister architectures with service dependencies +- Environment-agnostic deployments (same code works in local, staging, production) + +#### `IC_ROOT_KEY` + +The asset canister automatically includes the network's root key in the `ic_env` cookie. This is **not** set by icp-cli during deployment — the asset canister provides it directly based on the network it's running on. + +| Property | Value | +|----------|-------| +| Cookie key | `ic_root_key` (lowercase) | +| Cookie value | Hex-encoded root key | +| When Set | By the asset canister at request time | + +**Purpose:** The root key is required by the IC agent to verify response signatures. By providing it via the cookie, frontends work consistently across local networks and mainnet without code changes. + +**How frontends access these:** Use `@icp-sdk/core/agent/canister-env` to read the `ic_env` cookie. The library parses the hex-encoded root key and returns it as `Uint8Array`: + +```typescript +import { getCanisterEnv } from "@icp-sdk/core/agent/canister-env"; + +interface CanisterEnv { + "PUBLIC_CANISTER_ID:backend": string; + IC_ROOT_KEY: Uint8Array; // Converted from hex by the library +} + +const env = getCanisterEnv(); +const backendId = env["PUBLIC_CANISTER_ID:backend"]; +const rootKey = env.IC_ROOT_KEY; // Ready for agent's rootKey option +``` + +See [Canister Discovery](../concepts/canister-discovery.md) for detailed usage patterns. + +### Custom Variables + +Define custom canister environment variables in canister settings: + +```yaml +canisters: + - name: backend + settings: + environment_variables: + API_ENDPOINT: "https://api.example.com" + DEBUG: "false" +``` + +Override per environment: + +```yaml +environments: + - name: production + network: ic + canisters: [backend] + settings: + backend: + environment_variables: + API_ENDPOINT: "https://api.prod.example.com" +``` + +See [Canister Settings Reference](canister-settings.md#environment_variables) for full configuration options. + ## See Also +- [Canister Discovery](../concepts/canister-discovery.md) — How canisters discover each other +- [Local Development](../guides/local-development.md#frontend-development) — Frontend development workflow +- [Canister Settings Reference](canister-settings.md) — Full settings documentation - [Managing Identities](../guides/managing-identities.md) — Identity storage paths and directory contents - [Project Model](../concepts/project-model.md) — Project directory structure (`.icp/`) and what's safe to delete diff --git a/docs/tutorial.md b/docs/tutorial.md index e575b0bf..c1dd35b8 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -121,6 +121,8 @@ Deployed canisters: Open the **frontend URL** in your browser. You'll see a React app that calls your backend canister. +**How does the frontend know the backend's canister ID?** The asset canister (which serves your frontend) provides canister IDs via a cookie. The template's frontend code reads this cookie to discover the backend. This works the same way locally and on mainnet — see [Canister Discovery](concepts/canister-discovery.md) for details. + ### Candid UI Open the **Candid UI URL** (shown next to "backend"). Candid UI is a web interface that lets you interact with any canister that has a known [Candid](https://docs.internetcomputer.org/building-apps/interact-with-canisters/candid/candid-concepts) interface — no frontend code required. diff --git a/examples/icp-frontend-environment-variables/README.md b/examples/icp-frontend-environment-variables/README.md index d18fbc2a..9f22126d 100644 --- a/examples/icp-frontend-environment-variables/README.md +++ b/examples/icp-frontend-environment-variables/README.md @@ -1,60 +1,100 @@ # Frontend Environment Variables Example -This example demonstrates how to pass environment variables from the asset canister to the frontend webapp. +This example demonstrates how frontends discover backend canisters and verify responses using the `ic_env` cookie mechanism. ## Overview This project consists of two canisters: -- [backend](./backend/): a pre-built Hello World canister, together with its [`backend.did`](./backend/dist/hello_world.did) file. -- [frontend](./frontend/): a [Vite](https://vite.dev/) webapp deployed in an asset canister. +- [backend](./backend/): a pre-built Hello World canister with its [Candid interface](./backend/dist/hello_world.did) +- [frontend](./frontend/): a [Vite](https://vite.dev/) React app deployed to an asset canister -### Bindings Generation +## How It Works + +When a frontend calls a backend canister, it needs two things: + +1. **The backend's canister ID** — to know which canister to call +2. **The network's root key** — to verify response signatures -The [`@icp-sdk/bindgen`](https://npmjs.com/package/@icp-sdk/bindgen) library offers a plugin for Vite that generates the TypeScript Candid bindings from the [`backend.did`](./backend/dist/hello_world.did) file. The bindings are generated at build time by Vite and are saved in the [`frontend/app/src/backend/api/`](./frontend/app/src/backend/api/) folder. +Both are provided via a cookie named `ic_env`. The [`@icp-sdk/core/agent/canister-env`](https://www.npmjs.com/package/@icp-sdk/core) module parses this cookie and returns: -The plugin supports hot module replacement, so you can run the frontend in development mode (by running `npm run dev` in the [`frontend/app/`](./frontend/app/) folder) and make changes to the Candid declaration file to see the bindings being updated in real time. +- `PUBLIC_CANISTER_ID:backend` — the backend canister ID (string) +- `IC_ROOT_KEY` — the network's root key (Uint8Array, converted from hex internally) -See the [`vite.config.ts`](./frontend/app/vite.config.ts) file for how the plugin is used. +See [`App.tsx`](./frontend/app/src/App.tsx) for the implementation. -### Environment Variables +### Who Sets the Cookie? -The project is configured to pass the backend's canister ID to the frontend using a [cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cookies). The environment variables are made available to the frontend JS code that runs in the browser by following this flow: +**Asset canister (production):** When you deploy with `icp deploy`, the CLI sets `PUBLIC_CANISTER_ID:*` variables on all canisters. The asset canister then serves the `ic_env` cookie containing these variables plus the network's root key. -1. During deployment, the `icp` CLI sets the backend's canister ID in the frontend canister's (asset canister) environment variables -2. The asset canister sets a specific cookie (named `ic_env`) that contains some environment variables when the assets are uploaded -3. The asset canister serves the frontend assets with the cookie set -4. The frontend JS code uses the [`@icp-sdk/core/agent/canister-env`](https://js.icp.build/core/latest/canister-environment/) module to parse the cookie and extract the environment variables +**Dev server (development):** The Vite dev server mimics this behavior by setting the same cookie. See [`vite.config.ts`](./frontend/app/vite.config.ts) for how it fetches the canister ID, root key, and API URL from the CLI. -In the [`App.tsx`](./frontend/app/src/App.tsx) file, you can see how the frontend can obtain the backend's canister ID from the environment variables and use it to create an actor for the backend canister. +### Bindings Generation + +The [`@icp-sdk/bindgen`](https://npmjs.com/package/@icp-sdk/bindgen) Vite plugin generates TypeScript bindings from the Candid interface at build time. Output is saved to [`frontend/app/src/backend/api/`](./frontend/app/src/backend/api/). ## Prerequisites -Before you begin, ensure that you have the following installed: +- [Node.js](https://nodejs.org/) and npm +- [icp-cli](https://github.com/dfinity/icp-cli) -- [Node.js](https://nodejs.org/) -- [npm](https://docs.npmjs.com/) +## Running the Example -## Instructions +### Option 1: Deploy to Asset Canister -First, start a local network in a separate terminal window: +Start a local network: ```bash icp network start ``` -Then, deploy both canisters: +Deploy both canisters: ```bash icp deploy ``` -This command will output something like this: +Open the frontend at `http://.localhost:8000/` (the canister ID is shown in the deploy output). + +### Option 2: Use the Dev Server (Hot Reloading) + +Start a local network and deploy only the backend: ```bash -Syncing canisters: -[backend] ✔ Synced successfully: uqqxf-5h777-77774-qaaaa-cai -[frontend] ✔ Synced successfully: uxrrr-q7777-77774-qaaaq-cai # <- copy this canister id +icp network start +icp deploy backend ``` -Finally, open the deployed frontend in a browser. Copy the frontend's canister ID from the output of the `icp deploy` command and construct the URL in this way: `http://.localhost:8000/`. +> **Important:** The backend must be deployed before starting the dev server. The dev server needs to fetch the backend's canister ID to configure the `ic_env` cookie. If the backend isn't deployed, you'll see an error with instructions. + +Install dependencies and start the dev server: + +```bash +cd frontend/app +npm install +npm run dev +``` + +Open `http://localhost:5173/`. The dev server automatically configures the `ic_env` cookie and proxies API requests to the local network. + +### Targeting Different Environments + +The dev server supports any environment defined in your project: + +```bash +# Local (default) +npm run dev + +# Custom environment +ICP_ENVIRONMENT=staging npm run dev + +# IC mainnet +ICP_ENVIRONMENT=ic npm run dev +``` + +The [`vite.config.ts`](./frontend/app/vite.config.ts) uses `icp network status` to fetch the correct root key and API URL for the target environment. + +## Learn More + +- [Canister Discovery](../../docs/concepts/canister-discovery.md) — How the `ic_env` cookie mechanism works +- [Local Development Guide](../../docs/guides/local-development.md#frontend-development) — Frontend development workflow diff --git a/examples/icp-frontend-environment-variables/frontend/app/package.json b/examples/icp-frontend-environment-variables/frontend/app/package.json index cfdeaa9a..d48dd893 100644 --- a/examples/icp-frontend-environment-variables/frontend/app/package.json +++ b/examples/icp-frontend-environment-variables/frontend/app/package.json @@ -18,6 +18,7 @@ "devDependencies": { "@eslint/js": "^9.33.0", "@icp-sdk/bindgen": "^0.2.1", + "@types/node": "^22.0.0", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@vitejs/plugin-react": "^5.0.0", diff --git a/examples/icp-frontend-environment-variables/frontend/app/src/App.tsx b/examples/icp-frontend-environment-variables/frontend/app/src/App.tsx index de167746..37aa1752 100644 --- a/examples/icp-frontend-environment-variables/frontend/app/src/App.tsx +++ b/examples/icp-frontend-environment-variables/frontend/app/src/App.tsx @@ -3,24 +3,20 @@ import { createActor } from "./backend/api/hello_world"; import { getCanisterEnv } from "@icp-sdk/core/agent/canister-env"; import "./App.css"; -// Here we define the environment variables that the asset canister serves. -// By default, the CLI sets all the canister IDs in the environment variables of the asset canister -// using the `PUBLIC_CANISTER_ID:` format. -// For this reason, we can expect the `PUBLIC_CANISTER_ID:backend` environment variable to be set. +// The ic_env cookie provides canister IDs and root key. +// Set by the asset canister (production) or dev server (development). +// See: https://github.com/dfinity/icp-cli/blob/main/docs/concepts/canister-discovery.md interface CanisterEnv { readonly "PUBLIC_CANISTER_ID:backend": string; + readonly IC_ROOT_KEY: Uint8Array; // Parsed from hex by the library } -// We only want to access the environment variables when serving the frontend from the asset canister. -// In development mode, we use a fixed canister ID for the backend canister. const canisterEnv = getCanisterEnv(); const canisterId = canisterEnv["PUBLIC_CANISTER_ID:backend"]; -// We want to fetch the root key from the replica when developing locally. -const helloWorldActor = createActor(canisterId, { +const backendActor = createActor(canisterId, { agentOptions: { - rootKey: !import.meta.env.DEV ? canisterEnv!.IC_ROOT_KEY : undefined, - shouldFetchRootKey: import.meta.env.DEV, + rootKey: canisterEnv.IC_ROOT_KEY, }, }); @@ -33,7 +29,7 @@ function App() { "name" ) as HTMLInputElement; - helloWorldActor.greet(nameInput.value).then(setGreeting); + backendActor.greet(nameInput.value).then(setGreeting); return false; } diff --git a/examples/icp-frontend-environment-variables/frontend/app/vite.config.ts b/examples/icp-frontend-environment-variables/frontend/app/vite.config.ts index a5364e58..4cceb41d 100644 --- a/examples/icp-frontend-environment-variables/frontend/app/vite.config.ts +++ b/examples/icp-frontend-environment-variables/frontend/app/vite.config.ts @@ -1,34 +1,75 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import { icpBindgen } from "@icp-sdk/bindgen/plugins/vite"; +import { execSync } from "child_process"; -// Change these values to match your local replica. -// The `icp network start` command will print the root key -// and the `icp deploy` command will print the backend canister id. -const IC_ROOT_KEY_HEX = - "308182301d060d2b0601040182dc7c0503010201060c2b0601040182dc7c050302010361008b52b4994f94c7ce4be1c1542d7c81dc79fea17d49efe8fa42e8566373581d4b969c4a59e96a0ef51b711fe5027ec01601182519d0a788f4bfe388e593b97cd1d7e44904de79422430bca686ac8c21305b3397b5ba4d7037d17877312fb7ee34"; -const BACKEND_CANISTER_ID = "txyno-ch777-77776-aaaaq-cai"; +// Usage: ICP_ENVIRONMENT=staging npm run dev +const environment = process.env.ICP_ENVIRONMENT || "local"; +const CANISTER_NAME = "backend"; -// https://vite.dev/config/ -export default defineConfig({ - plugins: [ +export default defineConfig(({ command }) => { + const plugins = [ react(), icpBindgen({ - didFile: "../../backend/dist/hello_world.did", - outDir: "./src/backend/api", + didFile: `../../${CANISTER_NAME}/dist/hello_world.did`, + outDir: `./src/${CANISTER_NAME}/api`, }), - ], - server: { - headers: { - "Set-Cookie": `ic_env=${encodeURIComponent( - `ic_root_key=${IC_ROOT_KEY_HEX}&PUBLIC_CANISTER_ID:backend=${BACKEND_CANISTER_ID}` - )}; SameSite=Lax;`, - }, - proxy: { - "/api": { - target: "http://127.0.0.1:8000", - changeOrigin: true, + ]; + + // Build mode: asset canister handles ic_env cookie automatically + if (command !== "serve") { + return { plugins }; + } + + // Dev server mode: configure ic_env cookie and proxy + const networkStatus = JSON.parse( + execSync(`icp network status -e ${environment} --json`, { encoding: "utf-8" }) + ); + const rootKey: string = networkStatus.root_key; + // TODO: Use networkStatus.api_url when CLI supports it + const proxyTarget: string = `http://localhost:8000`; + + // Backend must be deployed before starting dev server + let canisterId: string; + try { + canisterId = execSync(`icp canister status ${CANISTER_NAME} -e ${environment} -i`, { + encoding: "utf-8", + }).trim(); + } catch { + console.error(` +❌ Backend canister "${CANISTER_NAME}" not found in environment "${environment}" + + Before running the dev server, deploy the backend canister: + + icp deploy ${CANISTER_NAME} -e ${environment} +`); + process.exit(1); + } + + console.log(` +🌐 ICP Dev Server Configuration + + Environment: ${environment} + Backend Canister ID: ${canisterId} + IC API URL: ${proxyTarget} + IC Root Key: ${rootKey.slice(0, 20)}...${rootKey.slice(-20)} +`); + + return { + plugins, + server: { + headers: { + // Note: ic_root_key must be lowercase - library converts to uppercase IC_ROOT_KEY + "Set-Cookie": `ic_env=${encodeURIComponent( + `PUBLIC_CANISTER_ID:${CANISTER_NAME}=${canisterId}&ic_root_key=${rootKey}` + )}; SameSite=Lax;`, + }, + proxy: { + "/api": { + target: proxyTarget, + changeOrigin: true, + }, }, }, - }, + }; }); diff --git a/examples/icp-frontend-environment-variables/frontend/package-lock.json b/examples/icp-frontend-environment-variables/frontend/package-lock.json index 612fb36f..63d12574 100644 --- a/examples/icp-frontend-environment-variables/frontend/package-lock.json +++ b/examples/icp-frontend-environment-variables/frontend/package-lock.json @@ -19,6 +19,7 @@ "devDependencies": { "@eslint/js": "^9.33.0", "@icp-sdk/bindgen": "^0.2.1", + "@types/node": "^22.0.0", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@vitejs/plugin-react": "^5.0.0", @@ -1523,6 +1524,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "22.19.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.8.tgz", + "integrity": "sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@types/react": { "version": "19.2.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", @@ -3373,6 +3385,13 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", diff --git a/scripts/prepare-docs.sh b/scripts/prepare-docs.sh index 91b4eed0..89636c41 100755 --- a/scripts/prepare-docs.sh +++ b/scripts/prepare-docs.sh @@ -56,33 +56,34 @@ find "$TARGET_DIR" -name "*.md" -type f | while read -r file; do parent_dirname=$(basename "$dirname_file") # For index.md files, only strip .md extensions and add trailing slashes + # The (##?[^)]*)? captures optional anchors (# or ## followed by non-) chars) if [[ "$basename_file" == "index.md" ]]; then - sed -i.bak -E 's|\]\(([^:)]+)\.md\)|\]\(\1/\)|g' "$file" + sed -i.bak -E 's|\]\(([^:)#]+)\.md(#[^)]*)?\)|\]\(\1/\2\)|g' "$file" rm "${file}.bak" continue fi # For root-level files (tutorial.md -> /tutorial/) if [[ "$parent_dirname" == ".docs-temp" ]]; then - # Links to subdirectories: guides/file.md -> ../guides/file/ - sed -i.bak -E 's|\]\(([^/)]+)/([^/)]+)\.md\)|\]\(../\1/\2/\)|g' "$file" + # Links to subdirectories: guides/file.md -> ../guides/file/ (with optional anchor) + sed -i.bak -E 's|\]\(([^/)#]+)/([^/)#]+)\.md(#[^)]*)?\)|\]\(../\1/\2/\3\)|g' "$file" # Links to root index: index.md -> ../ (root is served at /, not /index) - sed -i.bak -E 's|\]\(index\.md\)|\]\(../\)|g' "$file" - # Links to other root-level pages: file.md -> ../file/ - sed -i.bak -E 's|\]\(([^/)(]+)\.md\)|\]\(../\1/\)|g' "$file" + sed -i.bak -E 's|\]\(index\.md(#[^)]*)?\)|\]\(../\1\)|g' "$file" + # Links to other root-level pages: file.md -> ../file/ (with optional anchor) + sed -i.bak -E 's|\]\(([^/)(#]+)\.md(#[^)]*)?\)|\]\(../\1/\2\)|g' "$file" rm "${file}.bak" else # For files in subdirectories (guides/using-recipes.md -> /guides/using-recipes/) # Links to category index: ../concepts/index.md -> ../../concepts/ (not ../../concepts/index/) - sed -i.bak -E 's|\]\(\.\./([^/)]+)/index\.md\)|\]\(../../\1/\)|g' "$file" - # Links to other categories: ../concepts/file.md -> ../../concepts/file/ - sed -i.bak -E 's|\]\(\.\./([^/)]+)/([^/)]+)\.md\)|\]\(../../\1/\2/\)|g' "$file" + sed -i.bak -E 's|\]\(\.\./([^/)#]+)/index\.md(#[^)]*)?\)|\]\(../../\1/\2\)|g' "$file" + # Links to other categories: ../concepts/file.md -> ../../concepts/file/ (with optional anchor) + sed -i.bak -E 's|\]\(\.\./([^/)#]+)/([^/)#]+)\.md(#[^)]*)?\)|\]\(../../\1/\2/\3\)|g' "$file" # Links to root index: ../index.md -> ../../ (root is served at /, not /index) - sed -i.bak -E 's|\]\(\.\./index\.md\)|\]\(../../\)|g' "$file" - # Links up to other root pages: ../file.md -> ../../file/ - sed -i.bak -E 's|\]\(\.\./([^/)(]+)\.md\)|\]\(../../\1/\)|g' "$file" - # Same-directory links: file.md -> ../file/ - sed -i.bak -E 's|\]\(([^/.)][^/)]*)\.md\)|\]\(../\1/\)|g' "$file" + sed -i.bak -E 's|\]\(\.\./index\.md(#[^)]*)?\)|\]\(../../\1\)|g' "$file" + # Links up to other root pages: ../file.md -> ../../file/ (with optional anchor) + sed -i.bak -E 's|\]\(\.\./([^/)(#]+)\.md(#[^)]*)?\)|\]\(../../\1/\2\)|g' "$file" + # Same-directory links: file.md -> ../file/ (with optional anchor) + sed -i.bak -E 's|\]\(([^/.)#][^/)#]*)\.md(#[^)]*)?\)|\]\(../\1/\2\)|g' "$file" rm "${file}.bak" fi done