bynk/dev.rs
1//! `bynk dev` — build a project and serve it locally in one step.
2//!
3//! Collapses the manual recipe (compile → `cd` into the generated worker dir →
4//! `wrangler dev`) into a single command (proposal v0.57). The orchestration is
5//! **pre-flight → compile → select → serve**, and almost every piece is reused:
6//! [`compiler::resolve`](crate::compiler) for `bynkc`, the doctor `Deploy`
7//! capability for the Node + `wrangler` gate, and [`probe`] for locating
8//! `wrangler` with the same provenance ordering doctor reports.
9//!
10//! The serve step runs `wrangler dev` in **local mode** (Miniflare), which
11//! simulates KV / Durable Objects / queues keyed by *binding name* — so no
12//! namespace provisioning is needed and the generated `wrangler.toml` is served
13//! untouched (proposal §1, D4). Everything `wrangler`-specific is encapsulated
14//! here so the serve step can later be swapped for a first-party `workerd`
15//! server without touching the rest (proposal §4).
16//!
17//! Since #552 the step is **one `wrangler dev` per context**, not one per
18//! project: the processes discover each other through wrangler's dev registry
19//! and wire the emitted `[[services]]` bindings between themselves, so a
20//! cross-context call resolves locally. That makes the driver a supervisor of N
21//! children rather than a hand-off to one — hence the port allocation
22//! ([`allocate`]), the joint teardown (`terminate`, private), and the plural
23//! selection rule ([`select_contexts`]) that replaced ADR 0096 D3's ambiguity
24//! error.
25
26use 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
38// The build/select/`wrangler` plumbing `dev` shares with `deploy` now lives in
39// [`crate::workers`] (it is neither command's to own). Re-exported rather than
40// merely imported, so `bynk::dev::compile_once` and `bynk::dev::select_contexts`
41// — the paths this crate's integration tests already use — keep resolving.
42pub use crate::workers::{
43 SelectError, compile_once, discover_workers, prepare_build_dir, select_contexts,
44 wrangler_command,
45};
46
47/// Parsed `bynk dev` flags (the project `PATH` is resolved into `project_root`
48/// before we get here).
49#[derive(Debug, Clone, Default)]
50pub struct DevOptions {
51 /// `--context NAME`, repeatable — which contexts' workers to serve. Empty
52 /// serves **every** context in the project, wired (ADR 0096 D3 superseded).
53 pub contexts: Vec<String>,
54 /// `--base-port N` — the first port of the per-context allocation. `None`
55 /// leaves a lone worker on wrangler's own default, so `-- --port N` keeps
56 /// working exactly as it did when `dev` served one context.
57 pub base_port: Option<u16>,
58 /// `--inspect` (slice 3): start `wrangler dev` with the V8 inspector so a
59 /// JavaScript debugger can attach; breakpoints in `.bynk` resolve through the
60 /// emitted source maps composed into the worker bundle.
61 pub inspect: bool,
62 /// Base inspector port for `--inspect` (default 9229); allocated per context
63 /// exactly as `base_port` is.
64 pub inspect_port: u16,
65 /// `--env NAME` (default `"default"`) — which `bynk.deploy.lock` section
66 /// `--remote` reads the KV id from. `dev` never provisions and never
67 /// writes the ledger (unchanged); this only selects which of `deploy`'s
68 /// environments `--remote` connects the placeholder to. Purely a `bynk`
69 /// concept — never forwarded to `wrangler dev` itself, since `dev` curates
70 /// no Wrangler-side environment config (slice 4, #837 review: a project
71 /// deployed only under a non-default `--env` previously read as
72 /// "never provisioned" here, because this always looked at `"default"`).
73 pub environment: String,
74 /// Everything after `--`, forwarded to `wrangler dev` verbatim (D5).
75 pub wrangler_args: Vec<String>,
76}
77
78/// Wrangler's own default dev port — the base of the per-context allocation
79/// when `--base-port` is not given, so a multi-context project's first worker
80/// lands where a single-context one always has.
81const DEFAULT_BASE_PORT: u16 = 8787;
82
83/// One worker to serve: its dasherised context dir, its HTTP port (`None` = let
84/// wrangler choose, the lone-worker default), and its inspector port.
85#[derive(Debug, PartialEq, Eq)]
86pub struct Serving {
87 pub worker: String,
88 pub port: Option<u16>,
89 pub inspector_port: Option<u16>,
90}
91
92/// Orchestrate a local dev session: pre-flight, compile, select the worker, and
93/// hand off to `wrangler dev`. Returns wrangler's own exit code on a clean
94/// hand-off, or a pre-flight/build failure code before serving.
95pub fn run(
96 tb: &dyn Toolbox,
97 compiler: &Compiler,
98 project_root: &Path,
99 node_floor: u32,
100 opts: &DevOptions,
101) -> ExitCode {
102 // #837 review: once `--remote` reads a ledger section by `--env`, a
103 // `-- --env`/`-- --environment` passthrough would silently diverge —
104 // `bynk` materialises one environment's KV id while Wrangler actually
105 // connects to a different one. Checked only when `--remote` is present,
106 // since `--env` is otherwise inert (nothing reads the ledger without it).
107 if opts.wrangler_args.iter().any(|arg| arg == "--remote")
108 && let Some(conflict) = crate::deploy::conflicting_env_passthrough(&opts.wrangler_args)
109 {
110 eprintln!(
111 "bynk: `--env {}` conflicts with `{conflict}` after `--` — pass one or the other, not both",
112 opts.environment
113 );
114 return ExitCode::FAILURE;
115 }
116
117 // 1. Pre-flight — reuse doctor's Deploy gate (Node + wrangler) plus the
118 // always-on compile floor. Failing here, with doctor's remedy text, beats
119 // a confusing error out of a half-built tree (proposal §2.2).
120 let ctx = Context {
121 project_root: Some(project_root.to_path_buf()),
122 in_repo: false,
123 node_floor,
124 };
125 let preflight_opts = DoctorOptions {
126 only: Some(Capability::Deploy),
127 strict: false,
128 };
129 let report = doctor::diagnose(tb, compiler, &ctx, &preflight_opts);
130 if report.exit_nonzero(&preflight_opts) {
131 eprint!("{}", preflight_failure_message(&report));
132 return ExitCode::FAILURE;
133 }
134 // 2. Compile — in-process (slice 7: the driver links the pipeline instead of
135 // shelling `bynkc`). Into the managed `.bynk/dev/` build dir (D1).
136 // Compilation is additive (never prunes), so clear `workers/` first;
137 // otherwise a renamed/deleted context would linger and spuriously trip the
138 // §2.4 ambiguity check.
139 let build_dir = project_root.join(".bynk").join("dev");
140 if let Err(e) = prepare_build_dir(project_root, &build_dir) {
141 eprintln!("bynk: could not prepare build directory: {e}");
142 return ExitCode::FAILURE;
143 }
144 // #524: compile the SAME project shape as `bynkc compile <project_root>`
145 // — the shared rooting rule over the full `[paths]` layout. `dev`
146 // previously re-rooted on the first `include` entry only, silently
147 // dropping further includes and the whole `exclude` list.
148 if !compile_once(compiler, project_root, &build_dir, true) {
149 return ExitCode::FAILURE;
150 }
151
152 // 3. Select the workers — every context by default, or the `--context`
153 // subset (#552, superseding ADR 0096 D3's select-or-default). Serving
154 // them *together* is the whole point: a cross-context call only resolves
155 // when its callee is up too, so an ambiguity error here was the feature
156 // being withheld, not a project being wrong.
157 let workers_dir = build_dir.join("workers");
158 let available = discover_workers(&workers_dir);
159 let workers = match select_contexts(&available, &opts.contexts) {
160 Ok(w) => w,
161 Err(e) => {
162 eprintln!("bynk: {e}");
163 return ExitCode::FAILURE;
164 }
165 };
166 let serving = allocate(&workers, opts.base_port, opts);
167
168 // Where the driver injects a port it owns the allocation, so the same flag
169 // arriving through `--` is a conflict — and wrangler rejects a repeated
170 // `--port` with a usage dump rather than taking the last one. Catch it here
171 // and name the driver flag that owns it.
172 for (flag, owner, injected) in [
173 (
174 "--port",
175 "--base-port",
176 serving.iter().any(|s| s.port.is_some()),
177 ),
178 (
179 "--inspector-port",
180 "--inspect-port",
181 serving.iter().any(|s| s.inspector_port.is_some()),
182 ),
183 ] {
184 if injected && passthrough_has(&opts.wrangler_args, flag) {
185 eprintln!(
186 "bynk: `{flag}` is allocated per context — pass `{owner}` to `bynk dev` instead of `-- {flag}`."
187 );
188 return ExitCode::FAILURE;
189 }
190 }
191
192 // Remote dev reads the real Cloudflare KV id, unlike Miniflare's local
193 // mode. Resolve it from the deploy ledger immediately before Wrangler
194 // runs; a never-deployed project gets an actionable error instead of
195 // sending the generated placeholder to Cloudflare.
196 if opts.wrangler_args.iter().any(|arg| arg == "--remote") {
197 for s in &serving {
198 if let Err(e) = crate::deploy::materialise_deploy_state(
199 project_root,
200 &s.worker,
201 &workers_dir.join(&s.worker).join("wrangler.toml"),
202 &opts.environment,
203 ) {
204 eprintln!("bynk: {e}");
205 return ExitCode::FAILURE;
206 }
207 }
208 }
209
210 // 4. Serve — one `wrangler dev` per context, each from inside its own worker
211 // dir (the emitted `index.ts` imports `../../runtime.js`, so cwd must be
212 // the worker dir, exactly the manual recipe's `cd`). The processes find
213 // each other through wrangler's **dev registry** and wire the generated
214 // `[[services]]` bindings between themselves — verified: a binding starts
215 // `[not connected]` and converges to `[connected]` once its callee is up,
216 // so start order does not matter and we need not stage the spawns.
217 // Resolve wrangler once with doctor's provenance ordering; an npx
218 // resolution downloads on first use, so it is a notice, never a silent
219 // green path.
220 let probe = probe::detect(
221 tb,
222 "wrangler",
223 DetectOpts {
224 project_root: Some(project_root),
225 allow_npx: true,
226 },
227 );
228 if matches!(probe.provenance, Provenance::Npx) {
229 eprintln!("bynk: wrangler resolved via npx — it will download on first run.");
230 }
231 if let Some(notice) = wrangler_age_notice(&probe) {
232 eprintln!("{notice}");
233 }
234 if matches!(probe.provenance, Provenance::Missing) {
235 // The pre-flight gate should have caught this; defensive only.
236 eprintln!("bynk: wrangler not found (run `bynk doctor --only deploy`)");
237 return ExitCode::FAILURE;
238 }
239
240 // Inherited stdio (the default) keeps every session interactive. The driver
241 // and the wranglers share the terminal's foreground process group, so a
242 // Ctrl-C SIGINT reaches them all — we must not bail before reaping; we reap
243 // in the watch loop and propagate the first exit code (ADR 0096 §Exit).
244 let mut children: Vec<(String, Served)> = Vec::new();
245 for s in &serving {
246 let Some(mut cmd) = wrangler_command(&probe.provenance, "dev") else {
247 eprintln!("bynk: wrangler not found (run `bynk doctor --only deploy`)");
248 terminate(&mut children, &workers_dir);
249 return ExitCode::FAILURE;
250 };
251 cmd.current_dir(workers_dir.join(&s.worker));
252 for arg in serve_args(s) {
253 cmd.arg(arg);
254 }
255 for arg in &opts.wrangler_args {
256 cmd.arg(arg);
257 }
258 match cmd.spawn() {
259 Ok(child) => children.push((s.worker.clone(), Served::new(child))),
260 Err(e) => {
261 eprintln!("bynk: could not run wrangler for `{}`: {e}", s.worker);
262 terminate(&mut children, &workers_dir);
263 return ExitCode::FAILURE;
264 }
265 }
266 }
267 eprint!("{}", serving_report(&serving));
268
269 // 5. Watch — #524: `bynk dev` is the edit loop, so watch the project's
270 // `.bynk` sources (the full `[paths]` layout plus `bynk.toml`) and rebuild
271 // into the same build dir on change. Each `wrangler dev` watches its own
272 // built worker files, so one rebuild hot-reloads every context that changed
273 // without a restart; a failing rebuild renders diagnostics and keeps both
274 // the watch and the last good build serving. std-only mtime polling
275 // (500ms): no native watcher dependency, and an edit-loop latency well
276 // under a keystroke-to-glance.
277 eprintln!("bynk dev: watching for source changes (edit `.bynk` files to rebuild)");
278 let mut fingerprint = watch_fingerprint(project_root);
279 loop {
280 // Any worker exiting ends the session: the survivors' bindings now
281 // point at a context that is gone, so a half-served project would fail
282 // in a way that looks like a code bug. Stop them and propagate the
283 // first exit code.
284 for i in 0..children.len() {
285 let status = match children[i].1.child.try_wait() {
286 Ok(status) => status,
287 Err(e) => {
288 eprintln!("bynk: could not poll wrangler: {e}");
289 terminate(&mut children, &workers_dir);
290 return ExitCode::FAILURE;
291 }
292 };
293 if let Some(status) = status {
294 // Dropped here. On Windows that stops its job (#1762), and
295 // with it the `workerd`s the exited wrangler left running.
296 let (name, _) = children.remove(i);
297 if !children.is_empty() {
298 eprintln!("bynk dev: `{name}` exited — stopping the other contexts.");
299 }
300 terminate(&mut children, &workers_dir);
301 return ExitCode::from(exit_status_byte(&status));
302 }
303 }
304 std::thread::sleep(Duration::from_millis(500));
305 let now = watch_fingerprint(project_root);
306 if now != fingerprint {
307 fingerprint = now;
308 eprintln!("bynk dev: change detected — rebuilding…");
309 if compile_once(compiler, project_root, &build_dir, true) {
310 eprintln!("bynk dev: rebuilt");
311 }
312 // On failure the diagnostics are already rendered; keep serving
313 // the last good build and keep watching.
314 }
315 }
316}
317
318/// One `wrangler dev` that `bynk dev` is serving: the child it spawned and, on
319/// Windows, the job object that holds the child's whole process tree (#1762).
320struct Served {
321 child: std::process::Child,
322 /// `None` when the child could not be put in a job. It is then stopped
323 /// with `Child::kill` alone, as before #1762, which leaves its descendants
324 /// running.
325 #[cfg(windows)]
326 job: Option<crate::job::Job>,
327}
328
329impl Served {
330 fn new(child: std::process::Child) -> Served {
331 #[cfg(windows)]
332 {
333 let job = match crate::job::Job::assign(&child) {
334 Ok(job) => Some(job),
335 Err(e) => {
336 eprintln!(
337 "bynk dev: could not put wrangler in a job object ({e}); \
338 stopping it may leave its workerd processes running."
339 );
340 None
341 }
342 };
343 Served { child, job }
344 }
345 #[cfg(not(windows))]
346 Served { child }
347 }
348}
349
350/// Stop every remaining `wrangler dev` and reap it, so a session that ends on
351/// one worker's exit does not strand the others — each holds a port and a
352/// `workerd` child, and a stranded one makes the *next* `bynk dev` fail on a
353/// port clash. Signal them all first, then reap, so the shutdowns overlap.
354///
355/// Then [`sweep`](crate::sweep::sweep) what is still running under
356/// `workers_dir` (#1742). Signalling the children is not enough: via npx the
357/// child is `npx`, which does not pass the signal on, and a worker that exited
358/// on its own has already orphaned its `workerd`s. On Windows the sweep does
359/// nothing, and each child's job object stops its tree instead (#1762).
360fn terminate(children: &mut Vec<(String, Served)>, workers_dir: &Path) {
361 for (_, served) in children.iter_mut() {
362 request_stop(served);
363 }
364 for (_, served) in children.iter_mut() {
365 reap(&mut served.child);
366 }
367 children.clear();
368 crate::sweep::sweep(workers_dir, STOP_GRACE);
369}
370
371/// Ask one `wrangler dev` to stop **and take its own process tree with it**.
372///
373/// SIGTERM, not [`std::process::Child::kill`]'s SIGKILL: wrangler traps SIGTERM
374/// and tears down the `node` and `workerd` processes it spawned, whereas SIGKILL
375/// is untrappable — verified, a SIGKILLed wrangler strands an orphaned `workerd
376/// serve` still holding the port. std exposes no SIGTERM, so we go through POSIX
377/// `kill(1)`.
378///
379/// On Windows the child is a `cmd.exe` wrapper, and `Child::kill` stops only
380/// that. Terminating the child's job object stops the whole tree instead
381/// (#1762). It is a hard stop, but inside a job nothing is stranded by one.
382fn request_stop(served: &mut Served) {
383 #[cfg(windows)]
384 if let Some(job) = &served.job {
385 job.terminate();
386 return;
387 }
388 let child = &mut served.child;
389 #[cfg(unix)]
390 {
391 let sent = std::process::Command::new("kill")
392 .arg("-TERM")
393 .arg(child.id().to_string())
394 .status()
395 .is_ok_and(|s| s.success());
396 if sent {
397 return;
398 }
399 // `kill` missing or the process already gone — fall through.
400 }
401 let _ = child.kill();
402}
403
404/// How long a stopping wrangler gets to run its own teardown before SIGKILL.
405const STOP_GRACE: Duration = Duration::from_secs(10);
406
407/// Reap a signalled child, giving it a moment to run wrangler's own teardown
408/// before escalating to SIGKILL. Without the escalation a wrangler wedged in
409/// shutdown would hang `bynk dev` forever; without the grace period we would be
410/// back to stranding `workerd`.
411fn reap(child: &mut std::process::Child) {
412 const TICK: Duration = Duration::from_millis(50);
413 let mut waited = Duration::ZERO;
414 while waited < STOP_GRACE {
415 match child.try_wait() {
416 Ok(Some(_)) => return,
417 Ok(None) => {}
418 Err(_) => break,
419 }
420 std::thread::sleep(TICK);
421 waited += TICK;
422 }
423 let _ = child.kill();
424 let _ = child.wait();
425}
426
427/// #524: a change fingerprint over the project's watched inputs — every
428/// `.bynk` file under the `[paths] include` roots (author `exclude` subtrees
429/// and tool/VCS directories skipped) plus `bynk.toml` itself. Hashes each
430/// file's path, mtime, and length, so an edit, add, delete, or rename all
431/// change the fingerprint. I/O errors skip the entry rather than aborting the
432/// watch.
433fn watch_fingerprint(project_root: &Path) -> u64 {
434 use std::hash::{Hash, Hasher};
435 // The watch loop's own fingerprint has no error channel to surface a
436 // malformed `bynk.toml` through — the CLI's real build/check paths
437 // (`bynk-driver::project_options`) already do that, via
438 // `try_read_project_paths_with`. This falls back to the conventional
439 // layout on any error, same as `read_project_paths`'s deleted total form
440 // (R3.8, #1113) always did for this one call site.
441 let paths = try_read_project_paths(project_root)
442 .unwrap_or_else(|_| ProjectPaths::conventional(project_root));
443 let excludes: Vec<PathBuf> = paths.exclude.iter().map(|e| project_root.join(e)).collect();
444 let mut entries: Vec<(PathBuf, std::time::SystemTime, u64)> = Vec::new();
445 let record = |path: &Path, entries: &mut Vec<(PathBuf, std::time::SystemTime, u64)>| {
446 if let Ok(meta) = std::fs::metadata(path)
447 && let Ok(mtime) = meta.modified()
448 {
449 entries.push((path.to_path_buf(), mtime, meta.len()));
450 }
451 };
452 record(&project_root.join("bynk.toml"), &mut entries);
453 for root in &paths.include {
454 collect_bynk_files(&project_root.join(root), &excludes, &mut |p| {
455 record(p, &mut entries)
456 });
457 }
458 entries.sort();
459 let mut hasher = std::hash::DefaultHasher::new();
460 for (path, mtime, len) in &entries {
461 path.hash(&mut hasher);
462 mtime.hash(&mut hasher);
463 len.hash(&mut hasher);
464 }
465 hasher.finish()
466}
467
468/// Walk `dir` recursively, calling `visit` for each `.bynk` file. Skips the
469/// author `exclude` subtrees and the tool/VCS directories a source walk never
470/// wants (`.bynk` build dir, `.git`, `node_modules`, `target`).
471fn collect_bynk_files(dir: &Path, excludes: &[PathBuf], visit: &mut dyn FnMut(&Path)) {
472 const SKIP_DIRS: [&str; 4] = [".bynk", ".git", "node_modules", "target"];
473 let Ok(read) = std::fs::read_dir(dir) else {
474 return;
475 };
476 for entry in read.flatten() {
477 let path = entry.path();
478 if path.is_dir() {
479 let name = entry.file_name();
480 if SKIP_DIRS.iter().any(|s| name == *s) {
481 continue;
482 }
483 if excludes.iter().any(|e| path.starts_with(e)) {
484 continue;
485 }
486 collect_bynk_files(&path, excludes, visit);
487 } else if path.extension().is_some_and(|e| e == "bynk") {
488 visit(&path);
489 }
490 }
491}
492
493/// #1732: the warning `bynk dev` prints before serving with a wrangler older
494/// than [`bynk_emit::WRANGLER_MIN`]. Its `workerd` refuses the pinned
495/// compatibility date outright, and wrangler's own error says nothing about
496/// Bynk, so this names the cause and the fix first. A warning, not a refusal:
497/// the version is `doctor`'s judgement, and the wrangler itself has the last
498/// word. `None` when the wrangler is new enough, or can't be versioned (npx).
499pub fn wrangler_age_notice(probe: &probe::Probe) -> Option<String> {
500 let v = probe
501 .version
502 .filter(|_| doctor::wrangler_below_min(probe))?;
503 Some(format!(
504 "bynk: warning: wrangler {v} is older than {}, the first whose runtime serves \
505 compatibility date {}; `wrangler dev` will refuse it. Upgrade with \
506 `{}`.",
507 bynk_emit::WRANGLER_MIN,
508 bynk_emit::COMPATIBILITY_DATE,
509 doctor::wrangler_upgrade_remedy(probe)
510 ))
511}
512
513/// The text `bynk dev` prints when the deploy pre-flight fails: a lead line plus
514/// doctor's own human report, so the remedy lines are identical to `bynk
515/// doctor`. Pure (no I/O) so this deterministic surface is pinned by a golden
516/// (§5), unlike the non-deterministic `wrangler dev` stream.
517pub fn preflight_failure_message(report: &Report) -> String {
518 format!(
519 "bynk: environment not ready for `dev` — see below.\n\n{}",
520 report::render(report, Format::Human)
521 )
522}
523
524/// Allocate a port per worker (#552): `wrangler dev` binds one port per process,
525/// so serving N contexts means N distinct ports, assigned `base + i` over the
526/// deterministic worker order.
527///
528/// The one exception preserves the pre-#552 contract: a **lone** worker with no
529/// explicit `--base-port` gets `None` — no injected `--port` at all — so it
530/// lands on wrangler's own default and `-- --port N` still works. Injecting
531/// unconditionally would break that, because a repeated `--port` is a hard
532/// wrangler error, not last-wins.
533pub fn allocate(workers: &[String], base_port: Option<u16>, opts: &DevOptions) -> Vec<Serving> {
534 let lone = workers.len() == 1 && base_port.is_none();
535 let base = base_port.unwrap_or(DEFAULT_BASE_PORT);
536 workers
537 .iter()
538 .enumerate()
539 .map(|(i, worker)| Serving {
540 worker: worker.clone(),
541 port: (!lone).then(|| base.saturating_add(i as u16)),
542 inspector_port: opts
543 .inspect
544 .then(|| opts.inspect_port.saturating_add(i as u16)),
545 })
546 .collect()
547}
548
549/// Whether the `--` passthrough carries `flag`, which the driver also injects.
550/// Wrangler rejects a repeated `--port`/`--inspector-port` outright ("expects a
551/// single value, but received multiple"), so we catch the clash ourselves and
552/// say which driver flag owns it instead of letting wrangler's usage dump land.
553fn passthrough_has(args: &[String], flag: &str) -> bool {
554 args.iter()
555 .any(|a| a == flag || a.starts_with(&format!("{flag}=")))
556}
557
558/// The `wrangler dev` flags the driver injects for one worker: the ports it
559/// allocated (#552) — `--port` when serving several contexts, `--inspector-port`
560/// under `--inspect` (slice 3, ADR 0104), so a JavaScript debugger can attach and
561/// `.bynk` breakpoints resolve through the emitted source maps.
562///
563/// Empty for a lone worker without `--base-port` or `--inspect` — byte-for-byte
564/// the pre-#552 invocation, so that path keeps its `-- --port N` passthrough.
565fn serve_args(s: &Serving) -> Vec<String> {
566 let mut args = Vec::new();
567 if let Some(port) = s.port {
568 args.push("--port".to_string());
569 args.push(port.to_string());
570 }
571 if let Some(port) = s.inspector_port {
572 args.push("--inspector-port".to_string());
573 args.push(port.to_string());
574 }
575 args
576}
577
578/// The start-up report: which context answers on which URL, plus the inspector
579/// notice under `--inspect`. Pure and deterministic, so it is golden-pinned in
580/// the style of ADR 0096 §Exit — unlike the `wrangler dev` streams it precedes.
581///
582/// A lone worker on wrangler's own default port prints no table: there is no
583/// allocation to disclose and wrangler announces its own `Ready on` line, so
584/// that session reads exactly as it did before #552.
585pub fn serving_report(serving: &[Serving]) -> String {
586 let mut out = String::new();
587 if serving.iter().any(|s| s.port.is_some()) {
588 // Only claim the wiring when there is something to wire: a subset of
589 // one has no sibling to bind to, and saying otherwise would explain a
590 // cross-context call's failure as a bug rather than as the missing
591 // context it is.
592 out.push_str(&match serving.len() {
593 1 => "bynk dev: serving 1 context.\n".to_string(),
594 n => format!(
595 "bynk dev: serving {n} contexts — service bindings between them are wired.\n"
596 ),
597 });
598 let width = serving.iter().map(|s| s.worker.len()).max().unwrap_or(0);
599 for s in serving {
600 let Some(port) = s.port else { continue };
601 out.push_str(&format!(
602 " {:width$} http://localhost:{port}\n",
603 s.worker,
604 width = width
605 ));
606 }
607 }
608 let inspected = serving
609 .iter()
610 .filter(|s| s.inspector_port.is_some())
611 .count();
612 if inspected > 0 {
613 out.push_str(&match inspected {
614 1 => "bynk dev --inspect: the worker runs with the V8 inspector enabled.\n".to_string(),
615 _ => "bynk dev --inspect: each worker runs with the V8 inspector enabled, on its own port.\n"
616 .to_string(),
617 });
618 for s in serving {
619 let Some(port) = s.inspector_port else {
620 continue;
621 };
622 out.push_str(&format!(
623 " {} — inspector on port {port} (CDP discovery: http://127.0.0.1:{port}/json)\n",
624 s.worker
625 ));
626 }
627 out.push_str(
628 " Breakpoints set in `.bynk` sources resolve through the emitted source maps.\n\
629 \x20 A hand-rolled CDP client must send an `Origin` header — VS Code's\n\
630 \x20 JavaScript debugger does this for you.\n",
631 );
632 }
633 out
634}
635
636#[cfg(test)]
637mod tests {
638 use super::*;
639
640 fn names(v: &[&str]) -> Vec<String> {
641 v.iter().map(|s| s.to_string()).collect()
642 }
643
644 #[test]
645 fn a_sole_context_is_selected_without_a_flag() {
646 assert_eq!(
647 select_contexts(&names(&["links"]), &[]),
648 Ok(names(&["links"]))
649 );
650 }
651
652 #[test]
653 fn no_workers_is_its_own_error() {
654 assert_eq!(select_contexts(&[], &[]), Err(SelectError::NoneBuilt));
655 }
656
657 #[test]
658 fn exit_status_byte_maps_codes_and_signals() {
659 #[cfg(unix)]
660 {
661 use std::os::unix::process::ExitStatusExt;
662 use std::process::ExitStatus;
663 // Wait statuses: exit codes sit in the high byte; the low byte is
664 // the terminating signal.
665 assert_eq!(exit_status_byte(&ExitStatus::from_raw(0)), 0);
666 assert_eq!(exit_status_byte(&ExitStatus::from_raw(1 << 8)), 1);
667 // A shared Ctrl-C (SIGINT = 2) is a clean stop…
668 assert_eq!(exit_status_byte(&ExitStatus::from_raw(2)), 0);
669 // …but a SIGSEGV (11) or SIGKILL (9, the OOM killer) is a real
670 // failure — previously these read as passing in CI.
671 assert_eq!(exit_status_byte(&ExitStatus::from_raw(11)), 128 + 11);
672 assert_eq!(exit_status_byte(&ExitStatus::from_raw(9)), 128 + 9);
673 }
674 }
675
676 #[test]
677 fn inspect_injects_the_inspector_port() {
678 let off = DevOptions::default();
679 let lone = allocate(&names(&["links"]), None, &off);
680 assert!(
681 serve_args(&lone[0]).is_empty(),
682 "a lone worker without --inspect keeps the pre-#552 invocation"
683 );
684
685 let on = DevOptions {
686 inspect: true,
687 inspect_port: 9229,
688 ..Default::default()
689 };
690 let lone = allocate(&names(&["links"]), None, &on);
691 assert_eq!(
692 serve_args(&lone[0]),
693 vec!["--inspector-port".to_string(), "9229".to_string()]
694 );
695 }
696
697 // ---- #552: multi-context selection ------------------------------------
698
699 #[test]
700 fn no_context_flag_serves_every_context() {
701 // The defining change: several contexts is the expected shape, so the
702 // whole project is served rather than refused as ambiguous.
703 assert_eq!(
704 select_contexts(&names(&["api", "worker"]), &[]),
705 Ok(names(&["api", "worker"]))
706 );
707 }
708
709 #[test]
710 fn context_flags_narrow_to_a_subset() {
711 let avail = names(&["api", "commerce-payment", "worker"]);
712 assert_eq!(
713 select_contexts(&avail, &names(&["worker", "api"])),
714 Ok(names(&["api", "worker"])),
715 "the subset is served in the deterministic order, not the typed one"
716 );
717 // Dotted and dasherised forms both resolve, and repeats collapse.
718 assert_eq!(
719 select_contexts(&avail, &names(&["commerce.payment", "commerce-payment"])),
720 Ok(names(&["commerce-payment"]))
721 );
722 }
723
724 #[test]
725 fn selecting_many_reports_an_unknown_context() {
726 assert_eq!(
727 select_contexts(&names(&["api"]), &names(&["api", "nope"])),
728 Err(SelectError::NotFound {
729 requested: "nope".to_string(),
730 available: names(&["api"]),
731 })
732 );
733 }
734
735 #[test]
736 fn selecting_many_from_an_empty_build_is_still_none_built() {
737 assert_eq!(select_contexts(&[], &[]), Err(SelectError::NoneBuilt));
738 }
739
740 // ---- #552: port allocation --------------------------------------------
741
742 #[test]
743 fn ports_are_allocated_per_context_from_the_base() {
744 let opts = DevOptions {
745 inspect: true,
746 inspect_port: 9229,
747 ..Default::default()
748 };
749 let serving = allocate(&names(&["api", "worker"]), None, &opts);
750 assert_eq!(
751 serving.iter().map(|s| s.port).collect::<Vec<_>>(),
752 vec![Some(8787), Some(8788)],
753 "each wrangler dev binds its own port"
754 );
755 assert_eq!(
756 serving.iter().map(|s| s.inspector_port).collect::<Vec<_>>(),
757 vec![Some(9229), Some(9230)],
758 "inspector ports must not collide either"
759 );
760 assert_eq!(
761 serve_args(&serving[1]),
762 names(&["--port", "8788", "--inspector-port", "9230"])
763 );
764 }
765
766 #[test]
767 fn base_port_moves_the_whole_allocation() {
768 let serving = allocate(&names(&["a", "b"]), Some(9000), &DevOptions::default());
769 assert_eq!(
770 serving.iter().map(|s| s.port).collect::<Vec<_>>(),
771 vec![Some(9000), Some(9001)]
772 );
773 }
774
775 #[test]
776 fn a_lone_worker_keeps_wranglers_own_port() {
777 // The back-compat contract: no injected --port, so `-- --port N` still
778 // reaches wrangler (a repeated --port is a hard error, not last-wins).
779 let serving = allocate(&names(&["links"]), None, &DevOptions::default());
780 assert_eq!(serving[0].port, None);
781 assert!(serve_args(&serving[0]).is_empty());
782 // …but an explicit --base-port is honoured even for one context.
783 let pinned = allocate(&names(&["links"]), Some(8900), &DevOptions::default());
784 assert_eq!(pinned[0].port, Some(8900));
785 }
786
787 #[test]
788 fn passthrough_port_is_detected_in_both_spellings() {
789 assert!(passthrough_has(&names(&["--port", "8788"]), "--port"));
790 assert!(passthrough_has(&names(&["--port=8788"]), "--port"));
791 assert!(!passthrough_has(&names(&["--remote"]), "--port"));
792 // `--inspector-port` must not be mistaken for `--port`.
793 assert!(!passthrough_has(
794 &names(&["--inspector-port", "9229"]),
795 "--port"
796 ));
797 }
798
799 #[test]
800 fn the_serving_report_lists_context_urls() {
801 let serving = allocate(
802 &names(&["commerce-orders", "commerce-payment"]),
803 None,
804 &DevOptions::default(),
805 );
806 let report = serving_report(&serving);
807 assert!(report.contains("serving 2 contexts"), "{report}");
808 // Names are padded to the widest, so the URLs line up in a column.
809 assert!(
810 report.contains("commerce-orders http://localhost:8787"),
811 "{report}"
812 );
813 assert!(
814 report.contains("commerce-payment http://localhost:8788"),
815 "{report}"
816 );
817 // A lone default-port worker discloses no allocation — wrangler's own
818 // `Ready on` line is the announcement, exactly as before #552.
819 assert_eq!(
820 serving_report(&allocate(&names(&["links"]), None, &DevOptions::default())),
821 ""
822 );
823 }
824
825 #[test]
826 fn a_subset_of_one_claims_no_wiring() {
827 // "bindings between them are wired" would be a lie for a single
828 // context, and a misleading one: it invites reading a cross-context
829 // call's failure as a bug rather than as the context left unserved.
830 let report = serving_report(&allocate(
831 &names(&["commerce-payment"]),
832 Some(8890),
833 &DevOptions::default(),
834 ));
835 assert!(report.contains("serving 1 context."), "{report}");
836 assert!(!report.contains("bindings"), "{report}");
837 }
838}