Serving static assets over HTTP
This guide walks through an example project that demonstrates how to create a canister that can serve certified static assets (HTML, CSS, JS) over HTTP. The example project presents a very simple single-page JavaScript application. Assets are embedded into the canister when it is compiled.
This is not a beginner's canister development guide. Many fundamental concepts that a relatively experienced canister developer should already know will be omitted. Concepts specific to asset certification will be called out here and can help to understand the full code example.
The certification and serving of assets is based on the high-level ic-asset-certification
crate.
If more flexibility than what this crate provides is needed, the lower-level ic-http-certification
crate can be used. Be sure to check out the "Custom HTTP Canisters" and "Custom asset canisters" guides to learn more about how to use that library for serving assets.
The frontend assets
The frontend project used for this example is a simple starter project generated with npx degit solidjs/templates/ts my-app
. The only changes that have been made are in the vite.config.ts
file. The vite-plugin-compression
plugin was added and configured to generate Gzip and Brotli encoded assets, alongside the original assets. The ext
configuration affects the file extension and it's important to keep this consistent with the backend canister code that will be seen later in this guide.
import { defineConfig } from 'vite';
import solidPlugin from 'vite-plugin-solid';
// import the compression plugin
import viteCompressionPlugin from 'vite-plugin-compression';
export default defineConfig({
plugins: [
solidPlugin(),
// setup Gzip compression
viteCompressionPlugin({
algorithm: 'gzip',
// this extension will be referenced later in the canister code
ext: '.gz',
// ensure to not delete the original files
deleteOriginFile: false,
threshold: 0,
}),
// setup Brotli compression
viteCompressionPlugin({
algorithm: 'brotliCompress',
// this extension will be referenced later in the canister code
ext: '.br',
// ensure to not delete the original files
deleteOriginFile: false,
threshold: 0,
}),
],
server: {
port: 3000,
},
build: {
target: 'esnext',
},
});
The rest of this guide will address the canister code.
Lifecycle hooks
The first thing to do when the canister bootstraps for the first time is to certify all the assets. This is done in the init
hook. The certify_all_assets
function will be covered in a later section.
The asset certification is not stored in stable memory so it's necessary to re-certify the assets after a canister upgrade. This is done in the post_upgrade
hook.
#[init]
fn init() {
certify_all_assets();
}
#[post_upgrade]
fn post_upgrade() {
init();
}
Canister endpoints
There is only one canister endpoint in this example to serve assets, the http_request
query endpoint. The http_request
handler uses two auxiliary functions, serve_metrics
and serve_asset
, which are covered in a later section.
#[query]
fn http_request(req: HttpRequest) -> HttpResponse {
let path = req.get_path().expect("Failed to parse request path");
// if the request is for the metrics endpoint, serve the metrics
if path == "/metrics" {
return serve_metrics();
}
// otherwise, serve the requested asset
serve_asset(&req)
}
Loading assets
Assets are embedded into the canister's Wasm at build time. This is achieved using the include_dir
crate. Note that this works fine for a small number of assets, but a larger number of assets may cause longer compile times, as mentioned in the crate's documentation.
The assets are imported from the frontend build directory:
static ASSETS_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../frontend/dist");
With the assets loaded, they need to be converted into the Asset
type that the ic-asset-certification
crate uses.
/// Rescursively collect all assets from the provided directory
fn collect_assets<'content, 'path>(
dir: &'content Dir<'path>,
assets: &mut Vec<Asset<'content, 'path>>,
) {
for file in dir.files() {
assets.push(Asset::new(file.path().to_string_lossy(), file.contents()));
}
for dir in dir.dirs() {
collect_assets(dir, assets);
}
}
Certifying assets
Asset certification is configured using the AssetConfig
type. This type is used to specify the content type, headers, and any fallbacks for each asset.
To handle common headers, a helper function get_asset_headers
is used. The security headers added to responses are based on the OWASP Secure Headers project.
These security headers have been included as a reasonably secure default for most static asset APIs. However, it's vitally important for developers to educate themselves and make informed decisions in the context of their own project's needs.
fn get_asset_headers(additional_headers: Vec<HeaderField>) -> Vec<HeaderField> {
// set up the default headers and include additional headers provided by the caller
let mut headers = vec![
("strict-transport-security".to_string(), "max-age=31536000; includeSubDomains".to_string()),
("x-frame-options".to_string(), "DENY".to_string()),
("x-content-type-options".to_string(), "nosniff".to_string()),
("content-security-policy".to_string(), "default-src 'self'; img-src 'self' data:; form-action 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests; block-all-mixed-content".to_string()),
("referrer-policy".to_string(), "no-referrer".to_string()),
("permissions-policy".to_string(), "accelerometer=(),ambient-light-sensor=(),autoplay=(),battery=(),camera=(),display-capture=(),document-domain=(),encrypted-media=(),fullscreen=(),gamepad=(),geolocation=(),gyroscope=(),layout-animations=(self),legacy-image-formats=(self),magnetometer=(),microphone=(),midi=(),oversized-images=(self),payment=(),picture-in-picture=(),publickey-credentials-get=(),speaker-selection=(),sync-xhr=(self),unoptimized-images=(self),unsized-media=(self),usb=(),screen-wake-lock=(),web-share=(),xr-spatial-tracking=()".to_string()),
("cross-origin-embedder-policy".to_string(), "require-corp".to_string()),
("cross-origin-opener-policy".to_string(), "same-origin".to_string()),
];
headers.extend(additional_headers);
headers
}
For the index.html
file, the AssetConfig::File
variant is used to target the configuration to that file specifically. The fallback_for
field of this variant is used to specify that this asset is the fallback for all paths that don't exactly match a file and the aliased_by
field is used to specify alternative paths that will serve the asset.
For the remaining files, they can all be configured in bulk using the AssetConfig::Pattern
variant. This variant uses a glob pattern to match multiple files.
The certify_all_assets
function performs the following steps:
- Define the asset certification configurations.
- Collect all assets from the frontend build directory.
- Skip certification for the
/metrics
endpoint. - Certify the assets using the
certify_assets
function from theic-asset-certification
crate. - Set the canister's certified data.
thread_local! {
static HTTP_TREE: Rc<RefCell<HttpCertificationTree>> = Default::default();
static ASSET_ROUTER: RefCell<AssetRouter<'static>> = RefCell::new(AssetRouter::with_tree(HTTP_TREE.with(|tree| tree.clone())));
// initializing the asset router with an HTTP certification tree is optional.
// if direct access to the HTTP certification tree is not needed for certifying
// requests and responses outside of the asset router, then this step can be skipped
// and the asset router can be initialized like so:
static ASSET_ROUTER: RefCell<AssetRouter<'static>> = Default::default();
}
const IMMUTABLE_ASSET_CACHE_CONTROL: &str = "public, max-age=31536000, immutable";
const NO_CACHE_ASSET_CACHE_CONTROL: &str = "public, no-cache, no-store";
fn certify_all_assets() {
// 1. Define the asset certification configurations.
let encodings = vec![
AssetEncoding::Brotli.default(),
AssetEncoding::Gzip.default(),
];
let asset_configs = vec![
AssetConfig::File {
path: "index.html".to_string(),
content_type: Some("text/html".to_string()),
headers: get_asset_headers(vec![(
"cache-control".to_string(),
NO_CACHE_ASSET_CACHE_CONTROL.to_string(),
)]),
fallback_for: vec![AssetFallbackConfig {
scope: "/".to_string(),
status_code: Some(StatusCode::OK),
}],
aliased_by: vec!["/".to_string()],
encodings: encodings.clone(),
},
AssetConfig::Pattern {
pattern: "**/*.js".to_string(),
content_type: Some("text/javascript".to_string()),
headers: get_asset_headers(vec![(
"cache-control".to_string(),
IMMUTABLE_ASSET_CACHE_CONTROL.to_string(),
)]),
encodings: encodings.clone(),
},
AssetConfig::Pattern {
pattern: "**/*.css".to_string(),
content_type: Some("text/css".to_string()),
headers: get_asset_headers(vec![(
"cache-control".to_string(),
IMMUTABLE_ASSET_CACHE_CONTROL.to_string(),
)]),
encodings,
},
AssetConfig::Pattern {
pattern: "**/*.ico".to_string(),
content_type: Some("image/x-icon".to_string()),
headers: get_asset_headers(vec![(
"cache-control".to_string(),
IMMUTABLE_ASSET_CACHE_CONTROL.to_string(),
)]),
encodings: vec![],
},
AssetConfig::Redirect {
from: "/old-url".to_string(),
to: "/".to_string(),
kind: AssetRedirectKind::Permanent,
headers: get_asset_headers(vec![
("content-type".to_string(), "text/plain".to_string()),
(
"cache-control".to_string(),
NO_CACHE_ASSET_CACHE_CONTROL.to_string(),
),
]),
},
];
// 2. Collect all assets from the frontend build directory.
let mut assets = Vec::new();
collect_assets(&ASSETS_DIR, &mut assets);
// 3. Skip certification for the metrics endpoint.
HTTP_TREE.with(|tree| {
let mut tree = tree.borrow_mut();
let metrics_tree_path = HttpCertificationPath::exact("/metrics");
let metrics_certification = HttpCertification::skip();
let metrics_tree_entry =
HttpCertificationTreeEntry::new(metrics_tree_path, metrics_certification);
tree.insert(&metrics_tree_entry);
});
ASSET_ROUTER.with_borrow_mut(|asset_router| {
// 4. Certify the assets using the `certify_assets` function from the `ic-asset-certification` crate.
if let Err(err) = asset_router.certify_assets(assets, asset_configs) {
ic_cdk::trap(&format!("Failed to certify assets: {}", err));
}
// 5. Set the canister's certified data.
set_certified_data(&asset_router.root_hash());
});
}
Serving assets
The serve_asset
function from the AssetRouter
is responsible for serving assets. This function returns an HttpResponse
that can be returned to the caller.
fn serve_asset(req: &HttpRequest) -> HttpResponse<'static> {
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");
}
})
}
Serving metrics
The serve_metrics
function is responsible for serving metrics. Since metrics are not certified, this procedure is a bit more involved compared to serving assets, which is handled entirely by the asset_router
.
It's important to determine whether skipping certification is appropriate for the use case. In this example, metrics are not sensitive data and are not used to make decisions that could affect the canister's security. Therefore, it's determined to be acceptable to skip certification for this use case, but that may not be the case for every canister. The important takeaway from this example is to learn how to skip certification, when it is necessary and safe to do so.
The Metrics
struct is used to collect the number of assets, number of fallback assets, and the cycle balance and serialize this into JSON. The add_v2_certificate_header
function from the ic-http-certification
library is used to add the IC-Certificate
header to the response and then the IC-Certificate-Expression
header is added too. The get_asset_headers
function is used to get the same headers for the response that are used for asset responses.
fn serve_metrics() -> HttpResponse<'static> {
ASSET_ROUTER.with_borrow(|asset_router| {
let metrics = Metrics {
num_assets: asset_router.get_assets().len(),
num_fallback_assets: asset_router.get_fallback_assets().len(),
cycle_balance: canister_balance(),
};
let body = serde_json::to_vec(&metrics).expect("Failed to serialize metrics");
let headers = get_asset_headers(vec![
(
CERTIFICATE_EXPRESSION_HEADER_NAME.to_string(),
DefaultCelBuilder::skip_certification().to_string(),
),
("content-type".to_string(), "application/json".to_string()),
(
"cache-control".to_string(),
NO_CACHE_ASSET_CACHE_CONTROL.to_string(),
),
]);
let mut response = HttpResponse::builder()
.with_status_code(200)
.with_body(body)
.with_headers(headers)
.build();
HTTP_TREE.with(|tree| {
let tree = tree.borrow();
let metrics_tree_path = HttpCertificationPath::exact("/metrics");
let metrics_certification = HttpCertification::skip();
let metrics_tree_entry =
HttpCertificationTreeEntry::new(&metrics_tree_path, metrics_certification);
add_v2_certificate_header(
&data_certificate().expect("No data certificate available"),
&mut response,
&tree.witness(&metrics_tree_entry, "/metrics").unwrap(),
&metrics_tree_path.to_expr_path(),
);
response
})
})
}
Testing the canister
This example uses a canister called http_certification_assets_backend
.
To test the canister, you can use dfx
to start a local instance of the replica:
dfx start --background --clean
Then, deploy the canister:
dfx deploy http_certification_assets_backend
You can now access the canister's assets by navigating to the canister's URL in a web browser. The URL can also be found using the following command:
echo "http://$(dfx canister id http_certification_assets_backend).localhost:$(dfx info webserver-port)"
Alternatively, to make a request with curl
:
curl "http://$(dfx canister id http_certification_assets_backend).localhost:$(dfx info webserver-port)" --resolve "$(dfx canister id http_certification_assets_backend).localhost:$(dfx info webserver-port):127.0.0.1"