diff --git a/.node-version b/.node-version
index 0a49261..3fe3b15 100644
--- a/.node-version
+++ b/.node-version
@@ -1 +1 @@
-24.11.0
+24.13.0
diff --git a/Cargo.lock b/Cargo.lock
index 5ce261d..a6d10f0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -50,6 +50,12 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa"
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
[[package]]
name = "beef"
version = "0.5.2"
@@ -109,12 +115,28 @@ dependencies = [
"generic-array",
]
+[[package]]
+name = "bstr"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
+dependencies = [
+ "memchr",
+ "serde",
+]
+
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+[[package]]
+name = "bytes"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
+
[[package]]
name = "candid"
version = "0.10.19"
@@ -413,6 +435,25 @@ dependencies = [
"wasi",
]
+[[package]]
+name = "globset"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3"
+dependencies = [
+ "aho-corasick",
+ "bstr",
+ "log",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "half"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403"
+
[[package]]
name = "handlebars"
version = "6.3.2"
@@ -450,6 +491,39 @@ dependencies = [
"serde",
]
+[[package]]
+name = "http"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-canister"
+version = "0.1.0"
+dependencies = [
+ "candid",
+ "ic-asset-certification",
+ "ic-cdk",
+ "ic-http-certification",
+]
+
+[[package]]
+name = "ic-asset-certification"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c43805022a5e4f408de44ca26396128697a2c39f83f4fdaf33f8aa3ac653d78e"
+dependencies = [
+ "globset",
+ "http",
+ "ic-certification",
+ "ic-http-certification",
+ "thiserror 1.0.69",
+]
+
[[package]]
name = "ic-cdk"
version = "0.18.7"
@@ -502,6 +576,18 @@ dependencies = [
"syn 2.0.106",
]
+[[package]]
+name = "ic-certification"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ffb40d73f9f8273dc6569a68859003bbd467c9dc6d53c6fd7d174742f857209d"
+dependencies = [
+ "hex",
+ "serde",
+ "serde_bytes",
+ "sha2",
+]
+
[[package]]
name = "ic-error-types"
version = "0.2.0"
@@ -513,6 +599,23 @@ dependencies = [
"strum_macros",
]
+[[package]]
+name = "ic-http-certification"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d1a65b0ffb568e954750067f660e254f4564394f5c064a88e0e93b2eea4a532"
+dependencies = [
+ "base64",
+ "candid",
+ "http",
+ "ic-certification",
+ "ic-representation-independent-hash",
+ "serde",
+ "serde_cbor",
+ "thiserror 1.0.69",
+ "urlencoding",
+]
+
[[package]]
name = "ic-management-canister-types"
version = "0.3.3"
@@ -524,6 +627,16 @@ dependencies = [
"serde_bytes",
]
+[[package]]
+name = "ic-representation-independent-hash"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2800ba4910f21d9e1cb7b6ecbbbb0f76074bd2e127b4688c57d0936206caa6e"
+dependencies = [
+ "leb128",
+ "sha2",
+]
+
[[package]]
name = "ic-stable-structures"
version = "0.6.9"
@@ -1101,6 +1214,16 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "serde_cbor"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5"
+dependencies = [
+ "half",
+ "serde",
+]
+
[[package]]
name = "serde_core"
version = "1.0.225"
@@ -1441,6 +1564,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+[[package]]
+name = "urlencoding"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
+
[[package]]
name = "version_check"
version = "0.9.5"
diff --git a/Cargo.toml b/Cargo.toml
index 8ba562e..b78a18a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,3 +1,8 @@
[workspace]
-members = ["examples/todo", "examples/nns_proxy", "examples/icp_features"]
+members = [
+ "examples/http",
+ "examples/icp_features",
+ "examples/nns_proxy",
+ "examples/todo",
+]
resolver = "2"
diff --git a/bun.lock b/bun.lock
index c6b9248..5994225 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,6 +5,7 @@
"": {
"name": "pic-js",
"devDependencies": {
+ "@icp-sdk/core": "^5.0.0",
"@swc/core": "^1.15.3",
"@swc/jest": "^0.2.39",
"@tsconfig/node24": "^24.0.3",
@@ -46,6 +47,12 @@
"@dfinity/pic": "workspace:*",
},
},
+ "examples/http/tests": {
+ "name": "http-tests",
+ "devDependencies": {
+ "@dfinity/pic": "workspace:*",
+ },
+ },
"examples/icp_features/tests": {
"name": "icp-features-tests",
"devDependencies": {
@@ -72,7 +79,7 @@
},
"packages/pic": {
"name": "@dfinity/pic",
- "version": "0.17.1",
+ "version": "0.18.0",
"dependencies": {
"@icp-sdk/core": "^5.0.0",
"bip39": "^3.1.0",
@@ -730,6 +737,8 @@
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
+ "http-tests": ["http-tests@workspace:examples/http/tests"],
+
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
"icp-features-tests": ["icp-features-tests@workspace:examples/icp_features/tests"],
diff --git a/dfx.json b/dfx.json
index aa12ab9..9ca6052 100644
--- a/dfx.json
+++ b/dfx.json
@@ -1,6 +1,6 @@
{
"version": 1,
- "dfx": "0.29.1",
+ "dfx": "0.30.2",
"output_env_file": ".env",
"networks": {
"local": {
@@ -113,6 +113,18 @@
},
"gzip": true,
"optimize": "cycles"
+ },
+ "http": {
+ "type": "rust",
+ "candid": "examples/http/http.did",
+ "package": "http-canister",
+ "frontend": {},
+ "declarations": {
+ "bindings": ["js", "ts"],
+ "output": "examples/http/declarations"
+ },
+ "gzip": true,
+ "optimize": "cycles"
}
}
}
diff --git a/docs/src/content/docs/guides/more-examples.mdx b/docs/src/content/docs/guides/more-examples.mdx
index 66c679b..0f49ce0 100644
--- a/docs/src/content/docs/guides/more-examples.mdx
+++ b/docs/src/content/docs/guides/more-examples.mdx
@@ -15,3 +15,4 @@ but `@dfinity/pic` can be used with JavaScript and any other testing runner, suc
- The [NNS Proxy](https://github.com/dfinity/pic-js/tree/main/examples/nns_proxy) example demonstrates how to work with an NNS state directory.
- [Google Search](https://github.com/dfinity/pic-js/tree/main/examples/google_search) example demonstrates how to mock HTTPS Outcalls.
- [ICP Features](https://github.com/dfinity/pic-js/tree/main/examples/icp_features) example demonstrates how to work with PocketIC's ICP features.
+- [HTTP](https://github.com/dfinity/pic-js/tree/main/examples/http) example demonstrates how to use "live" mode with an HTTP canister.
diff --git a/examples/README.md b/examples/README.md
index c303b6a..90fdd4b 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -18,10 +18,12 @@ but `@dfinity/pic` can be used with JavaScript and any other testing runner, suc
- [Clock](./clock/README.md)
This example demonstrates how to work with the replica's system time, canister timers as well as checking for canister existence and cycle management.
- [Todo](./todo/README.md)
- This example demonstrates how to work with more complex canisters, identities, canister upgrades, and stable memory management.
+ This example demonstrates how to work with more complex canisters, identities, canister upgrades, and stable memory management. It also shows how to use "live" mode with agent-js (in contrast to the pic-js actor).
- [Multicanister](./multicanister/README.md)
This example demonstrates how to work with multiple canisters and multiple subnets.
- [NNS Proxy](./nns_proxy/README.md)
This example demonstrates how to work with an NNS state directory.
- [Google Search](./google_search/README.md)
This example demonstrates how to mock HTTPS Outcalls.
+- [HTTP](./http/README.md)
+ This example demonstrates how to use "live" mode with an HTTP canister.
diff --git a/examples/http/Cargo.toml b/examples/http/Cargo.toml
new file mode 100644
index 0000000..dc1f134
--- /dev/null
+++ b/examples/http/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "http-canister"
+version = "0.1.0"
+edition = "2024"
+
+[lib]
+crate-type = ["cdylib"]
+
+[dependencies]
+candid = "0.10"
+ic-cdk = "0.18"
+ic-http-certification = "3"
+ic-asset-certification = "3"
diff --git a/examples/http/README.md b/examples/http/README.md
new file mode 100644
index 0000000..be2a298
--- /dev/null
+++ b/examples/http/README.md
@@ -0,0 +1,15 @@
+# HTTP
+
+This example demonstrates how to use "live" mode with an HTTP canister.
+
+Build the HTTP canister:
+
+```shell
+bun build:http
+```
+
+Run the HTTP tests:
+
+```shell
+bun test:http
+```
diff --git a/examples/http/http.did b/examples/http/http.did
new file mode 100644
index 0000000..a7072fe
--- /dev/null
+++ b/examples/http/http.did
@@ -0,0 +1,18 @@
+type HttpRequest = record {
+ url : text;
+ method : text;
+ body : blob;
+ headers : vec record { text; text };
+ certificate_version : opt nat16;
+};
+
+type HttpResponse = record {
+ body : blob;
+ headers : vec record { text; text };
+ upgrade : opt bool;
+ status_code : nat16;
+};
+
+service : {
+ http_request : (HttpRequest) -> (HttpResponse) query;
+};
diff --git a/examples/http/src/index.html b/examples/http/src/index.html
new file mode 100644
index 0000000..06c94f5
--- /dev/null
+++ b/examples/http/src/index.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+ HTTP Canister
+
+
+ Hello, World!
+
+
diff --git a/examples/http/src/lib.rs b/examples/http/src/lib.rs
new file mode 100644
index 0000000..90fba5f
--- /dev/null
+++ b/examples/http/src/lib.rs
@@ -0,0 +1,53 @@
+use ic_asset_certification::{Asset, AssetConfig, AssetRouter};
+use ic_cdk::{
+ api::{certified_data_set, data_certificate},
+ *,
+};
+use ic_http_certification::{HttpRequest, HttpResponse};
+use std::cell::RefCell;
+
+thread_local! {
+ static ASSET_ROUTER: RefCell> = Default::default();
+}
+
+const INDEX_HTML: &[u8] = include_bytes!("index.html");
+
+#[init]
+fn init() {
+ let assets = vec![Asset::new("index.html", INDEX_HTML)];
+ let asset_configs = vec![AssetConfig::File {
+ path: "index.html".to_string(),
+ content_type: Some("text/html".to_string()),
+ headers: vec![],
+ fallback_for: vec![],
+ aliased_by: vec![],
+ encodings: vec![],
+ }];
+
+ ASSET_ROUTER.with_borrow_mut(|asset_router| {
+ if let Err(err) = asset_router.certify_assets(assets, asset_configs) {
+ ic_cdk::trap(&format!("Failed to certify assets: {}", err));
+ }
+
+ certified_data_set(&asset_router.root_hash());
+ });
+}
+
+#[post_upgrade]
+fn post_upgrade() {
+ init();
+}
+
+#[query]
+fn http_request(req: HttpRequest) -> HttpResponse {
+ ASSET_ROUTER.with_borrow(|asset_router| {
+ if let Ok(response) = asset_router.serve_asset(
+ &data_certificate().expect("No data certificate available"),
+ &req,
+ ) {
+ response
+ } else {
+ ic_cdk::trap("Failed to serve asset");
+ }
+ })
+}
diff --git a/examples/http/tests/global-setup.ts b/examples/http/tests/global-setup.ts
new file mode 100644
index 0000000..7d7aea6
--- /dev/null
+++ b/examples/http/tests/global-setup.ts
@@ -0,0 +1,15 @@
+import type { TestProject } from 'vitest/node';
+import { PocketIcServer } from '@dfinity/pic';
+
+let pic: PocketIcServer | undefined;
+
+export async function setup(ctx: TestProject): Promise {
+ pic = await PocketIcServer.start();
+ const url = pic.getUrl();
+
+ ctx.provide('PIC_URL', url);
+}
+
+export async function teardown(): Promise {
+ await pic?.stop();
+}
diff --git a/examples/http/tests/package.json b/examples/http/tests/package.json
new file mode 100644
index 0000000..5b7dd6e
--- /dev/null
+++ b/examples/http/tests/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "http-tests",
+ "devDependencies": {
+ "@dfinity/pic": "workspace:*"
+ }
+}
diff --git a/examples/http/tests/src/http.spec.ts b/examples/http/tests/src/http.spec.ts
new file mode 100644
index 0000000..2bd9de2
--- /dev/null
+++ b/examples/http/tests/src/http.spec.ts
@@ -0,0 +1,52 @@
+import { resolve } from 'node:path';
+import { PocketIc, SubnetStateType } from '@dfinity/pic';
+import { describe, beforeEach, afterEach, it, expect, inject } from 'vitest';
+import { _SERVICE } from '../../declarations/http.did';
+
+const WASM_PATH = resolve(
+ __dirname,
+ '..',
+ '..',
+ '..',
+ '..',
+ '.dfx',
+ 'local',
+ 'canisters',
+ 'http',
+ 'http.wasm.gz',
+);
+
+describe('HTTP', () => {
+ let pic: PocketIc;
+ let httpGatewayPort: number;
+ let canisterId: string;
+
+ beforeEach(async () => {
+ pic = await PocketIc.create(inject('PIC_URL'), {
+ nns: { state: { type: SubnetStateType.New } },
+ application: [{ state: { type: SubnetStateType.New } }],
+ });
+
+ const id = await pic.createCanister();
+ await pic.installCode({ canisterId: id, wasm: WASM_PATH });
+
+ httpGatewayPort = await pic.makeLive();
+ canisterId = id.toString();
+ });
+
+ afterEach(async () => {
+ await pic.stopLive();
+ await pic.tearDown();
+ });
+
+ it('should return an index.html page', async () => {
+ // Use ?canisterId= query param instead of subdomain-based routing
+ // (e.g. canisterId.localhost) for cross-platform compatibility.
+ // macOS does not resolve *.localhost subdomains on CI runners.
+ const res = await fetch(
+ `http://localhost:${httpGatewayPort}/index.html?canisterId=${canisterId}`,
+ );
+ const resBody = await res.text();
+ expect(resBody).toContain('Hello, World!
');
+ });
+});
diff --git a/examples/http/tests/tsconfig.json b/examples/http/tests/tsconfig.json
new file mode 100644
index 0000000..29957a5
--- /dev/null
+++ b/examples/http/tests/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../../tsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "types": ["node"]
+ },
+ "include": ["./src/**/*.ts", "./global-setup.ts", "types.d.ts"]
+}
diff --git a/examples/http/tests/types.d.ts b/examples/http/tests/types.d.ts
new file mode 100644
index 0000000..8b8ef52
--- /dev/null
+++ b/examples/http/tests/types.d.ts
@@ -0,0 +1,5 @@
+export declare module 'vitest' {
+ export interface ProvidedContext {
+ PIC_URL: string;
+ }
+}
diff --git a/examples/http/tests/vitest.config.ts b/examples/http/tests/vitest.config.ts
new file mode 100644
index 0000000..3b6ea28
--- /dev/null
+++ b/examples/http/tests/vitest.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ root: 'examples/http/tests',
+ globalSetup: './global-setup.ts',
+ testTimeout: 120_000,
+ hookTimeout: 120_000,
+ },
+});
diff --git a/examples/todo/README.md b/examples/todo/README.md
index fef888e..dfacfb3 100644
--- a/examples/todo/README.md
+++ b/examples/todo/README.md
@@ -1,6 +1,6 @@
# Todo
-This example demonstrates how to work with more complex canisters, identities, canister upgrades, and stable memory management.
+This example demonstrates how to work with more complex canisters, identities, canister upgrades, and stable memory management. It also shows how to use "live" mode with agent-js (in contrast to the pic-js actor).
Build the counter canister:
diff --git a/examples/todo/tests/src/live-todo.spec.ts b/examples/todo/tests/src/live-todo.spec.ts
new file mode 100644
index 0000000..899d2d5
--- /dev/null
+++ b/examples/todo/tests/src/live-todo.spec.ts
@@ -0,0 +1,110 @@
+import { resolve } from 'node:path';
+import { ActorSubclass, Actor, HttpAgent } from '@icp-sdk/core/agent';
+import { PocketIc, SubnetStateType, createIdentity } from '@dfinity/pic';
+import { _SERVICE, idlFactory } from '../../declarations/todo.did';
+
+const WASM_PATH = resolve(
+ __dirname,
+ '..',
+ '..',
+ '..',
+ '..',
+ '.dfx',
+ 'local',
+ 'canisters',
+ 'todo',
+ 'todo.wasm.gz',
+);
+
+describe('Live Todo', () => {
+ let pic: PocketIc;
+ let agent: HttpAgent;
+ let actor: ActorSubclass<_SERVICE>;
+
+ const alice = createIdentity('superSecretAlicePassword');
+ const bob = createIdentity('superSecretBobPassword');
+
+ beforeEach(async () => {
+ pic = await PocketIc.create(process.env.PIC_URL, {
+ nns: { state: { type: SubnetStateType.New } },
+ application: [{ state: { type: SubnetStateType.New } }],
+ });
+
+ const canisterId = await pic.createCanister();
+ await pic.installCode({ canisterId, wasm: WASM_PATH });
+
+ const httpGatewayPort = await pic.makeLive();
+ const httpGatewayUrl = `http://localhost:${httpGatewayPort}`;
+ agent = await HttpAgent.create({
+ host: httpGatewayUrl,
+ shouldFetchRootKey: true,
+ });
+ actor = Actor.createActor(idlFactory, { agent, canisterId });
+ });
+
+ afterEach(async () => {
+ await pic.stopLive();
+ await pic.tearDown();
+ });
+
+ it("should return alice's todos to alice and bob's todos to bob", async () => {
+ agent.replaceIdentity(alice);
+ const aliceCreateResponse = await actor.create_todo({
+ text: 'Learn Rust',
+ });
+ const aliceAfterCreateGetResponse = await actor.get_todos();
+
+ agent.replaceIdentity(bob);
+ const bobCreateResponse = await actor.create_todo({
+ text: 'Learn WebAssembly',
+ });
+ const bobAfterCreateGetResponse = await actor.get_todos();
+
+ expect(aliceAfterCreateGetResponse.todos).toHaveLength(1);
+ expect(aliceAfterCreateGetResponse.todos).toContainEqual({
+ id: aliceCreateResponse.id,
+ text: 'Learn Rust',
+ done: false,
+ });
+
+ expect(bobAfterCreateGetResponse.todos).toHaveLength(1);
+ expect(bobAfterCreateGetResponse.todos).toContainEqual({
+ id: bobCreateResponse.id,
+ text: 'Learn WebAssembly',
+ done: false,
+ });
+ });
+
+ it("should prevent bob from updating alice's todo", async () => {
+ agent.replaceIdentity(alice);
+ const aliceCreateResponse = await actor.create_todo({
+ text: 'Learn Rust',
+ });
+
+ agent.replaceIdentity(bob);
+ await expect(
+ actor.update_todo(aliceCreateResponse.id, {
+ text: ['Learn Rust and WebAssembly'],
+ done: [],
+ }),
+ ).rejects.toThrow(
+ `Caller with principal ${bob
+ .getPrincipal()
+ .toText()} does not own todo with id ${aliceCreateResponse.id}`,
+ );
+ });
+
+ it('should prevent bob from deleting alices todo', async () => {
+ agent.replaceIdentity(alice);
+ const aliceCreateResponse = await actor.create_todo({
+ text: 'Learn Rust',
+ });
+
+ agent.replaceIdentity(bob);
+ await expect(actor.delete_todo(aliceCreateResponse.id)).rejects.toThrow(
+ `Caller with principal ${bob
+ .getPrincipal()
+ .toText()} does not own todo with id ${aliceCreateResponse.id}`,
+ );
+ });
+});
diff --git a/package.json b/package.json
index 32544c8..4e11533 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,7 @@
"npm": "please use pnpm"
},
"type": "module",
- "packageManager": "pnpm@10.19.0+sha512.c9fc7236e92adf5c8af42fd5bf1612df99c2ceb62f27047032f4720b33f8eacdde311865e91c411f2774f618d82f320808ecb51718bfa82c060c4ba7c76a32b8",
+ "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264",
"workspaces": [
"examples/clock/tests",
"examples/counter/tests",
@@ -16,6 +16,7 @@
"examples/multicanister/tests",
"examples/nns_proxy/tests",
"examples/todo/tests",
+ "examples/http/tests",
"packages/pic",
"packages/pic-server",
"docs"
@@ -28,7 +29,7 @@
"test:pic": "jest -c ./packages/pic/jest.config.ts",
"build:test-canister": "dfx generate test_canister && dfx build test_canister --check && mv .dfx/local/canisters/test_canister/test_canister.wasm.gz packages/pic/tests/test-canister",
"build:examples": "dfx generate phonebook && dfx generate superheroes && dfx generate && dfx build --all --check",
- "test:examples": "run-p test:clock test:counter test:google-search test:icp-features test:multicanister test:nns-proxy test:todo",
+ "test:examples": "run-p test:clock test:counter test:google-search test:icp-features test:multicanister test:nns-proxy test:todo test:http",
"build:clock": "dfx generate clock && dfx build clock --check",
"test:clock": "jest -c ./examples/clock/tests/jest.config.ts",
"build:counter": "dfx generate counter && dfx build counter --check",
@@ -43,6 +44,8 @@
"test:nns-proxy": "jest -c ./examples/nns_proxy/tests/jest.config.ts",
"build:todo": "dfx generate todo && dfx build todo --check",
"test:todo": "jest -c ./examples/todo/tests/jest.config.ts",
+ "build:http": "dfx generate http && dfx build http --check",
+ "test:http": "vitest run -c ./examples/http/tests/vitest.config.ts",
"postinstall": "tar -xvf examples/nns_proxy/tests/state/nns_state.tar.xz -C examples/nns_proxy/tests/state"
},
"devDependencies": {
diff --git a/packages/pic/src/http2-client.ts b/packages/pic/src/http2-client.ts
index 249de19..0dfc72d 100644
--- a/packages/pic/src/http2-client.ts
+++ b/packages/pic/src/http2-client.ts
@@ -89,6 +89,10 @@ export class Http2Client {
return resBody;
}
+ if (typeof resBody === 'boolean') {
+ return resBody;
+ }
+
// server encountered an error, throw and try again
if ('message' in resBody) {
console.error(
@@ -208,15 +212,21 @@ async function getResBody(
jsonParser: typeof JSON.parse,
): Promise> {
const resBody = await res.text();
+ if (!resBody.length) {
+ return null as unknown as R;
+ }
+
try {
return jsonParser(resBody) as ApiResponse;
} catch (error) {
- const message = resBody;
-
console.error('Error parsing PocketIC server response body:', error);
- console.error('Original body:', message);
+ // don't break the user's console by logging large response bodies
+ if (resBody.length < 10_240) {
+ console.error('Original body:', resBody);
+ throw new Error(resBody);
+ }
- throw new Error(message);
+ throw new Error(String(error));
}
}
diff --git a/packages/pic/src/pocket-ic-client-types.ts b/packages/pic/src/pocket-ic-client-types.ts
index cace287..8042675 100644
--- a/packages/pic/src/pocket-ic-client-types.ts
+++ b/packages/pic/src/pocket-ic-client-types.ts
@@ -1164,3 +1164,31 @@ export function encodeAwaitCanisterCallRequest(
export type AwaitCanisterCallResponse = CanisterCallResponse;
//#endregion AwaitCanisterCall
+
+//#region LiveMode
+export type EncodedAutoProgressRequest = {
+ artificial_delay_ms?: number;
+};
+
+export type EncodedHttpGatewayRequest = {
+ ip_addr?: string;
+ port?: number;
+ forward_to: { Replica: string } | { PocketIcInstance: number };
+ domains?: string[];
+ https_config?: {
+ cert_path: string;
+ key_path: string;
+ };
+};
+
+export type EncodedHttpGatewayResponse =
+ | {
+ Error: { message: string };
+ }
+ | {
+ Created: {
+ instance_id: number;
+ port: number;
+ };
+ };
+//#endregion LiveMode
diff --git a/packages/pic/src/pocket-ic-client.ts b/packages/pic/src/pocket-ic-client.ts
index 7b08f2b..6573919 100644
--- a/packages/pic/src/pocket-ic-client.ts
+++ b/packages/pic/src/pocket-ic-client.ts
@@ -81,8 +81,11 @@ import {
GetControllersResponse,
decodeGetControllersResponse,
encodeGetControllersRequest,
+ EncodedAutoProgressRequest,
+ EncodedHttpGatewayRequest,
+ EncodedHttpGatewayResponse,
} from './pocket-ic-client-types';
-import { base64DecodePrincipal, isNotNil } from './util';
+import { base64DecodePrincipal, isNil, isNotNil } from './util';
import { Principal } from '@icp-sdk/core/principal';
const PROCESSING_TIME_VALUE_MS = 30_000;
@@ -90,10 +93,12 @@ const AWAIT_INGRESS_STATUS_ROUNDS = 100;
export class PocketIcClient {
private isInstanceDeleted = false;
+ private httpGatewayInstanceId: number | null = null;
private constructor(
private readonly serverClient: Http2Client,
private readonly instancePath: string,
+ private readonly instanceId: number,
private readonly ingressMaxRetries: number,
) {}
@@ -127,6 +132,7 @@ export class PocketIcClient {
return new PocketIcClient(
serverClient,
`/instances/${instanceId}`,
+ instanceId,
ingressMaxRetries,
);
}
@@ -382,6 +388,48 @@ export class PocketIcClient {
);
}
+ public async autoProgress(): Promise {
+ await this.post('/auto_progress', {
+ artificial_delay_ms: 0,
+ });
+ }
+
+ public async autoProgressEnabled(): Promise {
+ return await this.get('/auto_progress');
+ }
+
+ public async stopProgress(): Promise {
+ await this.post<{}, {}>('/stop_progress', {});
+ }
+
+ public async startHttpGateway(): Promise {
+ const res = await this.serverClient.jsonPost<
+ EncodedHttpGatewayRequest,
+ EncodedHttpGatewayResponse
+ >({
+ path: '/http_gateway',
+ body: { forward_to: { PocketIcInstance: this.instanceId } },
+ });
+
+ if ('Error' in res) {
+ throw new Error(res.Error.message);
+ }
+
+ this.httpGatewayInstanceId = res.Created.instance_id;
+ return res.Created.port;
+ }
+
+ public async stopHttpGateway(): Promise {
+ if (isNil(this.httpGatewayInstanceId)) {
+ return;
+ }
+
+ await this.serverClient.jsonPost<{}, {}>({
+ path: `/http_gateway/${this.httpGatewayInstanceId}/stop`,
+ });
+ this.httpGatewayInstanceId = null;
+ }
+
private async post(
endpoint: string,
body?: B,
diff --git a/packages/pic/src/pocket-ic.ts b/packages/pic/src/pocket-ic.ts
index 8b1a94c..7a0fe87 100644
--- a/packages/pic/src/pocket-ic.ts
+++ b/packages/pic/src/pocket-ic.ts
@@ -1,6 +1,6 @@
import { Principal } from '@icp-sdk/core/principal';
import { IDL } from '@icp-sdk/core/candid';
-import { optional, readFileAsBytes } from './util';
+import { isNil, optional, readFileAsBytes } from './util';
import { PocketIcClient } from './pocket-ic-client';
import { ActorInterface, Actor, createActorClass } from './pocket-ic-actor';
import {
@@ -85,6 +85,8 @@ const NANOS_PER_MILLISECOND = BigInt(1_000_000);
* ```
*/
export class PocketIc {
+ private httpGatewayPort: number | null = null;
+
private constructor(private readonly client: PocketIcClient) {}
/**
@@ -1475,4 +1477,101 @@ export class PocketIc {
additionalResponses,
});
}
+
+ /**
+ * Make the PocketIC instance live by enabling auto progress and starting the HTTP Gateway.
+ * If the server is already live, this method will return the HTTP Gateway URL.
+ * The PocketIC instance must be created with at least an NNS subnet in
+ * order for `fetchRootKey` to work correctly.
+ *
+ * @example
+ * ```ts
+ * import { Principal } from '@icp-sdk/core/principal';
+ * import { PocketIc, PocketIcServer } from '@dfinity/pic';
+ * import { resolve } from 'node:path';
+ *
+ * const canisterId = Principal.fromUint8Array(new Uint8Array([0]));
+ * const wasm = resolve('..', '..', 'canister.wasm');
+ *
+ * const picServer = await PocketIcServer.start();
+ * const pic = await PocketIc.create(picServer.getUrl(), {
+ * nns: { state: { type: SubnetStateType.New } },
+ * application: [{ state: { type: SubnetStateType.New } }],
+ * });
+ *
+ * const canister = await pic.installCode({ canisterId, wasm });
+ * await pic.installCode({ canisterId, wasm });
+ *
+ * const httpGatewayUrl = await pic.makeLive();
+ * const agent = await HttpAgent.create({
+ * host: httpGatewayUrl,
+ * shouldFetchRootKey: true,
+ * });
+ * const actor = Actor.createActor(idlFactory, { agent, canisterId });
+ *
+ * await pic.stopLive();
+ * await pic.tearDown();
+ * await picServer.stop();
+ * ```
+ *
+ * @returns The HTTP Gateway port.
+ */
+ public async makeLive(): Promise {
+ const isLive = await this.client.autoProgressEnabled();
+ if (isLive) {
+ if (isNil(this.httpGatewayPort)) {
+ throw new Error(
+ 'Inconsistent state, PocketIC server is live but no HTTP Gateway URL is known',
+ );
+ }
+
+ return this.httpGatewayPort;
+ }
+
+ await this.client.autoProgress();
+ this.httpGatewayPort = await this.client.startHttpGateway();
+
+ return this.httpGatewayPort;
+ }
+
+ /**
+ * Disables auto progress and stops the HTTP Gateway for the PocketIC instance.
+ *
+ *
+ * @example
+ * ```ts
+ * import { Principal } from '@icp-sdk/core/principal';
+ * import { PocketIc, PocketIcServer } from '@dfinity/pic';
+ * import { resolve } from 'node:path';
+ *
+ * const canisterId = Principal.fromUint8Array(new Uint8Array([0]));
+ * const wasm = resolve('..', '..', 'canister.wasm');
+ *
+ * const picServer = await PocketIcServer.start();
+ * const pic = await PocketIc.create(picServer.getUrl(), {
+ * nns: { state: { type: SubnetStateType.New } },
+ * application: [{ state: { type: SubnetStateType.New } }],
+ * });
+ *
+ * const canister = await pic.installCode({ canisterId, wasm });
+ * await pic.installCode({ canisterId, wasm });
+ *
+ * const httpGatewayUrl = await pic.makeLive();
+ * const agent = await HttpAgent.create({
+ * host: httpGatewayUrl,
+ * shouldFetchRootKey: true,
+ * });
+ * const actor = Actor.createActor(idlFactory, { agent, canisterId });
+ *
+ * await pic.stopLive();
+ * await pic.tearDown();
+ * await picServer.stop();
+ * ```
+ */
+ public async stopLive(): Promise {
+ this.httpGatewayPort = null;
+
+ await this.client.stopHttpGateway();
+ await this.client.stopProgress();
+ }
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a198575..74029e3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -86,6 +86,12 @@ importers:
specifier: workspace:*
version: link:../../../packages/pic
+ examples/http/tests:
+ devDependencies:
+ '@dfinity/pic':
+ specifier: workspace:*
+ version: link:../../../packages/pic
+
examples/icp_features/tests:
devDependencies:
'@dfinity/pic':
@@ -674,56 +680,67 @@ packages:
resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==}
cpu: [arm]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.53.3':
resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==}
cpu: [arm]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.53.3':
resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.53.3':
resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.53.3':
resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==}
cpu: [loong64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.53.3':
resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.53.3':
resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.53.3':
resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==}
cpu: [riscv64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.53.3':
resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.53.3':
resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.53.3':
resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-openharmony-arm64@4.53.3':
resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==}
@@ -812,24 +829,28 @@ packages:
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@swc/core-linux-arm64-musl@1.15.3':
resolution: {integrity: sha512-j4SJniZ/qaZ5g8op+p1G9K1z22s/EYGg1UXIb3+Cg4nsxEpF5uSIGEE4mHUfA70L0BR9wKT2QF/zv3vkhfpX4g==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@swc/core-linux-x64-gnu@1.15.3':
resolution: {integrity: sha512-aKttAZnz8YB1VJwPQZtyU8Uk0BfMP63iDMkvjhJzRZVgySmqt/apWSdnoIcZlUoGheBrcqbMC17GGUmur7OT5A==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@swc/core-linux-x64-musl@1.15.3':
resolution: {integrity: sha512-oe8FctPu1gnUsdtGJRO2rvOUIkkIIaHqsO9xxN0bTR7dFTlPTGi2Fhk1tnvXeyAvCPxLIcwD8phzKg6wLv9yug==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@swc/core-win32-arm64-msvc@1.15.3':
resolution: {integrity: sha512-L9AjzP2ZQ/Xh58e0lTRMLvEDrcJpR7GwZqAtIeNLcTK7JVE+QineSyHp0kLkO1rttCHyCy0U74kDTj0dRz6raA==}
@@ -981,41 +1002,49 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
+ libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 8d0e252..4bded22 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -2,6 +2,7 @@ packages:
- examples/clock/tests
- examples/counter/tests
- examples/google_search/tests
+ - examples/http/tests
- examples/icp_features/tests
- examples/multicanister/tests
- examples/nns_proxy/tests