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}