bynk 0.80.0

The Bynk developer front-end — links the compiler pipeline in-process and orchestrates the Node toolchain (doctor / new / dev).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
//! `bynk dev` — build a project and serve it locally in one step.
//!
//! Collapses the manual recipe (compile → `cd` into the generated worker dir →
//! `wrangler dev`) into a single command (proposal v0.57). The orchestration is
//! **pre-flight → compile → select → serve**, and almost every piece is reused:
//! [`compiler::resolve`](crate::compiler) for `bynkc`, the doctor `Deploy`
//! capability for the Node + `wrangler` gate, and [`probe`] for locating
//! `wrangler` with the same provenance ordering doctor reports.
//!
//! The serve step runs `wrangler dev` in **local mode** (Miniflare), which
//! simulates KV / Durable Objects / queues keyed by *binding name* — so no
//! namespace provisioning is needed and the generated `wrangler.toml` is served
//! untouched (proposal §1, D4). Everything `wrangler`-specific is encapsulated
//! here so the serve step can later be swapped for a first-party `workerd`
//! server without touching the rest (proposal §4).

use std::path::Path;
use std::process::{Command, ExitCode};

use bynk_emit::project::{BuildTarget, CompileOptions, ProjectFailure, read_project_paths};

use crate::compiler::Compiler;
use crate::doctor::{self, Capability, Context, DoctorOptions, Report};
use crate::probe::{self, DetectOpts, Provenance, Toolbox};
use crate::report::{self, Format};

/// Parsed `bynk dev` flags (the project `PATH` is resolved into `project_root`
/// before we get here).
#[derive(Debug, Clone, Default)]
pub struct DevOptions {
    /// `--context NAME` — which context's worker to serve.
    pub context: Option<String>,
    /// `--inspect` (slice 3): start `wrangler dev` with the V8 inspector so a
    /// JavaScript debugger can attach; breakpoints in `.bynk` resolve through the
    /// emitted source maps composed into the worker bundle.
    pub inspect: bool,
    /// Inspector port for `--inspect` (default 9229).
    pub inspect_port: u16,
    /// Everything after `--`, forwarded to `wrangler dev` verbatim (D5).
    pub wrangler_args: Vec<String>,
}

