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 World

Hello World

"; + let expected_html = r#"Hello World

Hello 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 World

Hello World

"; + let expected_html = r#"Hello World

Hello 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 World

Hello World

"; + let expected_html = r#"Hello World

Hello 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 World

Hello World

"#; + let expected_html = r#"Hello World

Hello 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 World

Hello 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"