use std::{
env, fs,
path::{Path, PathBuf},
process::Command,
};
fn main() {
println!("cargo:rerun-if-env-changed=CARGO_FEATURE_GRAPHIQL");
if env::var_os("CARGO_FEATURE_GRAPHIQL").is_some() {
return;
}
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
let out_dir = PathBuf::from(env::var("OUT_DIR").expect("missing OUT_DIR"));
let output_file = out_dir.join("laboratory.html");
let product_logo = manifest_dir.join("static/product_logo.svg");
let node_modules_dist = out_dir.join("node_modules/@graphql-hive/laboratory/dist");
fs::copy(
manifest_dir.join("package.json"),
out_dir.join("package.json"),
)
.expect("Failed to copy package.json");
fs::copy(
manifest_dir.join("package-lock.json"),
out_dir.join("package-lock.json"),
)
.expect("Failed to copy package-lock.json");
println!("cargo:rerun-if-changed={}", product_logo.display());
println!(
"cargo:rerun-if-changed={}",
manifest_dir.join("package.json").display()
);
println!(
"cargo:rerun-if-changed={}",
out_dir.join("node_modules").display()
);
println!(
"cargo:rerun-if-changed={}",
manifest_dir.join("package-lock.json").display()
);
if !node_modules_dist.exists() {
let status = Command::new("npm")
.args([
"install",
"--include=dev", ])
.current_dir(out_dir)
.status()
.expect("Failed to execute npm install");
if !status.success() {
panic!("npm install failed");
}
}
let html = build_inline_laboratory_html(&node_modules_dist, &product_logo);
fs::write(output_file, html).expect("failed to write generated laboratory.html");
}
fn build_inline_laboratory_html(dist_dir: &Path, product_logo: &Path) -> String {
let js_contents = fs::read_to_string(dist_dir.join("hive-laboratory.umd.js"))
.expect("failed to read hive-laboratory.umd.js");
let editor_worker =
fs::read_to_string(dist_dir.join("monacoeditorwork/editor.worker.bundle.js"))
.expect("failed to read editor worker");
let graphql_worker =
fs::read_to_string(dist_dir.join("monacoeditorwork/graphql.worker.bundle.js"))
.expect("failed to read graphql worker");
let json_worker = fs::read_to_string(dist_dir.join("monacoeditorwork/json.worker.bundle.js"))
.expect("failed to read json worker");
let typescript_worker =
fs::read_to_string(dist_dir.join("monacoeditorwork/ts.worker.bundle.js"))
.expect("failed to read typescript worker");
let product_logo_data_url = format!(
"data:image/svg+xml;base64,{}",
base64_encode(&fs::read(product_logo).expect("failed to read product logo"))
);
format!(
r##"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Hive Router Laboratory</title>
<link rel="icon" type="image/svg+xml" href="{product_logo_data_url}" />
<style>
html,
body,
#root {{
height: 100%;
}}
body {{
margin: 0;
}}
</style>
</head>
<body id="body" class="no-focus-outline">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script>
function prepareBlob(workerContent) {{
const blob = new Blob([workerContent], {{ type: "application/javascript" }});
return URL.createObjectURL(blob);
}}
const workers = {{
editorWorkerService: prepareBlob({editor_worker}),
typescript: prepareBlob({typescript_worker}),
json: prepareBlob({json_worker}),
graphql: prepareBlob({graphql_worker}),
}};
self["MonacoEnvironment"] = {{
globalAPI: false,
getWorkerUrl: function (_moduleId, label) {{
return workers[label];
}},
}};
// Global headers: attach configured headers to every request the Laboratory makes to this
// router, without showing them in the UI. Installed before the bundle so the bundle, which
// captures `globalThis.fetch` at init, uses this wrapper. The router replaces the placeholder
// with a header map (or {{}} when none are configured).
(function () {{
var globalHeaders = {{}};
try {{
globalHeaders = JSON.parse("__LABORATORY_GLOBAL_HEADERS__");
}} catch (error) {{
console.warn("Failed to read the Laboratory global headers", error);
}}
if (Object.keys(globalHeaders).length === 0) {{
return;
}}
function isSameOrigin(url) {{
try {{
return new URL(url, window.location.href).origin === window.location.origin;
}} catch (error) {{
// Fail closed: if the URL can't be parsed, don't add the headers.
return false;
}}
}}
try {{
// Built once; an invalid header name throws here (at install) rather than on every
// request, and the catch keeps a bad value from breaking the rest of the page.
var base = new Headers(globalHeaders);
var nativeFetch = window.fetch.bind(window);
window.fetch = function (input, init) {{
init = init || {{}};
var url = typeof input === "string" ? input : input && input.url;
// Only the router's own endpoint (same-origin) gets the global headers, so they never
// leak to a third-party endpoint a user repoints the Laboratory at.
if (isSameOrigin(url)) {{
var merged = new Headers(base);
// A per-operation header of the same name wins.
new Headers(init.headers || {{}}).forEach(function (value, key) {{
merged.set(key, value);
}});
init = Object.assign({{}}, init, {{ headers: merged }});
}}
return nativeFetch(input, init);
}};
}} catch (error) {{
console.warn("Failed to install Laboratory global headers", error);
}}
}})();
{js_contents}
// The router always replaces this placeholder, with {{}} when nothing is configured.
var hiveRouterSeed = {{}};
try {{
hiveRouterSeed = JSON.parse("__LABORATORY_PROPS__");
}} catch (error) {{
console.warn("Failed to read the Laboratory configuration", error);
}}
// Merges the seed with what the Laboratory has already persisted, so seeding does not
// discard the user's own tabs and operations.
function hiveRouterLaboratoryProps(seed) {{
var STORAGE_NAMESPACE = "hive-laboratory";
var SEEDED_TABS_KEY = "hive-router:seeded-tab-ids";
var props = {{}};
function readStored(key) {{
try {{
var raw = window.localStorage.getItem(STORAGE_NAMESPACE + ":" + key);
return raw ? JSON.parse(raw) : null;
}} catch (error) {{
return null;
}}
}}
// Seeded collections are refreshed from config wholesale; user-created ones are kept.
var seededCollections = seed.collections || [];
if (seededCollections.length > 0) {{
var storedCollections = readStored("collections") || [];
var seededCollectionIds = seededCollections.map(function (collection) {{
return collection.id;
}});
props.defaultCollections = seededCollections.concat(
storedCollections.filter(function (collection) {{
return seededCollectionIds.indexOf(collection.id) === -1;
}})
);
}}
var seededOperations = seed.operations || [];
if (seededOperations.length > 0) {{
var storedOperations = readStored("operations") || [];
var storedTabs = readStored("tabs") || [];
var seededOperationIds = seededOperations.map(function (operation) {{
return operation.id;
}});
// Seeded operations are refreshed from config; user-created ones are kept.
props.defaultOperations = seededOperations.concat(
storedOperations.filter(function (operation) {{
return seededOperationIds.indexOf(operation.id) === -1;
}})
);
// A seeded tab opens only the first time this browser sees it, so closing it makes it
// stay closed while newly configured operations still show up.
var alreadySeededTabIds = [];
try {{
alreadySeededTabIds =
JSON.parse(window.localStorage.getItem(SEEDED_TABS_KEY)) || [];
}} catch (error) {{
alreadySeededTabIds = [];
}}
var openTabIds = storedTabs.map(function (tab) {{
return tab.id;
}});
var newTabs = (seed.tabs || []).filter(function (tab) {{
return (
openTabIds.indexOf(tab.id) === -1 &&
alreadySeededTabIds.indexOf(tab.id) === -1
);
}});
props.defaultTabs = storedTabs.concat(newTabs);
try {{
var seenTabIds = alreadySeededTabIds.slice();
(seed.tabs || []).forEach(function (tab) {{
if (seenTabIds.indexOf(tab.id) === -1) {{
seenTabIds.push(tab.id);
}}
}});
window.localStorage.setItem(SEEDED_TABS_KEY, JSON.stringify(seenTabIds));
}} catch (error) {{
// Best-effort: unavailable storage only means a closed seeded tab may re-open later.
}}
// Keep the user where they left off, unless a brand new operation was just seeded.
var storedActiveTabId = readStored("activeTabId");
var storedTabIsStillOpen = props.defaultTabs.some(function (tab) {{
return tab.id === storedActiveTabId;
}});
if (newTabs.length > 0) {{
props.defaultActiveTabId = newTabs[0].id;
}} else if (storedTabIsStillOpen) {{
props.defaultActiveTabId = storedActiveTabId;
}} else if (seed.activeTabId) {{
props.defaultActiveTabId = seed.activeTabId;
}}
}}
return props;
}}
HiveLaboratory.renderLaboratory(
window.document.querySelector("#root"),
hiveRouterLaboratoryProps(hiveRouterSeed)
);
</script>
</body>
</html>
"##,
product_logo_data_url = product_logo_data_url,
editor_worker = js_string_literal(&editor_worker),
typescript_worker = js_string_literal(&typescript_worker),
json_worker = js_string_literal(&json_worker),
graphql_worker = js_string_literal(&graphql_worker),
js_contents = escape_inline_script(&js_contents),
)
}
fn escape_inline_script(value: &str) -> String {
value
.replace("</script", "<\\/script")
.replace("<!--", "<\\!--")
.replace("<script", "<\\script")
}
fn js_string_literal(value: &str) -> String {
let mut escaped = String::with_capacity(value.len() + 2);
escaped.push('"');
for ch in value.chars() {
match ch {
'\\' => escaped.push_str("\\\\"),
'"' => escaped.push_str("\\\""),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
'\t' => escaped.push_str("\\t"),
'\u{2028}' => escaped.push_str("\\u2028"),
'\u{2029}' => escaped.push_str("\\u2029"),
_ => escaped.push(ch),
}
}
escaped.push('"');
escape_inline_script(&escaped)
}
fn base64_encode(bytes: &[u8]) -> String {
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut output = String::with_capacity(bytes.len().div_ceil(3) * 4);
let mut chunks = bytes.chunks_exact(3);
for chunk in &mut chunks {
let n = ((chunk[0] as u32) << 16) | ((chunk[1] as u32) << 8) | chunk[2] as u32;
output.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
output.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
output.push(TABLE[((n >> 6) & 0x3f) as usize] as char);
output.push(TABLE[(n & 0x3f) as usize] as char);
}
let remainder = chunks.remainder();
if !remainder.is_empty() {
let first = remainder[0] as u32;
let second = remainder.get(1).copied().unwrap_or_default() as u32;
let n = (first << 16) | (second << 8);
output.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
output.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
if remainder.len() == 2 {
output.push(TABLE[((n >> 6) & 0x3f) as usize] as char);
output.push('=');
} else {
output.push('=');
output.push('=');
}
}
output
}