/// Orchestrate a local dev session: pre-flight, compile, select the worker, and
/// hand off to `wrangler dev`. Returns wrangler's own exit code on a clean
/// hand-off, or a pre-flight/build failure code before serving.
pub fn run(
    tb: &dyn Toolbox,
    compiler: &Compiler,
    project_root: &Path,
    src_rel: &Path,
    node_floor: u32,
    opts: &DevOptions,
) -> ExitCode {
    // 1. Pre-flight — reuse doctor's Deploy gate (Node + wrangler) plus the
    //    always-on compile floor. Failing here, with doctor's remedy text, beats
    //    a confusing error out of a half-built tree (proposal §2.2).
    let ctx = Context {
        project_root: Some(project_root.to_path_buf()),
        in_repo: false,
        node_floor,
    };
    let preflight_opts = DoctorOptions {
        only: Some(Capability::Deploy),
        strict: false,
    };
    let report = doctor::diagnose(tb, compiler, &ctx, &preflight_opts);
    if report.exit_nonzero(&preflight_opts) {
        eprint!("{}", preflight_failure_message(&report));
        return ExitCode::FAILURE;
    }
    // 2. Compile — in-process (slice 7: the driver links the pipeline instead of
    //    shelling `bynkc`). Into the managed `.bynk/dev/` build dir (D1).
    //    Compilation is additive (never prunes), so clear `workers/` first;
    //    otherwise a renamed/deleted context would linger and spuriously trip the
    //    §2.4 ambiguity check.
    let build_dir = project_root.join(".bynk").join("dev");
    if let Err(e) = prepare_build_dir(project_root, &build_dir) {
        eprintln!("bynk: could not prepare build directory: {e}");
        return ExitCode::FAILURE;
    }
    let src = project_root.join(src_rel);
    // Default: compile in-process. Escape hatch: if `BYNK_BYNKC` pointed the
    // driver at an external compiler (`Origin::Override`), shell *that* binary
    // instead — the only path on which a second, skewable compiler enters
    // (doctor reports its skew only here). With no override there is no separate
    // compiler to drift against.
    let used_override = matches!(compiler.origin, Some(crate::compiler::Origin::Override));
    if let (true, Some(bynkc)) = (used_override, compiler.path.as_deref()) {
        let status = Command::new(bynkc)
            .arg("compile")
            .arg(&src)
            .arg("--output")
            .arg(&build_dir)
            .arg("--target")
            .arg("workers")
            .status();
        match status {
            Ok(s) if s.success() => {}
            Ok(s) => return ExitCode::from(exit_byte(s.code())),
            Err(e) => {
                eprintln!("bynk: could not run bynkc ({}): {e}", bynkc.display());
                return ExitCode::FAILURE;
            }
        }
    } else {
        let options = dev_compile_options(&src);
        let output = match bynk_emit::project::compile_project(&options) {
            Ok(out) => out,
            Err(failure) => {
                // Render with full source context, exactly as the shelled `bynkc
                // compile` did — the front-end's flatten-then-delegate (ADR 0100):
                // the ProjectFailure → CompileError flattening stays here; the
                // per-error rendering delegates to `bynk-render`.
                render_project_failure(&failure);
                return ExitCode::FAILURE;
            }
        };
        if let Err(e) = bynk_emit::write_output(&output, &build_dir) {
            eprintln!(
                "bynk: could not write build output under `{}`: {e}",
                build_dir.display()
            );
            return ExitCode::FAILURE;
        }
    }

    // 3. Select the worker — exactly one, or the one named by `--context` (D3).
    let workers_dir = build_dir.join("workers");
    let available = discover_workers(&workers_dir);
    let worker = match select_context(&available, opts.context.as_deref()) {
        Ok(w) => w,
        Err(e) => {
            eprintln!("bynk: {e}");
            return ExitCode::FAILURE;
        }
    };
    let worker_dir = workers_dir.join(&worker);

    // 4. Serve — `wrangler dev` from inside the worker dir (its `index.ts`
    //    imports `../../runtime.js`, so cwd must be the worker dir, exactly the
    //    manual recipe's `cd`). Resolve wrangler with doctor's provenance
    //    ordering; an npx resolution downloads on first use, so it is a notice,
    //    never a silent green path.
    let probe = probe::detect(
        tb,
        "wrangler",
        DetectOpts {
            project_root: Some(project_root),
            allow_npx: true,
        },
    );
    let mut cmd = match wrangler_command(&probe.provenance) {
        Some(cmd) => cmd,
        None => {
            // The pre-flight gate should have caught this; defensive only.
            eprintln!("bynk: wrangler not found (run `bynk doctor --only deploy`)");
            return ExitCode::FAILURE;
        }
    };
    if matches!(probe.provenance, Provenance::Npx) {
        eprintln!("bynk: wrangler resolved via npx — it will download on first run.");
    }
    cmd.current_dir(&worker_dir);
    // Slice 3 (ADR 0104): `--inspect` starts wrangler with the V8 inspector so a
    // JavaScript debugger can attach. Injected before the `--` passthrough, so a
    // power user's explicit `-- --inspector-port N` still wins. A `.bynk`
    // breakpoint resolves through the emitted source map, which esbuild composes
    // into the worker bundle.
    for arg in inspector_args(opts) {
        cmd.arg(arg);
    }
    if opts.inspect {
        let port = opts.inspect_port;
        eprintln!("bynk dev --inspect: the worker runs with the V8 inspector enabled.");
        eprintln!("  Attach a JavaScript debugger to the inspector on port {port} (CDP discovery:");
        eprintln!("  http://127.0.0.1:{port}/json). Breakpoints set in `.bynk` sources resolve");
        eprintln!("  through the emitted source maps. A hand-rolled CDP client must send an");
        eprintln!("  `Origin` header — VS Code's JavaScript debugger does this for you.");
    }
    for arg in &opts.wrangler_args {
        cmd.arg(arg);
    }

    // Inherited stdio (the default) keeps the session interactive. The driver
    // and wrangler share the terminal's foreground process group, so a Ctrl-C
    // SIGINT reaches both — we must not bail before reaping the child; we wait
    // and propagate its exit code (proposal §2.5).
    match cmd.status() {
        Ok(s) => ExitCode::from(exit_byte(s.code())),
        Err(e) => {
            eprintln!("bynk: could not run wrangler: {e}");
            ExitCode::FAILURE
        }
    }
}

