Skip to main content

bynk/
workers.rs

1//! Shared Workers-build plumbing for `bynk dev` and `bynk deploy`.
2//!
3//! Both commands do the same four things before they diverge: prepare a managed
4//! build directory, compile the project into it, discover the worker directories
5//! that came out, and shell `wrangler`. That machinery grew up inside `dev.rs`
6//! and `deploy.rs` reached across for it, which left `dev` reading as `deploy`'s
7//! utility library. It lives here instead — a sibling of both, owned by neither.
8
9use std::path::Path;
10use std::process::Command;
11
12use bynk_emit::project::{BuildTarget, SchemaLock};
13
14use crate::compiler::Compiler;
15use crate::probe::Provenance;
16
17/// One compile of the project into `build_dir`, on the same rooting rule as
18/// `bynkc compile <project_root>` (#524, via [`bynk_driver::project_options`]).
19/// Default: in-process. Escape hatch: a `BYNK_BYNKC` override shells *that*
20/// binary instead — the only path on which a second, skewable compiler enters
21/// (doctor reports its skew only here). Returns `false` on failure with the
22/// diagnostics already rendered.
23///
24/// `schema_registry` (#980): `true` for the real `bynk dev`/`bynk deploy`
25/// call sites — otherwise a deploy could ship a `schemaVersion` computed
26/// purely from `@schema(N)` annotations, diverging from what `bynkc compile`
27/// would have shipped for the same source (the write is a no-op when the
28/// tree is already up to date). `false` for
29/// `compile_once_warnings_behaviour.rs`, the only other caller: it compiles a
30/// **committed** repo fixture in place, the same hazard
31/// `bynkc/tests/e2e.rs`'s in-place fixtures have — an unconditional write
32/// would leave a real `bynk.schema.lock` in the tree on every test run.
33pub fn compile_once(
34    compiler: &Compiler,
35    project_root: &Path,
36    build_dir: &Path,
37    schema_registry: bool,
38) -> bool {
39    let used_override = matches!(compiler.origin, Some(crate::compiler::Origin::Override));
40    if let (true, Some(bynkc)) = (used_override, compiler.path.as_deref()) {
41        let status = Command::new(bynkc)
42            .arg("compile")
43            .arg(project_root)
44            .arg("--output")
45            .arg(build_dir)
46            .arg("--target")
47            .arg("workers")
48            .status();
49        return match status {
50            Ok(s) if s.success() => true,
51            Ok(_) => false,
52            Err(e) => {
53                eprintln!("bynk: could not run bynkc ({}): {e}", bynkc.display());
54                false
55            }
56        };
57    }
58    // #1078: `bynk-emit` touches no disk for `bynk.schema.lock` — read its
59    // current content here (verified-absent `None` for a fresh project) and
60    // hand it in; write the reconciled content back after a clean compile.
61    let schema_lock = if schema_registry {
62        match bynk_driver::schema_lock::read(project_root) {
63            Ok(existing) => SchemaLock::On { existing },
64            Err(e) => {
65                eprintln!(
66                    "bynk: could not read {}: {e}",
67                    bynk_driver::schema_lock::lock_path(project_root).display()
68                );
69                return false;
70            }
71        }
72    } else {
73        SchemaLock::Off
74    };
75    let options = match bynk_driver::try_project_options(project_root) {
76        Ok(o) => o.target(BuildTarget::Workers).schema_registry(schema_lock),
77        Err(e) => {
78            eprintln!("bynk: {e}");
79            return false;
80        }
81    };
82    let output = match bynk_emit::project::compile_project(&options) {
83        Ok(out) => out,
84        Err(failure) => {
85            // Render with full source context, exactly as the shelled `bynkc
86            // compile` did — the front-end's flatten-then-delegate (ADR 0100),
87            // shared with `bynk check` (see `crate::diagnostics`).
88            crate::diagnostics::render_project_failure(&failure);
89            return false;
90        }
91    };
92    // A write failure here is reported but does not fail the build — the
93    // same non-fatal handling `schema_registry::write`'s own eprintln used
94    // to give it when this lived inside `compile_project`.
95    if let Some(content) = &output.schema_lock
96        && let Err(e) = bynk_driver::schema_lock::write(project_root, content)
97    {
98        eprintln!(
99            "bynk: could not write {}: {e}",
100            bynk_driver::schema_lock::lock_path(project_root).display()
101        );
102    }
103    if let Err(e) = bynk_driver::write_output(&output, build_dir) {
104        eprintln!(
105            "bynk: could not write build output under `{}`: {e}",
106            build_dir.display()
107        );
108        return false;
109    }
110    // ADR 0117: surface non-failing warnings — the `BYNK_BYNKC` override above
111    // already does, via the shelled `bynkc compile`'s own stdout/stderr.
112    crate::diagnostics::print_project_warnings(&output.warnings, &output.snapshots);
113    true
114}
115
116/// Ensure `.bynk/` is gitignored on first build (cargo's `target/.gitignore`
117/// precedent — a `dev` run never dirties `git status`), then clear the
118/// `workers/` tree so selection only ever sees this build's contexts (D1).
119pub fn prepare_build_dir(project_root: &Path, build_dir: &Path) -> std::io::Result<()> {
120    let bynk_dir = project_root.join(".bynk");
121    std::fs::create_dir_all(&bynk_dir)?;
122    let gitignore = bynk_dir.join(".gitignore");
123    if !gitignore.exists() {
124        std::fs::write(&gitignore, "*\n")?;
125    }
126    let workers = build_dir.join("workers");
127    match std::fs::remove_dir_all(&workers) {
128        Ok(()) => Ok(()),
129        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
130        Err(e) => Err(e),
131    }
132}
133
134/// The worker directories under `<build>/workers/` that carry a `wrangler.toml`
135/// (the unit `wrangler dev` can serve), sorted for deterministic messages.
136pub fn discover_workers(workers_dir: &Path) -> Vec<String> {
137    let mut names = Vec::new();
138    let Ok(entries) = std::fs::read_dir(workers_dir) else {
139        return names;
140    };
141    for entry in entries.flatten() {
142        let path = entry.path();
143        if path.join("wrangler.toml").is_file()
144            && let Some(name) = path.file_name().and_then(|n| n.to_str())
145        {
146            names.push(name.to_string());
147        }
148    }
149    names.sort();
150    names
151}
152
153/// Why context selection failed — rendered to the user with the next step.
154#[derive(Debug, PartialEq, Eq)]
155pub enum SelectError {
156    /// No worker was produced by the compile (e.g. an empty project).
157    NoneBuilt,
158    /// `--context NAME` named a context that doesn't exist.
159    NotFound {
160        requested: String,
161        available: Vec<String>,
162    },
163}
164
165impl std::fmt::Display for SelectError {
166    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167        match self {
168            SelectError::NoneBuilt => {
169                write!(
170                    f,
171                    "no workers were built — does the project define any contexts?"
172                )
173            }
174            SelectError::NotFound {
175                requested,
176                available,
177            } => write!(
178                f,
179                "no context `{requested}` — available: {}",
180                available.join(", ")
181            ),
182        }
183    }
184}
185
186/// Match one requested context against the built worker dirs, accepting either
187/// the dotted name or its dasherised form (`commerce.payment` /
188/// `commerce-payment`).
189fn resolve_one(available: &[String], name: &str) -> Result<String, SelectError> {
190    let dashed = name.replace('.', "-");
191    available
192        .iter()
193        .find(|d| d.as_str() == name || d.as_str() == dashed)
194        .cloned()
195        .ok_or_else(|| SelectError::NotFound {
196            requested: name.to_string(),
197            available: available.to_vec(),
198        })
199}
200
201/// Pick the workers `dev` will serve **together** (#552). No `--context` serves
202/// every context in the project — the whole point of the increment, since a
203/// cross-context call only resolves when its callee is up too. `--context` is
204/// repeatable and narrows to a subset, in `available`'s deterministic order
205/// rather than the order they were typed, and duplicates collapse.
206///
207/// There is no `Ambiguous` case: several contexts is the expected shape, not a
208/// failure. Pure (the FS scan is the caller's) so the rule is unit-tested.
209pub fn select_contexts(
210    available: &[String],
211    requested: &[String],
212) -> Result<Vec<String>, SelectError> {
213    if available.is_empty() {
214        return Err(SelectError::NoneBuilt);
215    }
216    if requested.is_empty() {
217        return Ok(available.to_vec());
218    }
219    let mut chosen = Vec::new();
220    for name in requested {
221        let worker = resolve_one(available, name)?;
222        if !chosen.contains(&worker) {
223            chosen.push(worker);
224        }
225    }
226    chosen.sort();
227    Ok(chosen)
228}
229
230/// Build the `wrangler dev` invocation for a resolved provenance: an installed
231/// binary is run directly; an npx-provisionable one goes through `npx --yes`.
232/// `None` when wrangler is genuinely missing.
233pub fn wrangler_command(provenance: &Provenance, subcommand: &str) -> Option<Command> {
234    match provenance {
235        Provenance::Path(p) | Provenance::ProjectLocal(p) => {
236            let mut cmd = Command::new(p);
237            cmd.arg(subcommand);
238            Some(cmd)
239        }
240        Provenance::Npx => {
241            let mut cmd = Command::new("npx");
242            // #524: pinned provisioning, per the repo's npx convention — an
243            // unpinned `wrangler` here meant the dev server could drift from
244            // the wrangler the tests and deploys run.
245            cmd.arg("--yes").arg("wrangler@4").arg(subcommand);
246            Some(cmd)
247        }
248        Provenance::Missing => None,
249    }
250}