pr_01m47d15m3e54sn21z27rpy5n9/crates/runner/src/deploy.rs

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