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 (default 9229); allocated per context exactly as
63    /// `base_port` is. Used under `--inspect`, and whenever several contexts
64    /// are served: wrangler's inspector is always on, so N wranglers left on
65    /// its default would all race for 9229 (#1954).
66    pub inspect_port: u16,
67    /// `--env NAME` (default `"default"`) — which `bynk.deploy.lock` section
68    /// `--remote` reads the KV id from. `dev` never provisions and never
69    /// writes the ledger (unchanged); this only selects which of `deploy`'s
70    /// environments `--remote` connects the placeholder to. Purely a `bynk`
71    /// concept — never forwarded to `wrangler dev` itself, since `dev` curates
72    /// no Wrangler-side environment config (slice 4, #837 review: a project
73    /// deployed only under a non-default `--env` previously read as
74    /// "never provisioned" here, because this always looked at `"default"`).
75    pub environment: String,
76    /// `--local-worker NAME`, repeatable (interop I5, #1972): a foreign
77    /// Worker a `Service` instance names that `wrangler dev` should reach in
78    /// the user's own locally running `wrangler dev` of it, through the dev
79    /// registry, rather than in the stand-in `dev` binds by default.
80    pub local_workers: Vec<String>,
81    /// Everything after `--`, forwarded to `wrangler dev` verbatim (D5).
82    pub wrangler_args: Vec<String>,
83    /// `--format` (#2094): `human` (the default) prints only the readable
84    /// stderr stream; `json` also writes one [`DevEvent`] per line to stdout.
85    pub format: DevFormat,
86    /// `--ready-json FILE` (#2138): once every served context answers a
87    /// probe, write `{ "<worker>": "<url>" }` to FILE ([`crate::ready`]).
88    pub ready_json: Option<PathBuf>,
89}
90
91/// `bynk dev --format` (#2094).
92#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
93pub enum DevFormat {
94    /// The readable stream on stderr, unchanged.
95    #[default]
96    Human,
97    /// The readable stream on stderr, plus one JSON [`DevEvent`] per line on
98    /// stdout. `wrangler dev`'s own stdout moves to stderr, so stdout carries
99    /// nothing but events.
100    Json,
101}
102
103/// One lifecycle event of a `bynk dev --format json` session (#2094): a stable,
104/// line-delimited interface for the editor and CI scripts, so neither has to
105/// parse the human report, whose wording is UX and changes. Serialised with an
106/// `event` tag in snake_case. A consumer should ignore fields and events it
107/// does not know: new ones may be added, existing ones keep their meaning.
108#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
109#[serde(tag = "event", rename_all = "snake_case")]
110pub enum DevEvent {
111    /// Every `wrangler dev` has been started. Ports are allocated, but a
112    /// worker may still be booting; poll its URL for readiness.
113    Serving { contexts: Vec<ServedContext> },
114    /// A source change was seen and a rebuild has begun.
115    Rebuilding,
116    /// The rebuild succeeded; the workers hot-reload from it.
117    RebuildOk,
118    /// The rebuild failed. Its diagnostics are on stderr, and the last good
119    /// build keeps serving.
120    RebuildFailed,
121    /// The session ended, with the exit code `bynk dev` is about to return.
122    Stopped { code: u8 },
123}
124
125/// One served context in a [`DevEvent::Serving`] event.
126#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
127pub struct ServedContext {
128    /// The context's worker dir (dasherised), as `--context` accepts it.
129    pub name: String,
130    /// The HTTP port it answers on.
131    pub port: u16,
132    /// `http://localhost:<port>`.
133    pub url: String,
134    /// Its V8 inspector port, when one was allocated (`--inspect`, or several
135    /// contexts served); `null` otherwise.
136    pub inspector_port: Option<u16>,
137}
138
139/// The [`DevEvent::Serving`] event for an allocation. A lone worker left on
140/// wrangler's default (`port: None`) reports the port it actually gets: a
141/// `-- --port N` passthrough, else wrangler's 8787.
142pub 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
162/// One event as its JSON line (no trailing newline).
163pub fn event_line(event: &DevEvent) -> String {
164    serde_json::to_string(event).expect("a dev event serialises")
165}
166
167/// Write `event` to stdout under `--format json`; nothing under `human`.
168fn 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
177/// The `--port` value of a `-- --port N` / `-- --port=N` passthrough.
178fn 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/// Interop I5 (#1972): one stand-in `bynk dev` runs for a foreign Worker that
189/// a served context binds: the Worker's name, and the context's bindings to
190/// it. It runs inside that context's own `wrangler dev`, as a second `-c`
191/// config, under a name of its own ([`stand_in_name`]).
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct StandIn {
194    pub worker: String,
195    pub bindings: Vec<String>,
196}
197
198/// The Worker name `context`'s stand-in for `worker` runs under. Not the
199/// foreign Worker's own: a `-c` config's Worker also registers in wrangler's
200/// dev registry, which every local `wrangler dev` shares (verified on 4.149.0),
201/// so under the real name it would answer other sessions' bindings to that
202/// Worker and, once stopped, could leave a stale entry for `--local-worker` to
203/// connect to. The context's binding is pointed at this name instead
204/// (`point_at_stand_ins`).
205///
206/// Kept within Cloudflare's 63-character Worker name limit: a longer name is
207/// cut and given a hash of the whole, so two stand-ins never share one.
208pub 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    // FNV-1a: stable across runs and toolchains, unlike `DefaultHasher`.
214    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
225/// The config a context with stand-ins is served from: its generated
226/// `wrangler.toml` with each binding to a foreign Worker pointed at the
227/// stand-in, beside the original so `main` resolves the same.
228pub const STAND_IN_CONFIG: &str = "wrangler.dev.toml";
229
230/// Write each served context's [`STAND_IN_CONFIG`] from its generated
231/// `wrangler.toml`, run after every build. A copy, not an edit: a rebuild
232/// rewrites `wrangler.toml` with the Workers' real names, and `wrangler dev`
233/// watching it would reload once with the real Worker bound (reaching a real
234/// local session of it, which the stand-in's own name exists to prevent)
235/// before an edit could retarget it (review of #2008). The copy is replaced
236/// by a rename, so wrangler only ever reads it whole, and only when it
237/// changes.
238fn 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
260/// Interop I5 (#1972): the Service Bindings in a generated `wrangler.toml`
261/// whose target is none of `project_workers`, as `(binding, worker)` in
262/// binding order: the bindings to foreign Workers, by the rule `bynk deploy`
263/// uses. An unreadable config has none; the serve step reports it.
264pub 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
284/// Interop I5 (#1972, track §11 Q8): the stand-ins one context's `wrangler
285/// dev` runs, one per foreign Worker it binds, in Worker order, less those
286/// `local` names. Verified on wrangler 4.149.0: a second `-c` config's Worker
287/// answers its primary's binding to it, and without one a binding to a Worker
288/// that is not running answers 503 ("Worker … not found").
289pub 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
305/// The stand-in's `wrangler.toml` and `index.js`. It answers every request
306/// with [`bynk_emit::emitter::wrangler::FOREIGN_SERVICE_DOUBLE_STATUS`] and a
307/// body saying what it is and how to reach the real Worker, as the
308/// integration test harness's stand-in does.
309pub 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
334/// Write each served context's stand-ins under `<build>/stand-ins/<context
335/// worker>/<foreign worker>/`, returning their config paths by context. The
336/// directory is rebuilt each session, like `workers/`.
337fn 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
364/// The start-up lines naming each stand-in, so a 503 from one is never a
365/// mystery. Empty when there are none.
366pub 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
380/// Wrangler's own default dev port — the base of the per-context allocation
381/// when `--base-port` is not given, so a multi-context project's first worker
382/// lands where a single-context one always has.
383const DEFAULT_BASE_PORT: u16 = 8787;
384
385/// One worker to serve: its dasherised context dir, its HTTP port (`None` = let
386/// wrangler choose, the lone-worker default), its inspector port, and whether
387/// the inspector was asked for (`--inspect`) rather than only allocated so the
388/// contexts' default inspectors do not collide (#1954).
389#[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
397/// Orchestrate a local dev session: pre-flight, compile, select the worker, and
398/// hand off to `wrangler dev`. Returns wrangler's own exit code on a clean
399/// hand-off, or a pre-flight/build failure code before serving.
400pub fn run(
401    tb: &dyn Toolbox,
402    compiler: &Compiler,
403    project_root: &Path,
404    node_floor: u32,
405    opts: &DevOptions,
406) -> ExitCode {
407    // #837 review: once `--remote` reads a ledger section by `--env`, a
408    // `-- --env`/`-- --environment` passthrough would silently diverge —
409    // `bynk` materialises one environment's KV id while Wrangler actually
410    // connects to a different one. Checked only when `--remote` is present,
411    // since `--env` is otherwise inert (nothing reads the ledger without it).
412    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    // 1. Pre-flight — reuse doctor's Deploy gate (Node + wrangler) plus the
423    //    always-on compile floor. Failing here, with doctor's remedy text, beats
424    //    a confusing error out of a half-built tree (proposal §2.2).
425    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    // 2. Compile — in-process (slice 7: the driver links the pipeline instead of
440    //    shelling `bynkc`). Into the managed `.bynk/dev/` build dir (D1).
441    //    Compilation is additive (never prunes), so clear `workers/` first;
442    //    otherwise a renamed/deleted context would linger and spuriously trip the
443    //    §2.4 ambiguity check.
444    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    // #524: compile the SAME project shape as `bynkc compile <project_root>`
450    // — the shared rooting rule over the full `[paths]` layout. `dev`
451    // previously re-rooted on the first `include` entry only, silently
452    // dropping further includes and the whole `exclude` list.
453    if !compile_once(compiler, project_root, &build_dir, true) {
454        return ExitCode::FAILURE;
455    }
456
457    // 3. Select the workers — every context by default, or the `--context`
458    //    subset (#552, superseding ADR 0096 D3's select-or-default). Serving
459    //    them *together* is the whole point: a cross-context call only resolves
460    //    when its callee is up too, so an ambiguity error here was the feature
461    //    being withheld, not a project being wrong.
462    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    // I2 (#1971): over every built Worker, not only the served ones, so a
473    // context served here alone still admits a caller served by another
474    // `bynk dev --context` session.
475    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    // Where the driver injects a port it owns the allocation, so the same flag
484    // arriving through `--` is a conflict — and wrangler rejects a repeated
485    // `--port` with a usage dump rather than taking the last one. Catch it here
486    // and name the driver flag that owns it.
487    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    // Now that inspectors are allocated without `--inspect` too (#1954), an
508    // overlap with the HTTP range is reachable from plain flags (`--base-port
509    // 9229` over two contexts). Name it here rather than leave wrangler to die
510    // with `Address already in use`.
511    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    // Remote dev reads the real Cloudflare KV id, unlike Miniflare's local
520    // mode. Resolve it from the deploy ledger immediately before Wrangler
521    // runs; a never-deployed project gets an actionable error instead of
522    // sending the generated placeholder to Cloudflare.
523    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    // Interop I5 (#1972, track §11 Q8): each context's Service Bindings to
538    // foreign Workers get a stand-in in its own `wrangler dev`, unless
539    // `--local-worker` names the Worker (the dev registry then reaches the
540    // user's own `wrangler dev` of it). Under `--remote` the bindings are the
541    // account's, so there is nothing to stand in for.
542    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    // 4. Serve — one `wrangler dev` per context, each from inside its own worker
584    //    dir (the emitted `index.ts` imports `../../runtime.js`, so cwd must be
585    //    the worker dir, exactly the manual recipe's `cd`). The processes find
586    //    each other through wrangler's **dev registry** and wire the generated
587    //    `[[services]]` bindings between themselves — verified: a binding starts
588    //    `[not connected]` and converges to `[connected]` once its callee is up,
589    //    so start order does not matter and we need not stage the spawns.
590    //    Resolve wrangler once with doctor's provenance ordering; an npx
591    //    resolution downloads on first use, so it is a notice, never a silent
592    //    green path.
593    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        // The pre-flight gate should have caught this; defensive only.
609        eprintln!("bynk: wrangler not found (run `bynk doctor --only deploy`)");
610        return ExitCode::FAILURE;
611    }
612
613    // #1957: N parallel first-run `npx wrangler dev`s race to install into the
614    // same `_npx` cache entry, and the loser dies at boot (ENOTEMPTY). Install
615    // it once, up front, so every spawn below finds it already there. Stdout
616    // is dropped (the version line is noise); stderr stays, because npm's
617    // progress is the only sign a slow first download is not a hang.
618    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            // Spawning anyway would re-run the very race this avoids.
627            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    // Inherited stdio (the default) keeps every session interactive. The driver
635    // and the wranglers share the terminal's foreground process group, so a
636    // Ctrl-C SIGINT reaches them all — we must not bail before reaping; we reap
637    // in the watch loop and propagate the first exit code (ADR 0096 §Exit).
638    //
639    // #1955: a SIGINT or SIGTERM sent to `bynk dev` *alone* (a CI step, a test
640    // harness) used to kill it outright and strand every wrangler and its
641    // `workerd`s. Record it instead, and let the watch loop stop the contexts.
642    // Installed before the spawns, so there is no window in which a child is
643    // up and the driver still dies of the default action; the children
644    // themselves get the default back at `exec`.
645    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        // Interop I5 (#1972): the context's own config first (wrangler's
655        // primary Worker, the one served on the port), then its stand-ins.
656        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            // #2094: stdout is the event stream, so wrangler's banner and
674            // request log go to stderr with the rest of the readable output.
675            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    // #2138: `--ready-json` — written from the watch loop below once every
691    // served context answers a probe. The probe is plain HTTP on localhost,
692    // so a server it can't reach never reads as ready; say so up front.
693    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    // 5. Watch — #524: `bynk dev` is the edit loop, so watch the project's
713    // `.bynk` sources (the full `[paths]` layout plus `bynk.toml`) and rebuild
714    // into the same build dir on change. Each `wrangler dev` watches its own
715    // built worker files, so one rebuild hot-reloads every context that changed
716    // without a restart; a failing rebuild renders diagnostics and keeps both
717    // the watch and the last good build serving. std-only mtime polling
718    // (500ms): no native watcher dependency, and an edit-loop latency well
719    // under a keystroke-to-glance.
720    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                    // Not retried: the session keeps serving, unsignalled.
728                    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                // Interop I5 (#1972): refresh the served copy from the
742                // rebuilt config. A binding added since start has no
743                // stand-in until `bynk dev` restarts.
744                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                // The diagnostics are already rendered; keep serving the last
751                // good build and keep watching.
752                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
763/// The watch loop: every 500ms, end the session if it was asked to stop or a
764/// worker exited, and otherwise run `tick` (the rebuild check). Returns the
765/// session's exit byte once every child is stopped and reaped.
766fn supervise(
767    children: &mut Vec<(String, Served)>,
768    workers_dir: &Path,
769    mut tick: impl FnMut(),
770) -> u8 {
771    loop {
772        // #1955: a SIGINT/SIGTERM recorded by `stop::install`. Checked before
773        // the children, so a Ctrl-C — which reaches the wranglers too — reads
774        // as the stop it is rather than as a worker exiting.
775        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        // Any worker exiting ends the session: the survivors' bindings now
784        // point at a context that is gone, so a half-served project would fail
785        // in a way that looks like a code bug. Stop them and propagate the
786        // first exit code.
787        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                // Dropped here. On Windows that stops its job (#1762), and
798                // with it the `workerd`s the exited wrangler left running.
799                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
812/// One `wrangler dev` that `bynk dev` is serving: the child it spawned and, on
813/// Windows, the job object that holds the child's whole process tree (#1762).
814struct Served {
815    child: std::process::Child,
816    /// `None` when the child could not be put in a job. It is then stopped
817    /// with `Child::kill` alone, as before #1762, which leaves its descendants
818    /// running.
819    #[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
844/// Stop every remaining `wrangler dev` and reap it, so a session that ends on
845/// one worker's exit does not strand the others — each holds a port and a
846/// `workerd` child, and a stranded one makes the *next* `bynk dev` fail on a
847/// port clash. Signal them all first, then reap, so the shutdowns overlap.
848///
849/// Then [`sweep`](crate::sweep::sweep) what is still running under
850/// `workers_dir` (#1742). Signalling the children is not enough: via npx the
851/// child is `npx`, which does not pass the signal on, and a worker that exited
852/// on its own has already orphaned its `workerd`s. On Windows the sweep does
853/// nothing, and each child's job object stops its tree instead (#1762).
854fn 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
865/// Ask one `wrangler dev` to stop **and take its own process tree with it**.
866///
867/// SIGTERM, not [`std::process::Child::kill`]'s SIGKILL: wrangler traps SIGTERM
868/// and tears down the `node` and `workerd` processes it spawned, whereas SIGKILL
869/// is untrappable — verified, a SIGKILLed wrangler strands an orphaned `workerd
870/// serve` still holding the port. std exposes no SIGTERM, so we go through POSIX
871/// `kill(1)`.
872///
873/// On Windows the child is a `cmd.exe` wrapper, and `Child::kill` stops only
874/// that. Terminating the child's job object stops the whole tree instead
875/// (#1762). It is a hard stop, but inside a job nothing is stranded by one.
876fn 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        // `kill` missing or the process already gone — fall through.
894    }
895    let _ = child.kill();
896}
897
898/// How long a stopping wrangler gets to run its own teardown before SIGKILL.
899const STOP_GRACE: Duration = Duration::from_secs(10);
900
901/// Reap a signalled child, giving it a moment to run wrangler's own teardown
902/// before escalating to SIGKILL. Without the escalation a wrangler wedged in
903/// shutdown would hang `bynk dev` forever; without the grace period we would be
904/// back to stranding `workerd`.
905fn 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
921/// #524: a change fingerprint over the project's watched inputs — every
922/// `.bynk` file under the `[paths] include` roots (author `exclude` subtrees
923/// and tool/VCS directories skipped), `bynk.toml` itself, and (#1883, slice
924/// 1b) every file in the project's `assets/` folder, so editing a stylesheet
925/// rebuilds and rehashes it. Hashes each
926/// file's path, mtime, and length, so an edit, add, delete, or rename all
927/// change the fingerprint. I/O errors skip the entry rather than aborting the
928/// watch.
929fn watch_fingerprint(project_root: &Path) -> u64 {
930    use std::hash::{Hash, Hasher};
931    // The watch loop's own fingerprint has no error channel to surface a
932    // malformed `bynk.toml` through — the CLI's real build/check paths
933    // (`bynk-driver::project_options`) already do that, via
934    // `try_read_project_paths_with`. This falls back to the conventional
935    // layout on any error, same as `read_project_paths`'s deleted total form
936    // (R3.8, #1113) always did for this one call site.
937    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    // #2387, slice 2: and every file in `embeds/`, so editing a template
960    // rebuilds the code that embeds it.
961    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    /// #2387, slice 2: an edit to a file in `embeds/` changes the fingerprint
981    /// too, so `bynk dev` rebuilds the code that embeds it.
982    #[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    /// #1883, slice 1b: an edit to a file in `assets/` changes the
999    /// fingerprint, so `bynk dev` rebuilds and the `.url` hash moves with it.
1000    #[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
1016/// Walk `dir` recursively, calling `visit` for each `.bynk` file. Skips the
1017/// author `exclude` subtrees and the tool/VCS directories a source walk never
1018/// wants (`.bynk` build dir, `.git`, `node_modules`, `target`).
1019fn 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
1041/// #1732: the warning `bynk dev` prints before serving with a wrangler older
1042/// than [`bynk_emit::WRANGLER_MIN`]. Its `workerd` refuses the pinned
1043/// compatibility date outright, and wrangler's own error says nothing about
1044/// Bynk, so this names the cause and the fix first. A warning, not a refusal:
1045/// the version is `doctor`'s judgement, and the wrangler itself has the last
1046/// word. `None` when the wrangler is new enough, or can't be versioned (npx).
1047pub 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
1061/// The text `bynk dev` prints when the deploy pre-flight fails: a lead line plus
1062/// doctor's own human report, so the remedy lines are identical to `bynk
1063/// doctor`. Pure (no I/O) so this deterministic surface is pinned by a golden
1064/// (§5), unlike the non-deterministic `wrangler dev` stream.
1065pub 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
1072/// Allocate a port per worker (#552): `wrangler dev` binds one port per process,
1073/// so serving N contexts means N distinct ports, assigned `base + i` over the
1074/// deterministic worker order.
1075///
1076/// The one exception preserves the pre-#552 contract: a **lone** worker with no
1077/// explicit `--base-port` gets `None` — no injected `--port` at all — so it
1078/// lands on wrangler's own default and `-- --port N` still works. Injecting
1079/// unconditionally would break that, because a repeated `--port` is a hard
1080/// wrangler error, not last-wins.
1081///
1082/// Inspector ports follow the same shape, from `--inspect-port`. They are
1083/// allocated under `--inspect`, and also whenever more than one worker is
1084/// served (#1954): `wrangler dev` always starts its inspector, on 9229 unless
1085/// told otherwise, so N wranglers started together all bind 9229 and every one
1086/// but the first dies with `Address already in use`. A lone worker without
1087/// `--inspect` gets none, keeping its invocation byte-for-byte as before.
1088pub 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
1104/// A port the allocation hands out twice: one context's HTTP port that is also
1105/// some context's inspector port.
1106fn 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
1113/// #1957: whether to install wrangler once before spawning the contexts. Only
1114/// an npx resolution installs on first use, and only several spawns at once
1115/// race each other in npm's `_npx` cache; a lone context installs alone.
1116pub fn needs_wrangler_warmup(provenance: &Provenance, contexts: usize) -> bool {
1117    matches!(provenance, Provenance::Npx) && contexts > 1
1118}
1119
1120/// Whether the `--` passthrough carries `flag`, which the driver also injects.
1121/// Wrangler rejects a repeated `--port`/`--inspector-port` outright ("expects a
1122/// single value, but received multiple"), so we catch the clash ourselves and
1123/// say which driver flag owns it instead of letting wrangler's usage dump land.
1124fn passthrough_has(args: &[String], flag: &str) -> bool {
1125    args.iter()
1126        .any(|a| a == flag || a.starts_with(&format!("{flag}=")))
1127}
1128
1129/// The `wrangler dev` flags the driver injects for one worker: the ports it
1130/// allocated (#552) — `--port` when serving several contexts, `--inspector-port`
1131/// under `--inspect` (slice 3, ADR 0104), so a JavaScript debugger can attach and
1132/// `.bynk` breakpoints resolve through the emitted source maps.
1133///
1134/// A lone worker without `--base-port` or `--inspect` gets no port flag — the
1135/// pre-#552 invocation, so that path keeps its `-- --port N` passthrough.
1136///
1137/// I2 (#1971): and the local key for each edge the Worker is on (`edge_keys`,
1138/// its [`crate::deploy::local_edge_keys`]), as `--var`s rather than a file:
1139/// every internal request (`/_bynk/call/`, `/_bynk/event/`) is signed under
1140/// its edge's key, and a lone context still delivers its own events to
1141/// itself. The fixed [`bynk_emit::LOCAL_CALL_KEY`], so two `bynk dev
1142/// --context` sessions run side by side admit each other's calls.
1143fn 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
1160/// The start-up report: which context answers on which URL, plus the inspector
1161/// notice under `--inspect`. Pure and deterministic, so it is golden-pinned in
1162/// the style of ADR 0096 §Exit — unlike the `wrangler dev` streams it precedes.
1163///
1164/// A lone worker on wrangler's own default port prints no table: there is no
1165/// allocation to disclose and wrangler announces its own `Ready on` line, so
1166/// that session reads exactly as it did before #552.
1167pub fn serving_report(serving: &[Serving]) -> String {
1168    let mut out = String::new();
1169    if serving.iter().any(|s| s.port.is_some()) {
1170        // Only claim the wiring when there is something to wire: a subset of
1171        // one has no sibling to bind to, and saying otherwise would explain a
1172        // cross-context call's failure as a bug rather than as the missing
1173        // context it is.
1174        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    // Only `--inspect` announces the inspector. Without it a multi-context
1191    // session still allocates inspector ports (#1954), but only so they do not
1192    // collide; that is wrangler's default inspector, moved, not a debug session.
1193    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            // Wait statuses: exit codes sit in the high byte; the low byte is
1249            // the terminating signal.
1250            assert_eq!(exit_status_byte(&ExitStatus::from_raw(0)), 0);
1251            assert_eq!(exit_status_byte(&ExitStatus::from_raw(1 << 8)), 1);
1252            // A shared Ctrl-C (SIGINT = 2) is a clean stop…
1253            assert_eq!(exit_status_byte(&ExitStatus::from_raw(2)), 0);
1254            // …but a SIGSEGV (11) or SIGKILL (9, the OOM killer) is a real
1255            // failure — previously these read as passing in CI.
1256            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    /// I2 (#1971): a Worker's edge keys come first, each the local key.
1262    #[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    // ---- #552: multi-context selection ------------------------------------
1302
1303    #[test]
1304    fn no_context_flag_serves_every_context() {
1305        // The defining change: several contexts is the expected shape, so the
1306        // whole project is served rather than refused as ambiguous.
1307        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        // Dotted and dasherised forms both resolve, and repeats collapse.
1322        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    // ---- #552: port allocation --------------------------------------------
1345
1346    #[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        // The back-compat contract: no injected --port, so `-- --port N` still
1382        // reaches wrangler (a repeated --port is a hard error, not last-wins).
1383        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        // …but an explicit --base-port is honoured even for one context.
1387        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        // #1954: wrangler's inspector is always on, so two wranglers left on
1394        // its default both bind 9229 and the second dies at boot.
1395        let plain = DevOptions {
1396            inspect_port: 9229, // the CLI default
1397            ..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        // `--inspect-port` moves that allocation even without `--inspect`…
1409        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        // …and a lone worker without `--inspect` still gets none.
1419        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        // HTTP 9228/9229 against inspectors 9229/9230.
1441        assert_eq!(port_clash(&allocate(&two, Some(9228), &opts)), Some(9229));
1442        assert_eq!(port_clash(&allocate(&two, None, &opts)), None);
1443        // A lone worker has neither, so nothing can clash.
1444        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        // #1957: parallel first-run `npx` installs race in npm's cache.
1450        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    /// #1955: a SIGTERM sent to `bynk dev` alone stops its contexts and what
1457    /// they started. `sh` stands in for wrangler and its `sleep`s for the
1458    /// `workerd`s: the `sh` dies of the SIGTERM `terminate` sends and orphans
1459    /// them, as a wrangler resolved via npx does, and the sweep must stop them.
1460    #[cfg(unix)]
1461    #[test]
1462    fn a_sigterm_to_the_driver_stops_every_context_and_its_children() {
1463        use std::process::{Command, Stdio};
1464        // #2055: the deploy tests that change signal dispositions must not
1465        // restore the default while this one signals the process.
1466        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        // What a CI step's `kill -TERM <bynk dev>` does. Without the handler
1495        // this would end the test binary.
1496        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        // `--inspector-port` must not be mistaken for `--port`.
1528        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        // Names are padded to the widest, so the URLs line up in a column.
1544        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        // A lone default-port worker discloses no allocation — wrangler's own
1553        // `Ready on` line is the announcement, exactly as before #552.
1554        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        // A lone worker on wrangler's default still reports a port: 8787, or a
1575        // `-- --port N` passthrough in either spelling.
1576        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        // "bindings between them are wired" would be a lie for a single
1609        // context, and a misleading one: it invites reading a cross-context
1610        // call's failure as a bug rather than as the context left unserved.
1611        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    // ---- interop I5 (#1972): stand-ins for foreign Workers ----
1621
1622    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    /// One stand-in per foreign Worker, carrying every binding to it, less
1644    /// the Workers `--local-worker` names.
1645    #[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    /// The stand-in runs under a name of its own, never the foreign Worker's
1671    /// (which wrangler's shared dev registry would hand to other sessions), and
1672    /// says what it is.
1673    #[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    /// The served copy points the binding at the stand-in; the generated
1719    /// config keeps the real name (review of #2008).
1720    #[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}