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
17use std::path::Path;
18use std::process::{Command, ExitCode};
19
20use bynk_emit::project::{BuildTarget, CompileOptions, ProjectFailure, read_project_paths};
21
22use crate::compiler::Compiler;
23use crate::doctor::{self, Capability, Context, DoctorOptions, Report};
24use crate::probe::{self, DetectOpts, Provenance, Toolbox};
25use crate::report::{self, Format};
26
27/// Parsed `bynk dev` flags (the project `PATH` is resolved into `project_root`
28/// before we get here).
29#[derive(Debug, Clone, Default)]
30pub struct DevOptions {
31    /// `--context NAME` — which context's worker to serve.
32    pub context: Option<String>,
33    /// `--inspect` (slice 3): start `wrangler dev` with the V8 inspector so a
34    /// JavaScript debugger can attach; breakpoints in `.bynk` resolve through the
35    /// emitted source maps composed into the worker bundle.
36    pub inspect: bool,
37    /// Inspector port for `--inspect` (default 9229).
38    pub inspect_port: u16,
39    /// Everything after `--`, forwarded to `wrangler dev` verbatim (D5).
40    pub wrangler_args: Vec<String>,
41}
42
43/// Orchestrate a local dev session: pre-flight, compile, select the worker, and
44/// hand off to `wrangler dev`. Returns wrangler's own exit code on a clean
45/// hand-off, or a pre-flight/build failure code before serving.
46pub fn run(
47    tb: &dyn Toolbox,
48    compiler: &Compiler,
49    project_root: &Path,
50    src_rel: &Path,
51    node_floor: u32,
52    opts: &DevOptions,
53) -> ExitCode {
54    // 1. Pre-flight — reuse doctor's Deploy gate (Node + wrangler) plus the
55    //    always-on compile floor. Failing here, with doctor's remedy text, beats
56    //    a confusing error out of a half-built tree (proposal §2.2).
57    let ctx = Context {
58        project_root: Some(project_root.to_path_buf()),
59        in_repo: false,
60        node_floor,
61    };
62    let preflight_opts = DoctorOptions {
63        only: Some(Capability::Deploy),
64        strict: false,
65    };
66    let report = doctor::diagnose(tb, compiler, &ctx, &preflight_opts);
67    if report.exit_nonzero(&preflight_opts) {
68        eprint!("{}", preflight_failure_message(&report));
69        return ExitCode::FAILURE;
70    }
71    // 2. Compile — in-process (slice 7: the driver links the pipeline instead of
72    //    shelling `bynkc`). Into the managed `.bynk/dev/` build dir (D1).
73    //    Compilation is additive (never prunes), so clear `workers/` first;
74    //    otherwise a renamed/deleted context would linger and spuriously trip the
75    //    §2.4 ambiguity check.
76    let build_dir = project_root.join(".bynk").join("dev");
77    if let Err(e) = prepare_build_dir(project_root, &build_dir) {
78        eprintln!("bynk: could not prepare build directory: {e}");
79        return ExitCode::FAILURE;
80    }
81    let src = project_root.join(src_rel);
82    // Default: compile in-process. Escape hatch: if `BYNK_BYNKC` pointed the
83    // driver at an external compiler (`Origin::Override`), shell *that* binary
84    // instead — the only path on which a second, skewable compiler enters
85    // (doctor reports its skew only here). With no override there is no separate
86    // compiler to drift against.
87    let used_override = matches!(compiler.origin, Some(crate::compiler::Origin::Override));
88    if let (true, Some(bynkc)) = (used_override, compiler.path.as_deref()) {
89        let status = Command::new(bynkc)
90            .arg("compile")
91            .arg(&src)
92            .arg("--output")
93            .arg(&build_dir)
94            .arg("--target")
95            .arg("workers")
96            .status();
97        match status {
98            Ok(s) if s.success() => {}
99            Ok(s) => return ExitCode::from(exit_byte(s.code())),
100            Err(e) => {
101                eprintln!("bynk: could not run bynkc ({}): {e}", bynkc.display());
102                return ExitCode::FAILURE;
103            }
104        }
105    } else {
106        let options = dev_compile_options(&src);
107        let output = match bynk_emit::project::compile_project(&options) {
108            Ok(out) => out,
109            Err(failure) => {
110                // Render with full source context, exactly as the shelled `bynkc
111                // compile` did — the front-end's flatten-then-delegate (ADR 0100):
112                // the ProjectFailure → CompileError flattening stays here; the
113                // per-error rendering delegates to `bynk-render`.
114                render_project_failure(&failure);
115                return ExitCode::FAILURE;
116            }
117        };
118        if let Err(e) = bynk_emit::write_output(&output, &build_dir) {
119            eprintln!(
120                "bynk: could not write build output under `{}`: {e}",
121                build_dir.display()
122            );
123            return ExitCode::FAILURE;
124        }
125    }
126
127    // 3. Select the worker — exactly one, or the one named by `--context` (D3).
128    let workers_dir = build_dir.join("workers");
129    let available = discover_workers(&workers_dir);
130    let worker = match select_context(&available, opts.context.as_deref()) {
131        Ok(w) => w,
132        Err(e) => {
133            eprintln!("bynk: {e}");
134            return ExitCode::FAILURE;
135        }
136    };
137    let worker_dir = workers_dir.join(&worker);
138
139    // 4. Serve — `wrangler dev` from inside the worker dir (its `index.ts`
140    //    imports `../../runtime.js`, so cwd must be the worker dir, exactly the
141    //    manual recipe's `cd`). Resolve wrangler with doctor's provenance
142    //    ordering; an npx resolution downloads on first use, so it is a notice,
143    //    never a silent green path.
144    let probe = probe::detect(
145        tb,
146        "wrangler",
147        DetectOpts {
148            project_root: Some(project_root),
149            allow_npx: true,
150        },
151    );
152    let mut cmd = match wrangler_command(&probe.provenance) {
153        Some(cmd) => cmd,
154        None => {
155            // The pre-flight gate should have caught this; defensive only.
156            eprintln!("bynk: wrangler not found (run `bynk doctor --only deploy`)");
157            return ExitCode::FAILURE;
158        }
159    };
160    if matches!(probe.provenance, Provenance::Npx) {
161        eprintln!("bynk: wrangler resolved via npx — it will download on first run.");
162    }
163    cmd.current_dir(&worker_dir);
164    // Slice 3 (ADR 0104): `--inspect` starts wrangler with the V8 inspector so a
165    // JavaScript debugger can attach. Injected before the `--` passthrough, so a
166    // power user's explicit `-- --inspector-port N` still wins. A `.bynk`
167    // breakpoint resolves through the emitted source map, which esbuild composes
168    // into the worker bundle.
169    for arg in inspector_args(opts) {
170        cmd.arg(arg);
171    }
172    if opts.inspect {
173        let port = opts.inspect_port;
174        eprintln!("bynk dev --inspect: the worker runs with the V8 inspector enabled.");
175        eprintln!("  Attach a JavaScript debugger to the inspector on port {port} (CDP discovery:");
176        eprintln!("  http://127.0.0.1:{port}/json). Breakpoints set in `.bynk` sources resolve");
177        eprintln!("  through the emitted source maps. A hand-rolled CDP client must send an");
178        eprintln!("  `Origin` header — VS Code's JavaScript debugger does this for you.");
179    }
180    for arg in &opts.wrangler_args {
181        cmd.arg(arg);
182    }
183
184    // Inherited stdio (the default) keeps the session interactive. The driver
185    // and wrangler share the terminal's foreground process group, so a Ctrl-C
186    // SIGINT reaches both — we must not bail before reaping the child; we wait
187    // and propagate its exit code (proposal §2.5).
188    match cmd.status() {
189        Ok(s) => ExitCode::from(exit_byte(s.code())),
190        Err(e) => {
191            eprintln!("bynk: could not run wrangler: {e}");
192            ExitCode::FAILURE
193        }
194    }
195}
196
197/// The text `bynk dev` prints when the deploy pre-flight fails: a lead line plus
198/// doctor's own human report, so the remedy lines are identical to `bynk
199/// doctor`. Pure (no I/O) so this deterministic surface is pinned by a golden
200/// (§5), unlike the non-deterministic `wrangler dev` stream.
201pub fn preflight_failure_message(report: &Report) -> String {
202    format!(
203        "bynk: environment not ready for `dev` — see below.\n\n{}",
204        report::render(report, Format::Human)
205    )
206}
207
208/// Ensure `.bynk/` is gitignored on first build (cargo's `target/.gitignore`
209/// precedent — a `dev` run never dirties `git status`), then clear the
210/// `workers/` tree so selection only ever sees this build's contexts (D1).
211fn prepare_build_dir(project_root: &Path, build_dir: &Path) -> std::io::Result<()> {
212    let bynk_dir = project_root.join(".bynk");
213    std::fs::create_dir_all(&bynk_dir)?;
214    let gitignore = bynk_dir.join(".gitignore");
215    if !gitignore.exists() {
216        std::fs::write(&gitignore, "*\n")?;
217    }
218    let workers = build_dir.join("workers");
219    match std::fs::remove_dir_all(&workers) {
220        Ok(()) => Ok(()),
221        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
222        Err(e) => Err(e),
223    }
224}
225
226/// The worker directories under `<build>/workers/` that carry a `wrangler.toml`
227/// (the unit `wrangler dev` can serve), sorted for deterministic messages.
228fn discover_workers(workers_dir: &Path) -> Vec<String> {
229    let mut names = Vec::new();
230    let Ok(entries) = std::fs::read_dir(workers_dir) else {
231        return names;
232    };
233    for entry in entries.flatten() {
234        let path = entry.path();
235        if path.join("wrangler.toml").is_file()
236            && let Some(name) = path.file_name().and_then(|n| n.to_str())
237        {
238            names.push(name.to_string());
239        }
240    }
241    names.sort();
242    names
243}
244
245/// Why context selection failed — rendered to the user with the next step.
246#[derive(Debug, PartialEq, Eq)]
247pub enum SelectError {
248    /// No worker was produced by the compile (e.g. an empty project).
249    NoneBuilt,
250    /// More than one context, and no `--context` to disambiguate.
251    Ambiguous(Vec<String>),
252    /// `--context NAME` named a context that doesn't exist.
253    NotFound {
254        requested: String,
255        available: Vec<String>,
256    },
257}
258
259impl std::fmt::Display for SelectError {
260    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261        match self {
262            SelectError::NoneBuilt => {
263                write!(
264                    f,
265                    "no workers were built — does the project define any contexts?"
266                )
267            }
268            SelectError::Ambiguous(available) => write!(
269                f,
270                "this project has several contexts — pass --context to choose one of: {}",
271                available.join(", ")
272            ),
273            SelectError::NotFound {
274                requested,
275                available,
276            } => write!(
277                f,
278                "no context `{requested}` — available: {}",
279                available.join(", ")
280            ),
281        }
282    }
283}
284
285/// Pick the worker dir to serve. Pure (the FS scan is done by the caller) so the
286/// select-or-default rule (D3) is unit-tested directly.
287///
288/// `available` are worker *directory* names (dots already dasherised, e.g.
289/// `commerce-payment`). A requested `--context` matches either the raw name or
290/// its dasherised form, so both `--context commerce.payment` and `--context
291/// commerce-payment` resolve.
292pub fn select_context(
293    available: &[String],
294    requested: Option<&str>,
295) -> Result<String, SelectError> {
296    match requested {
297        Some(name) => {
298            let dashed = name.replace('.', "-");
299            available
300                .iter()
301                .find(|d| d.as_str() == name || d.as_str() == dashed)
302                .cloned()
303                .ok_or_else(|| SelectError::NotFound {
304                    requested: name.to_string(),
305                    available: available.to_vec(),
306                })
307        }
308        None => match available {
309            [] => Err(SelectError::NoneBuilt),
310            [one] => Ok(one.clone()),
311            many => Err(SelectError::Ambiguous(many.to_vec())),
312        },
313    }
314}
315
316/// Build the `wrangler dev` invocation for a resolved provenance: an installed
317/// binary is run directly; an npx-provisionable one goes through `npx --yes`.
318/// `None` when wrangler is genuinely missing.
319fn wrangler_command(provenance: &Provenance) -> Option<Command> {
320    match provenance {
321        Provenance::Path(p) | Provenance::ProjectLocal(p) => {
322            let mut cmd = Command::new(p);
323            cmd.arg("dev");
324            Some(cmd)
325        }
326        Provenance::Npx => {
327            let mut cmd = Command::new("npx");
328            cmd.arg("--yes").arg("wrangler").arg("dev");
329            Some(cmd)
330        }
331        Provenance::Missing => None,
332    }
333}
334
335/// Map a child exit code to a process exit byte. A `None` code means the child
336/// was terminated by a signal (e.g. the Ctrl-C the terminal also delivered to
337/// us) — treat that as a clean stop rather than a driver failure.
338fn exit_byte(code: Option<i32>) -> u8 {
339    code.unwrap_or(0).clamp(0, 255) as u8
340}
341
342/// The `wrangler dev` flags `--inspect` injects (slice 3): the inspector port, so
343/// a JavaScript debugger can attach. Empty without `--inspect`. Injected ahead of
344/// the `--` passthrough, so an explicit `-- --inspector-port N` still wins.
345fn inspector_args(opts: &DevOptions) -> Vec<String> {
346    if opts.inspect {
347        vec![
348            "--inspector-port".to_string(),
349            opts.inspect_port.to_string(),
350        ]
351    } else {
352        Vec::new()
353    }
354}
355
356/// The compile options `bynk dev` builds for an in-process Workers compile —
357/// mirrors `bynkc`'s `project_options` (split when `<src>` is a project root,
358/// else single) so the build is identical to the previously-shelled
359/// `bynkc compile <src> --target workers`.
360fn dev_compile_options(src: &Path) -> CompileOptions {
361    if src.join("bynk.toml").exists() || src.join("src").is_dir() {
362        CompileOptions::split(src.to_path_buf(), read_project_paths(src))
363    } else {
364        CompileOptions::single(src.to_path_buf())
365    }
366    .target(BuildTarget::Workers)
367}
368
369/// Render a project compile failure with full ariadne source context — the
370/// front-end's flatten-then-delegate (ADR 0100, matching `bynkc`'s
371/// `print_project_failure`): attribute each error to its file snapshot here, in
372/// the front-end, and delegate the per-error rendering to `bynk-render`. An
373/// unattributed (project-level) error keeps the plain `[category] message` form.
374fn render_project_failure(failure: &ProjectFailure) {
375    let texts: std::collections::HashMap<&Path, &str> = failure
376        .snapshots
377        .iter()
378        .map(|(p, t)| (p.as_path(), t.as_str()))
379        .collect();
380    for ae in &failure.errors {
381        match ae
382            .source_path
383            .as_deref()
384            .and_then(|p| texts.get(p).map(|t| (p, *t)))
385        {
386            Some((path, text)) => {
387                let label = path.to_string_lossy().replace('\\', "/");
388                bynk_render::print_errors(std::slice::from_ref(&ae.error), text, &label);
389            }
390            None => {
391                eprintln!("[{}] {}", ae.error.category, ae.error.message);
392                for note in &ae.error.notes {
393                    eprintln!("  note: {note}");
394                }
395            }
396        }
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    fn names(v: &[&str]) -> Vec<String> {
405        v.iter().map(|s| s.to_string()).collect()
406    }
407
408    #[test]
409    fn sole_context_is_served_without_a_flag() {
410        assert_eq!(
411            select_context(&names(&["links"]), None),
412            Ok("links".to_string())
413        );
414    }
415
416    #[test]
417    fn ambiguous_without_context_lists_the_options() {
418        assert_eq!(
419            select_context(&names(&["api", "worker"]), None),
420            Err(SelectError::Ambiguous(names(&["api", "worker"])))
421        );
422    }
423
424    #[test]
425    fn no_workers_is_its_own_error() {
426        assert_eq!(select_context(&[], None), Err(SelectError::NoneBuilt));
427    }
428
429    #[test]
430    fn context_flag_selects_by_raw_or_dasherised_name() {
431        let avail = names(&["api", "commerce-payment"]);
432        assert_eq!(
433            select_context(&avail, Some("commerce-payment")),
434            Ok("commerce-payment".to_string())
435        );
436        // Dotted context name resolves to its dasherised worker dir.
437        assert_eq!(
438            select_context(&avail, Some("commerce.payment")),
439            Ok("commerce-payment".to_string())
440        );
441    }
442
443    #[test]
444    fn unknown_context_reports_what_is_available() {
445        assert_eq!(
446            select_context(&names(&["api"]), Some("nope")),
447            Err(SelectError::NotFound {
448                requested: "nope".to_string(),
449                available: names(&["api"]),
450            })
451        );
452    }
453
454    #[test]
455    fn exit_byte_maps_codes_and_signals() {
456        assert_eq!(exit_byte(Some(0)), 0);
457        assert_eq!(exit_byte(Some(1)), 1);
458        // Signal termination (None) is a clean stop, not a driver failure.
459        assert_eq!(exit_byte(None), 0);
460    }
461
462    #[test]
463    fn inspect_injects_the_inspector_port() {
464        let off = DevOptions::default();
465        assert!(
466            inspector_args(&off).is_empty(),
467            "no inspector args without --inspect"
468        );
469
470        let on = DevOptions {
471            inspect: true,
472            inspect_port: 9229,
473            ..Default::default()
474        };
475        assert_eq!(
476            inspector_args(&on),
477            vec!["--inspector-port".to_string(), "9229".to_string()]
478        );
479    }
480}