Skip to main content

bynk_check/
worker_bindings.rs

1//! A Worker's binding names must be unique (`bynk.context.duplicate_binding`).
2//!
3//! A Worker's bindings come from derivations that know nothing of each other:
4//! the KV namespace's fixed [`crate::firstparty::KV_BINDING_NAME`], a consumed
5//! context's name (`a.b` → `A_B`), an agent's class name (`OrderEntity` →
6//! `ORDER_ENTITY`) and a derived capability's (`Billing` →
7//! `SERVICE_BILLING`). Two of them can meet: an agent `Kv` in a context that
8//! consumes `bynk.cloudflare { Kv }` is `KV` twice, and an agent
9//! `ServiceBilling` and a `capability Billing = Service` are both
10//! `SERVICE_BILLING`. Wrangler takes such a
11//! config without complaint and one binding hides the other, so the clash is
12//! a compile error. The names themselves are never adjusted to avoid it,
13//! because deployed Workers depend on them.
14//!
15//! The names are the emitter's (`bynk_emit::emitter::wrangler::
16//! worker_bindings`, the list `wrangler.toml` is written from); this module
17//! owns the check over them and the diagnostic.
18
19use std::collections::{BTreeMap, HashMap};
20use std::path::PathBuf;
21
22use bynk_project::ParsedFile;
23use bynk_syntax::ast::{CommonsItem, SourceUnit};
24use bynk_syntax::error::CompileError;
25use bynk_syntax::span::Span;
26
27use crate::firstparty;
28use crate::project_model::ErrorSink;
29
30/// A declaration's name in screaming snake case (`OrderEntity` →
31/// `ORDER_ENTITY`): the casing of an agent's binding and of a derived
32/// capability's. One function, so the two kinds of name the duplicate check
33/// compares cannot drift apart (review of #1968).
34pub fn screaming_snake(name: &str) -> String {
35    let mut out = String::new();
36    for (i, ch) in name.chars().enumerate() {
37        if i > 0 && ch.is_uppercase() {
38            out.push('_');
39        }
40        out.push(ch.to_ascii_uppercase());
41    }
42    out
43}
44
45/// S4a (#1966): the Worker binding name of a derived capability, the kind's
46/// prefix then the capability name in [`screaming_snake`] case (`Sessions` →
47/// `KV_SESSIONS`, `PriceCache` → `KV_PRICE_CACHE`).
48pub fn instance_binding_name(prefix: &str, capability: &str) -> String {
49    format!("{prefix}_{}", screaming_snake(capability))
50}
51
52/// What put a binding into a Worker's `wrangler.toml`: the source a
53/// `bynk.context.duplicate_binding` diagnostic names.
54///
55/// Each variant's doc also says whether a handler's `deps.env` type exposes
56/// it on Workers (#1950, `bynk_emit::emitter::wrangler::handler_bindings`).
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum BindingSource {
59    /// A `[[services]]` Service Binding to another context's Worker: a
60    /// consumed context whose services the context calls (#1961 — consuming
61    /// one for its types or events alone binds nothing), or one subscribing to
62    /// an event it publishes. Handlers see a called context's binding; never a
63    /// subscriber's, which only the events fan-out calls.
64    Service { context: String },
65    /// The `[[kv_namespaces]]` binding the cloudflare adapter's `Kv` reads.
66    /// Never seen by handlers: they reach KV through the `WorkersKv` provider,
67    /// which takes the Worker's `env` at compose time.
68    Kv,
69    /// S4a (#1966): the `[[kv_namespaces]]` binding of a derived capability
70    /// (`capability Sessions = Kv`), one per instance. Never seen by handlers:
71    /// they reach it through the instance's `WorkersKv`.
72    KvInstance { capability: String },
73    /// Interop I5 (#1972): the `[[services]]` binding of a `Service`
74    /// instance (`capability Billing = Service @worker("legacy-billing")`), a
75    /// Service Binding to a Worker outside the project. Never seen by
76    /// handlers: they reach it through the instance's `WorkersService`.
77    ForeignService { capability: String, worker: String },
78    /// P5 (#2323): the `[[r2_buckets]]` binding of an `R2` instance
79    /// (`capability Archives = R2`), one bucket per instance, pinned to
80    /// `jurisdiction` when it names one. Never seen by handlers: they reach
81    /// it through the instance's `WorkersR2`.
82    R2Instance {
83        capability: String,
84        jurisdiction: Option<String>,
85    },
86    /// Standard-library P3 (#2342): the `[[ratelimits]]` binding of a
87    /// `RateLimit` instance (`capability LoginLimit = RateLimit @limit(100)
88    /// @period(60.seconds)`), allowing `limit` calls per key every `period`
89    /// seconds. Never seen by handlers: they reach it through the instance's
90    /// `WorkersRateLimit`.
91    RateLimitInstance {
92        capability: String,
93        limit: i64,
94        period: i64,
95    },
96    /// A `[[durable_objects.bindings]]` entry for one of the context's agents.
97    /// A handler that constructs an agent sees every local agent's binding.
98    Agent { class_name: String },
99    /// The `[[durable_objects.bindings]]` entry for the events fan-out class
100    /// of a context whose handlers emit. Never seen by handlers: they reach it
101    /// through `deps.__eventsDispatch`.
102    EventsFanout { class_name: String },
103}
104
105impl BindingSource {
106    /// The source in a diagnostic's words.
107    pub fn describe(&self) -> String {
108        match self {
109            Self::Service { context } => format!("the Service Binding to context `{context}`"),
110            Self::Kv => format!(
111                "the KV namespace binding of `{}`'s `Kv`",
112                firstparty::CLOUDFLARE_UNIT
113            ),
114            Self::KvInstance { capability } => {
115                format!("the KV namespace binding of derived capability `{capability}`")
116            }
117            Self::ForeignService { capability, worker } => format!(
118                "the Service Binding of derived capability `{capability}` (to Worker `{worker}`)"
119            ),
120            Self::R2Instance { capability, .. } => {
121                format!("the R2 bucket binding of derived capability `{capability}`")
122            }
123            Self::RateLimitInstance { capability, .. } => {
124                format!("the rate limiting binding of derived capability `{capability}`")
125            }
126            Self::Agent { class_name } => {
127                format!("the Durable Object binding of agent `{class_name}`")
128            }
129            Self::EventsFanout { .. } => {
130                "the Durable Object binding of the events fan-out".to_string()
131            }
132        }
133    }
134}
135
136/// One binding a Worker declares: the name its `env` field and its
137/// `wrangler.toml` entry share, and where that name came from.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct WorkerBinding {
140    pub name: String,
141    pub source: BindingSource,
142}
143
144/// The bindings in `bindings` whose name an earlier one already took, each
145/// paired with that earlier one, in list order.
146pub fn duplicate_bindings(bindings: &[WorkerBinding]) -> Vec<(&WorkerBinding, &WorkerBinding)> {
147    let mut first: HashMap<&str, &WorkerBinding> = HashMap::new();
148    let mut duplicates = Vec::new();
149    for binding in bindings {
150        match first.get(binding.name.as_str()) {
151            Some(earlier) => duplicates.push((*earlier, binding)),
152            None => {
153                first.insert(&binding.name, binding);
154            }
155        }
156    }
157    duplicates
158}
159
160/// `bynk.context.duplicate_binding`: each context's Worker declares every
161/// binding name once. `bindings` holds each context's bindings as its
162/// `wrangler.toml` would list them. One error per clash, at the declaration
163/// that brought in the second binding (an agent, or a `consumes` clause),
164/// preferring one the author wrote over `consumes bynk.cloudflare`, naming
165/// both sources. A clash between two sources with no declaration of their own
166/// (Service Bindings added for event subscribers) is reported at the
167/// context's name.
168pub fn phase_duplicate_bindings(
169    parsed: &[ParsedFile],
170    groups: &BTreeMap<String, Vec<usize>>,
171    bindings: &BTreeMap<String, Vec<WorkerBinding>>,
172    errors: &mut ErrorSink,
173) {
174    for (ctx, ctx_bindings) in bindings {
175        let indices = groups.get(ctx).map(Vec::as_slice).unwrap_or_default();
176        for (earlier, later) in duplicate_bindings(ctx_bindings) {
177            // At the declaration the author can change: the later source's,
178            // else the earlier's, but never the `bynk.cloudflare` clause
179            // while the other source has a declaration of its own.
180            let mut sources = [later, earlier];
181            sources.sort_by_key(|b| b.source == BindingSource::Kv);
182            let (file, span) = sources
183                .iter()
184                .find_map(|b| declared_at(parsed, indices, b))
185                .or_else(|| context_header(parsed, indices))
186                .map_or((None, Span::default()), |(file, span)| (Some(file), span));
187            errors.push_for(
188                file.as_deref(),
189                CompileError::new(
190                    "bynk.context.duplicate_binding",
191                    span,
192                    format!(
193                        "the Worker for `{ctx}` would declare two bindings named `{}`: {} and {}",
194                        later.name,
195                        earlier.source.describe(),
196                        later.source.describe(),
197                    ),
198                )
199                .with_note(
200                    "a Worker's bindings share one `env`, so each name must be unique; binding names follow agent, context and derived capability names, so rename the declaration behind one of them",
201                ),
202            );
203        }
204    }
205}
206
207/// Where the context declares the source of `binding`: an agent's name, or
208/// the `consumes` clause naming a context (or `bynk.cloudflare`, for the KV
209/// namespace). `None` for a source with no declaration of its own: the events
210/// fan-out, or a Service Binding added for an event subscriber.
211fn declared_at(
212    parsed: &[ParsedFile],
213    indices: &[usize],
214    binding: &WorkerBinding,
215) -> Option<(PathBuf, Span)> {
216    indices.iter().find_map(|&i| {
217        let SourceUnit::Context(c) = parsed[i].unit() else {
218            return None;
219        };
220        let consumed = |target: &str| {
221            c.consumes
222                .iter()
223                .find(|d| d.target.joined() == target)
224                .map(|d| d.span)
225        };
226        let span = match &binding.source {
227            BindingSource::Agent { class_name } => c.items.iter().find_map(|item| {
228                let CommonsItem::Agent(a) = item else {
229                    return None;
230                };
231                (a.name.name == *class_name).then_some(a.name.span)
232            }),
233            BindingSource::Service { context } => consumed(context),
234            BindingSource::Kv => consumed(firstparty::CLOUDFLARE_UNIT),
235            BindingSource::KvInstance { capability }
236            | BindingSource::ForeignService { capability, .. }
237            | BindingSource::R2Instance { capability, .. }
238            | BindingSource::RateLimitInstance { capability, .. } => {
239                c.items.iter().find_map(|item| {
240                    let CommonsItem::Capability(d) = item else {
241                        return None;
242                    };
243                    (d.name.name == *capability && d.derived.is_some()).then_some(d.span)
244                })
245            }
246            BindingSource::EventsFanout { .. } => None,
247        }?;
248        Some((parsed[i].identity_path(), span))
249    })
250}
251
252/// The context's name in its first file: where a clash between two sources
253/// with no declaration of their own is reported.
254fn context_header(parsed: &[ParsedFile], indices: &[usize]) -> Option<(PathBuf, Span)> {
255    indices.iter().find_map(|&i| {
256        let SourceUnit::Context(c) = parsed[i].unit() else {
257            return None;
258        };
259        Some((parsed[i].identity_path(), c.name.span))
260    })
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    fn service(name: &str, context: &str) -> WorkerBinding {
268        WorkerBinding {
269            name: name.to_string(),
270            source: BindingSource::Service {
271                context: context.to_string(),
272            },
273        }
274    }
275
276    #[test]
277    fn distinct_names_do_not_clash() {
278        assert!(duplicate_bindings(&[service("A", "a"), service("B", "b")]).is_empty());
279    }
280
281    /// Three bindings on one name are two clashes, each against the first.
282    #[test]
283    fn every_repeat_is_reported_against_the_first() {
284        let bindings = [
285            service("A_B", "a.b"),
286            service("A_B", "a_b"),
287            service("A_B", "x"),
288        ];
289        let duplicates = duplicate_bindings(&bindings);
290        assert_eq!(duplicates.len(), 2);
291        assert!(
292            duplicates
293                .iter()
294                .all(|(earlier, _)| *earlier == &bindings[0])
295        );
296        assert_eq!(duplicates[1].1, &bindings[2]);
297    }
298}