Skip to main content

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}