1use std::collections::{BTreeMap, BTreeSet, HashMap};
41use std::sync::Arc;
42
43use bynk_check::project_model::TypeDecl;
44use bynk_check::resolver::{CrossContextService, cross_context_service_for};
45use bynk_check::symbols::UnitTable;
46use bynk_check::wire::{TypeBody, TypeRef};
47use bynk_ts::{
48 TsDecl, TsExpr, TsLit, TsObjectEntry, TsParam, TsProgram, TsStmt, TsType, TsTypeMember,
49};
50
51use super::runtime_use::RuntimeUse;
52use super::serialisation;
53
54pub const CLIENT_FILE: &str = "bynk-client.ts";
58
59pub const CLIENT_PACKAGE: &str = "@bynk/wire-client";
61
62const SURFACE_TYPES: &[&str] = &[
66 "Admits",
67 "Client",
68 "ConnectOptions",
69 "Env",
70 "ForeignCaller",
71 "Services",
72];
73
74const PACKAGE_NAMES: &[(&str, &str)] = &[
79 ("Err", "Err"),
80 ("None", "None"),
81 ("Ok", "Ok"),
82 ("Option", "type Option"),
83 ("Result", "type Result"),
84 ("Some", "Some"),
85 ("ValidationError", "type ValidationError"),
86 ("__BoundaryError", "type __BoundaryError"),
87 ("__JsonValue", "type __JsonValue"),
88 ("__ServiceBinding", "type __ServiceBinding"),
89 ("__WireVersion", "type WireVersion as __WireVersion"),
90 ("__boundaryError", "__boundaryError"),
91 ("__bynkBytesFromBase64", "__bynkBytesFromBase64"),
92 ("__bynkBytesToBase64", "__bynkBytesToBase64"),
93 ("__callService", "__callService"),
94];
95
96struct ClientService {
98 svc: CrossContextService,
99 admits: Vec<String>,
101 hash: String,
102}
103
104pub fn foreign_contracts(
109 table: &UnitTable,
110 own_types: &HashMap<String, Arc<TypeDecl>>,
111) -> BTreeMap<String, BTreeMap<String, String>> {
112 let mut out: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
113 for s in client_services(table, own_types) {
114 for actor in &s.admits {
115 out.entry(actor.clone())
116 .or_default()
117 .insert(s.svc.name.clone(), s.hash.clone());
118 }
119 }
120 out
121}
122
123fn client_services(
125 table: &UnitTable,
126 own_types: &HashMap<String, Arc<TypeDecl>>,
127) -> Vec<ClientService> {
128 let mut names: Vec<&String> = table.services.keys().collect();
129 names.sort();
130 let mut out = Vec::new();
131 for name in names {
132 let sdecl = &table.services[name];
133 let Some(svc) = cross_context_service_for(name, sdecl) else {
134 continue;
135 };
136 let mut admits: Vec<String> = sdecl
137 .handlers
138 .iter()
139 .find_map(|h| bynk_check::actors::call_admission_for(h, &table.actors))
140 .map(|a| a.foreign.into_iter().map(|f| f.actor).collect())
141 .unwrap_or_default();
142 if admits.is_empty() {
143 continue;
144 }
145 admits.sort();
146 admits.dedup();
147 let hash = bynk_check::contract::service_contract_hash(&svc, own_types);
148 out.push(ClientService { svc, admits, hash });
149 }
150 out
151}
152
153pub(crate) fn emit_client(
157 ctx_name: &str,
158 table: &UnitTable,
159 own_types: &HashMap<String, Arc<TypeDecl>>,
160) -> Option<TsProgram> {
161 let services = client_services(table, own_types);
162 if services.is_empty() {
163 return None;
164 }
165 let actors: BTreeSet<&String> = services.iter().flat_map(|s| s.admits.iter()).collect();
166
167 let mut roots: Vec<TypeRef> = Vec::new();
171 for s in &services {
172 roots.extend(s.svc.params.iter().map(|(_, t)| t.clone()));
173 roots.push(s.svc.return_type.clone());
174 }
175 let (closure, insts) = serialisation::collect_codec_closure(&roots, own_types);
176 let mut generic_bases: BTreeSet<String> = BTreeSet::new();
177 for i in &insts {
178 if let serialisation::GenericInst::RecordInst { name, .. }
179 | serialisation::GenericInst::SumInst { name, .. } = i
180 {
181 generic_bases.insert(name.clone());
182 }
183 }
184
185 let mut declared: BTreeSet<String> = closure.iter().cloned().collect();
189 declared.extend(generic_bases.iter().cloned());
190 let mut subst: HashMap<String, String> = HashMap::new();
191 for name in &declared {
192 if SURFACE_TYPES.contains(&name.as_str()) {
193 let mut to = name.clone();
194 while SURFACE_TYPES.contains(&to.as_str()) || declared.contains(&to) {
195 to.push('_');
196 }
197 subst.insert(name.clone(), to);
198 }
199 }
200 let renamed = |t: &TypeRef| serialisation::rename_type_ref(t, &subst);
201 let types: HashMap<String, Arc<TypeDecl>> = if subst.is_empty() {
202 own_types.clone()
203 } else {
204 own_types
205 .iter()
206 .map(|(k, d)| {
207 let decl = super::rename_in_decl(d, &subst);
208 (
209 subst.get(k).cloned().unwrap_or_else(|| k.clone()),
210 Arc::new(decl),
211 )
212 })
213 .collect()
214 };
215 let roots: Vec<TypeRef> = roots.iter().map(renamed).collect();
216 let (names, insts) = serialisation::collect_codec_closure(&roots, &types);
217 let generic_bases: BTreeSet<String> = generic_bases
218 .into_iter()
219 .map(|n| subst.get(&n).cloned().unwrap_or(n))
220 .collect();
221
222 let mut qual: HashMap<String, String> = HashMap::new();
227 for n in names.iter().chain(generic_bases.iter()) {
228 qual.insert(n.clone(), n.clone());
229 }
230
231 let ru = RuntimeUse::default();
232 let mut body: Vec<TsStmt> = Vec::new();
233
234 body.extend(doc_const(
236 "The context this client calls.",
237 "CONTEXT",
238 None,
239 str_lit(ctx_name),
240 ));
241 body.extend(doc_const(
242 "The wire format version this client was generated for. Typed by the\n`@bynk/wire-client` package's own version, so a package that speaks another\nwire version fails `tsc` here.",
243 "WIRE_VERSION",
244 Some(TsType::named("__WireVersion")),
245 str_lit(&bynk_check::wire_format::WIRE_VERSION.to_string()),
246 ));
247 let secret = bynk_check::wire_format::edge_key_to(ctx_name).unwrap_or_else(|| {
248 panic!("bynk internal error: `{ctx_name}` is not an edge name, so it has no call key")
249 });
250 body.extend(doc_const(
251 "The secret this Worker signs its calls with, as `bynk deploy` hands it over.",
252 "SECRET",
253 None,
254 str_lit(&secret),
255 ));
256 let contract_entries: Vec<(String, TsExpr)> = services
257 .iter()
258 .map(|s| (s.svc.name.clone(), str_lit(&s.hash)))
259 .collect();
260 body.extend(doc_const(
261 &contracts_doc(&services, ctx_name),
262 "CONTRACTS",
263 None,
264 TsExpr::As {
265 expr: Box::new(TsExpr::multiline_object(contract_entries)),
266 ty: TsType::named("const"),
267 },
268 ));
269
270 body.extend(doc(
272 "The foreign callers the context declares, by actor name.",
273 TsDecl::TypeAlias {
274 name: "ForeignCaller".to_string(),
275 type_params: Vec::new(),
276 ty: TsType::union(actors.iter().map(|a| TsType::named(quoted(a))).collect()),
277 },
278 ));
279 body.extend(doc(
280 "What the calling Worker's `env` must hold.",
281 TsDecl::Interface {
282 name: "Env".to_string(),
283 type_params: Vec::new(),
284 members: vec![TsTypeMember::readonly_prop(
285 secret.clone(),
286 TsType::named("string"),
287 )],
288 },
289 ));
290 let mut service_members = Vec::new();
291 for s in &services {
292 service_members.push(TsTypeMember::method(
293 s.svc.name.clone(),
294 service_params(&s.svc, &renamed),
295 service_return(&s.svc, &renamed),
296 ));
297 }
298 body.extend(doc(
299 "Every service a foreign caller of the context may call.",
300 TsDecl::Interface {
301 name: "Services".to_string(),
302 type_params: Vec::new(),
303 members: service_members,
304 },
305 ));
306 body.extend(doc(
307 "The foreign callers each service admits.",
308 TsDecl::Interface {
309 name: "Admits".to_string(),
310 type_params: Vec::new(),
311 members: services
312 .iter()
313 .map(|s| {
314 TsTypeMember::readonly_prop(
315 s.svc.name.clone(),
316 TsType::union(s.admits.iter().map(|a| TsType::named(quoted(a))).collect()),
317 )
318 })
319 .collect(),
320 },
321 ));
322 body.extend(doc(
323 "The services `A` may call: calling one its handler does not admit is a\n`tsc` error rather than a `403`.",
324 TsDecl::TypeAlias {
325 name: "Client".to_string(),
326 type_params: vec!["A extends ForeignCaller".to_string()],
327 ty: TsType::named(
328 "{ readonly [S in keyof Services as [A] extends [Admits[S]] ? S : never]: Services[S] }",
329 ),
330 },
331 ));
332 body.extend(doc(
333 "How to reach the context: the actor this Worker is declared as, its Service\nBinding to the context's Worker, and its `env`, which holds `SECRET`.",
334 TsDecl::Interface {
335 name: "ConnectOptions".to_string(),
336 type_params: vec!["A extends ForeignCaller".to_string()],
337 members: vec![
338 TsTypeMember::readonly_prop("caller", TsType::named("A")),
339 TsTypeMember::readonly_prop("binding", TsType::named("__ServiceBinding")),
340 TsTypeMember::readonly_prop("env", TsType::named("Env")),
341 ],
342 },
343 ));
344
345 let opt = |p: &str| TsExpr::Member {
347 object: Box::new(TsExpr::Ident("options".to_string())),
348 property: p.to_string(),
349 };
350 let mut methods = Vec::new();
351 for s in &services {
352 let encoded: Vec<TsExpr> = s
353 .svc
354 .params
355 .iter()
356 .map(|(name, t)| {
357 serialisation::serialise_expr_via(&renamed(t), &super::ts_ident(name), "", &ru)
358 })
359 .collect();
360 let args = match encoded.len() {
361 0 => TsExpr::object_entries(Vec::new()),
362 1 => encoded.into_iter().next().expect("one argument"),
363 _ => TsExpr::object_entries(
364 s.svc
365 .params
366 .iter()
367 .zip(encoded)
368 .map(|((name, _), e)| TsObjectEntry::Prop(name.clone(), e))
369 .collect(),
370 ),
371 };
372 let call = TsExpr::Call {
373 callee: Box::new(TsExpr::Ident("__callService".to_string())),
374 args: vec![
375 opt("binding"),
376 str_lit(&s.svc.name),
377 args,
378 serialisation::deserialise_ref_via(&renamed(&s.svc.return_type), "", &ru),
379 opt("caller"),
380 TsExpr::Member {
381 object: Box::new(TsExpr::Ident("CONTRACTS".to_string())),
382 property: s.svc.name.clone(),
383 },
384 opt("env"),
385 TsExpr::Ident("CONTEXT".to_string()),
386 ],
387 };
388 methods.push(TsObjectEntry::Method {
389 name: s.svc.name.clone(),
390 is_async: false,
391 generics: Vec::new(),
392 params: service_params(&s.svc, &renamed),
393 return_type: Some(service_return(&s.svc, &renamed)),
394 doc: None,
395 inline: false,
396 body: vec![TsStmt::return_stmt(Some(call), None)],
397 });
398 }
399 body.extend(doc(
400 "A client calling the context as the foreign caller `options.caller`. Each\ncall is signed under `SECRET`, stamped with its service's contract hash,\nand decoded with the codec a Bynk caller uses. A refusal or transport failure\nis thrown; `callErrorOf` (from `@bynk/wire-client`) reads it.\n\n const client = connect({ caller: \"<Actor>\", binding: env.<BINDING>, env });",
401 TsDecl::Function {
402 name: "connect".to_string(),
403 generics: vec!["A extends ForeignCaller".to_string()],
404 params: vec![TsParam {
405 name: "options".to_string(),
406 ty: Some(TsType::named_with_args(
407 "ConnectOptions",
408 vec![TsType::named("A")],
409 )),
410 optional: false,
411 }],
412 return_type: Some(TsType::named_with_args("Client", vec![TsType::named("A")])),
413 body: vec![
414 TsStmt::const_stmt(
415 bynk_ts::TsBindingName::Ident("services".to_string()),
416 Some(TsType::named("Services")),
417 TsExpr::multiline_object_entries(methods),
418 None,
419 ),
420 TsStmt::return_stmt(
421 Some(TsExpr::As {
422 expr: Box::new(TsExpr::As {
423 expr: Box::new(TsExpr::Ident("services".to_string())),
424 ty: TsType::named("unknown"),
425 }),
426 ty: TsType::named_with_args("Client", vec![TsType::named("A")]),
427 }),
428 None,
429 ),
430 ],
431 is_async: false,
432 inline: false,
433 },
434 ));
435
436 let mut type_names: Vec<String> = names
438 .iter()
439 .filter(|n| types.get(*n).is_some_and(|d| d.type_params.is_empty()))
440 .cloned()
441 .collect();
442 type_names.extend(generic_bases.iter().cloned());
443 type_names.sort();
444 type_names.dedup();
445 for name in &type_names {
446 if let Some(decl) = types.get(name) {
447 body.extend(boundary_type(name, decl));
448 }
449 }
450 body.extend(serialisation::decls_as_stmts(
451 serialisation::emit_helpers_for_owner_qualified(&names, &types, ctx_name, &qual, &ru),
452 ));
453 body.extend(serialisation::decls_as_stmts(
454 serialisation::emit_generic_helpers_qualified(&insts, &types, &qual, &ru),
455 ));
456
457 let unbound = bynk_ts::unbound_names(&TsProgram {
460 stmts: body.clone(),
461 });
462 let imports: Vec<String> = PACKAGE_NAMES
463 .iter()
464 .filter(|(name, _)| unbound.iter().any(|u| u == name))
465 .map(|(_, spec)| spec.to_string())
466 .collect();
467 let mut stmts = vec![
468 TsStmt::comment("Generated by bynkc — do not edit by hand.", None),
469 TsStmt::comment(
470 [
471 "The typed client for context `",
472 ctx_name,
473 "`, for a Worker that is not Bynk.",
474 ]
475 .concat(),
476 None,
477 ),
478 TsStmt::comment(
479 "It imports `@bynk/wire-client`: https://bynk-lang.org/book/reference/typescript-client/",
480 None,
481 ),
482 TsStmt::decl(
483 TsDecl::Import {
484 type_only: false,
485 names: imports,
486 from: CLIENT_PACKAGE.to_string(),
487 },
488 None,
489 ),
490 ];
491 stmts.extend(body);
492 Some(TsProgram { stmts })
493}
494
495fn contracts_doc(services: &[ClientService], ctx_name: &str) -> String {
499 let mut lines = vec![
500 "The contract hash of each service, as the callee was built. A call stamps".to_string(),
501 "it, and the callee refuses a different one with `409 ContractMismatch`.".to_string(),
502 String::new(),
503 "The context's project pins them per foreign caller in `bynk.foreign.toml`,".to_string(),
504 "beside its `bynk.toml`, and `bynk deploy` refuses a contract change that".to_string(),
505 "breaks a pin. Regenerate this client, then update the pin:".to_string(),
506 ];
507 let mut by_actor: BTreeMap<&String, Vec<&ClientService>> = BTreeMap::new();
508 for s in services {
509 for a in &s.admits {
510 by_actor.entry(a).or_default().push(s);
511 }
512 }
513 for (actor, svcs) in by_actor {
514 lines.push(String::new());
515 lines.push([" [\"", ctx_name, "\".", actor, "]"].concat());
516 for s in svcs {
517 lines.push([" ", &s.svc.name, " = \"", &s.hash, "\""].concat());
518 }
519 }
520 lines.join("\n")
521}
522
523fn service_params(
525 svc: &CrossContextService,
526 renamed: &dyn Fn(&TypeRef) -> TypeRef,
527) -> Vec<TsParam> {
528 svc.params
529 .iter()
530 .map(|(name, t)| TsParam {
531 name: super::ts_ident(name),
532 ty: Some(ts_type(&renamed(t))),
533 optional: false,
534 })
535 .collect()
536}
537
538fn service_return(svc: &CrossContextService, renamed: &dyn Fn(&TypeRef) -> TypeRef) -> TsType {
540 let ret = match renamed(&svc.return_type) {
541 TypeRef::Effect(inner, _) => *inner,
542 other => other,
543 };
544 TsType::named_with_args("globalThis.Promise", vec![ts_type(&ret)])
545}
546
547fn ts_type(t: &TypeRef) -> TsType {
549 super::ts_type_ref_qualified_multi_ts_type(t, &HashMap::new())
550}
551
552fn boundary_type(name: &str, decl: &TypeDecl) -> Vec<TsStmt> {
559 let params: Vec<String> = decl
560 .type_params
561 .iter()
562 .map(|p| p.name.name.clone())
563 .collect();
564 let d = match &decl.body {
565 TypeBody::Record(r) => TsDecl::Interface {
566 name: name.to_string(),
567 type_params: params,
568 members: r
569 .fields
570 .iter()
571 .map(|f| TsTypeMember::readonly_prop(f.name.name.clone(), ts_type(&f.type_ref)))
572 .collect(),
573 },
574 TypeBody::Sum(sum) => TsDecl::TypeAlias {
575 name: name.to_string(),
576 type_params: params,
577 ty: TsType::multiline_union(
578 sum.variants
579 .iter()
580 .map(|v| {
581 let mut members = vec![TsTypeMember::readonly_prop(
582 "tag",
583 TsType::named(quoted(&v.name.name)),
584 )];
585 members.extend(v.payload.iter().map(|f| {
586 TsTypeMember::readonly_prop(
587 super::payload_prop(&f.name.name),
588 ts_type(&f.type_ref),
589 )
590 }));
591 TsType::Object(members)
592 })
593 .collect(),
594 ),
595 },
596 TypeBody::Refined {
597 base, base_span, ..
598 } => TsDecl::TypeAlias {
599 name: name.to_string(),
600 type_params: params,
601 ty: ts_type(&TypeRef::Base(*base, *base_span)),
602 },
603 TypeBody::Opaque {
604 base, base_span, ..
605 } => TsDecl::TypeAlias {
606 name: name.to_string(),
607 type_params: params,
608 ty: TsType::intersection(vec![
609 ts_type(&TypeRef::Base(*base, *base_span)),
610 TsType::Object(vec![TsTypeMember::readonly_prop(
611 "__opaque",
612 TsType::named(quoted(name)),
613 )]),
614 ]),
615 },
616 };
617 let mut out = Vec::new();
618 if let Some(text) = decl.documentation.as_deref() {
619 out.push(TsStmt::doc_comment(text, None));
620 }
621 let mut stmt = TsStmt::decl(TsDecl::Export(Box::new(d)), None);
622 stmt.no_blank_before = !out.is_empty();
623 out.push(stmt);
624 out
625}
626
627fn str_lit(s: &str) -> TsExpr {
628 TsExpr::Lit(TsLit::Str(s.to_string()))
629}
630
631fn quoted(name: &str) -> String {
634 ["\"", name, "\""].concat()
635}
636
637fn doc(text: &str, decl: TsDecl) -> Vec<TsStmt> {
639 let mut stmt = TsStmt::decl(TsDecl::Export(Box::new(decl)), None);
640 stmt.no_blank_before = true;
641 vec![TsStmt::doc_comment(text, None), stmt]
642}
643
644fn doc_const(text: &str, name: &str, ty: Option<TsType>, init: TsExpr) -> Vec<TsStmt> {
645 doc(
646 text,
647 TsDecl::ConstDecl {
648 name: name.to_string(),
649 ty,
650 init,
651 },
652 )
653}