flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/crates/runner/src/deploy.rs

689 lines25,007 bytesCodeBlame
1//! Builds one commit of a repository and hands the result to Cloudflare, as
2//! a preview of a pull request or as the repository's production.
3//!
4//! The sandbox never holds a Cloudflare credential that could touch anything
5//! else. The deployments service opens an upload for exactly the files
6//! this build produced and gives back a key that can only upload those;
7//! the sandbox uploads them with it, and sends the Worker's code to the
8//! service, which puts the app in place.
9//!
10//! What gets built:
11//!
12//! - A Workers project (a `wrangler.jsonc`, `wrangler.json` or
13//! `wrangler.toml`): bundled by `wrangler deploy --dry-run`, with its
14//! static assets, compatibility settings and `vars`. Other bindings (D1,
15//! KV, R2, Durable Objects…) are not provisioned yet; the deployment says
16//! which were left out.
17//! - Anything else: a static site. Its `build` script runs, and the first
18//! of `dist`, `build`, `out`, `public`, `_site` or `.output/public` that
19//! exists is served, or the repository itself if it has an `index.html`.
20//!
21//! Configuration comes from the environment:
22//!
23//! - `G1T_API`, `DEPLOY_ID`, `DEPLOY_TOKEN`: where and how to report.
24//! - `GIT_REMOTE`, `GIT_COMMIT`, `G1T_USER`, `G1T_TOKEN`: what to check out.
25//! - `ROOT_DIR`: where in the repository the project lives; empty for all
26//! of it. Everything below is relative to it.
27//! - `BUILD_COMMAND`, `OUTPUT_DIR`: the project's own choices, if any.
28//! - `BUILD_ENV`, `BUILD_SECRETS`: JSON objects of the repository's
29//! variables and secrets for deploy builds. Both are set for the build;
30//! secrets' values are redacted from its log.
31
32use std::collections::BTreeMap;
33use std::path::{Path, PathBuf};
34use std::time::Instant;
35
36use anyhow::{Context, Result, bail};
37use base64::Engine;
38use base64::engine::general_purpose::STANDARD;
39use serde::{Deserialize, Serialize};
40use serde_json::{Value, json};
41use sha2::{Digest, Sha256};
42
43use crate::checks::{redact, run_command};
44use crate::{WORKDIR, auth_option, env, git};
45
46/// Where `wrangler deploy --dry-run` writes the bundle.
47const BUNDLE_DIR: &str = "/work/g1t-bundle";
48/// Cloudflare's limits on a Worker's static assets.
49const MAX_FILES: usize = 20_000;
50const MAX_FILE_BYTES: u64 = 25 * 1024 * 1024;
51/// How much of the build's output is kept for the deployment's log.
52const MAX_LOG_CHARS: usize = 20_000;
53/// Directories a static build usually writes to, in the order they are tried.
54const OUTPUT_DIRS: [&str; 6] = ["dist", "build", "out", "public", "_site", ".output/public"];
55/// Bindings a Workers project may declare that are not provisioned yet.
56const UNSUPPORTED_BINDINGS: [&str; 10] = [
57 "kv_namespaces",
58 "d1_databases",
59 "r2_buckets",
60 "durable_objects",
61 "services",
62 "queues",
63 "vectorize",
64 "hyperdrive",
65 "ai",
66 "workflows",
67];
68
69struct Reporter {
70 base: String,
71 token: String,
72}
73
74impl Reporter {
75 fn send(&self, step: &str, mut body: Value) -> Result<Value> {
76 body["token"] = self.token.clone().into();
77 let response = ureq::post(&format!("{}/{step}", self.base))
78 .send_json(body)
79 .with_context(|| format!("could not report `{step}` to g1t"))?;
80 Ok(response.into_json().unwrap_or(Value::Null))
81 }
82}
83
84/// The build's log, kept to its end.
85#[derive(Default)]
86struct Log {
87 text: String,
88}
89
90impl Log {
91 fn line(&mut self, line: &str) {
92 self.text.push_str(line);
93 self.text.push('\n');
94 }
95
96 fn tail(&self) -> String {
97 let length = self.text.chars().count();
98 if length <= MAX_LOG_CHARS {
99 return self.text.clone();
100 }
101 let kept: String = self.text.chars().skip(length - MAX_LOG_CHARS).collect();
102 format!("… (earlier output not shown)\n{kept}")
103 }
104}
105
106/// Runs a command in the checkout, logging it; fails if it fails.
107fn step(log: &mut Log, command: &str, secrets: &[String]) -> Result<()> {
108 log.line(&format!("$ {command}"));
109 let result = run_command(command, &project_dir(), secrets);
110 if !result.output_text().is_empty() {
111 log.line(result.output_text());
112 }
113 if !result.passed {
114 bail!("`{command}` failed");
115 }
116 Ok(())
117}
118
119/// A Workers project's settings, from whichever config file it has.
120#[derive(Debug, Default, Deserialize)]
121struct WranglerConfig {
122 main: Option<String>,
123 compatibility_date: Option<String>,
124 #[serde(default)]
125 compatibility_flags: Vec<String>,
126 assets: Option<AssetsConfig>,
127 #[serde(default)]
128 vars: BTreeMap<String, Value>,
129 #[serde(flatten)]
130 rest: BTreeMap<String, Value>,
131}
132
133#[derive(Debug, Default, Deserialize)]
134struct AssetsConfig {
135 directory: Option<String>,
136 binding: Option<String>,
137 html_handling: Option<String>,
138 not_found_handling: Option<String>,
139}
140
141/// JSON with comments and trailing commas, as `wrangler.jsonc` allows.
142fn strip_jsonc(text: &str) -> String {
143 let mut out = String::with_capacity(text.len());
144 let mut chars = text.chars().peekable();
145 let mut in_string = false;
146 while let Some(c) = chars.next() {
147 if in_string {
148 out.push(c);
149 if c == '\\' {
150 if let Some(next) = chars.next() {
151 out.push(next);
152 }
153 } else if c == '"' {
154 in_string = false;
155 }
156 continue;
157 }
158 match (c, chars.peek()) {
159 ('"', _) => {
160 in_string = true;
161 out.push(c);
162 }
163 ('/', Some('/')) => {
164 for c in chars.by_ref() {
165 if c == '\n' {
166 out.push('\n');
167 break;
168 }
169 }
170 }
171 ('/', Some('*')) => {
172 chars.next();
173 let mut last = ' ';
174 for c in chars.by_ref() {
175 if last == '*' && c == '/' {
176 break;
177 }
178 last = c;
179 }
180 }
181 _ => out.push(c),
182 }
183 }
184 // Trailing commas before a closing bracket.
185 let mut cleaned = String::with_capacity(out.len());
186 let chars: Vec<char> = out.chars().collect();
187 let mut in_string = false;
188 let mut i = 0;
189 while i < chars.len() {
190 let c = chars[i];
191 if c == '"' && (i == 0 || chars[i - 1] != '\\') {
192 in_string = !in_string;
193 }
194 if c == ',' && !in_string {
195 let next = chars[i + 1..].iter().find(|c| !c.is_whitespace());
196 if matches!(next, Some('}') | Some(']')) {
197 i += 1;
198 continue;
199 }
200 }
201 cleaned.push(c);
202 i += 1;
203 }
204 cleaned
205}
206
207fn read_wrangler(dir: &Path) -> Result<Option<WranglerConfig>> {
208 for name in ["wrangler.jsonc", "wrangler.json"] {
209 let path = dir.join(name);
210 if path.exists() {
211 let text = std::fs::read_to_string(&path)?;
212 return Ok(Some(
213 serde_json::from_str(&strip_jsonc(&text)).with_context(|| format!("could not read {name}"))?,
214 ));
215 }
216 }
217 let path = dir.join("wrangler.toml");
218 if path.exists() {
219 let text = std::fs::read_to_string(&path)?;
220 return Ok(Some(toml::from_str(&text).context("could not read wrangler.toml")?));
221 }
222 Ok(None)
223}
224
225/// How to install the project's dependencies, judged by its lockfile.
226fn install_command(dir: &Path) -> Option<&'static str> {
227 if !dir.join("package.json").exists() {
228 return None;
229 }
230 Some(if dir.join("pnpm-lock.yaml").exists() {
231 "corepack enable && pnpm install --frozen-lockfile"
232 } else if dir.join("yarn.lock").exists() {
233 "corepack enable && yarn install"
234 } else if dir.join("bun.lockb").exists() || dir.join("bun.lock").exists() {
235 "npx --yes bun install"
236 } else if dir.join("package-lock.json").exists() {
237 "npm ci"
238 } else {
239 "npm install"
240 })
241}
242
243fn has_build_script(dir: &Path) -> bool {
244 std::fs::read_to_string(dir.join("package.json"))
245 .ok()
246 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
247 .is_some_and(|package| package["scripts"]["build"].is_string())
248}
249
250/// One file of the site, as Cloudflare's asset upload names it.
251struct Asset {
252 path: String,
253 hash: String,
254 size: u64,
255 file: PathBuf,
256}
257
258fn content_type(path: &str) -> &'static str {
259 let extension = path.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
260 match extension.as_str() {
261 "html" | "htm" => "text/html",
262 "css" => "text/css",
263 "js" | "mjs" => "application/javascript",
264 "json" | "map" => "application/json",
265 "svg" => "image/svg+xml",
266 "png" => "image/png",
267 "jpg" | "jpeg" => "image/jpeg",
268 "gif" => "image/gif",
269 "webp" => "image/webp",
270 "avif" => "image/avif",
271 "ico" => "image/x-icon",
272 "woff" => "font/woff",
273 "woff2" => "font/woff2",
274 "ttf" => "font/ttf",
275 "txt" => "text/plain",
276 "xml" => "application/xml",
277 "wasm" => "application/wasm",
278 "pdf" => "application/pdf",
279 "mp4" => "video/mp4",
280 "webm" => "video/webm",
281 _ => "application/octet-stream",
282 }
283}
284
285/// Every file under `root`, but for what never belongs in a site.
286fn collect(root: &Path, dir: &Path, skip_project: bool, out: &mut Vec<Asset>) -> Result<()> {
287 for entry in std::fs::read_dir(dir)? {
288 let entry = entry?;
289 let name = entry.file_name().to_string_lossy().into_owned();
290 let path = entry.path();
291 let kind = entry.file_type()?;
292 if name == ".git" || (skip_project && (name == "node_modules" || name.starts_with(".g1t"))) {
293 continue;
294 }
295 if kind.is_dir() {
296 collect(root, &path, skip_project, out)?;
297 continue;
298 }
299 if !kind.is_file() || name == "_headers" || name == "_redirects" {
300 continue;
301 }
302 let size = entry.metadata()?.len();
303 let relative = path
304 .strip_prefix(root)?
305 .to_string_lossy()
306 .replace('\\', "/");
307 if size > MAX_FILE_BYTES {
308 bail!("{relative} is larger than Cloudflare's 25 MiB limit for one file");
309 }
310 let bytes = std::fs::read(&path)?;
311 let digest = hex::encode(Sha256::digest(&bytes));
312 out.push(Asset {
313 path: format!("/{relative}"),
314 hash: digest[..32].to_owned(),
315 size,
316 file: path,
317 });
318 if out.len() > MAX_FILES {
319 bail!("the site has more than {MAX_FILES} files, Cloudflare's limit");
320 }
321 }
322 Ok(())
323}
324
325#[derive(Deserialize)]
326#[serde(rename_all = "camelCase")]
327struct UploadSession {
328 jwt: String,
329 #[serde(default)]
330 buckets: Vec<Vec<String>>,
331 upload_url: String,
332}
333
334/// Sends one bucket of files with the upload's key. The last bucket's
335/// answer carries the key that completes the upload.
336fn upload_bucket(session: &UploadSession, bucket: &[String], by_hash: &BTreeMap<&str, &Asset>) -> Result<Option<String>> {
337 let boundary = format!("g1t-{}", hex::encode(Sha256::digest(bucket.join(",").as_bytes()))[..24].to_owned());
338 let mut body: Vec<u8> = Vec::new();
339 for hash in bucket {
340 let asset = by_hash
341 .get(hash.as_str())
342 .with_context(|| format!("Cloudflare asked for a file this build does not have ({hash})"))?;
343 let bytes = std::fs::read(&asset.file)?;
344 body.extend_from_slice(
345 format!(
346 "--{boundary}\r\nContent-Disposition: form-data; name=\"{hash}\"; filename=\"{hash}\"\r\nContent-Type: {}\r\n\r\n",
347 content_type(&asset.path)
348 )
349 .as_bytes(),
350 );
351 body.extend_from_slice(STANDARD.encode(bytes).as_bytes());
352 body.extend_from_slice(b"\r\n");
353 }
354 body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
355 let response = ureq::post(&session.upload_url)
356 .set("authorization", &format!("Bearer {}", session.jwt))
357 .set("content-type", &format!("multipart/form-data; boundary={boundary}"))
358 .send_bytes(&body);
359 let response = match response {
360 Ok(response) => response,
361 Err(ureq::Error::Status(code, response)) => {
362 bail!("Cloudflare refused the upload ({code}): {}", response.into_string().unwrap_or_default())
363 }
364 Err(error) => bail!("could not upload to Cloudflare: {error}"),
365 };
366 let answer: Value = response.into_json().unwrap_or(Value::Null);
367 Ok(answer["result"]["jwt"].as_str().map(str::to_owned))
368}
369
370#[derive(Serialize)]
371#[serde(rename_all = "camelCase")]
372struct Module {
373 name: String,
374 content_base64: String,
375 content_type: String,
376}
377
378/// The bundle `wrangler deploy --dry-run` wrote, main module first.
379fn bundle_modules(main: &str) -> Result<(String, Vec<Module>)> {
380 let stem = Path::new(main)
381 .file_stem()
382 .map(|stem| stem.to_string_lossy().into_owned())
383 .unwrap_or_else(|| "index".to_owned());
384 let mut modules = Vec::new();
385 let mut files = Vec::new();
386 collect_files(Path::new(BUNDLE_DIR), &mut files)?;
387 for file in files {
388 let name = file
389 .strip_prefix(BUNDLE_DIR)?
390 .to_string_lossy()
391 .replace('\\', "/");
392 let kind = match name.rsplit('.').next().unwrap_or("") {
393 "js" | "mjs" => "application/javascript+module",
394 "wasm" => "application/wasm",
395 "map" | "md" => continue,
396 _ => "text/plain",
397 };
398 modules.push(Module {
399 content_base64: STANDARD.encode(std::fs::read(&file)?),
400 content_type: kind.to_owned(),
401 name,
402 });
403 }
404 let main_name = modules
405 .iter()
406 .map(|module| module.name.clone())
407 .find(|name| *name == format!("{stem}.js") || *name == format!("{stem}.mjs"))
408 .or_else(|| {
409 modules
410 .iter()
411 .find(|module| module.content_type == "application/javascript+module")
412 .map(|module| module.name.clone())
413 })
414 .context("wrangler wrote no JavaScript module")?;
415 Ok((main_name, modules))
416}
417
418fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
419 for entry in std::fs::read_dir(dir)? {
420 let entry = entry?;
421 if entry.file_type()?.is_dir() {
422 collect_files(&entry.path(), out)?;
423 } else {
424 out.push(entry.path());
425 }
426 }
427 Ok(())
428}
429
430/// What was built: the Worker's code and settings, and where its site is.
431struct Built {
432 worker: Value,
433 assets_dir: Option<PathBuf>,
434 warnings: Vec<String>,
435}
436
437fn build(log: &mut Log, secrets: &[String]) -> Result<Built> {
438 let project = project_dir();
439 let dir = project.as_path();
440 let config = read_wrangler(dir)?;
441 let custom_build = std::env::var("BUILD_COMMAND").ok().filter(|c| !c.trim().is_empty());
442 if let Some(install) = install_command(dir) {
443 step(log, install, secrets)?;
444 }
445 let mut warnings = Vec::new();
446 match config {
447 Some(config) => {
448 if let Some(command) = &custom_build {
449 step(log, command, secrets)?;
450 }
451 for binding in UNSUPPORTED_BINDINGS {
452 if config.rest.get(binding).is_some_and(|value| !value.is_null()) {
453 warnings.push(format!(
454 "`{binding}` is not provisioned on g1t.page yet, so the app runs without it."
455 ));
456 }
457 }
458 let mut worker = json!({
459 "compatibilityDate": config.compatibility_date.clone().unwrap_or_else(|| "2026-09-26".to_owned()),
460 "compatibilityFlags": config.compatibility_flags,
461 "vars": config.vars,
462 });
463 if let Some(main) = &config.main {
464 // `--dry-run` runs the project's own build and bundles it,
465 // without deploying anywhere.
466 step(
467 log,
468 &format!("npx --yes wrangler@4 deploy --dry-run --outdir {BUNDLE_DIR}"),
469 secrets,
470 )?;
471 let (main_module, modules) = bundle_modules(main)?;
472 worker["mainModule"] = main_module.into();
473 worker["modules"] = serde_json::to_value(modules)?;
474 }
475 let assets = config.assets.unwrap_or_default();
476 let assets_dir = assets.directory.as_ref().map(|directory| dir.join(directory));
477 worker["assetsBinding"] = assets.binding.into();
478 worker["htmlHandling"] = assets.html_handling.into();
479 worker["notFoundHandling"] = assets.not_found_handling.into();
480 Ok(Built {
481 worker,
482 assets_dir,
483 warnings,
484 })
485 }
486 None => {
487 if let Some(command) = &custom_build {
488 step(log, command, secrets)?;
489 } else if has_build_script(dir) {
490 step(log, "npm run build", secrets)?;
491 }
492 let chosen = std::env::var("OUTPUT_DIR").ok().filter(|d| !d.trim().is_empty());
493 let assets_dir = match chosen {
494 Some(chosen) => {
495 let path = dir.join(chosen.trim_matches('/'));
496 if !path.is_dir() {
497 bail!("the output directory `{chosen}` does not exist after the build");
498 }
499 path
500 }
501 None => OUTPUT_DIRS
502 .iter()
503 .map(|name| dir.join(name))
504 .find(|path| path.join("index.html").exists() || (path.is_dir() && path != &dir.join("public")))
505 .or_else(|| dir.join("index.html").exists().then(|| dir.to_path_buf()))
506 .context(
507 "found nothing to serve: no Workers config, no index.html, and none of dist, build, out, public, _site or .output/public",
508 )?,
509 };
510 let spa = !assets_dir.join("404.html").exists();
511 Ok(Built {
512 worker: json!({
513 "compatibilityDate": "2026-09-26",
514 "compatibilityFlags": [],
515 "vars": {},
516 "notFoundHandling": if spa { "single-page-application" } else { "404-page" },
517 }),
518 assets_dir: Some(assets_dir),
519 warnings,
520 })
521 }
522 }
523}
524
525/// Where the project lives in the checkout: its root directory.
526fn project_dir() -> PathBuf {
527 let root = std::env::var("ROOT_DIR").unwrap_or_default();
528 let root = root.trim_matches('/');
529 if root.is_empty() || root.split('/').any(|part| part == "..") {
530 PathBuf::from(WORKDIR)
531 } else {
532 Path::new(WORKDIR).join(root)
533 }
534}
535
536fn check_out(secrets: &[String]) -> Result<()> {
537 let remote = env("GIT_REMOTE")?;
538 let commit = env("GIT_COMMIT")?;
539 let auth = auth_option(&env("G1T_USER")?, &env("G1T_TOKEN")?);
540 std::fs::create_dir_all("/work")?;
541 let cloned = git(Path::new("/work"), &["-c", &auth, "clone", "--quiet", &remote, WORKDIR]).and_then(|_| {
542 git(
543 Path::new(WORKDIR),
544 &["-c", "advice.detachedHead=false", "checkout", "--quiet", &commit],
545 )
546 });
547 if let Err(error) = cloned {
548 bail!("{}", redact(&format!("{error:#}"), secrets));
549 }
550 Ok(())
551}
552
553fn deploy(reporter: &Reporter, log: &mut Log, secrets: &[String]) -> Result<Value> {
554 check_out(secrets).context("the commit could not be checked out")?;
555 // The repository's variables and secrets for deploy builds.
556 for source in ["BUILD_ENV", "BUILD_SECRETS"] {
557 if let Ok(vars) = std::env::var(source)
558 && let Ok(Value::Object(vars)) = serde_json::from_str::<Value>(&vars)
559 {
560 for (name, value) in vars {
561 if let Some(value) = value.as_str() {
562 // SAFETY: single-threaded; set before any command runs.
563 unsafe { std::env::set_var(name, value) };
564 }
565 }
566 }
567 }
568 let built = build(log, secrets)?;
569 let mut finish = json!({
570 "worker": built.worker,
571 "warnings": built.warnings,
572 });
573 if let Some(dir) = &built.assets_dir {
574 let skip_project = *dir == project_dir();
575 let mut assets = Vec::new();
576 collect(dir, dir, skip_project, &mut assets)?;
577 if assets.is_empty() {
578 bail!("the site to serve is empty");
579 }
580 log.line(&format!("Uploading {} files.", assets.len()));
581 for special in ["_headers", "_redirects"] {
582 if let Ok(text) = std::fs::read_to_string(dir.join(special)) {
583 finish["worker"][special] = text.into();
584 }
585 }
586 let manifest: BTreeMap<&str, Value> = assets
587 .iter()
588 .map(|asset| (asset.path.as_str(), json!({ "hash": asset.hash, "size": asset.size })))
589 .collect();
590 let answer = reporter.send("session", json!({ "manifest": manifest }))?;
591 if answer["ok"] == false {
592 bail!("{}", answer["error"]["message"].as_str().unwrap_or("g1t refused the upload"));
593 }
594 let session: UploadSession =
595 serde_json::from_value(answer["value"].clone()).context("g1t's answer to the upload was not understood")?;
596 let by_hash: BTreeMap<&str, &Asset> = assets.iter().map(|asset| (asset.hash.as_str(), asset)).collect();
597 let mut completion = session.jwt.clone();
598 for bucket in &session.buckets {
599 if let Some(jwt) = upload_bucket(&session, bucket, &by_hash)? {
600 completion = jwt;
601 }
602 }
603 finish["completionJwt"] = completion.into();
604 }
605 Ok(finish)
606}
607
608pub fn main() -> i32 {
609 let reporter = match (env("G1T_API"), env("DEPLOY_ID"), env("DEPLOY_TOKEN")) {
610 (Ok(api), Ok(id), Ok(token)) => Reporter {
611 base: format!("{api}/deployments/jobs/{id}"),
612 token,
613 },
614 _ => {
615 eprintln!("g1t-runner: G1T_API, DEPLOY_ID and DEPLOY_TOKEN must be set");
616 return 2;
617 }
618 };
619 let mut secrets: Vec<String> = ["G1T_TOKEN", "DEPLOY_TOKEN"]
620 .iter()
621 .filter_map(|name| std::env::var(name).ok())
622 .filter(|secret| !secret.is_empty())
623 .collect();
624 // The repository's build secrets never appear in the log.
625 if let Ok(Value::Object(build)) = serde_json::from_str::<Value>(&std::env::var("BUILD_SECRETS").unwrap_or_default()) {
626 secrets.extend(build.values().filter_map(Value::as_str).filter(|v| v.len() >= 4).map(str::to_owned));
627 }
628 if let Err(error) = reporter.send("started", json!({})) {
629 eprintln!("g1t-runner: {error:#}");
630 return 1;
631 }
632 let started = Instant::now();
633 let mut log = Log::default();
634 let outcome = deploy(&reporter, &mut log, &secrets);
635 let seconds = started.elapsed().as_secs();
636 let sent = match outcome {
637 Ok(mut finish) => {
638 finish["log"] = redact(&log.tail(), &secrets).into();
639 finish["buildSeconds"] = seconds.into();
640 reporter.send("finish", finish)
641 }
642 Err(error) => {
643 let message = redact(&format!("{error:#}"), &secrets);
644 log.line(&format!("The build failed: {message}"));
645 reporter.send(
646 "fail",
647 json!({ "message": message, "log": redact(&log.tail(), &secrets), "buildSeconds": seconds }),
648 )
649 }
650 };
651 match sent {
652 Ok(_) => 0,
653 Err(error) => {
654 eprintln!("g1t-runner: {error:#}");
655 1
656 }
657 }
658}
659
660#[cfg(test)]
661mod tests {
662 use super::*;
663
664 #[test]
665 fn jsonc_comments_and_trailing_commas_are_dropped() {
666 let text = r#"{
667 // a comment
668 "main": "src/index.ts", /* another */
669 "vars": { "URL": "https://x.dev//not-a-comment", },
670 }"#;
671 let config: WranglerConfig = serde_json::from_str(&strip_jsonc(text)).unwrap();
672 assert_eq!(config.main.as_deref(), Some("src/index.ts"));
673 assert_eq!(config.vars["URL"], "https://x.dev//not-a-comment");
674 }
675
676 #[test]
677 fn unsupported_bindings_are_noticed() {
678 let config: WranglerConfig =
679 serde_json::from_str(r#"{ "main": "a.js", "d1_databases": [{ "binding": "DB" }] }"#).unwrap();
680 assert!(config.rest.contains_key("d1_databases"));
681 }
682
683 #[test]
684 fn files_are_typed_by_extension() {
685 assert_eq!(content_type("/index.HTML"), "text/html");
686 assert_eq!(content_type("/a/b.woff2"), "font/woff2");
687 assert_eq!(content_type("/LICENSE"), "application/octet-stream");
688 }
689}