/// The text `bynk dev` prints when the deploy pre-flight fails: a lead line plus
/// doctor's own human report, so the remedy lines are identical to `bynk
/// doctor`. Pure (no I/O) so this deterministic surface is pinned by a golden
/// (§5), unlike the non-deterministic `wrangler dev` stream.
pub fn preflight_failure_message(report: &Report) -> String {
    format!(
        "bynk: environment not ready for `dev` — see below.\n\n{}",
        report::render(report, Format::Human)
    )
}

/// Ensure `.bynk/` is gitignored on first build (cargo's `target/.gitignore`
/// precedent — a `dev` run never dirties `git status`), then clear the
/// `workers/` tree so selection only ever sees this build's contexts (D1).
fn prepare_build_dir(project_root: &Path, build_dir: &Path) -> std::io::Result<()> {
    let bynk_dir = project_root.join(".bynk");
    std::fs::create_dir_all(&bynk_dir)?;
    let gitignore = bynk_dir.join(".gitignore");
    if !gitignore.exists() {
        std::fs::write(&gitignore, "*\n")?;
    }
    let workers = build_dir.join("workers");
    match std::fs::remove_dir_all(&workers) {
        Ok(()) => Ok(()),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(e) => Err(e),
    }
}

/// The worker directories under `<build>/workers/` that carry a `wrangler.toml`
/// (the unit `wrangler dev` can serve), sorted for deterministic messages.
fn discover_workers(workers_dir: &Path) -> Vec<String> {
    let mut names = Vec::new();
    let Ok(entries) = std::fs::read_dir(workers_dir) else {
        return names;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.join("wrangler.toml").is_file()
            && let Some(name) = path.file_name().and_then(|n| n.to_str())
        {
            names.push(name.to_string());
        }
    }
    names.sort();
    names
}

/// Why context selection failed — rendered to the user with the next step.
#[derive(Debug, PartialEq, Eq)]
pub enum SelectError {
    /// No worker was produced by the compile (e.g. an empty project).
    NoneBuilt,
    /// More than one context, and no `--context` to disambiguate.
    Ambiguous(Vec<String>),
    /// `--context NAME` named a context that doesn't exist.
    NotFound {
        requested: String,
        available: Vec<String>,
    },
}

impl std::fmt::Display for SelectError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            SelectError::NoneBuilt => {
                write!(
                    f,
                    "no workers were built — does the project define any contexts?"
                )
            }
            SelectError::Ambiguous(available) => write!(
                f,
                "this project has several contexts — pass --context to choose one of: {}",
                available.join(", ")
            ),
            SelectError::NotFound {
                requested,
                available,
            } => write!(
                f,
                "no context `{requested}` — available: {}",
                available.join(", ")
            ),
        }
    }
}

/// Pick the worker dir to serve. Pure (the FS scan is done by the caller) so the
/// select-or-default rule (D3) is unit-tested directly.
///
/// `available` are worker *directory* names (dots already dasherised, e.g.
/// `commerce-payment`). A requested `--context` matches either the raw name or
/// its dasherised form, so both `--context commerce.payment` and `--context
/// commerce-payment` resolve.
pub fn select_context(
    available: &[String],
    requested: Option<&str>,
) -> Result<String, SelectError> {
    match requested {
        Some(name) => {
            let dashed = name.replace('.', "-");
            available
                .iter()
                .find(|d| d.as_str() == name || d.as_str() == dashed)
                .cloned()
                .ok_or_else(|| SelectError::NotFound {
                    requested: name.to_string(),
                    available: available.to_vec(),
                })
        }
        None => match available {
            [] => Err(SelectError::NoneBuilt),
            [one] => Ok(one.clone()),
            many => Err(SelectError::Ambiguous(many.to_vec())),
        },
    }
}

/// Build the `wrangler dev` invocation for a resolved provenance: an installed
/// binary is run directly; an npx-provisionable one goes through `npx --yes`.
/// `None` when wrangler is genuinely missing.
fn wrangler_command(provenance: &Provenance) -> Option<Command> {
    match provenance {
        Provenance::Path(p) | Provenance::ProjectLocal(p) => {
            let mut cmd = Command::new(p);
            cmd.arg("dev");
            Some(cmd)
        }
        Provenance::Npx => {
            let mut cmd = Command::new("npx");
            cmd.arg("--yes").arg("wrangler").arg("dev");
            Some(cmd)
        }
        Provenance::Missing => None,
    }
}

/// Map a child exit code to a process exit byte. A `None` code means the child
/// was terminated by a signal (e.g. the Ctrl-C the terminal also delivered to
/// us) — treat that as a clean stop rather than a driver failure.
fn exit_byte(code: Option<i32>) -> u8 {
    code.unwrap_or(0).clamp(0, 255) as u8
}

