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