Skip to main content

bynk_emit/emitter/
workers_entry.rs

1//! Per-Worker `index.ts` generation (v0.8 §4.3, v0.9 §5.1).
2//!
3//! Each Worker exports a `default { fetch }` handler that first checks
4//! for the internal Service Binding prefix (`/_bynk/call/`) and dispatches
5//! to the matching `on call` service operation, then matches the request
6//! method + path against any `on http` routes the context declares.
7//! Validation and uncaught errors map to 400 / 500 respectively.
8
9use std::fmt::Write as _;
10
11use crate::emitter::http_handler_method_name;
12use crate::project::UnitTable;
13use bynk_syntax::ast::*;
14
15pub fn emit_worker_entry(context: &str, table: &UnitTable) -> String {
16    let mut out = String::new();
17    let _ = writeln!(out, "// Generated by bynkc — do not edit by hand.");
18    let _ = writeln!(out, "// Worker entry point for context `{context}`.");
19    writeln!(out).unwrap();
20
21    // v0.79: if any handler uses `~>`, each entry point captures the runtime's
22    // execution context and threads it into `compose`, so a fire-and-forget send
23    // can hand its promise to `waitUntil`. Otherwise the signatures are unchanged.
24    let ctx_uses_send = table.services.values().any(|s| {
25        s.handlers
26            .iter()
27            .any(|h| crate::emitter::block_uses_send(&h.body))
28    });
29    let exec_param = if ctx_uses_send {
30        ", ctx: { waitUntil(promise: Promise<unknown>): void }"
31    } else {
32        ""
33    };
34    let compose_call = if ctx_uses_send {
35        "compose(env, ctx)"
36    } else {
37        "compose(env)"
38    };
39
40    // Collect HTTP routes across all services, sorted so literal-segment
41    // routes precede parameter-segment routes that share the same prefix.
42    let mut http_routes: Vec<HttpRoute> = Vec::new();
43    let mut service_names: Vec<&String> = table.services.keys().collect();
44    service_names.sort();
45    for sname in &service_names {
46        let service = table.services.get(*sname).unwrap();
47        for h in &service.handlers {
48            if let HandlerKind::Http { method, path } = &h.kind {
49                http_routes.push(HttpRoute {
50                    service: (*sname).clone(),
51                    method: *method,
52                    path: path.clone(),
53                    handler: h.clone(),
54                    // v0.47: a Bearer handler's surface wrapper runs the
55                    // verification seam and needs the request passed in.
56                    bearer: bynk_check::actors::bearer_seam_for(h, &table.actors).is_some(),
57                    signature: bynk_check::actors::signature_seam_for(h, &table.actors),
58                    // v0.52: a multi-actor sum handler's wrapper owns the whole
59                    // boundary (raw read, first-wins resolution, body parse), so
60                    // the entry just passes `request` and skips the body parse.
61                    sum: bynk_check::actors::sum_members_for(h, &table.actors).is_some(),
62                });
63            }
64        }
65    }
66    http_routes.sort_by(|a, b| {
67        // Sort by literal-specificity: a path with fewer parameter segments
68        // (more literals) wins. Stable secondary sort by method + path keeps
69        // the diff deterministic.
70        param_count(&a.path)
71            .cmp(&param_count(&b.path))
72            .then_with(|| a.method.as_str().cmp(b.method.as_str()))
73            .then_with(|| a.path.cmp(&b.path))
74    });
75
76    // v0.10a: collect cron handlers across all services, carrying the per-service
77    // declaration index (the method-name key) and sorting by schedule expression
78    // for deterministic switch output.
79    let mut cron_routes: Vec<CronRoute> = Vec::new();
80    for sname in &service_names {
81        let service = table.services.get(*sname).unwrap();
82        let mut cron_idx = 0usize;
83        for h in &service.handlers {
84            if let HandlerKind::Cron { expr } = &h.kind {
85                cron_routes.push(CronRoute {
86                    service: (*sname).clone(),
87                    index: cron_idx,
88                    expr: expr.clone(),
89                    has_param: !h.params.is_empty(),
90                });
91                cron_idx += 1;
92            }
93        }
94    }
95    cron_routes.sort_by(|a, b| a.expr.cmp(&b.expr));
96
97    // v0.10b: collect queue consumers across all services, carrying the
98    // per-service declaration index and sorting by queue name.
99    let mut queue_routes: Vec<QueueRoute> = Vec::new();
100    for sname in &service_names {
101        let service = table.services.get(*sname).unwrap();
102        let mut queue_idx = 0usize;
103        for h in &service.handlers {
104            if let HandlerKind::Message = &h.kind {
105                // v0.44: the bound queue name lives on the service header
106                // (`from queue("name")`), not on the handler.
107                let ServiceProtocol::Queue { name } = &service.protocol else {
108                    continue;
109                };
110                let msg_type = h.params.first().map(|p| p.type_ref.clone());
111                queue_routes.push(QueueRoute {
112                    service: (*sname).clone(),
113                    index: queue_idx,
114                    name: name.clone(),
115                    msg_type,
116                });
117                queue_idx += 1;
118            }
119        }
120    }
121    queue_routes.sort_by(|a, b| a.name.cmp(&b.name));
122
123    // v0.104 (real-time track slice 3b): the `from WebSocket` upgrade routes. An
124    // `Upgrade: websocket` request dispatches to the service's edge wrapper
125    // (`ws_<service>_open`), which authenticates and forwards to the hosting DO.
126    // Route params come from the upgrade URL's query string (the v1 convention).
127    let mut ws_open_routes: Vec<(&String, &Handler)> = Vec::new();
128    for sname in &service_names {
129        let service = table.services.get(*sname).unwrap();
130        for h in &service.handlers {
131            if matches!(h.kind, HandlerKind::Open) {
132                ws_open_routes.push((*sname, h));
133            }
134        }
135    }
136
137    let has_http = !http_routes.is_empty();
138
139    let mut imports: Vec<&str> = vec![
140        "Ok",
141        "Err",
142        "type Result",
143        "type JsonValue",
144        "type BoundaryError",
145        "boundaryError",
146    ];
147    if has_http {
148        imports.push("matchPath");
149        imports.push("httpResultToResponse");
150    }
151    // v0.51: a context with a Signature handler imports the HMAC verifier.
152    if http_routes.iter().any(|r| r.signature.is_some()) {
153        imports.push("verifySignatureHmacSha256");
154    }
155    let _ = writeln!(
156        out,
157        "import {{ {} }} from \"../../runtime.js\";",
158        imports.join(", ")
159    );
160    let _ = writeln!(out, "import {{ compose, type Env }} from \"./compose.js\";");
161    let _ = writeln!(out, "import * as handlers from \"./handlers.js\";");
162    writeln!(out).unwrap();
163
164    // v0.9.2: re-export each agent's Durable Object class from the Worker
165    // entry. Cloudflare resolves a `class_name` binding against the named
166    // exports of the Worker's `main`, so the DO classes declared in
167    // `handlers.ts` must be visible here.
168    let mut agent_names: Vec<&String> = table.agents.keys().collect();
169    agent_names.sort();
170    if !agent_names.is_empty() {
171        let joined = agent_names
172            .iter()
173            .map(|n| n.as_str())
174            .collect::<Vec<_>>()
175            .join(", ");
176        let _ = writeln!(out, "export {{ {joined} }} from \"./handlers.js\";");
177        writeln!(out).unwrap();
178    }
179
180    let _ = writeln!(out, "export default {{");
181    let _ = writeln!(
182        out,
183        "  async fetch(request: Request, env: Env{exec_param}): Promise<Response> {{"
184    );
185    let _ = writeln!(out, "    const url = new URL(request.url);");
186    let _ = writeln!(out, "    const path = url.pathname;");
187    let _ = writeln!(out, "    const method = request.method;");
188    let _ = writeln!(out, "    const surface = {compose_call};");
189    let _ = writeln!(out, "    try {{");
190
191    // 1. Internal Service Binding dispatch.
192    let _ = writeln!(out, "      if (path.startsWith(\"/_bynk/call/\")) {{");
193    let _ = writeln!(
194        out,
195        "        const servicePath = path.slice(\"/_bynk/call/\".length);"
196    );
197    let _ = writeln!(out, "        switch (servicePath) {{");
198    for sname in &service_names {
199        let service = table.services.get(*sname).unwrap();
200        let Some(h) = service
201            .handlers
202            .iter()
203            .find(|h| matches!(h.kind, HandlerKind::Call))
204        else {
205            continue;
206        };
207
208        let _ = writeln!(out, "          case \"{sname}\": {{");
209        let _ = writeln!(
210            out,
211            "            const args = await request.json() as JsonValue;"
212        );
213        emit_call_handler_dispatch(&mut out, sname, h, &table.actors);
214        let _ = writeln!(out, "          }}");
215    }
216    let _ = writeln!(out, "          default:");
217    let _ = writeln!(
218        out,
219        "            return new Response(\"Not found\", {{ status: 404 }});"
220    );
221    let _ = writeln!(out, "        }}");
222    let _ = writeln!(out, "      }}");
223    writeln!(out).unwrap();
224
225    // 1.5. WebSocket upgrade dispatch (v0.104, slice 3b). An `Upgrade: websocket`
226    // request routes to the `from WebSocket` service's edge wrapper, which runs the
227    // fail-closed auth seam and forwards to the hosting Durable Object. Route params
228    // are read from the upgrade URL's query string by name (the v1 convention; a
229    // missing required param is a `400`).
230    if !ws_open_routes.is_empty() {
231        let _ = writeln!(
232            out,
233            "      if (request.headers.get(\"Upgrade\") === \"websocket\") {{"
234        );
235        for (sname, h) in &ws_open_routes {
236            let mut args: Vec<String> = vec!["request".to_string()];
237            for p in &h.params {
238                let pn = &p.name.name;
239                let _ = writeln!(
240                    out,
241                    "        const __ws_{pn} = url.searchParams.get(\"{pn}\");"
242                );
243                let _ = writeln!(
244                    out,
245                    "        if (__ws_{pn} === null) return new Response(\"Missing parameter: {pn}\", {{ status: 400 }});"
246                );
247                args.push(format!("__ws_{pn}"));
248            }
249            let _ = writeln!(
250                out,
251                "        return surface.ws_{sname}_open({});",
252                args.join(", ")
253            );
254        }
255        let _ = writeln!(out, "      }}");
256        writeln!(out).unwrap();
257    }
258
259    // 2. External HTTP routes.
260    for route in &http_routes {
261        emit_http_route_dispatch(&mut out, route);
262    }
263
264    let _ = writeln!(
265        out,
266        "      return new Response(\"Not Found\", {{ status: 404 }});"
267    );
268    let _ = writeln!(out, "    }} catch {{");
269    let _ = writeln!(
270        out,
271        "      return new Response(\"Internal Server Error\", {{ status: 500 }});"
272    );
273    let _ = writeln!(out, "    }}");
274    let _ = writeln!(out, "  }},");
275
276    // v0.10a: scheduled (cron) entry point. Dispatches on `event.cron`. A
277    // failing run has no retry channel, so an `Err` is logged and the run
278    // completes (v0.10 §5.1, [DECISION 3]).
279    if !cron_routes.is_empty() {
280        emit_scheduled_handler(&mut out, &cron_routes, exec_param, compose_call);
281    }
282
283    // v0.10b: queue (consumer) entry point. Dispatches on `batch.queue`,
284    // deserialises each message, invokes the handler, and acks on `Ok` /
285    // retries on `Err` (a deserialisation failure also retries).
286    if !queue_routes.is_empty() {
287        emit_queue_handler(&mut out, &queue_routes, exec_param, compose_call);
288    }
289
290    let _ = writeln!(out, "}};");
291
292    out
293}
294
295/// Emit the Worker `scheduled` handler aggregating every `on cron` handler in
296/// the context. `event` is typed structurally (`{ cron: string }`) to avoid a
297/// dependency on `@cloudflare/workers-types`, matching how the rest of the
298/// emitter hand-declares the minimal ambient shapes it needs.
299fn emit_scheduled_handler(
300    out: &mut String,
301    cron_routes: &[CronRoute],
302    exec_param: &str,
303    compose_call: &str,
304) {
305    let _ = writeln!(
306        out,
307        "  async scheduled(event: {{ readonly cron: string; readonly scheduledTime: number }}, env: Env{exec_param}): Promise<void> {{"
308    );
309    let _ = writeln!(out, "    const surface = {compose_call};");
310    let _ = writeln!(out, "    switch (event.cron) {{");
311    for route in cron_routes {
312        let method_key = crate::emitter::cron_handler_method_name(&route.service, route.index);
313        let expr_lit = route.expr.replace('\\', "\\\\").replace('"', "\\\"");
314        // Pass the scheduled fire time (epoch ms) when the handler asked for it.
315        let arg = if route.has_param {
316            "event.scheduledTime"
317        } else {
318            ""
319        };
320        let _ = writeln!(out, "      case \"{expr_lit}\": {{");
321        let _ = writeln!(
322            out,
323            "        const result = await surface.{method_key}({arg});"
324        );
325        let _ = writeln!(
326            out,
327            "        if (result.tag === \"Err\") console.error(\"cron {expr_lit} failed\", result.error);"
328        );
329        let _ = writeln!(out, "        return;");
330        let _ = writeln!(out, "      }}");
331    }
332    let _ = writeln!(out, "      default:");
333    let _ = writeln!(out, "        return;");
334    let _ = writeln!(out, "    }}");
335    let _ = writeln!(out, "  }},");
336}
337
338/// Emit the Worker `queue` handler aggregating every `on queue` consumer in the
339/// context. Dispatches on `batch.queue`; for each message it deserialises the
340/// body (v0.8 wire-format), invokes the handler, and acks on `Ok` / retries on
341/// `Err`. A deserialisation failure or a thrown error also retries. `batch` is
342/// typed structurally to avoid a `@cloudflare/workers-types` dependency.
343fn emit_queue_handler(
344    out: &mut String,
345    queue_routes: &[QueueRoute],
346    exec_param: &str,
347    compose_call: &str,
348) {
349    let _ = writeln!(
350        out,
351        "  async queue(batch: {{ readonly queue: string; readonly messages: ReadonlyArray<{{ readonly body: unknown; ack(): void; retry(): void }}> }}, env: Env{exec_param}): Promise<void> {{"
352    );
353    let _ = writeln!(out, "    const surface = {compose_call};");
354    let _ = writeln!(out, "    switch (batch.queue) {{");
355    for route in queue_routes {
356        let method_key = crate::emitter::queue_handler_method_name(&route.service, route.index);
357        let name_lit = route.name.replace('\\', "\\\\").replace('"', "\\\"");
358        let dser = match &route.msg_type {
359            Some(t) => deserialise_call(t, "(msg.body as JsonValue)", "$"),
360            None => "Ok(msg.body as any) as Result<any, BoundaryError>".to_string(),
361        };
362        let _ = writeln!(out, "      case \"{name_lit}\": {{");
363        let _ = writeln!(out, "        for (const msg of batch.messages) {{");
364        let _ = writeln!(out, "          try {{");
365        let _ = writeln!(out, "            const __r = {dser};");
366        let _ = writeln!(
367            out,
368            "            if (__r.tag === \"Err\") {{ console.error(\"queue {name_lit} deserialise failed\", __r.error); msg.retry(); continue; }}"
369        );
370        let _ = writeln!(
371            out,
372            "            const result = await surface.{method_key}(__r.value);"
373        );
374        let _ = writeln!(out, "            if (result.tag === \"Ack\") msg.ack();");
375        let _ = writeln!(
376            out,
377            "            else {{ console.error(\"queue {name_lit} retry\", result.reason); msg.retry(); }}"
378        );
379        let _ = writeln!(out, "          }} catch (e) {{");
380        let _ = writeln!(
381            out,
382            "            console.error(\"queue {name_lit} threw\", e); msg.retry();"
383        );
384        let _ = writeln!(out, "          }}");
385        let _ = writeln!(out, "        }}");
386        let _ = writeln!(out, "        return;");
387        let _ = writeln!(out, "      }}");
388    }
389    let _ = writeln!(out, "      default:");
390    let _ = writeln!(out, "        return;");
391    let _ = writeln!(out, "    }}");
392    let _ = writeln!(out, "  }},");
393}
394
395/// Count the number of `:param` segments in a path pattern.
396fn param_count(path: &str) -> usize {
397    path.split('/')
398        .filter(|s| s.starts_with(':') && s.len() > 1)
399        .count()
400}
401
402#[derive(Debug, Clone)]
403struct HttpRoute {
404    service: String,
405    method: HttpMethod,
406    path: String,
407    handler: Handler,
408    /// v0.47: the handler's `by` clause names a Bearer actor, so its surface
409    /// wrapper runs the verification seam and takes the request as its first
410    /// argument.
411    bearer: bool,
412    /// v0.51: the handler's `by` clause names a Signature actor — the entry
413    /// dispatch reads the raw body, verifies the HMAC, and parses the body from
414    /// those same bytes.
415    signature: Option<bynk_check::actors::SignatureSeam>,
416    /// v0.52: the handler's `by` clause names a multi-actor sum — its wrapper
417    /// owns the boundary, so the entry passes `request` (+ path params) and does
418    /// not read or parse the body itself.
419    sum: bool,
420}
421
422/// One `on cron` handler, identified by its service and per-service declaration
423/// index (which together form its `cron_<service>_<index>` method key).
424#[derive(Debug, Clone)]
425struct CronRoute {
426    service: String,
427    index: usize,
428    expr: String,
429    /// Whether the handler declares the optional scheduled-time parameter.
430    has_param: bool,
431}
432
433/// One `on queue` consumer, identified by its service and per-service
434/// declaration index (its `queue_<service>_<index>` method key), plus the
435/// message parameter's type for wire-format deserialisation.
436#[derive(Debug, Clone)]
437struct QueueRoute {
438    service: String,
439    index: usize,
440    name: String,
441    msg_type: Option<TypeRef>,
442}
443
444/// Generate the per-route dispatch block for one `on http` handler. The
445/// router has already been entered via `try`; this block extracts path
446/// parameters, deserialises the body (when present), invokes the handler,
447/// and serialises the HttpResult through `httpResultToResponse`.
448fn emit_http_route_dispatch(out: &mut String, route: &HttpRoute) {
449    let h = &route.handler;
450    let method_key = http_handler_method_name(route.method, &route.path);
451    let has_path_params = param_count(&route.path) > 0;
452    let path_lit = route.path.replace('"', "\\\"");
453    let _ = writeln!(out, "      {{");
454    if has_path_params {
455        let _ = writeln!(out, "        const __m = matchPath(\"{path_lit}\", path);");
456        let _ = writeln!(
457            out,
458            "        if (method === \"{}\" && __m) {{",
459            route.method.as_str()
460        );
461    } else {
462        let _ = writeln!(
463            out,
464            "        if (method === \"{}\" && path === \"{path_lit}\") {{",
465            route.method.as_str()
466        );
467    }
468    // Extract path parameters from the matched params map.
469    let mut call_args: Vec<String> = Vec::new();
470    for p in &h.params {
471        let pname = &p.name.name;
472        if pname == "body" {
473            // Body deserialisation happens below.
474            continue;
475        }
476        // It's a path parameter — extract from __m.params and construct.
477        let _ = writeln!(
478            out,
479            "          const __raw_{pname} = __m.params[\"{pname}\"];"
480        );
481        emit_path_param_construction(out, pname, &p.type_ref);
482        call_args.push(pname.clone());
483    }
484    // Body parameter (POST/PUT/PATCH). A multi-actor sum route's wrapper reads
485    // and parses the body itself (it must verify over the raw bytes first), so
486    // the entry skips the body here and passes only `request`.
487    if !route.sum
488        && let Some(body_param) = h.params.iter().find(|p| p.name.name == "body")
489    {
490        let _ = writeln!(out, "          let __body_json: JsonValue;");
491        if let Some(seam) = &route.signature {
492            // v0.51: read the raw body once, verify the HMAC fail-closed (401),
493            // then parse the body param from the *same* bytes (not a re-read /
494            // re-serialisation — the signature is over these exact bytes).
495            let secret = seam.secret.replace('\\', "\\\\").replace('"', "\\\"");
496            let header = seam.header.replace('\\', "\\\\").replace('"', "\\\"");
497            let _ = writeln!(out, "          let __raw: string;");
498            let _ = writeln!(out, "          try {{");
499            let _ = writeln!(out, "            __raw = await request.text();");
500            let _ = writeln!(out, "          }} catch {{");
501            let _ = writeln!(
502                out,
503                "            return new Response(JSON.stringify({{ kind: \"MalformedJson\", details: \"Invalid request body\" }}), {{ status: 400, headers: {{ \"content-type\": \"application/json\" }} }});"
504            );
505            let _ = writeln!(out, "          }}");
506            let _ = writeln!(
507                out,
508                "          const __secret = (env as Record<string, unknown>)[\"{secret}\"] ?? (globalThis as {{ process?: {{ env?: Record<string, unknown> }} }}).process?.env?.[\"{secret}\"];"
509            );
510            let _ = writeln!(
511                out,
512                "          if (typeof __secret !== \"string\") return new Response(null, {{ status: 401 }});"
513            );
514            // Timestamp (when bound): must be present; passed to the verifier.
515            let ts_expr = match &seam.timestamp_header {
516                Some(th) => {
517                    let th = th.replace('\\', "\\\\").replace('"', "\\\"");
518                    let _ = writeln!(out, "          const __ts = request.headers.get(\"{th}\");");
519                    let _ = writeln!(
520                        out,
521                        "          if (__ts === null) return new Response(null, {{ status: 401 }});"
522                    );
523                    "__ts"
524                }
525                None => "null",
526            };
527            let tol = match seam.tolerance_secs {
528                Some(n) => n.to_string(),
529                None => "null".to_string(),
530            };
531            let _ = writeln!(
532                out,
533                "          const __ok = await verifySignatureHmacSha256(__raw, __secret, request.headers.get(\"{header}\"), {ts_expr}, {tol});"
534            );
535            let _ = writeln!(
536                out,
537                "          if (!__ok) return new Response(null, {{ status: 401 }});"
538            );
539            let _ = writeln!(out, "          try {{");
540            let _ = writeln!(
541                out,
542                "            __body_json = JSON.parse(__raw) as JsonValue;"
543            );
544            let _ = writeln!(out, "          }} catch {{");
545            let _ = writeln!(
546                out,
547                "            return new Response(JSON.stringify({{ kind: \"MalformedJson\", details: \"Invalid request body\" }}), {{ status: 400, headers: {{ \"content-type\": \"application/json\" }} }});"
548            );
549            let _ = writeln!(out, "          }}");
550        } else {
551            let _ = writeln!(out, "          try {{");
552            let _ = writeln!(
553                out,
554                "            __body_json = (await request.json()) as JsonValue;"
555            );
556            let _ = writeln!(out, "          }} catch {{");
557            let _ = writeln!(
558                out,
559                "            return new Response(JSON.stringify({{ kind: \"MalformedJson\", details: \"Invalid request body\" }}), {{ status: 400, headers: {{ \"content-type\": \"application/json\" }} }});"
560            );
561            let _ = writeln!(out, "          }}");
562        }
563        let dser = deserialise_call(&body_param.type_ref, "__body_json", "$");
564        let _ = writeln!(out, "          const __r_body = {dser};");
565        let _ = writeln!(
566            out,
567            "          if (__r_body.tag === \"Err\") return new Response(JSON.stringify(__r_body.error), {{ status: 400, headers: {{ \"content-type\": \"application/json\" }} }});"
568        );
569        let _ = writeln!(out, "          const body = __r_body.value;");
570        call_args.push("body".to_string());
571    }
572    // Invoke the handler and serialise the HttpResult. The handler is
573    // wrapped on the surface so its deps are wired by `compose`. v0.47: a
574    // Bearer wrapper takes the request first (it runs the verification seam).
575    let surface_args = if route.bearer || route.sum {
576        // The Bearer and sum wrappers take the request first (they run the
577        // verification seam); a sum wrapper also reads/parses the body itself,
578        // so `call_args` here carries only the path params.
579        let mut a = vec!["request".to_string()];
580        a.extend(call_args.iter().cloned());
581        a.join(", ")
582    } else {
583        call_args.join(", ")
584    };
585    let _ = writeln!(
586        out,
587        "          const result = await surface.{method_key}({surface_args});",
588    );
589    let _ = route.service.as_str();
590    let inner = http_result_inner(&h.return_type);
591    let ser_fn = http_value_serialiser(&inner);
592    let _ = writeln!(
593        out,
594        "          return httpResultToResponse(result, {ser_fn});"
595    );
596    let _ = writeln!(out, "        }}");
597    let _ = writeln!(out, "      }}");
598}
599
600/// Synthesise a deps object literal for invoking a handler from the
601/// fetch entry point. Mirrors `compose`'s deps construction so handlers
602/// see the same shape whether invoked through the surface or directly.
603fn emit_call_handler_dispatch(
604    out: &mut String,
605    sname: &str,
606    h: &Handler,
607    actors: &std::collections::HashMap<String, bynk_syntax::ast::ActorDecl>,
608) {
609    // v0.54: a `by c: Caller` handler reads the caller's context name from the
610    // `X-Bynk-Caller` header (stamped by `callService`) and threads it into the
611    // surface call. The internal channel is trusted, but a missing caller means
612    // a malformed / non-Bynk call — fail-closed (the `Internal` 401-analogue).
613    let caller_args = if bynk_check::actors::caller_binder_for(h, actors).is_some() {
614        let _ = writeln!(
615            out,
616            "            const __caller = request.headers.get(\"X-Bynk-Caller\");"
617        );
618        let _ = writeln!(
619            out,
620            "            if (__caller === null || __caller === \"\") return new Response(JSON.stringify({{ kind: \"Unauthorized\", details: \"missing caller identity\" }}), {{ status: 401, headers: {{ \"content-type\": \"application/json\" }} }});"
621        );
622        "__caller, "
623    } else {
624        ""
625    };
626    if h.params.len() == 1 {
627        let p = &h.params[0];
628        let pname = &p.name.name;
629        let dser_call = deserialise_call(&p.type_ref, "args", "$");
630        let _ = writeln!(out, "            const __r_{pname} = {dser_call};");
631        let _ = writeln!(
632            out,
633            "            if (__r_{pname}.tag === \"Err\") return new Response(JSON.stringify(__r_{pname}.error), {{ status: 400, headers: {{ \"content-type\": \"application/json\" }} }});"
634        );
635        let _ = writeln!(out, "            const {pname} = __r_{pname}.value;");
636        let _ = writeln!(
637            out,
638            "            const result = await surface.{sname}({caller_args}{pname});"
639        );
640    } else {
641        let _ = writeln!(
642            out,
643            "            if (typeof args !== \"object\" || args === null || Array.isArray(args)) return new Response(JSON.stringify({{ kind: \"StructuralMismatch\", path: \"$\", expected: \"object\", actual: typeof args }}), {{ status: 400, headers: {{ \"content-type\": \"application/json\" }} }});"
644        );
645        let _ = writeln!(
646            out,
647            "            const argsObj = args as {{ [k: string]: JsonValue }};"
648        );
649        let mut names = Vec::new();
650        for p in &h.params {
651            let pname = &p.name.name;
652            let dser = deserialise_call(
653                &p.type_ref,
654                &format!("argsObj[\"{pname}\"]"),
655                &format!("$.{pname}"),
656            );
657            let _ = writeln!(out, "            const __r_{pname} = {dser};");
658            let _ = writeln!(
659                out,
660                "            if (__r_{pname}.tag === \"Err\") return new Response(JSON.stringify(__r_{pname}.error), {{ status: 400, headers: {{ \"content-type\": \"application/json\" }} }});"
661            );
662            let _ = writeln!(out, "            const {pname} = __r_{pname}.value;");
663            names.push(pname.clone());
664        }
665        // Prepend the caller (when present) without a dangling comma for the
666        // zero-param case.
667        let mut call_args: Vec<&str> = Vec::new();
668        if !caller_args.is_empty() {
669            call_args.push("__caller");
670        }
671        call_args.extend(names.iter().map(String::as_str));
672        let _ = writeln!(
673            out,
674            "            const result = await surface.{sname}({});",
675            call_args.join(", ")
676        );
677    }
678    let ser_expr = serialise_call(&h.return_type, "result");
679    let _ = writeln!(out, "            const body = {ser_expr};");
680    let _ = writeln!(
681        out,
682        "            return new Response(JSON.stringify(body), {{ status: 200, headers: {{ \"content-type\": \"application/json\" }} }});"
683    );
684}
685
686/// Emit code that converts the raw path-parameter string to the parameter's
687/// declared type. For plain `String`, this is a direct assignment; for
688/// refined or opaque `String`-based types we lower through `T.of(...)`
689/// which performs refinement validation and returns 400 on failure.
690fn emit_path_param_construction(out: &mut String, pname: &str, t: &TypeRef) {
691    match t {
692        TypeRef::Base(BaseType::String, _) => {
693            let _ = writeln!(out, "          const {pname} = __raw_{pname};");
694        }
695        TypeRef::Named(id) => {
696            let _ = writeln!(
697                out,
698                "          const __r_{pname} = handlers.{}.of(__raw_{pname});",
699                id.name
700            );
701            let _ = writeln!(
702                out,
703                "          if (__r_{pname}.tag === \"Err\") return new Response(JSON.stringify({{ kind: \"RefinementViolation\", path: \"path.{pname}\", violation: __r_{pname}.error }}), {{ status: 400, headers: {{ \"content-type\": \"application/json\" }} }});"
704            );
705            let _ = writeln!(out, "          const {pname} = __r_{pname}.value;");
706        }
707        _ => {
708            // Other shapes are rejected by the static check; emit a fallback.
709            let _ = writeln!(out, "          const {pname} = __raw_{pname} as any;");
710        }
711    }
712}
713
714/// Strip the `Effect[HttpResult[_]]` wrapper to expose the inner payload type
715/// `T`. Used to choose the right serialiser when emitting the HttpResult
716/// body.
717fn http_result_inner(t: &TypeRef) -> TypeRef {
718    let inner = match t {
719        TypeRef::Effect(t, _) => t.as_ref(),
720        other => other,
721    };
722    match inner {
723        TypeRef::HttpResult(payload, _) => (**payload).clone(),
724        other => other.clone(),
725    }
726}
727
728/// TypeScript expression that serialises a value of the `HttpResult[T]`
729/// payload type to a `JsonValue`. Wraps the per-type serialise helper for
730/// named types; uses pass-through for base types.
731fn http_value_serialiser(t: &TypeRef) -> String {
732    match t {
733        TypeRef::Base(_, _) => "(v: any) => v as JsonValue".to_string(),
734        // v0.20a: function types are confined to non-boundary positions
735        // (`bynk.types.function_at_boundary`), so the serialisation machinery
736        // can never legally see one.
737        TypeRef::Fn(..) | TypeRef::Query(..) | TypeRef::Stream(..) | TypeRef::Connection(..) => {
738            unreachable!("function/query/stream types are rejected at boundaries")
739        }
740        TypeRef::Unit(_) => "(_v: any) => null".to_string(),
741        TypeRef::Named(id) => format!("handlers.serialise_{}", id.name),
742        TypeRef::Result(_, _, _)
743        | TypeRef::Option(_, _)
744        | TypeRef::List(_, _)
745        | TypeRef::Map(_, _, _) => {
746            let inst_name = inner_ts_name(t);
747            format!("handlers.serialise_{inst_name}")
748        }
749        TypeRef::Effect(inner, _) => http_value_serialiser(inner),
750        TypeRef::HttpResult(_, _)
751        | TypeRef::QueueResult(_)
752        | TypeRef::ValidationError(_)
753        | TypeRef::JsonError(_) => "(v: any) => v as JsonValue".to_string(),
754    }
755}
756
757pub(crate) fn deserialise_call(t: &TypeRef, json_expr: &str, path: &str) -> String {
758    match t {
759        // v0.20a: function types are confined to non-boundary positions
760        // (`bynk.types.function_at_boundary`), so the serialisation machinery
761        // can never legally see one.
762        TypeRef::Fn(..) | TypeRef::Query(..) | TypeRef::Stream(..) | TypeRef::Connection(..) => {
763            unreachable!("function/query/stream types are rejected at boundaries")
764        }
765        // v0.110 (ADR 0142 D8): a `Bytes` at a `workers` boundary is diagnosed
766        // as not-yet-supported by the project validator, so this arm is
767        // normally unreachable. Emit a correct base64 decode anyway (defence in
768        // depth — never a silent mis-encode) rather than fall through to the
769        // number/string typeof check.
770        TypeRef::Base(BaseType::Bytes, _) => {
771            format!(
772                "(typeof {json_expr} === \"string\" ? ((__b) => __b.tag === \"Some\" ? Ok(__b.value) : Err({{ kind: \"StructuralMismatch\", path: \"{path}\", expected: \"base64 string\", actual: \"invalid base64\" }})) (__bynkBytesFromBase64({json_expr})) : Err({{ kind: \"StructuralMismatch\", path: \"{path}\", expected: \"base64 string\", actual: typeof {json_expr} }})) as Result<any, BoundaryError>"
773            )
774        }
775        TypeRef::Base(b, _) => {
776            let typeof_str = match b {
777                BaseType::Int => "number",
778                BaseType::String => "string",
779                BaseType::Bool => "boolean",
780                BaseType::Float => "number",
781                BaseType::Duration | BaseType::Instant => "number",
782                // Unreachable: handled by the dedicated `Bytes` arm above.
783                BaseType::Bytes => "string",
784            };
785            // v0.22b: bare `Int` params validate integrality (ADR 0049). v0.86:
786            // a `Duration` is whole milliseconds, so it validates integrality too.
787            if *b == BaseType::Int || *b == BaseType::Duration || *b == BaseType::Instant {
788                return format!(
789                    "(typeof {json_expr} === \"number\" && Number.isInteger({json_expr}) ? Ok({json_expr}) : Err({{ kind: \"StructuralMismatch\", path: \"{path}\", expected: \"integer\", actual: String({json_expr}) }}) as Result<any, BoundaryError>)"
790                );
791            }
792            // v0.21: boundary `Float` values are finite (ADR 0040).
793            if *b == BaseType::Float {
794                return format!(
795                    "(typeof {json_expr} === \"number\" && Number.isFinite({json_expr}) ? Ok({json_expr}) : Err({{ kind: \"StructuralMismatch\", path: \"{path}\", expected: \"finite number\", actual: String({json_expr}) }}) as Result<any, BoundaryError>)"
796                );
797            }
798            format!(
799                "(typeof {json_expr} === \"{typeof_str}\" ? Ok({json_expr}) : Err({{ kind: \"StructuralMismatch\", path: \"{path}\", expected: \"{typeof_str}\", actual: typeof {json_expr} }}) as Result<any, BoundaryError>)"
800            )
801        }
802        TypeRef::Named(id) => {
803            format!("handlers.deserialise_{}({json_expr}, \"{path}\")", id.name)
804        }
805        TypeRef::Result(_, _, _)
806        | TypeRef::Option(_, _)
807        | TypeRef::List(_, _)
808        | TypeRef::Map(_, _, _) => {
809            let inst_name = inner_ts_name(t);
810            format!("handlers.deserialise_{inst_name}({json_expr}, \"{path}\")")
811        }
812        TypeRef::Effect(inner, _) => deserialise_call(inner, json_expr, path),
813        TypeRef::HttpResult(_, _)
814        | TypeRef::QueueResult(_)
815        | TypeRef::ValidationError(_)
816        | TypeRef::JsonError(_)
817        | TypeRef::Unit(_) => {
818            format!("Ok({json_expr} as any) as Result<any, BoundaryError>")
819        }
820    }
821}
822
823fn serialise_call(t: &TypeRef, value: &str) -> String {
824    match t {
825        // v0.21: serialising a non-finite `Float` is a contract violation
826        // (ADR 0040) — `JSON.stringify(NaN)` would silently produce `null`.
827        TypeRef::Base(BaseType::Float, _) => format!(
828            "((v: number) => {{ if (!Number.isFinite(v)) throw new Error(\"non-finite Float at boundary\"); return v as JsonValue; }})({value})"
829        ),
830        TypeRef::Base(_, _) => format!("{value} as JsonValue"),
831        // v0.20a: function types are confined to non-boundary positions
832        // (`bynk.types.function_at_boundary`), so the serialisation machinery
833        // can never legally see one.
834        TypeRef::Fn(..) | TypeRef::Query(..) | TypeRef::Stream(..) | TypeRef::Connection(..) => {
835            unreachable!("function/query/stream types are rejected at boundaries")
836        }
837        TypeRef::Named(id) => format!("handlers.serialise_{}({value})", id.name),
838        TypeRef::Result(_, _, _)
839        | TypeRef::Option(_, _)
840        | TypeRef::List(_, _)
841        | TypeRef::Map(_, _, _) => {
842            let inst_name = inner_ts_name(t);
843            format!("handlers.serialise_{inst_name}({value})")
844        }
845        TypeRef::Effect(inner, _) => serialise_call(inner, value),
846        // Unit serialises to JSON `null` (the value is `void`, which cannot be
847        // cast to `JsonValue` under `tsc --strict`).
848        TypeRef::Unit(_) => "null".to_string(),
849        TypeRef::HttpResult(_, _)
850        | TypeRef::QueueResult(_)
851        | TypeRef::ValidationError(_)
852        | TypeRef::JsonError(_) => {
853            format!("{value} as JsonValue")
854        }
855    }
856}
857
858fn inner_ts_name(t: &TypeRef) -> String {
859    match t {
860        TypeRef::Base(b, _) => b.name().to_string(),
861        // v0.20a: function types are confined to non-boundary positions
862        // (`bynk.types.function_at_boundary`), so the serialisation machinery
863        // can never legally see one.
864        TypeRef::Fn(..) | TypeRef::Query(..) | TypeRef::Stream(..) | TypeRef::Connection(..) => {
865            unreachable!("function/query/stream types are rejected at boundaries")
866        }
867        TypeRef::Named(id) => id.name.clone(),
868        TypeRef::Result(a, b, _) => format!("Result_{}_{}", inner_ts_name(a), inner_ts_name(b)),
869        TypeRef::Option(a, _) => format!("Option_{}", inner_ts_name(a)),
870        TypeRef::Effect(a, _) => format!("Effect_{}", inner_ts_name(a)),
871        TypeRef::HttpResult(a, _) => format!("HttpResult_{}", inner_ts_name(a)),
872        TypeRef::List(a, _) => format!("List_{}", inner_ts_name(a)),
873        TypeRef::Map(k, v, _) => format!("Map_{}_{}", inner_ts_name(k), inner_ts_name(v)),
874        TypeRef::QueueResult(_) => "QueueResult".to_string(),
875        TypeRef::ValidationError(_) => "ValidationError".to_string(),
876        TypeRef::JsonError(_) => "JsonError".to_string(),
877        TypeRef::Unit(_) => "Unit".to_string(),
878    }
879}