diff --git a/Cargo.lock b/Cargo.lock
index 56ca303..d9cbf4d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -321,6 +321,15 @@ version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
+[[package]]
+name = "env_as_html_data"
+version = "0.0.0"
+dependencies = [
+ "html5ever",
+ "markup5ever_rcdom",
+ "uuid",
+]
+
[[package]]
name = "equivalent"
version = "1.0.1"
@@ -423,6 +432,16 @@ dependencies = [
"regex",
]
+[[package]]
+name = "futf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
+dependencies = [
+ "mac",
+ "new_debug_unreachable",
+]
+
[[package]]
name = "futures-channel"
version = "0.3.30"
@@ -587,6 +606,16 @@ dependencies = [
"windows-sys 0.52.0",
]
+[[package]]
+name = "html5ever"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6452c4751a24e1b99c3260d505eaeee76a050573e61f30ac2c924ddc7236f01e"
+dependencies = [
+ "log",
+ "markup5ever",
+]
+
[[package]]
name = "iana-time-zone"
version = "0.1.60"
@@ -882,12 +911,27 @@ version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
[[package]]
name = "log"
version = "0.4.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
+[[package]]
+name = "mac"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
+
[[package]]
name = "markdown"
version = "1.0.0-alpha.17"
@@ -897,6 +941,29 @@ dependencies = [
"unicode-id",
]
+[[package]]
+name = "markup5ever"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c3294c4d74d0742910f8c7b466f44dda9eb2d5742c1e430138df290a1e8451c"
+dependencies = [
+ "log",
+ "tendril",
+ "web_atoms",
+]
+
+[[package]]
+name = "markup5ever_rcdom"
+version = "0.36.0+unofficial"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e5fc8802e8797c0dfdd2ce5c21aa0aee21abbc7b3b18559100651b3352a7b63"
+dependencies = [
+ "html5ever",
+ "markup5ever",
+ "tendril",
+ "xml5ever",
+]
+
[[package]]
name = "memchr"
version = "2.7.4"
@@ -941,6 +1008,12 @@ dependencies = [
"adler2",
]
+[[package]]
+name = "new_debug_unreachable"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+
[[package]]
name = "node-semver"
version = "2.1.0"
@@ -1038,6 +1111,29 @@ dependencies = [
"num-traits",
]
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
[[package]]
name = "pathdiff"
version = "0.2.1"
@@ -1060,6 +1156,45 @@ dependencies = [
"indexmap",
]
+[[package]]
+name = "phf"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
+dependencies = [
+ "phf_shared",
+ "serde",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
+dependencies = [
+ "fastrand",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
+dependencies = [
+ "siphasher",
+]
+
[[package]]
name = "pin-project-lite"
version = "0.2.14"
@@ -1087,6 +1222,12 @@ dependencies = [
"zerocopy",
]
+[[package]]
+name = "precomputed-hash"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
+
[[package]]
name = "proc-macro2"
version = "1.0.86"
@@ -1248,6 +1389,12 @@ dependencies = [
"winapi-util",
]
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
[[package]]
name = "semver"
version = "1.0.23"
@@ -1327,6 +1474,12 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+[[package]]
+name = "siphasher"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
+
[[package]]
name = "slab"
version = "0.4.9"
@@ -1336,6 +1489,12 @@ dependencies = [
"autocfg",
]
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
[[package]]
name = "spin"
version = "0.9.8"
@@ -1347,6 +1506,7 @@ name = "static-web-server"
version = "0.0.0"
dependencies = [
"commons",
+ "env_as_html_data",
"indexmap",
"indoc",
"libcnb 0.25.0",
@@ -1370,6 +1530,31 @@ dependencies = [
"toml",
]
+[[package]]
+name = "string_cache"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
+dependencies = [
+ "new_debug_unreachable",
+ "parking_lot",
+ "phf_shared",
+ "precomputed-hash",
+ "serde",
+]
+
+[[package]]
+name = "string_cache_codegen"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+]
+
[[package]]
name = "subtle"
version = "2.6.1"
@@ -1410,6 +1595,17 @@ dependencies = [
"windows-sys 0.59.0",
]
+[[package]]
+name = "tendril"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
+dependencies = [
+ "futf",
+ "mac",
+ "utf-8",
+]
+
[[package]]
name = "termcolor"
version = "1.4.1"
@@ -1588,6 +1784,12 @@ dependencies = [
"percent-encoding",
]
+[[package]]
+name = "utf-8"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
+
[[package]]
name = "utf8-width"
version = "0.1.7"
@@ -1687,6 +1889,18 @@ version = "0.2.93"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484"
+[[package]]
+name = "web_atoms"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "acd0c322f146d0f8aad130ce6c187953889359584497dac6561204c8e17bb43d"
+dependencies = [
+ "phf",
+ "phf_codegen",
+ "string_cache",
+ "string_cache_codegen",
+]
+
[[package]]
name = "webpki-roots"
version = "0.26.5"
@@ -1757,6 +1971,12 @@ dependencies = [
"windows-targets",
]
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -1860,6 +2080,16 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "539a77ee7c0de333dcc6da69b177380a0b81e0dacfa4f7344c465a36871ee601"
+[[package]]
+name = "xml5ever"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f57dd51b88a4b9f99f9b55b136abb86210629d61c48117ddb87f567e51e66be7"
+dependencies = [
+ "log",
+ "markup5ever",
+]
+
[[package]]
name = "zerocopy"
version = "0.7.35"
diff --git a/Cargo.toml b/Cargo.toml
index a1233b3..3886883 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,6 +4,7 @@ members = [
"buildpacks/website-ember",
"buildpacks/website-public-html",
"buildpacks/static-web-server",
+ "common/env_as_html_data",
"common/static_web_server_utils"
]
@@ -29,4 +30,4 @@ module_name_repetitions = "allow"
test_support = { path = "./test_support" }
[profile.release]
-strip = true
\ No newline at end of file
+strip = true
diff --git a/buildpacks/static-web-server/Cargo.toml b/buildpacks/static-web-server/Cargo.toml
index 2f94ed3..e3001a8 100644
--- a/buildpacks/static-web-server/Cargo.toml
+++ b/buildpacks/static-web-server/Cargo.toml
@@ -14,6 +14,7 @@ indoc = "2"
serde = "1"
serde_json = "1"
static_web_server_utils = { path = "../../common/static_web_server_utils" }
+env_as_html_data = { path = "../../common/env_as_html_data" }
tempfile = "3"
toml = { version = "0.8", features = ["preserve_order"] }
ureq = "2"
diff --git a/buildpacks/static-web-server/README.md b/buildpacks/static-web-server/README.md
index ca8f056..4586294 100644
--- a/buildpacks/static-web-server/README.md
+++ b/buildpacks/static-web-server/README.md
@@ -2,15 +2,14 @@
This buildpack implements www hosting support for a static web app.
-* Defines [`project.toml` configuration](#configuration), `[com.heroku.static-web-server]`
+* Defines [`project.toml` configuration](#build-time-configuration), `[com.heroku.static-web-server]`
* At build:
* Installs a static web server (currently [Caddy](https://caddyserver.com/)).
- * Includes [heroku/release-phase buildpack](https://github.com/heroku/buildpacks-release-phase) to enable Release Phase Build & Static Artifacts.
- * [Inherits configuration](#inherited-configuration) from the Build Plan `[requires.metadata]` of other buildpacks.
+ * [Inherits configuration](#inherited-build-time-configuration) from the Build Plan `[requires.metadata]` of other buildpacks.
* Transforms the configuration into native configuration for the web server.
- * Optionally, runs a `build` command, such as `npm build` for minification & bundling of a Javascript app.
+ * Optionally, runs a [static build command](#static-build-command).
* At launch, the default `web` process:
- * Loads static artifacts for the release using [heroku/release-phase buildpack](https://github.com/heroku/buildpacks-release-phase)
+ * Performs [runtime app configuration](#runtime-app-configuration), `PUBLIC_*` environment variables are written into `
` attributes of the default HTML file in the document root.
* Starts the web server listing on the `PORT`, using the server's native config generated during build.
* Honors process signals for graceful shutdown.
@@ -23,52 +22,131 @@ In the app source repo, add the buildpack to [`project.toml`](https://buildpacks
id = "heroku/static-web-server"
```
-## Configuration
+## Runtime App Configuration
-In the app source repo, set custom configuration in [`project.toml`](https://buildpacks.io/docs/reference/config/project-descriptor/).
+_Dynamic config used by the static web app at runtime, to support different app instances, such as a backend API URL that differs between Staging and Production._
-### Release Build Command
+These are set in the container's environment variables ([Heroku Config Vars](https://devcenter.heroku.com/articles/config-vars)) and during CNB launch, written into the default HTML document. To access runtime app config, the javascript app's source code must read configuration values from the global `document.body.dataset`, [HTML data-* attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/data-*).
-*Default: (none)*
+**Do not set secret values into these environment variables.** They will be injected into the website, where anyone on the internet can see the values. As a precaution, only environment variables prefixed with `PUBLIC_` prefix will be exposed.
-The command to generate static artifacts for a website, such as a JavaScript compiler/bundler. It is automatically executed during [Heroku Release Phase](https://devcenter.heroku.com/articles/release-phase), for changes to config vars, pipeline promotions, and rollbacks.
+**This feature parses and rewrites the HTML document.** If the document's HTML syntax is invalid, the parser ([Servo's html5ever](https://github.com/servo/html5ever)) will correct the document using the same heuristics as web browsers.
-This command must write its output to the `static-artifacts/` directory (`/workspace/static-artifacts/` in the container). The generated `static-artifacts/` from each release are saved in an object store (AWS S3), separate from the container image itself, and loaded into `web` containers as they start-up.
+This Runtime App Configuration feature can be [disabled through Build-time Configuration](#runtime-configuration-enabled).
-Any dependencies to run this build command should be installed by an earlier buildpack, such as Node & npm engines for JavaScript.
+### Runtime Config Usage
+
+*Default: runtime config is written into `public/index.html`, unless [document root](#document-root) or [index document](#index-document) are custom configured.*
+
+For example, an app is started with the environment:
-```toml
-[com.heroku.phase.release-build]
-command = "sh"
-args = ["-c", "npm build"]
+```
+PUBLIC_API_URL=https://localhost:3001
+PUBLIC_RELEASE_VERSION=v42
+PORT=3000
+HOME=/workspace
```
-If the output is sent to a different directory, for example `dist/`, it should be copied to the expected location:
+When the default HTML document is fetched by a web browser, loading the app, the `PUBLIC_*` vars can be accessed from javascript using the [HTML Data Attribtes](https://developer.mozilla.org/en-US/docs/Web/HTML/How_to/Use_data_attributes) via `document.body.dataset`:
-```toml
-[com.heroku.phase.release-build]
-command = "sh"
-args = ["-c", "npm run build && mkdir -p static-artifacts && cp -rL dist/* static-artifacts/"]
+```javascript
+document.body.dataset.public_api_url
+// → "https://api-staging.example.com"
+document.body.dataset.public_release_version
+// → "v42"
+
+// Not exposed because not prefixed with PUBLIC_
+document.body.dataset.port
+// → null
+document.body.dataset.home
+// → null
```
+**The variable names are case-insensitive, accessed as lowercase.** Although enviroment variables are colloquially uppercased, the resulting HTML Data Attributes are set & accessed lowercased, because [they are case-insensitive XML names](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/data-*).
+
+For example, the `public_api_url` might be used for a `fetch()` call:
+
+```javascript
+// If the PUBLIC_API_URL variable is not set, default to the production API host.
+const apiUrl = document.body.dataset.public_api_url || 'https://api.example.com';
+const response = await fetch(apiUrl, {
+ method: "POST",
+ // …
+});
+```
+
+Alternatively, default values can be preset in the HTML document's body element:
+
+```html
+
+
+ Example
+
+
+
+ Example
+
+
+```
+
+Then, the javascript does not need a default value specified.
+
+```javascript
+const response = await fetch(document.body.dataset.public_api_url, {
+ method: "POST",
+ // …
+});
+```
+
+## Build-time Configuration
+
+_Static config that controls how the app is built, and how the web server delivers it._
+
+This is set in the app source repo [`project.toml`](https://buildpacks.io/docs/reference/config/project-descriptor/) file and processed during CNB build. Rebuild is necessary to apply any changes.
+
### Static Build Command
*Default: (none)*
-This buildpack also supports a executing a build command during CNB Build process. The output of this command is saved in the container image, and will not be re-built during release, versus the [Release Build Command](#release-build-command)).
+This buildpack supports a executing a build command during CNB Build process. The output of this command is saved in the container image.
+
+For apps built with Node.js, execution of the build command is typically handled automatically by [heroku/nodejs CNB's build script hooks](https://github.com/heroku/buildpacks-nodejs/blob/main/README.md#build-script-hooks), and does not need to be configured here.
+
+If your static web app is a static site generator built in a language other than JS, then you may need to configure the static site build command here. For example, [Hugo](https://gohugo.io) written in Go:
```toml
[com.heroku.static-web-server.build]
command = "sh"
-args = ["-c", "npm build"]
+args = ["-c", "hugo"]
```
This static build command does not have access to Heroku app config vars, but still can be configured using CNB Build variables in `project.toml`:
```toml
[[io.buildpacks.build.env]]
- name = "API_URL"
- value = "https://test.example.com/api/v7"
+ name = "HUGO_ENABLE_ROBOTS_TXT"
+ value = "true"
+```
+
+When dependent on another language's compiled program like this, ensure that the app's buildpacks are ordered with `heroku/static-web-server` last, after the language buildpack.
+
+```toml
+[[io.buildpacks.group]]
+id = "heroku/go"
+
+[[io.buildpacks.group]]
+id = "heroku/static-web-server"
+```
+
+### Runtime Configuration Enabled
+
+*Default: true*
+
+The [Runtime App Configuration](#runtime-app-configuration) feature may be disabled, such as when it is completely uneccesary or undesirable for a specific app.
+
+```toml
+[com.heroku.static-web-server]
+runtime_config_enabled = false
```
### Document Root
@@ -156,7 +234,7 @@ file_path = "index.html"
status = 200
```
-## Inherited Configuration
+## Inherited Build-time Configuration
Other buildpacks can return a [Build Plan](https://github.com/buildpacks/spec/blob/main/buildpack.md#build-plan-toml) from `detect` for Static Web Server configuration.
diff --git a/buildpacks/static-web-server/src/bin/env-as-html-data.rs b/buildpacks/static-web-server/src/bin/env-as-html-data.rs
new file mode 100644
index 0000000..1a55221
--- /dev/null
+++ b/buildpacks/static-web-server/src/bin/env-as-html-data.rs
@@ -0,0 +1,34 @@
+#![allow(unused_crate_dependencies)]
+use std::{env, path::Path};
+
+use env_as_html_data::env_as_html_data;
+
+fn main() {
+ let command_env: std::collections::HashMap = env::vars().collect();
+
+ let file_paths: Vec<&str> = command_env.get("ENV_AS_HTML_DATA_TARGET_FILES").or_else(|| {
+ eprintln!("Runtime configuration failed: env-as-html-data requires comma-delimited list of target files, the paths of the HTML documents to process. Set with environment variable: ENV_AS_HTML_DATA_TARGET_FILES. (This should be automatically set during CNB build.)");
+ std::process::exit(1);
+ }).map(|v| v.split(',').collect()).expect("should exit failure when none");
+
+ for file_path in file_paths {
+ let fp = Path::new(file_path.trim());
+ if !fp.exists() {
+ eprintln!(
+ "Runtime configuration skipping file '{}' because it does not exist.",
+ fp.display()
+ );
+ continue;
+ }
+ match env_as_html_data(&command_env, &fp.to_path_buf()) {
+ Err(e) => {
+ eprintln!("Runtime configuration failed: {e:?}");
+ std::process::exit(1);
+ }
+ Ok(true) => {
+ eprintln!("Runtime configuration written into {file_path}");
+ }
+ _ => (),
+ }
+ }
+}
diff --git a/buildpacks/static-web-server/src/config_web_server.rs b/buildpacks/static-web-server/src/config_web_server.rs
index 082bafc..5b09dc8 100644
--- a/buildpacks/static-web-server/src/config_web_server.rs
+++ b/buildpacks/static-web-server/src/config_web_server.rs
@@ -1,12 +1,15 @@
use crate::caddy_config::CaddyConfig;
-use crate::heroku_web_server_config::HerokuWebServerConfig;
+use crate::heroku_web_server_config::{HerokuWebServerConfig, DEFAULT_DOC_INDEX, DEFAULT_DOC_ROOT};
use crate::{StaticWebServerBuildpack, StaticWebServerBuildpackError, BUILD_PLAN_ID};
+use libcnb::additional_buildpack_binary_path;
use libcnb::data::layer_name;
use libcnb::layer::LayerRef;
+use libcnb::layer_env::{ModificationBehavior, Scope};
use libcnb::{build::BuildContext, layer::UncachedLayerDefinition};
use libherokubuildpack::log::log_info;
use static_web_server_utils::read_project_config;
use std::fs;
+use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use toml::Table;
@@ -30,6 +33,27 @@ pub(crate) fn config_web_server(
generate_config_with_inheritance(project_config.as_ref(), &build_plan_config)?;
let build_command_opt = heroku_config.build.clone();
+ let runtime_config_enabled_opt = heroku_config.runtime_config_enabled;
+
+ // Resolve web root and index doc
+ let doc_root_path = heroku_config
+ .root
+ .clone()
+ .or(Some(PathBuf::from(DEFAULT_DOC_ROOT)))
+ .expect("should always default to some value");
+ let doc_root = doc_root_path.to_string_lossy();
+ let doc_index = heroku_config
+ .index
+ .clone()
+ .or(Some(DEFAULT_DOC_INDEX.to_owned()))
+ .expect("should always default to some value");
+ let default_doc_path = format!("{doc_root}/{doc_index}");
+ let default_doc_string = if doc_root.is_empty() {
+ doc_index.as_str()
+ } else {
+ default_doc_path.as_str()
+ };
+ let default_doc_path = Path::new(default_doc_string);
// Transform web server config to Caddy native JSON config
let caddy_config = CaddyConfig::try_from(heroku_config)?;
@@ -53,6 +77,36 @@ pub(crate) fn config_web_server(
.map_err(StaticWebServerBuildpackError::BuildCommandFailed)?;
}
+ // Set-up runtime configuration; defaults to enabled
+ if runtime_config_enabled_opt
+ .or(Some(true))
+ .expect("should always default to some boolean")
+ {
+ log_info("Installing runtime configuration process…");
+ let web_exec_destination = configuration_layer.path().join("exec.d/web");
+ let exec_path = web_exec_destination.join("env-as-html-data");
+ log_info(format!(" {}", exec_path.display()));
+ fs::create_dir_all(&web_exec_destination)
+ .map_err(StaticWebServerBuildpackError::CannotCreatWebExecD)?;
+ fs::copy(
+ additional_buildpack_binary_path!("env-as-html-data"),
+ exec_path,
+ )
+ .map_err(StaticWebServerBuildpackError::CannotInstallEnvAsHtmlData)?;
+
+ // Set env-to-html-data param as env variable
+ let mut configuration_layer_env = configuration_layer.read_env()?;
+ configuration_layer_env.insert(
+ Scope::Process("web".to_string()),
+ ModificationBehavior::Override,
+ "ENV_AS_HTML_DATA_TARGET_FILES",
+ default_doc_path,
+ );
+ configuration_layer.write_env(configuration_layer_env)?;
+ } else {
+ log_info("Runtime configuration is not enabled.");
+ }
+
Ok(configuration_layer)
}
@@ -73,7 +127,7 @@ fn generate_build_plan_config(
{
let mut all_values = existing_values.clone();
new_values.into_iter().for_each(|(nk, nv)| {
- all_values.insert(nk.to_string(), nv.clone());
+ all_values.insert(nk.clone(), nv.clone());
});
build_plan_config.insert(k.to_owned(), all_values.into());
} else {
diff --git a/buildpacks/static-web-server/src/errors.rs b/buildpacks/static-web-server/src/errors.rs
index 7a02c92..e58c5d8 100644
--- a/buildpacks/static-web-server/src/errors.rs
+++ b/buildpacks/static-web-server/src/errors.rs
@@ -22,6 +22,8 @@ pub(crate) enum StaticWebServerBuildpackError {
CannotCreateCaddyInstallationDir(std::io::Error),
CannotCreateCaddyTarballFile(std::io::Error),
BuildCommandFailed(std::io::Error),
+ CannotCreatWebExecD(std::io::Error),
+ CannotInstallEnvAsHtmlData(std::io::Error),
}
pub(crate) fn on_error(error: libcnb::Error) {
@@ -71,6 +73,20 @@ fn on_buildpack_error(error: StaticWebServerBuildpackError, logger: Box on_build_command_error(&e, logger),
+ StaticWebServerBuildpackError::CannotCreatWebExecD(error) => {
+ print_error_details(logger, &error)
+ .announce()
+ .error(&formatdoc! {"
+ Cannot create exec.d/web for {buildpack_name}
+ ", buildpack_name = fmt::value(BUILDPACK_NAME) });
+ }
+ StaticWebServerBuildpackError::CannotInstallEnvAsHtmlData(error) => {
+ print_error_details(logger, &error)
+ .announce()
+ .error(&formatdoc! {"
+ Cannot install env-as-html-data (runtime configuration program) for {buildpack_name}
+ ", buildpack_name = fmt::value(BUILDPACK_NAME) });
+ }
}
}
diff --git a/buildpacks/static-web-server/src/heroku_web_server_config.rs b/buildpacks/static-web-server/src/heroku_web_server_config.rs
index e73698c..440e289 100644
--- a/buildpacks/static-web-server/src/heroku_web_server_config.rs
+++ b/buildpacks/static-web-server/src/heroku_web_server_config.rs
@@ -15,6 +15,7 @@ pub(crate) struct HerokuWebServerConfig {
pub(crate) errors: Option,
#[serde(default, deserialize_with = "deserialize_headers")]
pub(crate) headers: Option>,
+ pub(crate) runtime_config_enabled: Option,
}
#[derive(Deserialize, Eq, PartialEq, Debug, Default, Clone)]
@@ -133,6 +134,20 @@ mod tests {
assert_eq!(parsed_config.errors, None);
}
+ #[test]
+ fn custom_runtime_config_enabled() {
+ let toml_config = toml! {
+ runtime_config_enabled = false
+ };
+
+ let parsed_config = toml_config.try_into::().unwrap();
+ assert_eq!(parsed_config.build, None);
+ assert_eq!(parsed_config.root, None);
+ assert_eq!(parsed_config.runtime_config_enabled, Some(false));
+ assert_eq!(parsed_config.headers, None);
+ assert_eq!(parsed_config.errors, None);
+ }
+
#[test]
fn custom_root() {
let toml_config = toml! {
diff --git a/buildpacks/static-web-server/src/main.rs b/buildpacks/static-web-server/src/main.rs
index d738ffc..cd65dd2 100644
--- a/buildpacks/static-web-server/src/main.rs
+++ b/buildpacks/static-web-server/src/main.rs
@@ -16,6 +16,8 @@ use libcnb::generic::{GenericMetadata, GenericPlatform};
use libcnb::{buildpack_main, Buildpack, Error};
use libherokubuildpack::log::log_header;
+use env_as_html_data as _;
+
// Silence unused dependency warning for
// dependencies only used in tests
#[cfg(test)]
diff --git a/buildpacks/static-web-server/tests/fixtures/nonexistent_doc_root/not-the-expected-dir-name/index.html b/buildpacks/static-web-server/tests/fixtures/nonexistent_doc_root/not-the-expected-dir-name/index.html
new file mode 100644
index 0000000..0880cd7
--- /dev/null
+++ b/buildpacks/static-web-server/tests/fixtures/nonexistent_doc_root/not-the-expected-dir-name/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+ CNB Static Web Server Doc Root Test
+
+
+
+ Welcome to CNB Static Web Server Doc Root Test!
+
+
+
diff --git a/buildpacks/static-web-server/tests/fixtures/nonexistent_doc_root/project.toml b/buildpacks/static-web-server/tests/fixtures/nonexistent_doc_root/project.toml
new file mode 100644
index 0000000..61c68a8
--- /dev/null
+++ b/buildpacks/static-web-server/tests/fixtures/nonexistent_doc_root/project.toml
@@ -0,0 +1,2 @@
+[com.heroku.static-web-server]
+root = "configured-but-missing"
diff --git a/buildpacks/static-web-server/tests/integration_test.rs b/buildpacks/static-web-server/tests/integration_test.rs
index 14b2105..f113605 100644
--- a/buildpacks/static-web-server/tests/integration_test.rs
+++ b/buildpacks/static-web-server/tests/integration_test.rs
@@ -217,3 +217,69 @@ fn client_side_routing() {
);
});
}
+
+#[test]
+#[ignore = "integration test"]
+fn runtime_configuration() {
+ static_web_server_integration_test("./fixtures/no_project_toml", |ctx| {
+ assert_contains!(ctx.pack_stdout, "Static Web Server");
+ start_container(
+ &ctx,
+ ContainerConfig::new().env(
+ "PUBLIC_INTEGRATION_TEST",
+ "runtime-config-via-container-env",
+ ),
+ |_container, socket_addr| {
+ let response_result = retry(DEFAULT_RETRIES, DEFAULT_RETRY_DELAY, || {
+ ureq::get(&format!("http://{socket_addr}/")).call()
+ });
+ match response_result {
+ Ok(response) => {
+ let response_body = response.into_string().unwrap();
+ assert_contains!(
+ response_body,
+ r#"data-public_integration_test="runtime-config-via-container-env""#
+ );
+ }
+ Err(error) => {
+ panic!("should respond 200 Ok, but received: {error:?}");
+ }
+ }
+ },
+ );
+ });
+}
+
+#[test]
+#[ignore = "integration test"]
+fn runtime_configuration_nonexistent_doc() {
+ static_web_server_integration_test("./fixtures/nonexistent_doc_root", |ctx| {
+ assert_contains!(ctx.pack_stdout, "Static Web Server");
+ start_container(
+ &ctx,
+ ContainerConfig::new().env(
+ "PUBLIC_INTEGRATION_TEST",
+ "runtime-config-via-container-env",
+ ),
+ |container, socket_addr| {
+ let log_output = container.logs_now();
+ assert_contains!(log_output.stderr, "Runtime configuration skipping file");
+
+ let response_result = retry(DEFAULT_RETRIES, DEFAULT_RETRY_DELAY, || {
+ ureq::get(&format!("http://{socket_addr}/")).call()
+ });
+ match response_result {
+ Err(ureq::Error::Status(code, _response)) => {
+ assert_eq!(code, 404);
+ }
+ Ok(_) => {
+ panic!("should respond 404 Not Found, but got 200 ok");
+ }
+ Err(error) => {
+ panic!("should respond 404 Not Found, but got other error: {error:?}");
+ }
+ }
+ },
+ );
+ });
+}
diff --git a/buildpacks/website-ember/README.md b/buildpacks/website-ember/README.md
index 913376f..17404cc 100644
--- a/buildpacks/website-ember/README.md
+++ b/buildpacks/website-ember/README.md
@@ -4,10 +4,10 @@
* At build:
* Creates Build Plan `[requires.metadata]` for static-web-server, defining:
* Ember's `dist` root
- * support for client-side routing
- * the framework's `build` command.
+ * support for client-side routing.
* At launch:
* [static-web-server](../../buildpacks/static-web-server/README.md) runs with config generated during build.
+ * Performs [runtime app configuration](../../buildpacks/static-web-server/README.md#runtime-app-configuration).
## Usage
diff --git a/buildpacks/website-ember/src/main.rs b/buildpacks/website-ember/src/main.rs
index 3a62304..5b88874 100644
--- a/buildpacks/website-ember/src/main.rs
+++ b/buildpacks/website-ember/src/main.rs
@@ -50,9 +50,10 @@ impl Buildpack for WebsiteEmberBuildpack {
};
let mut static_web_server_req = Require::new("static-web-server");
+
static_web_server_req
.metadata(toml! {
- root = "/workspace/static-artifacts"
+ root = "/workspace/dist"
index = "index.html"
[errors.404]
@@ -61,44 +62,10 @@ impl Buildpack for WebsiteEmberBuildpack {
})
.map_err(WebsiteEmberBuildpackError::SettingBuildPlanMetadata)?;
- let mut release_phase_req = Require::new("release-phase");
- let mut release_phase_metadata = toml::Table::new();
- let mut release_build_command = toml::Table::new();
- release_build_command.insert("command".to_string(), "bash".to_string().into());
- let pkg_mgr_build_command = if context.app_dir.join("yarn.lock").exists() {
- "yarn run build"
- } else if context.app_dir.join("pnpm-lock.yaml").exists() {
- "pnpm run build"
- } else {
- "npm run build"
- };
- release_build_command.insert(
- "args".to_string(),
- vec![
- "-c",
- format!("{pkg_mgr_build_command} && mkdir -p static-artifacts && cp -rL dist/* static-artifacts/").as_str(),
- ]
- .into(),
- );
- release_build_command.insert("source".to_string(), BUILDPACK_NAME.to_string().into());
- release_phase_metadata.insert("release-build".to_string(), release_build_command.into());
- release_phase_req
- .metadata(release_phase_metadata)
- .map_err(WebsiteEmberBuildpackError::SettingBuildPlanMetadata)?;
-
- let mut nodejs_require = Require::new("heroku/nodejs");
- nodejs_require.metadata = toml! {
- // The package.json build scripts are automatically executed by the heroku/nodejs
- // component buildpacks responsible for installing dependencies for the detected
- // package manager (i.e.; npm, pnpm, or Yarn). This needs to be disabled so that
- // the build process can be deferred to the release-build phase.
- enabled = false
- skip_pruning = true
- };
+ let nodejs_require = Require::new("heroku/nodejs");
let plan_builder = BuildPlanBuilder::new()
.requires(static_web_server_req)
- .requires(release_phase_req)
.requires(nodejs_require);
if depends_on_ember_cli {
diff --git a/buildpacks/website-ember/tests/integration_test.rs b/buildpacks/website-ember/tests/integration_test.rs
index 535a390..c3b41ae 100644
--- a/buildpacks/website-ember/tests/integration_test.rs
+++ b/buildpacks/website-ember/tests/integration_test.rs
@@ -1,87 +1,24 @@
// Required due to: https://github.com/rust-lang/rust/issues/95513
#![allow(unused_crate_dependencies)]
-use std::{fs, os::unix::fs::PermissionsExt};
-
use libcnb_test::{assert_contains, ContainerConfig};
-use tempfile::tempdir;
use test_support::{
- retry, start_container, start_container_entrypoint, website_nodejs_integration_test,
- DEFAULT_RETRIES, DEFAULT_RETRY_DELAY,
+ retry, start_container, website_nodejs_integration_test, DEFAULT_RETRIES, DEFAULT_RETRY_DELAY,
};
-use uuid::Uuid;
#[test]
#[ignore = "integration test"]
fn ember_cli_app() {
website_nodejs_integration_test("./fixtures/ember_cli_app", |ctx| {
- let unique = Uuid::new_v4();
-
- let temp_dir = tempdir().expect("should create temporary directory for artifact storage");
- let temp_sub_dir = "static-artifacts-storage";
- let local_storage_path = temp_dir.path().join(temp_sub_dir);
- println!("local_storage_path: {local_storage_path:?}");
-
- // Workaround for GitHub Runner & Docker container not running with same gid/uid/permissions:
- // create & set the temp local storage dir permissions to be world-accessible.
- fs::create_dir_all(&local_storage_path)
- .expect("local_storage_path directory should be created");
- let mut perms = fs::metadata(&local_storage_path)
- .expect("local dir already exists")
- .permissions();
- perms.set_mode(0o777);
- fs::set_permissions(&local_storage_path, perms).expect("local dir permission can be set");
-
- let container_volume_path = "/static-artifacts-storage";
- let container_volume_url = "file://".to_owned() + container_volume_path;
-
assert_contains!(ctx.pack_stdout, "Website (Ember.js)");
assert_contains!(ctx.pack_stdout, "Static Web Server");
- assert_contains!(ctx.pack_stdout, "Release Phase");
- assert_contains!(ctx.pack_stdout, "Installing yarn CLI");
- assert_contains!(
- ctx.pack_stdout,
- "Not running `build` as it was disabled by a participating buildpack"
- );
-
- start_container_entrypoint(
- &ctx,
- ContainerConfig::new()
- .env("RELEASE_ID", unique)
- .env("STATIC_ARTIFACTS_URL", &container_volume_url)
- .bind_mount(&local_storage_path, container_volume_path),
- &"release".to_string(),
- |container| {
- let log_output = container.logs_now();
- assert_contains!(log_output.stderr, "release-phase plan");
- assert_contains!(
- log_output.stderr,
- "release-phase executing release-build command: bash -c yarn run build"
- );
-
- assert_contains!(
- log_output.stderr,
- format!("save-release-artifacts writing archive: release-{unique}.tgz")
- .as_str()
- );
- assert_contains!(log_output.stderr, "release-phase complete.");
- },
- );
start_container(
&ctx,
- ContainerConfig::new()
- .env("RELEASE_ID", unique)
- .env("STATIC_ARTIFACTS_URL", &container_volume_url)
- .bind_mount(&local_storage_path, container_volume_path),
- |container, socket_addr| {
- let log_output = container.logs_now();
- assert_contains!(
- log_output.stderr,
- format!("load-release-artifacts reading archive: release-{unique}.tgz")
- .as_str(),
- );
- assert_contains!(log_output.stderr, "load-release-artifacts complete.");
-
+ ContainerConfig::new().env(
+ "PUBLIC_INTEGRATION_TEST",
+ "runtime-config-via-container-env",
+ ),
+ |_container, socket_addr| {
let response = retry(DEFAULT_RETRIES, DEFAULT_RETRY_DELAY, || {
ureq::get(&format!("http://{socket_addr}/")).call()
})
@@ -90,6 +27,10 @@ fn ember_cli_app() {
// This is a unique part of the Ember app's rendered HTML.
assert_contains!(response_body, "cnb-ember-web-app/config/environment");
+ assert_contains!(
+ response_body,
+ r#"data-public_integration_test="runtime-config-via-container-env""#
+ );
let response = retry(DEFAULT_RETRIES, DEFAULT_RETRY_DELAY, || {
ureq::get(&format!("http://{socket_addr}/test-client-side-routing")).call()
@@ -99,6 +40,10 @@ fn ember_cli_app() {
// This is a unique part of the Ember app's rendered HTML.
assert_contains!(response_body, "cnb-ember-web-app/config/environment");
+ assert_contains!(
+ response_body,
+ r#"data-public_integration_test="runtime-config-via-container-env""#
+ );
},
);
});
diff --git a/common/env_as_html_data/Cargo.toml b/common/env_as_html_data/Cargo.toml
new file mode 100644
index 0000000..3d28646
--- /dev/null
+++ b/common/env_as_html_data/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "env_as_html_data"
+rust-version.workspace = true
+edition.workspace = true
+
+[lints]
+workspace = true
+
+[dependencies]
+html5ever = "0.36.1"
+markup5ever_rcdom = "0.36.0"
+
+[dev-dependencies]
+uuid = { version = "1.10.0", features = ["v4", "serde"] }
diff --git a/common/env_as_html_data/src/errors.rs b/common/env_as_html_data/src/errors.rs
new file mode 100644
index 0000000..06f5674
--- /dev/null
+++ b/common/env_as_html_data/src/errors.rs
@@ -0,0 +1,39 @@
+use std::fmt::{self, Debug};
+
+#[derive(Debug)]
+pub enum Error {
+ ElementExpected(String),
+ EncodeError(String),
+ FileError(std::io::Error, String),
+ NoBodyElementError,
+ ParseError(String),
+ SerializeError(String),
+}
+
+impl fmt::Display for Error {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Error::ElementExpected(got_name) => {
+ write!(
+ f,
+ "Env-as-HTML-Data expected an Element node, but got {got_name}"
+ )
+ }
+ Error::EncodeError(error) => {
+ write!(f, "Env-as-HTML-Data output encoding error, {error}")
+ }
+ Error::FileError(error, error_desc) => {
+ write!(f, "Env-as-HTML-Data file error, {error_desc}, {error:#?}")
+ }
+ Error::NoBodyElementError => {
+ write!(f, "Env-as-HTML-Data no body element found in document")
+ }
+ Error::ParseError(error) => {
+ write!(f, "Env-as-HTML-Data document parsing error, {error}")
+ }
+ Error::SerializeError(error) => {
+ write!(f, "Env-as-HTML-Data document serialization error, {error}")
+ }
+ }
+ }
+}
diff --git a/common/env_as_html_data/src/lib.rs b/common/env_as_html_data/src/lib.rs
new file mode 100644
index 0000000..a898510
--- /dev/null
+++ b/common/env_as_html_data/src/lib.rs
@@ -0,0 +1,317 @@
+extern crate html5ever;
+extern crate markup5ever_rcdom as rcdom;
+
+mod errors;
+use errors::Error;
+
+use std::fs::File;
+use std::io::{Read, Write};
+use std::path::PathBuf;
+use std::{collections::HashMap, hash::BuildHasher, rc::Rc, str::FromStr};
+
+use html5ever::driver::ParseOpts;
+use html5ever::serialize::SerializeOpts;
+use html5ever::tendril::{StrTendril, TendrilSink};
+use html5ever::tree_builder::TreeBuilderOpts;
+use html5ever::{ns, parse_document, serialize, Attribute, LocalName, QualName};
+use rcdom::{Handle, Node, NodeData, RcDom, SerializableHandle};
+
+pub fn env_as_html_data(
+ data: &HashMap,
+ file_path: &PathBuf,
+) -> Result {
+ let mut html_file = File::options()
+ .read(true)
+ .open(file_path)
+ .map_err(|e| Error::FileError(e, format!("Tried to open {}", file_path.display())))?;
+ let mut buffer = Vec::new();
+ html_file
+ .read_to_end(&mut buffer)
+ .map_err(|e| Error::FileError(e, format!("Tried to read {}", file_path.display())))?;
+
+ match parse_html_and_inject_data(data, &buffer)? {
+ (true, html_result) => {
+ let mut rewrite_html_file = File::options()
+ .write(true)
+ .truncate(true)
+ .open(file_path)
+ .map_err(|e| {
+ Error::FileError(
+ e,
+ format!("Tried to reopen for writing {}", file_path.display()),
+ )
+ })?;
+ rewrite_html_file
+ .write_all(html_result.as_bytes())
+ .map_err(|e| {
+ Error::FileError(e, format!("Tried to write {}", file_path.display()))
+ })?;
+ Ok(true)
+ }
+ (false, _) => Ok(false),
+ }
+}
+
+pub(crate) fn parse_html_and_inject_data(
+ data: &HashMap,
+ html_bytes: &[u8],
+) -> Result<(bool, String), Error> {
+ let opts = ParseOpts {
+ tree_builder: TreeBuilderOpts {
+ ..Default::default()
+ },
+ ..Default::default()
+ };
+ let html = std::str::from_utf8(html_bytes)
+ .map_err(|e| Error::ParseError(format!("could not decode HTML as UTF-8 {e:?}")))?;
+ let dom = parse_document(RcDom::default(), opts).one(html);
+
+ if let (_, false) = match_html_body_and_inject_data(data, &dom.document)? {
+ return Ok((false, html.to_owned()));
+ }
+
+ let document: SerializableHandle = dom.document.clone().into();
+
+ let mut buf = Vec::new();
+ serialize(&mut buf, &document, SerializeOpts::default())
+ .map_err(|e| Error::SerializeError(e.to_string()))?;
+ let output =
+ std::str::from_utf8(buf.as_slice()).map_err(|e| Error::EncodeError(e.to_string()))?;
+ Ok((true, output.to_string()))
+}
+
+#[allow(clippy::type_complexity)]
+fn match_html_body_and_inject_data(
+ data: &HashMap,
+ node: &Handle,
+) -> Result<(bool, bool), Error> {
+ // Closure around the reference-counted HTML DOM document/nodes, to support recursing to find the body element
+ struct RecurseToMatch<'r> {
+ f: &'r dyn Fn(&RecurseToMatch, &Rc) -> Result<(bool, bool), Error>,
+ }
+ let recurse_to_match = RecurseToMatch {
+ f: &|recurse_to_match: &RecurseToMatch, n: &Rc| -> Result<(bool, bool), Error> {
+ let m: Option<&Rc> = match n.data {
+ NodeData::Element { ref name, .. } => {
+ if *name.local == *"body" {
+ Some(n)
+ } else {
+ None
+ }
+ }
+
+ _ => None,
+ };
+
+ if m.is_none() {
+ let mut children = n.children.borrow_mut();
+ for child in children.iter_mut() {
+ if let Ok((true, did_inject)) = (recurse_to_match.f)(recurse_to_match, child) {
+ return Ok((true, did_inject));
+ }
+ }
+ return Ok((false, false));
+ }
+
+ let did_inject = inject_html_data_attrs::(
+ data,
+ m.expect("Document Node is already known to be Some"),
+ )?;
+ Ok((true, did_inject))
+ },
+ };
+
+ match (recurse_to_match.f)(&recurse_to_match, node) {
+ Ok((true, did_inject)) => Ok((true, did_inject)),
+ Ok((false, _)) => Err(Error::NoBodyElementError),
+ Err(e) => Err(e),
+ }
+}
+
+fn inject_html_data_attrs(
+ data: &HashMap,
+ element: &Rc,
+) -> Result {
+ let NodeData::Element {
+ name: qual_name,
+ attrs,
+ ..
+ } = &element.data
+ else {
+ return Err(Error::ElementExpected(format!("{:?}", &element.data)));
+ };
+
+ let mut keys: Vec = data
+ .keys()
+ .filter(|k| k.starts_with("PUBLIC_") || k.starts_with("public_"))
+ .cloned()
+ .collect();
+ keys.sort_unstable();
+
+ if keys.is_empty() {
+ return Ok(false);
+ }
+
+ let mut attrs_borrow = attrs.borrow_mut();
+ for k in &keys {
+ let name = format_html_data_attr_name(k);
+ let value =
+ StrTendril::from_str(data[k].as_str()).expect("Data key is already known to exist");
+
+ // Replace existing attribute value, or create a new attribute.
+ if let Some(attr) = attrs_borrow.iter_mut().find(|a| a.name.local == name) {
+ attr.value = value;
+ } else {
+ let new_attr = Attribute {
+ name: QualName {
+ prefix: qual_name.prefix.clone(),
+ ns: ns!(),
+ local: LocalName::from(name),
+ },
+ value,
+ };
+ attrs_borrow.push(new_attr);
+ }
+ }
+
+ Ok(true)
+}
+
+fn format_html_data_attr_name(name: &str) -> String {
+ format!("data-{}", name.to_lowercase())
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::{env_as_html_data, parse_html_and_inject_data};
+ use std::{
+ collections::HashMap,
+ fs::{self, File},
+ io::{Read, Write},
+ path::Path,
+ };
+ use uuid::Uuid;
+
+ #[test]
+ #[allow(clippy::assertions_on_constants)]
+ fn env_as_html_data_writes_into_file() {
+ let mut data: HashMap = HashMap::new();
+ data.insert(
+ "PUBLIC_API_URL".to_string(),
+ "https://api.example.com/v1".to_string(),
+ );
+ let html =
+ "Hello WorldHello World
";
+ let expected_html = r#"Hello WorldHello World
"#;
+
+ let unique = Uuid::new_v4();
+ let test_dir = format!("env-as-html-data-test-{unique}");
+ let test_path = Path::new(&test_dir);
+ fs::create_dir_all(test_path).expect("test dir should be created");
+ let index_html_path = test_path.join("index.html");
+ File::create(&index_html_path)
+ .and_then(|mut file| file.write_all(html.as_bytes()))
+ .expect("HTML file shoud be written");
+
+ let mut result_html = Vec::new();
+ match env_as_html_data(&data, &index_html_path) {
+ Ok(true) => {
+ let mut result_file =
+ File::open(index_html_path).expect("the test file should exist");
+ result_file
+ .read_to_end(&mut result_html)
+ .expect("test fixture file should contain bytes");
+ }
+ Ok(v) => assert!(v, "should have returned 'true' that inject was successful"),
+ Err(e) => assert!(false, "returned error {e:?}"),
+ }
+
+ fs::remove_dir_all(test_path).unwrap_or_default();
+
+ assert_eq!(
+ str::from_utf8(&result_html).expect("HTML contains valid UTF8 characters"),
+ expected_html
+ );
+ }
+
+ #[test]
+ fn parse_html_and_inject_data_sets_public_data() {
+ let mut data: HashMap = HashMap::new();
+ data.insert(
+ "PUBLIC_API_URL".to_string(),
+ "https://api.example.com/v1".to_string(),
+ );
+ data.insert("PUBLIC_RELEASE_VERSION".to_string(), "v101".to_string());
+ data.insert(
+ "NOT_PUBLIC_VAR".to_string(),
+ "non-public should not be included".to_string(),
+ );
+ let html =
+ "Hello WorldHello World
";
+ let expected_html = r#"Hello WorldHello World
"#;
+
+ match parse_html_and_inject_data(&data, html.as_bytes()) {
+ Ok((true, result_value)) => assert_eq!(&result_value, expected_html),
+ Ok((false, _)) => panic!("should have returned 'true' that inject was successful"),
+ Err(e) => panic!("returned error {e:?}"),
+ }
+ }
+
+ #[test]
+ fn parse_html_and_inject_data_corrects_invalid_doc() {
+ let mut data: HashMap = HashMap::new();
+ data.insert(
+ "PUBLIC_API_URL".to_string(),
+ "https://api.example.com/v1".to_string(),
+ );
+ data.insert("PUBLIC_RELEASE_VERSION".to_string(), "v101".to_string());
+ data.insert(
+ "NOT_PUBLIC_VAR".to_string(),
+ "non-public should not be included".to_string(),
+ );
+ let html = "Hello WorldHello World
";
+ let expected_html = r#"Hello WorldHello World
"#;
+
+ match parse_html_and_inject_data(&data, html.as_bytes()) {
+ Ok((true, result_value)) => assert_eq!(&result_value, expected_html),
+ Ok((false, _)) => panic!("should have returned 'true' that inject was successful"),
+ Err(e) => panic!("returned error {e:?}"),
+ }
+ }
+
+ #[test]
+ fn parse_html_and_inject_data_overwrites_existing_attrs() {
+ let mut data: HashMap = HashMap::new();
+ data.insert(
+ "PUBLIC_API_URL".to_string(),
+ "https://api.example.com/v1".to_string(),
+ );
+ data.insert("PUBLIC_DEBUG_MODE".to_string(), "true".to_string());
+ data.insert("PUBLIC_RELEASE_VERSION".to_string(), "v101".to_string());
+ let html = r#"Hello WorldHello World
"#;
+ let expected_html = r#"Hello WorldHello World
"#;
+
+ match parse_html_and_inject_data(&data, html.as_bytes()) {
+ Ok((true, result_value)) => assert_eq!(&result_value, expected_html),
+ Ok((false, _)) => panic!("should have returned 'true' that inject was successful"),
+ Err(e) => panic!("returned error {e:?}"),
+ }
+ }
+
+ #[test]
+ fn parse_html_and_inject_data_without_public_data_makes_no_diff() {
+ let mut data: HashMap = HashMap::new();
+ data.insert(
+ "NOT_PUBLIC_VAR".to_string(),
+ "non-public should not be included".to_string(),
+ );
+ let html =
+ "Hello WorldHello World
";
+
+ match parse_html_and_inject_data(&data, html.as_bytes()) {
+ Ok((false, _)) => (),
+ Ok((true, _)) => panic!("should have returned 'false' that inject did not happen"),
+ Err(e) => panic!("returned error {e:?}"),
+ }
+ }
+}
diff --git a/meta-buildpacks/website-nodejs/README.md b/meta-buildpacks/website-nodejs/README.md
index a5219f6..faf9a32 100644
--- a/meta-buildpacks/website-nodejs/README.md
+++ b/meta-buildpacks/website-nodejs/README.md
@@ -7,10 +7,10 @@ Deploy a static web app that requires Node.js for build.
* At build:
* Creates Build Plan `[requires.metadata]` for Static Web Server, setting specific values to support the detected framwork:
* the framework's root & index document
- * client-side routing
- * the framework's `build` command.
+ * client-side routing.
* At launch:
* [static-web-server](../../buildpacks/static-web-server/README.md) runs with config generated during build.
+ * Performs [runtime app configuration](../../buildpacks/static-web-server/README.md#runtime-app-configuration).
## Usage
diff --git a/meta-buildpacks/website-nodejs/buildpack.toml b/meta-buildpacks/website-nodejs/buildpack.toml
index 560799b..b8eb25d 100644
--- a/meta-buildpacks/website-nodejs/buildpack.toml
+++ b/meta-buildpacks/website-nodejs/buildpack.toml
@@ -12,10 +12,6 @@ type = "MIT"
[[order]]
-[[order.group]]
-id = "heroku/release-phase"
-version = "1.0.4"
-
[[order.group]]
id = "heroku/nodejs"
version = "5.0.0"
diff --git a/meta-buildpacks/website-nodejs/package.toml b/meta-buildpacks/website-nodejs/package.toml
index 09edd2f..b26bdc8 100644
--- a/meta-buildpacks/website-nodejs/package.toml
+++ b/meta-buildpacks/website-nodejs/package.toml
@@ -9,6 +9,3 @@ uri = "docker://heroku/buildpack-nodejs:5.0.0"
[[dependencies]]
uri = "libcnb:heroku/static-web-server"
-
-[[dependencies]]
-uri = "docker://heroku/buildpack-release-phase:1.0.4"
diff --git a/meta-buildpacks/website/buildpack.toml b/meta-buildpacks/website/buildpack.toml
index 727d120..11dc3f7 100644
--- a/meta-buildpacks/website/buildpack.toml
+++ b/meta-buildpacks/website/buildpack.toml
@@ -12,10 +12,6 @@ type = "MIT"
[[order]]
-[[order.group]]
-id = "heroku/release-phase"
-version = "1.0.4"
-
[[order.group]]
id = "heroku/static-web-server"
version = "1.0.8"
diff --git a/meta-buildpacks/website/package.toml b/meta-buildpacks/website/package.toml
index d581396..38080b3 100644
--- a/meta-buildpacks/website/package.toml
+++ b/meta-buildpacks/website/package.toml
@@ -6,6 +6,3 @@ uri = "libcnb:heroku/website-public-html"
[[dependencies]]
uri = "libcnb:heroku/static-web-server"
-
-[[dependencies]]
-uri = "docker://heroku/buildpack-release-phase:1.0.4"