An ICP assets canister and icp-cli sync plugin for serving certified static assets.
- Using it — to deploy a site, see the user documentation: getting started, routing & clean URLs, redirects, custom headers, site files, and how it works.
- Examples — runnable projects in
examples/: a minimal static site, custom headers, redirects, clean URLs, a catch-all 404, and access protection. Each deploys withicp deployand doubles as an e2e test. - Hacking on it — see
ARCHITECTURE.mdfor the crate layout and how the canister and plugin fit together.
The rest of this README covers building, releasing, and the recipe — the mechanics a contributor or releaser needs.
make wasm # build both modules into dist/
make canister # canister wasm only
make plugin # sync-plugin wasm only
make release # publishable build (canister + plugin + candid + checksums)Artifacts land in dist/ under stable names (dist/canister.wasm,
dist/plugin.wasm). The Makefile is the single source of truth
for the build recipe — which target triple and profile each module uses — and
is reused by the e2e tests and the release workflow so they all ship the same
artifacts. The canister is built with the size-optimized canister-release
profile (see Cargo.toml).
make wasm is what the tests and manual builds use; it needs no extra tooling.
make release produces the publishable artifacts under separate *-release
names (so it never clobbers the plain dist/canister.wasm/dist/plugin.wasm):
dist/canister-release.wasm—certified-assets.didattached ascandid:servicemetadata viaic-wasm(cargo install ic-wasm), anddist/canister-release.wasm.gz, its gzipped form.dist/plugin-release.wasm— copied as-is for now; it'll be gzipped once the icp-cli sync plugin can load a gzipped wasi module.dist/certified-assets.did— the candid interface, for integrators.dist/<file>.sha256— a SHA-256 checksum beside each published file, so a downloaded artifact can be verified on its own (shasum -a 256 -c <file>.sha256).
The release identity stamped into both modules is the single workspace
semver from Cargo.toml, read at build time from
CARGO_PKG_VERSION — so every build (dev or release) knows its own version and
a deployed canister only pairs with the sync plugin that reports the identical
version. See Releasing for what the number means and how to bump it.
Every crate shares one semver, and that version doubles as the canister↔plugin
release identity: the canister returns it from the version query and the
plugin refuses to sync unless it matches its own exactly (they're a locked
pair). The version value also says whether moving the canister between two
releases keeps its state — following Cargo's 0.x rule while major is 0:
| Change | Bump | Canister switch |
|---|---|---|
| Non-breaking | patch (0.1.0 → 0.1.1) |
in-place upgrade; post_upgrade recovers all state |
| Breaking | minor (0.1.x → 0.2.0) |
reinstall; state is wiped, a fresh sync re-uploads everything |
(From 1.0.0 on, a major bump is the breaking one.) Choosing breaking vs
non-breaking is a deliberate call — the axis is canister state upgradability —
so bump it by hand:
# 1. edit version in [workspace.package] in Cargo.toml, commit
make tag # tags HEAD `v<version>` from Cargo.toml (prints the push command)
git push origin v<version> # triggers .github/workflows/release.ymlThe workflow re-reads the version from Cargo.toml, rejects a tag that doesn't
match, then runs make release and publishes dist/canister-release.wasm.gz
and dist/plugin-release.wasm to a GitHub release named for the version.
Most users don't reference the canister/plugin wasm directly — they use the
recipe published to dfinity/icp-cli-recipes,
which icp-cli expands into a canister build + sync config:
canisters:
- name: frontend
recipe:
type: "@dfinity/[email protected]"
configuration:
dir: dist # required: the single asset directory
build: [npm run build] # optional: commands run before sync
# metadata: [...] # optional: name/value pairs injected via ic-wasmThe recipe (recipe.hbs) is generated from one source —
crates/recipe-gen/src/recipe.hbs.in —
in two variants that differ only in how the wasm is pinned:
make recipe-local # pins wasm by local path -> dist/recipe.local.hbs (for manual testing)
make recipe-release # pins wasm by release URL + sha256 -> dist/recipe.hbs (for publishing)The e2e tests exercise the local variant through the real icp CLI
(crates/e2e/tests/recipe.rs), and recipe-gen's
unit tests assert every config field renders to valid icp-cli YAML.
To publish a new recipe version after a v<version> release exists:
scripts/publish-recipe.sh v<version> # generates + commits on a branch in ../icp-cli-recipes; prints the PR command
scripts/publish-recipe.sh v<version> --push # also pushes and opens the PRThe script downloads the release's .sha256 assets to pin the exact published
wasm, branches off a fresh origin/main in your icp-cli-recipes clone, and
writes recipes/certified-assets/{recipe.hbs,README.md}. After the PR merges,
tag certified-assets-v<version> in icp-cli-recipes to cut the recipe release.
certified-assets.did is the canister's public interface
and the single source of truth for its Candid types. The candid_interface_compatibility test
in crates/canister/src/lib.rs checks the
Rust-exported service against this file (via service_equal), so the two stay in
lockstep.
The HTTP types (HeaderField, HttpRequest, HttpResponse) come from the official
IC HTTP Gateway specification.
This canister doesn't use the streaming-callback strategy — large assets are served as
certified 206 range responses the gateway reassembles into a full 200 — so
HttpResponse carries no streaming_strategy field and there is no streaming-callback
token, strategy, or method. The file is self-contained (no import) so it can be
attached to the canister wasm as Candid metadata.