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}