/// The `wrangler dev` flags `--inspect` injects (slice 3): the inspector port, so
/// a JavaScript debugger can attach. Empty without `--inspect`. Injected ahead of
/// the `--` passthrough, so an explicit `-- --inspector-port N` still wins.
fn inspector_args(opts: &DevOptions) -> Vec<String> {
    if opts.inspect {
        vec![
            "--inspector-port".to_string(),
            opts.inspect_port.to_string(),
        ]
    } else {
        Vec::new()
    }
}

/// The compile options `bynk dev` builds for an in-process Workers compile —
/// mirrors `bynkc`'s `project_options` (split when `<src>` is a project root,
/// else single) so the build is identical to the previously-shelled
/// `bynkc compile <src> --target workers`.
fn dev_compile_options(src: &Path) -> CompileOptions {
    if src.join("bynk.toml").exists() || src.join("src").is_dir() {
        CompileOptions::split(src.to_path_buf(), read_project_paths(src))
    } else {
        CompileOptions::single(src.to_path_buf())
    }
    .target(BuildTarget::Workers)
}

/// Render a project compile failure with full ariadne source context — the
/// front-end's flatten-then-delegate (ADR 0100, matching `bynkc`'s
/// `print_project_failure`): attribute each error to its file snapshot here, in
/// the front-end, and delegate the per-error rendering to `bynk-render`. An
/// unattributed (project-level) error keeps the plain `[category] message` form.
fn render_project_failure(failure: &ProjectFailure) {
    let texts: std::collections::HashMap<&Path, &str> = failure
        .snapshots
        .iter()
        .map(|(p, t)| (p.as_path(), t.as_str()))
        .collect();
    for ae in &failure.errors {
        match ae
            .source_path
            .as_deref()
            .and_then(|p| texts.get(p).map(|t| (p, *t)))
        {
            Some((path, text)) => {
                let label = path.to_string_lossy().replace('\\', "/");
                bynk_render::print_errors(std::slice::from_ref(&ae.error), text, &label);
            }
            None => {
                eprintln!("[{}] {}", ae.error.category, ae.error.message);
                for note in &ae.error.notes {
                    eprintln!("  note: {note}");
                }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn names(v: &[&str]) -> Vec<String> {
        v.iter().map(|s| s.to_string()).collect()
    }

    #[test]
    fn sole_context_is_served_without_a_flag() {
        assert_eq!(
            select_context(&names(&["links"]), None),
            Ok("links".to_string())
        );
    }

    #[test]
    fn ambiguous_without_context_lists_the_options() {
        assert_eq!(
            select_context(&names(&["api", "worker"]), None),
            Err(SelectError::Ambiguous(names(&["api", "worker"])))
        );
    }

    #[test]
    fn no_workers_is_its_own_error() {
        assert_eq!(select_context(&[], None), Err(SelectError::NoneBuilt));
    }

    #[test]
    fn context_flag_selects_by_raw_or_dasherised_name() {
        let avail = names(&["api", "commerce-payment"]);
        assert_eq!(
            select_context(&avail, Some("commerce-payment")),
            Ok("commerce-payment".to_string())
        );
        // Dotted context name resolves to its dasherised worker dir.
        assert_eq!(
            select_context(&avail, Some("commerce.payment")),
            Ok("commerce-payment".to_string())
        );
    }

    #[test]
    fn unknown_context_reports_what_is_available() {
        assert_eq!(
            select_context(&names(&["api"]), Some("nope")),
            Err(SelectError::NotFound {
                requested: "nope".to_string(),
                available: names(&["api"]),
            })
        );
    }

    #[test]
    fn exit_byte_maps_codes_and_signals() {
        assert_eq!(exit_byte(Some(0)), 0);
        assert_eq!(exit_byte(Some(1)), 1);
        // Signal termination (None) is a clean stop, not a driver failure.
        assert_eq!(exit_byte(None), 0);
    }

    #[test]
    fn inspect_injects_the_inspector_port() {
        let off = DevOptions::default();
        assert!(
            inspector_args(&off).is_empty(),
            "no inspector args without --inspect"
        );

        let on = DevOptions {
            inspect: true,
            inspect_port: 9229,
            ..Default::default()
        };
        assert_eq!(
            inspector_args(&on),
            vec!["--inspector-port".to_string(), "9229".to_string()]
        );
    }
}