1use std::path::{Path, PathBuf};
27use std::process::ExitCode;
28use std::time::Duration;
29
30use bynk_emit::project::{ProjectPaths, try_read_project_paths};
31
32use crate::compiler::Compiler;
33use crate::doctor::{self, Capability, Context, DoctorOptions, Report};
34use crate::probe::{self, DetectOpts, Provenance, Toolbox};
35use crate::report::{self, Format};
36use crate::shell::exit_status_byte;
37
38pub use crate::workers::{
43 SelectError, compile_once, discover_workers, prepare_build_dir, select_contexts,
44 wrangler_command,
45};
46
47#[derive(Debug, Clone, Default)]
50pub struct DevOptions {
51 pub contexts: Vec<String>,
54 pub base_port: Option<u16>,
58 pub inspect: bool,
62 pub inspect_port: u16,
67 pub environment: String,
76 pub local_workers: Vec<String>,
81 pub wrangler_args: Vec<String>,
83 pub format: DevFormat,
86 pub ready_json: Option<PathBuf>,
89}
90
91#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
93pub enum DevFormat {
94 #[default]
96 Human,
97 Json,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
109#[serde(tag = "event", rename_all = "snake_case")]
110pub enum DevEvent {
111 Serving { contexts: Vec<ServedContext> },
114 Rebuilding,
116 RebuildOk,
118 RebuildFailed,
121 Stopped { code: u8 },
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
127pub struct ServedContext {
128 pub name: String,
130 pub port: u16,
132 pub url: String,
134 pub inspector_port: Option<u16>,
137}
138
139pub fn serving_event(serving: &[Serving], wrangler_args: &[String]) -> DevEvent {
143 DevEvent::Serving {
144 contexts: serving
145 .iter()
146 .map(|s| {
147 let port = s
148 .port
149 .or_else(|| passthrough_port(wrangler_args))
150 .unwrap_or(DEFAULT_BASE_PORT);
151 ServedContext {
152 name: s.worker.clone(),
153 port,
154 url: format!("http://localhost:{port}"),
155 inspector_port: s.inspector_port,
156 }
157 })
158 .collect(),
159 }
160}
161
162pub fn event_line(event: &DevEvent) -> String {
164 serde_json::to_string(event).expect("a dev event serialises")
165}
166
167fn emit(format: DevFormat, event: &DevEvent) {
169 if format == DevFormat::Json {
170 use std::io::Write;
171 let mut out = std::io::stdout().lock();
172 let _ = writeln!(out, "{}", event_line(event));
173 let _ = out.flush();
174 }
175}
176
177fn passthrough_port(args: &[String]) -> Option<u16> {
179 args.iter().enumerate().find_map(|(i, a)| {
180 if a == "--port" {
181 args.get(i + 1).and_then(|v| v.parse().ok())
182 } else {
183 a.strip_prefix("--port=").and_then(|v| v.parse().ok())
184 }
185 })
186}
187
188#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct StandIn {
194 pub worker: String,
195 pub bindings: Vec<String>,
196}
197
198pub fn stand_in_name(context: &str, worker: &str) -> String {
209 let name = format!("bynk-stand-in-{context}-{worker}");
210 if name.len() <= 63 {
211 return name;
212 }
213 let hash = name.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |h, b| {
215 (h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3)
216 });
217 let suffix = format!("-{:08x}", hash as u32);
218 let mut cut = 63 - suffix.len();
219 while !name.is_char_boundary(cut) {
220 cut -= 1;
221 }
222 format!("{}{suffix}", name[..cut].trim_end_matches('-'))
223}
224
225pub const STAND_IN_CONFIG: &str = "wrangler.dev.toml";
229
230fn point_at_stand_ins(workers_dir: &Path, plans: &[(String, Vec<StandIn>)]) -> Result<(), String> {
239 for (context, stand_ins) in plans {
240 let targets: std::collections::BTreeMap<String, String> = stand_ins
241 .iter()
242 .flat_map(|s| {
243 let name = stand_in_name(context, &s.worker);
244 s.bindings.iter().map(move |b| (b.clone(), name.clone()))
245 })
246 .collect();
247 let dir = workers_dir.join(context);
248 let text = std::fs::read_to_string(dir.join("wrangler.toml")).map_err(|e| e.to_string())?;
249 let out = bynk_emit::emitter::wrangler::retarget_services(&text, &targets)?;
250 let path = dir.join(STAND_IN_CONFIG);
251 if std::fs::read_to_string(&path).ok().as_deref() != Some(out.as_str()) {
252 let staged = dir.join(format!("{STAND_IN_CONFIG}.tmp"));
253 std::fs::write(&staged, out).map_err(|e| e.to_string())?;
254 std::fs::rename(&staged, &path).map_err(|e| e.to_string())?;
255 }
256 }
257 Ok(())
258}
259
260pub fn foreign_services(config_text: &str, project_workers: &[String]) -> Vec<(String, String)> {
265 let Ok(doc) = config_text.parse::<toml::Table>() else {
266 return Vec::new();
267 };
268 let mut out: Vec<(String, String)> = doc
269 .get("services")
270 .and_then(toml::Value::as_array)
271 .into_iter()
272 .flatten()
273 .filter_map(|s| {
274 let binding = s.get("binding")?.as_str()?;
275 let worker = s.get("service")?.as_str()?;
276 (!project_workers.iter().any(|w| w == worker))
277 .then(|| (binding.to_string(), worker.to_string()))
278 })
279 .collect();
280 out.sort();
281 out
282}
283
284pub fn stand_ins(foreign: &[(String, String)], local: &[String]) -> Vec<StandIn> {
290 let mut by_worker: std::collections::BTreeMap<&str, Vec<String>> = Default::default();
291 for (binding, worker) in foreign {
292 if !local.iter().any(|l| l == worker) {
293 by_worker.entry(worker).or_default().push(binding.clone());
294 }
295 }
296 by_worker
297 .into_iter()
298 .map(|(worker, bindings)| StandIn {
299 worker: worker.to_string(),
300 bindings,
301 })
302 .collect()
303}
304
305pub fn stand_in_files(context: &str, stand_in: &StandIn) -> (String, String) {
310 let config = format!(
311 "# Generated by bynk dev — `{context}`'s local stand-in for Worker `{w}`.\nname = {name}\nmain = \"index.js\"\ncompatibility_date = \"{date}\"\n",
312 w = stand_in.worker,
313 name = toml::Value::String(stand_in_name(context, &stand_in.worker)),
314 date = bynk_emit::emitter::wrangler::COMPATIBILITY_DATE,
315 );
316 let body = format!(
317 "{} Run that Worker locally with `wrangler dev` and pass `--local-worker {}` to `bynk dev` to call it instead.",
318 bynk_emit::emitter::wrangler::foreign_service_double_body(
319 &stand_in.bindings.join("`, `"),
320 &stand_in.worker
321 ),
322 stand_in.worker
323 );
324 let script = format!(
325 "// Generated by bynk dev — a local stand-in for Worker `{w}`.\nexport default {{\n async fetch() {{\n return new Response({body}, {{\n status: {status},\n headers: {{ \"content-type\": \"text/plain; charset=utf-8\", \"x-bynk-stand-in\": {name} }},\n }});\n }},\n}};\n",
326 w = stand_in.worker,
327 body = serde_json::Value::String(body),
328 status = bynk_emit::emitter::wrangler::FOREIGN_SERVICE_DOUBLE_STATUS,
329 name = serde_json::Value::String(stand_in.worker.clone()),
330 );
331 (config, script)
332}
333
334fn write_stand_ins(
338 build_dir: &Path,
339 plans: &[(String, Vec<StandIn>)],
340) -> std::io::Result<std::collections::BTreeMap<String, Vec<PathBuf>>> {
341 let root = build_dir.join("stand-ins");
342 match std::fs::remove_dir_all(&root) {
343 Ok(()) => {}
344 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
345 Err(e) => return Err(e),
346 }
347 let mut configs = std::collections::BTreeMap::new();
348 for (context, stand_ins) in plans {
349 for stand_in in stand_ins {
350 let dir = root.join(context).join(&stand_in.worker);
351 std::fs::create_dir_all(&dir)?;
352 let (config, script) = stand_in_files(context, stand_in);
353 std::fs::write(dir.join("wrangler.toml"), config)?;
354 std::fs::write(dir.join("index.js"), script)?;
355 configs
356 .entry(context.clone())
357 .or_insert_with(Vec::new)
358 .push(dir.join("wrangler.toml"));
359 }
360 }
361 Ok(configs)
362}
363
364pub fn stand_in_report(plans: &[(String, Vec<StandIn>)]) -> String {
367 let mut out = String::new();
368 for (context, stand_ins) in plans {
369 for s in stand_ins {
370 out.push_str(&format!(
371 "bynk dev: `{context}` binds Worker `{w}` ({}) to a local stand-in that answers 503; pass `--local-worker {w}` to reach a `wrangler dev` of it instead.\n",
372 s.bindings.join(", "),
373 w = s.worker,
374 ));
375 }
376 }
377 out
378}
379
380const DEFAULT_BASE_PORT: u16 = 8787;
384
385#[derive(Debug, PartialEq, Eq)]
390pub struct Serving {
391 pub worker: String,
392 pub port: Option<u16>,
393 pub inspector_port: Option<u16>,
394 pub inspect: bool,
395}
396
397pub fn run(
401 tb: &dyn Toolbox,
402 compiler: &Compiler,
403 project_root: &Path,
404 node_floor: u32,
405 opts: &DevOptions,
406) -> ExitCode {
407 if opts.wrangler_args.iter().any(|arg| arg == "--remote")
413 && let Some(conflict) = crate::deploy::conflicting_env_passthrough(&opts.wrangler_args)
414 {
415 eprintln!(
416 "bynk: `--env {}` conflicts with `{conflict}` after `--` — pass one or the other, not both",
417 opts.environment
418 );
419 return ExitCode::FAILURE;
420 }
421
422 let ctx = Context {
426 project_root: Some(project_root.to_path_buf()),
427 in_repo: false,
428 node_floor,
429 };
430 let preflight_opts = DoctorOptions {
431 only: Some(Capability::Deploy),
432 strict: false,
433 };
434 let report = doctor::diagnose(tb, compiler, &ctx, &preflight_opts);
435 if report.exit_nonzero(&preflight_opts) {
436 eprint!("{}", preflight_failure_message(&report));
437 return ExitCode::FAILURE;
438 }
439 let build_dir = project_root.join(".bynk").join("dev");
445 if let Err(e) = prepare_build_dir(project_root, &build_dir) {
446 eprintln!("bynk: could not prepare build directory: {e}");
447 return ExitCode::FAILURE;
448 }
449 if !compile_once(compiler, project_root, &build_dir, true) {
454 return ExitCode::FAILURE;
455 }
456
457 let workers_dir = build_dir.join("workers");
463 let available = discover_workers(&workers_dir);
464 let workers = match select_contexts(&available, &opts.contexts) {
465 Ok(w) => w,
466 Err(e) => {
467 eprintln!("bynk: {e}");
468 return ExitCode::FAILURE;
469 }
470 };
471 let serving = allocate(&workers, opts.base_port, opts);
472 let edge_keys = match crate::deploy::local_edge_keys(&workers_dir, &available) {
476 Ok(keys) => keys,
477 Err(e) => {
478 eprintln!("bynk: {e}");
479 return ExitCode::FAILURE;
480 }
481 };
482
483 for (flag, owner, injected) in [
488 (
489 "--port",
490 "--base-port",
491 serving.iter().any(|s| s.port.is_some()),
492 ),
493 (
494 "--inspector-port",
495 "--inspect-port",
496 serving.iter().any(|s| s.inspector_port.is_some()),
497 ),
498 ] {
499 if injected && passthrough_has(&opts.wrangler_args, flag) {
500 eprintln!(
501 "bynk: `{flag}` is allocated per context — pass `{owner}` to `bynk dev` instead of `-- {flag}`."
502 );
503 return ExitCode::FAILURE;
504 }
505 }
506
507 if let Some(port) = port_clash(&serving) {
512 eprintln!(
513 "bynk: port {port} is allocated both to a context's server and to an inspector — \
514 move `--base-port` or `--inspect-port` so the two ranges do not overlap."
515 );
516 return ExitCode::FAILURE;
517 }
518
519 if opts.wrangler_args.iter().any(|arg| arg == "--remote") {
524 for s in &serving {
525 if let Err(e) = crate::deploy::materialise_deploy_state(
526 project_root,
527 &s.worker,
528 &workers_dir.join(&s.worker).join("wrangler.toml"),
529 &opts.environment,
530 ) {
531 eprintln!("bynk: {e}");
532 return ExitCode::FAILURE;
533 }
534 }
535 }
536
537 let remote = opts.wrangler_args.iter().any(|arg| arg == "--remote");
543 let mut stand_in_plans: Vec<(String, Vec<StandIn>)> = Vec::new();
544 let mut bound_foreign: Vec<String> = Vec::new();
545 for s in &serving {
546 let config = std::fs::read_to_string(workers_dir.join(&s.worker).join("wrangler.toml"))
547 .unwrap_or_default();
548 let foreign = foreign_services(&config, &available);
549 bound_foreign.extend(foreign.iter().map(|(_, w)| w.clone()));
550 let planned = stand_ins(&foreign, &opts.local_workers);
551 if !remote && !planned.is_empty() {
552 stand_in_plans.push((s.worker.clone(), planned));
553 }
554 }
555 for local in &opts.local_workers {
556 if !bound_foreign.contains(local) {
557 eprintln!(
558 "bynk: warning: `--local-worker {local}` names no Worker the served contexts bind with a `Service` instance."
559 );
560 }
561 }
562 if !stand_in_plans.is_empty()
563 && (passthrough_has(&opts.wrangler_args, "-c")
564 || passthrough_has(&opts.wrangler_args, "--config"))
565 {
566 eprintln!(
567 "bynk: `-c`/`--config` is the driver's when it runs stand-ins for foreign Workers — pass `--local-worker NAME` instead of a config after `--`."
568 );
569 return ExitCode::FAILURE;
570 }
571 let stand_in_configs = match write_stand_ins(&build_dir, &stand_in_plans) {
572 Ok(configs) => configs,
573 Err(e) => {
574 eprintln!("bynk: could not write the stand-ins for foreign Workers: {e}");
575 return ExitCode::FAILURE;
576 }
577 };
578 if let Err(e) = point_at_stand_ins(&workers_dir, &stand_in_plans) {
579 eprintln!("bynk: could not bind the stand-ins for foreign Workers: {e}");
580 return ExitCode::FAILURE;
581 }
582
583 let probe = probe::detect(
594 tb,
595 "wrangler",
596 DetectOpts {
597 project_root: Some(project_root),
598 allow_npx: true,
599 },
600 );
601 if matches!(probe.provenance, Provenance::Npx) {
602 eprintln!("bynk: wrangler resolved via npx — it will download on first run.");
603 }
604 if let Some(notice) = wrangler_age_notice(&probe) {
605 eprintln!("{notice}");
606 }
607 if matches!(probe.provenance, Provenance::Missing) {
608 eprintln!("bynk: wrangler not found (run `bynk doctor --only deploy`)");
610 return ExitCode::FAILURE;
611 }
612
613 if needs_wrangler_warmup(&probe.provenance, serving.len()) {
619 eprintln!("bynk dev: installing wrangler via npx once before starting the contexts…");
620 let warmed = wrangler_command(&probe.provenance, "--version").map(|mut cmd| {
621 cmd.stdout(std::process::Stdio::null())
622 .status()
623 .is_ok_and(|s| s.success())
624 });
625 if warmed != Some(true) {
626 eprintln!(
628 "bynk: `npx --yes wrangler@4 --version` failed — wrangler could not be installed via npx."
629 );
630 return ExitCode::FAILURE;
631 }
632 }
633
634 crate::stop::install();
646 let mut children: Vec<(String, Served)> = Vec::new();
647 for s in &serving {
648 let Some(mut cmd) = wrangler_command(&probe.provenance, "dev") else {
649 eprintln!("bynk: wrangler not found (run `bynk doctor --only deploy`)");
650 terminate(&mut children, &workers_dir);
651 return ExitCode::FAILURE;
652 };
653 cmd.current_dir(workers_dir.join(&s.worker));
654 if let Some(configs) = stand_in_configs.get(&s.worker) {
657 cmd.args(["-c", STAND_IN_CONFIG]);
658 for config in configs {
659 cmd.arg("-c").arg(config);
660 }
661 }
662 let keys = edge_keys
663 .get(&s.worker)
664 .map(Vec::as_slice)
665 .unwrap_or_default();
666 for arg in serve_args(s, keys) {
667 cmd.arg(arg);
668 }
669 for arg in &opts.wrangler_args {
670 cmd.arg(arg);
671 }
672 if opts.format == DevFormat::Json {
673 cmd.stdout(std::io::stderr());
676 }
677 match cmd.spawn() {
678 Ok(child) => children.push((s.worker.clone(), Served::new(child))),
679 Err(e) => {
680 eprintln!("bynk: could not run wrangler for `{}`: {e}", s.worker);
681 terminate(&mut children, &workers_dir);
682 return ExitCode::FAILURE;
683 }
684 }
685 }
686 eprint!("{}", serving_report(&serving));
687 eprint!("{}", stand_in_report(&stand_in_plans));
688 let serving_now = serving_event(&serving, &opts.wrangler_args);
689 emit(opts.format, &serving_now);
690 if opts.ready_json.is_some()
694 && let Some(why) = crate::ready::unprobeable(&opts.wrangler_args)
695 {
696 eprintln!(
697 "bynk dev: warning: {why}, which the `--ready-json` probe (plain HTTP on localhost) can't reach, so the file may never be written."
698 );
699 }
700 let mut readiness = opts.ready_json.as_ref().map(|file| {
701 let DevEvent::Serving { contexts } = &serving_now else {
702 unreachable!("serving_event builds a Serving event")
703 };
704 crate::ready::Readiness::new(
705 file.clone(),
706 contexts
707 .iter()
708 .map(|c| (c.name.clone(), c.port, c.url.clone())),
709 )
710 });
711
712 eprintln!("bynk dev: watching for source changes (edit `.bynk` files to rebuild)");
721 let mut fingerprint = watch_fingerprint(project_root);
722 let code = supervise(&mut children, &workers_dir, || {
723 if let Some(r) = readiness.as_mut() {
724 if let Some(written) = r.poll(crate::ready::probe) {
725 match written {
726 Ok(()) => eprintln!("bynk dev: every context answers — ready"),
727 Err(e) => eprintln!("bynk: could not write the `--ready-json` file: {e}"),
729 }
730 }
731 if let Some(note) = r.slow_note(std::time::Instant::now()) {
732 eprintln!("{note}");
733 }
734 }
735 let now = watch_fingerprint(project_root);
736 if now != fingerprint {
737 fingerprint = now;
738 eprintln!("bynk dev: change detected — rebuilding…");
739 emit(opts.format, &DevEvent::Rebuilding);
740 if compile_once(compiler, project_root, &build_dir, true) {
741 if let Err(e) = point_at_stand_ins(&workers_dir, &stand_in_plans) {
745 eprintln!("bynk: could not bind the stand-ins for foreign Workers: {e}");
746 }
747 eprintln!("bynk dev: rebuilt");
748 emit(opts.format, &DevEvent::RebuildOk);
749 } else {
750 emit(opts.format, &DevEvent::RebuildFailed);
753 }
754 }
755 });
756 if let Some(r) = &readiness {
757 r.stop();
758 }
759 emit(opts.format, &DevEvent::Stopped { code });
760 ExitCode::from(code)
761}
762
763fn supervise(
767 children: &mut Vec<(String, Served)>,
768 workers_dir: &Path,
769 mut tick: impl FnMut(),
770) -> u8 {
771 loop {
772 if let Some(sig) = crate::stop::received() {
776 eprintln!(
777 "bynk dev: received {} — stopping the contexts.",
778 crate::stop::name(sig)
779 );
780 terminate(children, workers_dir);
781 return crate::stop::exit_byte(sig);
782 }
783 for i in 0..children.len() {
788 let status = match children[i].1.child.try_wait() {
789 Ok(status) => status,
790 Err(e) => {
791 eprintln!("bynk: could not poll wrangler: {e}");
792 terminate(children, workers_dir);
793 return 1;
794 }
795 };
796 if let Some(status) = status {
797 let (name, _) = children.remove(i);
800 if !children.is_empty() {
801 eprintln!("bynk dev: `{name}` exited — stopping the other contexts.");
802 }
803 terminate(children, workers_dir);
804 return exit_status_byte(&status);
805 }
806 }
807 std::thread::sleep(Duration::from_millis(500));
808 tick();
809 }
810}
811
812struct Served {
815 child: std::process::Child,
816 #[cfg(windows)]
820 job: Option<crate::job::Job>,
821}
822
823impl Served {
824 fn new(child: std::process::Child) -> Served {
825 #[cfg(windows)]
826 {
827 let job = match crate::job::Job::assign(&child) {
828 Ok(job) => Some(job),
829 Err(e) => {
830 eprintln!(
831 "bynk dev: could not put wrangler in a job object ({e}); \
832 stopping it may leave its workerd processes running."
833 );
834 None
835 }
836 };
837 Served { child, job }
838 }
839 #[cfg(not(windows))]
840 Served { child }
841 }
842}
843
844fn terminate(children: &mut Vec<(String, Served)>, workers_dir: &Path) {
855 for (_, served) in children.iter_mut() {
856 request_stop(served);
857 }
858 for (_, served) in children.iter_mut() {
859 reap(&mut served.child);
860 }
861 children.clear();
862 crate::sweep::sweep(workers_dir, STOP_GRACE);
863}
864
865fn request_stop(served: &mut Served) {
877 #[cfg(windows)]
878 if let Some(job) = &served.job {
879 job.terminate();
880 return;
881 }
882 let child = &mut served.child;
883 #[cfg(unix)]
884 {
885 let sent = std::process::Command::new("kill")
886 .arg("-TERM")
887 .arg(child.id().to_string())
888 .status()
889 .is_ok_and(|s| s.success());
890 if sent {
891 return;
892 }
893 }
895 let _ = child.kill();
896}
897
898const STOP_GRACE: Duration = Duration::from_secs(10);
900
901fn reap(child: &mut std::process::Child) {
906 const TICK: Duration = Duration::from_millis(50);
907 let mut waited = Duration::ZERO;
908 while waited < STOP_GRACE {
909 match child.try_wait() {
910 Ok(Some(_)) => return,
911 Ok(None) => {}
912 Err(_) => break,
913 }
914 std::thread::sleep(TICK);
915 waited += TICK;
916 }
917 let _ = child.kill();
918 let _ = child.wait();
919}
920
921fn watch_fingerprint(project_root: &Path) -> u64 {
930 use std::hash::{Hash, Hasher};
931 let paths = try_read_project_paths(project_root)
938 .unwrap_or_else(|_| ProjectPaths::conventional(project_root));
939 let excludes: Vec<PathBuf> = paths.exclude.iter().map(|e| project_root.join(e)).collect();
940 let mut entries: Vec<(PathBuf, std::time::SystemTime, u64)> = Vec::new();
941 let record = |path: &Path, entries: &mut Vec<(PathBuf, std::time::SystemTime, u64)>| {
942 if let Ok(meta) = std::fs::metadata(path)
943 && let Ok(mtime) = meta.modified()
944 {
945 entries.push((path.to_path_buf(), mtime, meta.len()));
946 }
947 };
948 record(&project_root.join("bynk.toml"), &mut entries);
949 for root in &paths.include {
950 collect_bynk_files(&project_root.join(root), &excludes, &mut |p| {
951 record(p, &mut entries)
952 });
953 }
954 if let Ok(assets) = bynk_emit::project::assets::list_asset_files(project_root) {
955 for rel in assets {
956 record(&project_root.join(rel), &mut entries);
957 }
958 }
959 if let Ok(embeds) = bynk_emit::project::assets::list_embed_files(project_root) {
962 for rel in embeds {
963 record(&project_root.join(rel), &mut entries);
964 }
965 }
966 entries.sort();
967 let mut hasher = std::hash::DefaultHasher::new();
968 for (path, mtime, len) in &entries {
969 path.hash(&mut hasher);
970 mtime.hash(&mut hasher);
971 len.hash(&mut hasher);
972 }
973 hasher.finish()
974}
975
976#[cfg(test)]
977mod watch_tests {
978 use super::*;
979
980 #[test]
983 fn an_embed_edit_changes_the_watch_fingerprint() {
984 let root =
985 std::env::temp_dir().join(format!("bynk-dev-watch-embed-{}", std::process::id()));
986 let _ = std::fs::remove_dir_all(&root);
987 std::fs::create_dir_all(root.join("src")).unwrap();
988 std::fs::create_dir_all(root.join("embeds")).unwrap();
989 std::fs::write(root.join("src/a.bynk"), "commons a\n").unwrap();
990 std::fs::write(root.join("embeds/page.html"), "<p>").unwrap();
991 let before = watch_fingerprint(&root);
992 std::fs::write(root.join("embeds/page.html"), "<p>changed").unwrap();
993 let after = watch_fingerprint(&root);
994 let _ = std::fs::remove_dir_all(&root);
995 assert_ne!(before, after, "an embed edit must trigger a rebuild");
996 }
997
998 #[test]
1001 fn an_asset_edit_changes_the_watch_fingerprint() {
1002 let root = std::env::temp_dir().join(format!("bynk-dev-watch-{}", std::process::id()));
1003 let _ = std::fs::remove_dir_all(&root);
1004 std::fs::create_dir_all(root.join("src")).unwrap();
1005 std::fs::create_dir_all(root.join("assets")).unwrap();
1006 std::fs::write(root.join("src/a.bynk"), "commons a\n").unwrap();
1007 std::fs::write(root.join("assets/board.css"), "a{}").unwrap();
1008 let before = watch_fingerprint(&root);
1009 std::fs::write(root.join("assets/board.css"), "a{color:red}").unwrap();
1010 let after = watch_fingerprint(&root);
1011 let _ = std::fs::remove_dir_all(&root);
1012 assert_ne!(before, after, "an asset edit must trigger a rebuild");
1013 }
1014}
1015
1016fn collect_bynk_files(dir: &Path, excludes: &[PathBuf], visit: &mut dyn FnMut(&Path)) {
1020 const SKIP_DIRS: [&str; 4] = [".bynk", ".git", "node_modules", "target"];
1021 let Ok(read) = std::fs::read_dir(dir) else {
1022 return;
1023 };
1024 for entry in read.flatten() {
1025 let path = entry.path();
1026 if path.is_dir() {
1027 let name = entry.file_name();
1028 if SKIP_DIRS.iter().any(|s| name == *s) {
1029 continue;
1030 }
1031 if excludes.iter().any(|e| path.starts_with(e)) {
1032 continue;
1033 }
1034 collect_bynk_files(&path, excludes, visit);
1035 } else if path.extension().is_some_and(|e| e == "bynk") {
1036 visit(&path);
1037 }
1038 }
1039}
1040
1041pub fn wrangler_age_notice(probe: &probe::Probe) -> Option<String> {
1048 let v = probe
1049 .version
1050 .filter(|_| doctor::wrangler_below_min(probe))?;
1051 Some(format!(
1052 "bynk: warning: wrangler {v} is older than {}, the first whose runtime serves \
1053 compatibility date {}; `wrangler dev` will refuse it. Upgrade with \
1054 `{}`.",
1055 bynk_emit::WRANGLER_MIN,
1056 bynk_emit::COMPATIBILITY_DATE,
1057 doctor::wrangler_upgrade_remedy(probe)
1058 ))
1059}
1060
1061pub fn preflight_failure_message(report: &Report) -> String {
1066 format!(
1067 "bynk: environment not ready for `dev` — see below.\n\n{}",
1068 report::render(report, Format::Human)
1069 )
1070}
1071
1072pub fn allocate(workers: &[String], base_port: Option<u16>, opts: &DevOptions) -> Vec<Serving> {
1089 let lone = workers.len() == 1 && base_port.is_none();
1090 let base = base_port.unwrap_or(DEFAULT_BASE_PORT);
1091 let inspectors = opts.inspect || workers.len() > 1;
1092 workers
1093 .iter()
1094 .enumerate()
1095 .map(|(i, worker)| Serving {
1096 worker: worker.clone(),
1097 port: (!lone).then(|| base.saturating_add(i as u16)),
1098 inspector_port: inspectors.then(|| opts.inspect_port.saturating_add(i as u16)),
1099 inspect: opts.inspect,
1100 })
1101 .collect()
1102}
1103
1104fn port_clash(serving: &[Serving]) -> Option<u16> {
1107 serving
1108 .iter()
1109 .filter_map(|s| s.port)
1110 .find(|p| serving.iter().any(|s| s.inspector_port == Some(*p)))
1111}
1112
1113pub fn needs_wrangler_warmup(provenance: &Provenance, contexts: usize) -> bool {
1117 matches!(provenance, Provenance::Npx) && contexts > 1
1118}
1119
1120fn passthrough_has(args: &[String], flag: &str) -> bool {
1125 args.iter()
1126 .any(|a| a == flag || a.starts_with(&format!("{flag}=")))
1127}
1128
1129fn serve_args(s: &Serving, edge_keys: &[String]) -> Vec<String> {
1144 let mut args = Vec::new();
1145 for key in edge_keys {
1146 args.push("--var".to_string());
1147 args.push(format!("{key}:{}", bynk_emit::LOCAL_CALL_KEY));
1148 }
1149 if let Some(port) = s.port {
1150 args.push("--port".to_string());
1151 args.push(port.to_string());
1152 }
1153 if let Some(port) = s.inspector_port {
1154 args.push("--inspector-port".to_string());
1155 args.push(port.to_string());
1156 }
1157 args
1158}
1159
1160pub fn serving_report(serving: &[Serving]) -> String {
1168 let mut out = String::new();
1169 if serving.iter().any(|s| s.port.is_some()) {
1170 out.push_str(&match serving.len() {
1175 1 => "bynk dev: serving 1 context.\n".to_string(),
1176 n => format!(
1177 "bynk dev: serving {n} contexts — service bindings between them are wired.\n"
1178 ),
1179 });
1180 let width = serving.iter().map(|s| s.worker.len()).max().unwrap_or(0);
1181 for s in serving {
1182 let Some(port) = s.port else { continue };
1183 out.push_str(&format!(
1184 " {:width$} http://localhost:{port}\n",
1185 s.worker,
1186 width = width
1187 ));
1188 }
1189 }
1190 let inspected = serving
1194 .iter()
1195 .filter(|s| s.inspect && s.inspector_port.is_some())
1196 .count();
1197 if inspected > 0 {
1198 out.push_str(&match inspected {
1199 1 => "bynk dev --inspect: the worker runs with the V8 inspector enabled.\n".to_string(),
1200 _ => "bynk dev --inspect: each worker runs with the V8 inspector enabled, on its own port.\n"
1201 .to_string(),
1202 });
1203 for s in serving {
1204 let (true, Some(port)) = (s.inspect, s.inspector_port) else {
1205 continue;
1206 };
1207 out.push_str(&format!(
1208 " {} — inspector on port {port} (CDP discovery: http://127.0.0.1:{port}/json)\n",
1209 s.worker
1210 ));
1211 }
1212 out.push_str(
1213 " Breakpoints set in `.bynk` sources resolve through the emitted source maps.\n\
1214 \x20 A hand-rolled CDP client must send an `Origin` header — VS Code's\n\
1215 \x20 JavaScript debugger does this for you.\n",
1216 );
1217 }
1218 out
1219}
1220
1221#[cfg(test)]
1222mod tests {
1223 use super::*;
1224
1225 fn names(v: &[&str]) -> Vec<String> {
1226 v.iter().map(|s| s.to_string()).collect()
1227 }
1228
1229 #[test]
1230 fn a_sole_context_is_selected_without_a_flag() {
1231 assert_eq!(
1232 select_contexts(&names(&["links"]), &[]),
1233 Ok(names(&["links"]))
1234 );
1235 }
1236
1237 #[test]
1238 fn no_workers_is_its_own_error() {
1239 assert_eq!(select_contexts(&[], &[]), Err(SelectError::NoneBuilt));
1240 }
1241
1242 #[test]
1243 fn exit_status_byte_maps_codes_and_signals() {
1244 #[cfg(unix)]
1245 {
1246 use std::os::unix::process::ExitStatusExt;
1247 use std::process::ExitStatus;
1248 assert_eq!(exit_status_byte(&ExitStatus::from_raw(0)), 0);
1251 assert_eq!(exit_status_byte(&ExitStatus::from_raw(1 << 8)), 1);
1252 assert_eq!(exit_status_byte(&ExitStatus::from_raw(2)), 0);
1254 assert_eq!(exit_status_byte(&ExitStatus::from_raw(11)), 128 + 11);
1257 assert_eq!(exit_status_byte(&ExitStatus::from_raw(9)), 128 + 9);
1258 }
1259 }
1260
1261 #[test]
1263 fn every_edge_key_is_passed_as_the_local_key() {
1264 let serving = allocate(&names(&["api"]), None, &DevOptions::default());
1265 assert_eq!(
1266 serve_args(
1267 &serving[0],
1268 &names(&["BYNK_CALL_FROM_WORKER", "BYNK_CALL_TO_WORKER"])
1269 ),
1270 names(&[
1271 "--var",
1272 "BYNK_CALL_FROM_WORKER:bynk-local-call-key",
1273 "--var",
1274 "BYNK_CALL_TO_WORKER:bynk-local-call-key",
1275 ])
1276 );
1277 }
1278
1279 #[test]
1280 fn inspect_injects_the_inspector_port() {
1281 let off = DevOptions::default();
1282 let lone = allocate(&names(&["links"]), None, &off);
1283 assert_eq!(
1284 serve_args(&lone[0], &[]),
1285 Vec::<String>::new(),
1286 "a lone worker without --inspect keeps the pre-#552 invocation"
1287 );
1288
1289 let on = DevOptions {
1290 inspect: true,
1291 inspect_port: 9229,
1292 ..Default::default()
1293 };
1294 let lone = allocate(&names(&["links"]), None, &on);
1295 assert_eq!(
1296 serve_args(&lone[0], &[]),
1297 names(&["--inspector-port", "9229"])
1298 );
1299 }
1300
1301 #[test]
1304 fn no_context_flag_serves_every_context() {
1305 assert_eq!(
1308 select_contexts(&names(&["api", "worker"]), &[]),
1309 Ok(names(&["api", "worker"]))
1310 );
1311 }
1312
1313 #[test]
1314 fn context_flags_narrow_to_a_subset() {
1315 let avail = names(&["api", "commerce-payment", "worker"]);
1316 assert_eq!(
1317 select_contexts(&avail, &names(&["worker", "api"])),
1318 Ok(names(&["api", "worker"])),
1319 "the subset is served in the deterministic order, not the typed one"
1320 );
1321 assert_eq!(
1323 select_contexts(&avail, &names(&["commerce.payment", "commerce-payment"])),
1324 Ok(names(&["commerce-payment"]))
1325 );
1326 }
1327
1328 #[test]
1329 fn selecting_many_reports_an_unknown_context() {
1330 assert_eq!(
1331 select_contexts(&names(&["api"]), &names(&["api", "nope"])),
1332 Err(SelectError::NotFound {
1333 requested: "nope".to_string(),
1334 available: names(&["api"]),
1335 })
1336 );
1337 }
1338
1339 #[test]
1340 fn selecting_many_from_an_empty_build_is_still_none_built() {
1341 assert_eq!(select_contexts(&[], &[]), Err(SelectError::NoneBuilt));
1342 }
1343
1344 #[test]
1347 fn ports_are_allocated_per_context_from_the_base() {
1348 let opts = DevOptions {
1349 inspect: true,
1350 inspect_port: 9229,
1351 ..Default::default()
1352 };
1353 let serving = allocate(&names(&["api", "worker"]), None, &opts);
1354 assert_eq!(
1355 serving.iter().map(|s| s.port).collect::<Vec<_>>(),
1356 vec![Some(8787), Some(8788)],
1357 "each wrangler dev binds its own port"
1358 );
1359 assert_eq!(
1360 serving.iter().map(|s| s.inspector_port).collect::<Vec<_>>(),
1361 vec![Some(9229), Some(9230)],
1362 "inspector ports must not collide either"
1363 );
1364 assert_eq!(
1365 serve_args(&serving[1], &[]),
1366 names(&["--port", "8788", "--inspector-port", "9230"])
1367 );
1368 }
1369
1370 #[test]
1371 fn base_port_moves_the_whole_allocation() {
1372 let serving = allocate(&names(&["a", "b"]), Some(9000), &DevOptions::default());
1373 assert_eq!(
1374 serving.iter().map(|s| s.port).collect::<Vec<_>>(),
1375 vec![Some(9000), Some(9001)]
1376 );
1377 }
1378
1379 #[test]
1380 fn a_lone_worker_keeps_wranglers_own_port() {
1381 let serving = allocate(&names(&["links"]), None, &DevOptions::default());
1384 assert_eq!(serving[0].port, None);
1385 assert_eq!(serve_args(&serving[0], &[]), Vec::<String>::new());
1386 let pinned = allocate(&names(&["links"]), Some(8900), &DevOptions::default());
1388 assert_eq!(pinned[0].port, Some(8900));
1389 }
1390
1391 #[test]
1392 fn several_contexts_get_distinct_inspector_ports_without_inspect() {
1393 let plain = DevOptions {
1396 inspect_port: 9229, ..Default::default()
1398 };
1399 let serving = allocate(&names(&["api", "worker"]), None, &plain);
1400 assert_eq!(
1401 serving.iter().map(|s| s.inspector_port).collect::<Vec<_>>(),
1402 vec![Some(9229), Some(9230)]
1403 );
1404 assert_eq!(
1405 serve_args(&serving[1], &[]),
1406 names(&["--port", "8788", "--inspector-port", "9230"])
1407 );
1408 let moved = DevOptions {
1410 inspect_port: 9300,
1411 ..Default::default()
1412 };
1413 let serving = allocate(&names(&["api", "worker"]), None, &moved);
1414 assert_eq!(
1415 serving.iter().map(|s| s.inspector_port).collect::<Vec<_>>(),
1416 vec![Some(9300), Some(9301)]
1417 );
1418 let lone = allocate(&names(&["links"]), Some(8900), &DevOptions::default());
1420 assert_eq!(lone[0].inspector_port, None);
1421 }
1422
1423 #[test]
1424 fn an_allocated_inspector_is_announced_only_under_inspect() {
1425 let report = serving_report(&allocate(
1426 &names(&["api", "worker"]),
1427 None,
1428 &DevOptions::default(),
1429 ));
1430 assert!(!report.contains("inspector"), "{report}");
1431 }
1432
1433 #[test]
1434 fn overlapping_http_and_inspector_ranges_are_caught() {
1435 let opts = DevOptions {
1436 inspect_port: 9229,
1437 ..Default::default()
1438 };
1439 let two = names(&["api", "worker"]);
1440 assert_eq!(port_clash(&allocate(&two, Some(9228), &opts)), Some(9229));
1442 assert_eq!(port_clash(&allocate(&two, None, &opts)), None);
1443 assert_eq!(port_clash(&allocate(&names(&["links"]), None, &opts)), None);
1445 }
1446
1447 #[test]
1448 fn npx_is_warmed_up_only_for_several_contexts() {
1449 assert!(needs_wrangler_warmup(&Provenance::Npx, 2));
1451 assert!(!needs_wrangler_warmup(&Provenance::Npx, 1));
1452 let installed = Provenance::Path(PathBuf::from("/usr/bin/wrangler"));
1453 assert!(!needs_wrangler_warmup(&installed, 2));
1454 }
1455
1456 #[cfg(unix)]
1461 #[test]
1462 fn a_sigterm_to_the_driver_stops_every_context_and_its_children() {
1463 use std::process::{Command, Stdio};
1464 let _lock = crate::stop::TEST_LOCK
1467 .lock()
1468 .unwrap_or_else(|e| e.into_inner());
1469 let root = std::env::temp_dir().join(format!("bynk-dev-sigterm-{}", std::process::id()));
1470 let _ = std::fs::remove_dir_all(&root);
1471 let mut children = Vec::new();
1472 for ctx in ["api", "worker"] {
1473 let dir = root.join(ctx);
1474 std::fs::create_dir_all(&dir).unwrap();
1475 let child = Command::new("sh")
1476 .args(["-c", "sleep 300 & sleep 300 & wait"])
1477 .current_dir(&dir)
1478 .stdin(Stdio::null())
1479 .stdout(Stdio::null())
1480 .stderr(Stdio::null())
1481 .spawn()
1482 .expect("sh spawns");
1483 children.push((ctx.to_string(), Served::new(child)));
1484 }
1485 let started = std::time::Instant::now();
1486 while crate::sweep::stragglers(&root).len() < 6 {
1487 assert!(
1488 started.elapsed() < Duration::from_secs(10),
1489 "the stand-ins never started their sleeps"
1490 );
1491 std::thread::sleep(Duration::from_millis(20));
1492 }
1493
1494 crate::stop::install();
1497 let sent = Command::new("kill")
1498 .args(["-TERM", &std::process::id().to_string()])
1499 .status()
1500 .is_ok_and(|s| s.success());
1501 assert!(sent, "kill(1) runs");
1502 let started = std::time::Instant::now();
1503 while crate::stop::received().is_none() {
1504 assert!(
1505 started.elapsed() < Duration::from_secs(10),
1506 "the SIGTERM was never recorded"
1507 );
1508 std::thread::sleep(Duration::from_millis(10));
1509 }
1510
1511 let code = supervise(&mut children, &root, || {
1512 panic!("a recorded stop must end the session before any rebuild")
1513 });
1514 crate::stop::reset();
1515 let left = crate::sweep::stragglers(&root);
1516 let _ = std::fs::remove_dir_all(&root);
1517 assert_eq!(code, 143, "a SIGTERM stop exits 128 + 15");
1518 assert!(children.is_empty());
1519 assert!(left.is_empty(), "processes survived the stop: {left:?}");
1520 }
1521
1522 #[test]
1523 fn passthrough_port_is_detected_in_both_spellings() {
1524 assert!(passthrough_has(&names(&["--port", "8788"]), "--port"));
1525 assert!(passthrough_has(&names(&["--port=8788"]), "--port"));
1526 assert!(!passthrough_has(&names(&["--remote"]), "--port"));
1527 assert!(!passthrough_has(
1529 &names(&["--inspector-port", "9229"]),
1530 "--port"
1531 ));
1532 }
1533
1534 #[test]
1535 fn the_serving_report_lists_context_urls() {
1536 let serving = allocate(
1537 &names(&["commerce-orders", "commerce-payment"]),
1538 None,
1539 &DevOptions::default(),
1540 );
1541 let report = serving_report(&serving);
1542 assert!(report.contains("serving 2 contexts"), "{report}");
1543 assert!(
1545 report.contains("commerce-orders http://localhost:8787"),
1546 "{report}"
1547 );
1548 assert!(
1549 report.contains("commerce-payment http://localhost:8788"),
1550 "{report}"
1551 );
1552 assert_eq!(
1555 serving_report(&allocate(&names(&["links"]), None, &DevOptions::default())),
1556 ""
1557 );
1558 }
1559
1560 #[test]
1561 fn the_serving_event_reports_every_port() {
1562 let serving = allocate(
1563 &names(&["commerce-orders", "commerce-payment"]),
1564 None,
1565 &DevOptions {
1566 inspect_port: 9229,
1567 ..Default::default()
1568 },
1569 );
1570 assert_eq!(
1571 event_line(&serving_event(&serving, &[])),
1572 r#"{"event":"serving","contexts":[{"name":"commerce-orders","port":8787,"url":"http://localhost:8787","inspector_port":9229},{"name":"commerce-payment","port":8788,"url":"http://localhost:8788","inspector_port":9230}]}"#
1573 );
1574 let lone = allocate(&names(&["links"]), None, &DevOptions::default());
1577 let port = |args: &[&str]| match serving_event(&lone, &names(args)) {
1578 DevEvent::Serving { contexts } => (contexts[0].port, contexts[0].inspector_port),
1579 other => panic!("{other:?}"),
1580 };
1581 assert_eq!(port(&[]), (8787, None));
1582 assert_eq!(port(&["--port", "9000"]), (9000, None));
1583 assert_eq!(port(&["--remote", "--port=9001"]), (9001, None));
1584 }
1585
1586 #[test]
1587 fn lifecycle_events_are_tagged_lines() {
1588 assert_eq!(
1589 event_line(&DevEvent::Rebuilding),
1590 r#"{"event":"rebuilding"}"#
1591 );
1592 assert_eq!(
1593 event_line(&DevEvent::RebuildOk),
1594 r#"{"event":"rebuild_ok"}"#
1595 );
1596 assert_eq!(
1597 event_line(&DevEvent::RebuildFailed),
1598 r#"{"event":"rebuild_failed"}"#
1599 );
1600 assert_eq!(
1601 event_line(&DevEvent::Stopped { code: 130 }),
1602 r#"{"event":"stopped","code":130}"#
1603 );
1604 }
1605
1606 #[test]
1607 fn a_subset_of_one_claims_no_wiring() {
1608 let report = serving_report(&allocate(
1612 &names(&["commerce-payment"]),
1613 Some(8890),
1614 &DevOptions::default(),
1615 ));
1616 assert!(report.contains("serving 1 context."), "{report}");
1617 assert!(!report.contains("bindings"), "{report}");
1618 }
1619
1620 const BILLING_CONFIG: &str = "name = \"shop-billing\"\n\n[[services]]\nbinding = \"SHOP_FRONT\"\nservice = \"shop-front\"\n\n[[services]]\nbinding = \"SERVICE_LEDGER\"\nservice = \"ledger\"\n\n[[services]]\nbinding = \"SERVICE_BILLING\"\nservice = \"legacy-billing\"\n\n[[services]]\nbinding = \"SERVICE_INVOICES\"\nservice = \"legacy-billing\"\n";
1623
1624 fn pairs(v: &[(&str, &str)]) -> Vec<(String, String)> {
1625 v.iter()
1626 .map(|(a, b)| (a.to_string(), b.to_string()))
1627 .collect()
1628 }
1629
1630 #[test]
1631 fn foreign_services_are_the_bindings_to_workers_outside_the_build() {
1632 assert_eq!(
1633 foreign_services(BILLING_CONFIG, &names(&["shop-billing", "shop-front"])),
1634 pairs(&[
1635 ("SERVICE_BILLING", "legacy-billing"),
1636 ("SERVICE_INVOICES", "legacy-billing"),
1637 ("SERVICE_LEDGER", "ledger"),
1638 ])
1639 );
1640 assert!(foreign_services("not toml [", &[]).is_empty());
1641 }
1642
1643 #[test]
1646 fn stand_ins_are_one_per_foreign_worker_less_the_local_ones() {
1647 let foreign = foreign_services(BILLING_CONFIG, &names(&["shop-front"]));
1648 assert_eq!(
1649 stand_ins(&foreign, &[]),
1650 [
1651 StandIn {
1652 worker: "ledger".into(),
1653 bindings: names(&["SERVICE_LEDGER"]),
1654 },
1655 StandIn {
1656 worker: "legacy-billing".into(),
1657 bindings: names(&["SERVICE_BILLING", "SERVICE_INVOICES"]),
1658 },
1659 ]
1660 );
1661 assert_eq!(
1662 stand_ins(&foreign, &names(&["legacy-billing"])),
1663 [StandIn {
1664 worker: "ledger".into(),
1665 bindings: names(&["SERVICE_LEDGER"]),
1666 }]
1667 );
1668 }
1669
1670 #[test]
1674 fn a_stand_in_is_named_for_its_worker_and_answers_503() {
1675 let (config, script) = stand_in_files(
1676 "shop-billing",
1677 &StandIn {
1678 worker: "legacy-billing".into(),
1679 bindings: names(&["SERVICE_BILLING"]),
1680 },
1681 );
1682 let doc: toml::Table = config.parse().expect("valid TOML");
1683 assert_eq!(
1684 doc["name"].as_str(),
1685 Some("bynk-stand-in-shop-billing-legacy-billing")
1686 );
1687 assert_eq!(doc["main"].as_str(), Some("index.js"));
1688 assert_eq!(
1689 doc["compatibility_date"].as_str(),
1690 Some(bynk_emit::emitter::wrangler::COMPATIBILITY_DATE)
1691 );
1692 assert!(script.contains("status: 503"), "{script}");
1693 assert!(
1694 script.contains("`--local-worker legacy-billing`"),
1695 "{script}"
1696 );
1697 assert!(script.contains("`SERVICE_BILLING`"), "{script}");
1698 }
1699
1700 #[test]
1701 fn a_stand_in_name_fits_a_worker_name() {
1702 assert_eq!(
1703 stand_in_name("shop-billing", "legacy-billing"),
1704 "bynk-stand-in-shop-billing-legacy-billing"
1705 );
1706 let long_context = "commerce-fulfilment-warehouse-allocation";
1707 let a = stand_in_name(long_context, "legacy-billing-reconciliation");
1708 let b = stand_in_name(long_context, "legacy-billing-reconciliation-eu");
1709 assert!(a.len() <= 63 && b.len() <= 63, "{a} / {b}");
1710 assert_ne!(a, b);
1711 assert_eq!(
1712 a,
1713 stand_in_name(long_context, "legacy-billing-reconciliation")
1714 );
1715 assert!(!a.contains("--"), "{a}");
1716 }
1717
1718 #[test]
1721 fn the_stand_in_config_is_a_retargeted_copy() {
1722 let dir = std::env::temp_dir().join(format!("bynk-stand-in-copy-{}", std::process::id()));
1723 let ctx = dir.join("shop-billing");
1724 std::fs::create_dir_all(&ctx).unwrap();
1725 let generated = "name = \"shop-billing\"\n\n[[services]]\nbinding = \"SERVICE_BILLING\"\nservice = \"legacy-billing\"\n";
1726 std::fs::write(ctx.join("wrangler.toml"), generated).unwrap();
1727 let plans = vec![(
1728 "shop-billing".to_string(),
1729 vec![StandIn {
1730 worker: "legacy-billing".into(),
1731 bindings: names(&["SERVICE_BILLING"]),
1732 }],
1733 )];
1734 point_at_stand_ins(&dir, &plans).unwrap();
1735 assert_eq!(
1736 std::fs::read_to_string(ctx.join("wrangler.toml")).unwrap(),
1737 generated
1738 );
1739 let served = std::fs::read_to_string(ctx.join(STAND_IN_CONFIG)).unwrap();
1740 assert!(
1741 served.contains("service = \"bynk-stand-in-shop-billing-legacy-billing\""),
1742 "{served}"
1743 );
1744 assert!(!ctx.join(format!("{STAND_IN_CONFIG}.tmp")).exists());
1745 let _ = std::fs::remove_dir_all(&dir);
1746 }
1747
1748 #[test]
1749 fn the_stand_in_report_names_each_and_the_way_out() {
1750 assert_eq!(stand_in_report(&[]), "");
1751 let report = stand_in_report(&[(
1752 "shop-billing".into(),
1753 vec![StandIn {
1754 worker: "legacy-billing".into(),
1755 bindings: names(&["SERVICE_BILLING"]),
1756 }],
1757 )]);
1758 assert_eq!(
1759 report,
1760 "bynk dev: `shop-billing` binds Worker `legacy-billing` (SERVICE_BILLING) to a local stand-in that answers 503; pass `--local-worker legacy-billing` to reach a `wrangler dev` of it instead.\n"
1761 );
1762 }
1763}