Skip to main content

actr_web_protoc_codegen/
codegen.rs

1//! Web code generator — produces all build artifacts from a `WebCodegenRequest`.
2//!
3//! Generated artifacts:
4//! - `src/generated/actr-config.ts`  — Configuration from actr.toml
5//! - `src/generated/*.actorref.ts`   — Typed ActorRef wrappers
6//! - `src/generated/index.ts`        — Re-exports
7//! - `wasm/`                         — Rust WASM crate (Cargo.toml, build.sh, src/lib.rs, handlers)
8//! - `public/actor.sw.js`            — Service Worker entry (config from DOM_PORT_INIT)
9//! - `build.sh`                      — Root build script
10
11use crate::request::{FileInfo, ServiceInfo, WebCodegenRequest, WebCodegenResponse};
12use heck::{ToKebabCase, ToLowerCamelCase, ToSnakeCase};
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15use tracing::info;
16
17/// Run the full code generation pipeline.
18pub fn generate(request: &WebCodegenRequest) -> WebCodegenResponse {
19    match generate_inner(request) {
20        Ok(files) => WebCodegenResponse {
21            success: true,
22            generated_files: files,
23            errors: Vec::new(),
24        },
25        Err(e) => WebCodegenResponse {
26            success: false,
27            generated_files: Vec::new(),
28            errors: vec![e],
29        },
30    }
31}
32
33fn generate_inner(req: &WebCodegenRequest) -> Result<Vec<PathBuf>, String> {
34    let mut generated = Vec::new();
35
36    // 1. actr-config.ts
37    let config_path = req.output_dir.join("actr-config.ts");
38    let config_content = gen_actr_config(req)?;
39    write_file(&config_path, &config_content)?;
40    generated.push(config_path);
41
42    // 2. ActorRef wrappers for local services
43    for svc in &req.local_services {
44        let file_name = format!("{}.actorref.ts", to_kebab_case(&svc.name));
45        let ref_path = req.output_dir.join(&file_name);
46        let ref_content = gen_actor_ref(svc, req)?;
47        write_file(&ref_path, &ref_content)?;
48        generated.push(ref_path);
49    }
50
51    // 3. index.ts
52    let index_path = req.output_dir.join("index.ts");
53    let index_content = gen_index(req)?;
54    write_file(&index_path, &index_content)?;
55    generated.push(index_path);
56
57    // 4. wasm/ scaffold
58    let wasm_files = gen_wasm_scaffold(req)?;
59    generated.extend(wasm_files);
60
61    // 5. public/actor.sw.js
62    let sw_path = req.project_root.join("public/actor.sw.js");
63    let sw_content = gen_service_worker(req)?;
64    write_file(&sw_path, &sw_content)?;
65    generated.push(sw_path);
66
67    // 6. Root build.sh (only if not already present)
68    let build_sh = req.project_root.join("build.sh");
69    if !build_sh.exists() {
70        let build_content = gen_root_build_sh();
71        write_file(&build_sh, &build_content)?;
72        make_executable(&build_sh)?;
73        generated.push(build_sh);
74    }
75
76    Ok(generated)
77}
78
79// ═══════════════════════════════════════════════════════════════════
80// actr-config.ts
81// ═══════════════════════════════════════════════════════════════════
82
83fn gen_actr_config(req: &WebCodegenRequest) -> Result<String, String> {
84    let manufacturer = &req.manufacturer;
85    let actr_name = &req.actr_name;
86    let signaling_url = &req.signaling_url;
87    let realm_id = req.realm_id;
88
89    let edition = req.edition();
90    let exports = req.exports_list();
91    let platform_web = req.platform_web();
92    let raw_acl = req.raw_acl();
93
94    // Dependencies
95    let mut dep_entries = Vec::new();
96    for dep in &req.dependencies {
97        if let Some(ref at) = dep.actr_type {
98            dep_entries.push(format!(
99                "  '{}': {{ actr_type: '{}:{}:{}' }},",
100                dep.alias, at.manufacturer, at.name, at.version
101            ));
102        } else {
103            dep_entries.push(format!("  '{}': {{}},", dep.alias));
104        }
105    }
106
107    // Connection role is negotiated per-peer at runtime, so every actor
108    // exports the same symbol regardless of typical initiator/acceptor role.
109    let type_export_name = "actrType";
110
111    let mut out = String::new();
112    out.push_str(
113        "/**\n * Auto-generated Actr configuration\n * Source: actr.toml\n *\n * DO NOT EDIT this file manually\n */\n\n",
114    );
115    out.push_str("import type { ActorClientConfig, SwRuntimeConfig } from '@actr/web';\n\n");
116
117    // Edition
118    out.push_str("// ── Full actr.toml info ──\n\n");
119    out.push_str(&format!(
120        "/** actr.toml edition */\nexport const edition = {edition};\n\n"
121    ));
122
123    // Exports
124    out.push_str("/** Exported proto files */\n");
125    if exports.is_empty() {
126        out.push_str("export const exports: string[] = [];\n\n");
127    } else {
128        out.push_str(&format!(
129            "export const exports = [{}];\n\n",
130            exports
131                .iter()
132                .map(|e| format!("'{e}'"))
133                .collect::<Vec<_>>()
134                .join(", ")
135        ));
136    }
137
138    // Package info
139    let authors_js: Vec<String> = req.authors.iter().map(|a| format!("'{a}'")).collect();
140    let tags_js: Vec<String> = req.tags.iter().map(|t| format!("'{t}'")).collect();
141    out.push_str("/** Package info */\n");
142    out.push_str("export const packageInfo = {\n");
143    out.push_str(&format!("  name: '{}',\n", req.package_name));
144    out.push_str(&format!("  description: '{}',\n", req.description));
145    out.push_str(&format!("  authors: [{}],\n", authors_js.join(", ")));
146    out.push_str(&format!("  license: '{}',\n", req.license));
147    out.push_str(&format!("  tags: [{}],\n", tags_js.join(", ")));
148    out.push_str("} as const;\n\n");
149
150    // ActrType
151    let version = &req.version;
152    out.push_str("/** ActrType */\n");
153    out.push_str(&format!("export const {type_export_name} = {{\n"));
154    out.push_str(&format!("  manufacturer: '{manufacturer}',\n"));
155    out.push_str(&format!("  name: '{actr_name}',\n"));
156    out.push_str(&format!("  version: '{version}',\n"));
157    out.push_str(&format!(
158        "  fullType: '{manufacturer}:{actr_name}:{version}',\n"
159    ));
160    out.push_str("} as const;\n\n");
161
162    // Dependencies
163    out.push_str("/** Dependencies */\n");
164    if dep_entries.is_empty() {
165        out.push_str("export const dependencies = {} as const;\n\n");
166    } else {
167        out.push_str("export const dependencies = {\n");
168        for entry in &dep_entries {
169            out.push_str(entry);
170            out.push('\n');
171        }
172        out.push_str("} as const;\n\n");
173    }
174
175    // Platform.web
176    if let Some(pw) = &platform_web {
177        out.push_str("/** Web platform config */\n");
178        out.push_str("export const platform = {\n  web: ");
179        out.push_str(&format_toml_as_ts(pw, 2));
180        out.push_str(",\n} as const;\n\n");
181    }
182
183    // System config
184    out.push_str("/** System config */\n");
185    out.push_str("export const system = {\n");
186    out.push_str("  signaling: {\n");
187    out.push_str(&format!(
188        "    url: '{}',\n",
189        signaling_url.trim_end_matches('/')
190    ));
191    out.push_str("  },\n");
192    out.push_str("  deployment: {\n");
193    out.push_str(&format!("    realm_id: {realm_id},\n"));
194    if !req.ais_endpoint.is_empty() {
195        out.push_str(&format!("    ais_endpoint: '{}',\n", req.ais_endpoint));
196    }
197    out.push_str("  },\n");
198    out.push_str("  discovery: {\n");
199    out.push_str(&format!("    visible: {},\n", req.visible_in_discovery));
200    out.push_str("  },\n");
201    out.push_str("  observability: {\n");
202    out.push_str(&format!(
203        "    filter_level: '{}',\n",
204        req.observability.filter_level
205    ));
206    out.push_str(&format!(
207        "    tracing_enabled: {},\n",
208        req.observability.tracing_enabled
209    ));
210    if req.observability.tracing_enabled {
211        out.push_str(&format!(
212            "    tracing_endpoint: '{}',\n",
213            req.observability.tracing_endpoint
214        ));
215        out.push_str(&format!(
216            "    tracing_service_name: '{}',\n",
217            req.observability.tracing_service_name
218        ));
219    }
220    out.push_str("  },\n");
221    out.push_str(&format!(
222        "  webrtc: {{\n    force_relay: {},\n",
223        req.force_relay
224    ));
225    if !req.stun_urls.is_empty() {
226        out.push_str(&format!(
227            "    stun_urls: [{}],\n",
228            req.stun_urls
229                .iter()
230                .map(|u| format!("'{u}'"))
231                .collect::<Vec<_>>()
232                .join(", ")
233        ));
234    }
235    if !req.turn_urls.is_empty() {
236        out.push_str(&format!(
237            "    turn_urls: [{}],\n",
238            req.turn_urls
239                .iter()
240                .map(|u| format!("'{u}'"))
241                .collect::<Vec<_>>()
242                .join(", ")
243        ));
244    }
245    out.push_str("  },\n");
246    out.push_str("} as const;\n\n");
247
248    // ACL
249    if let Some(acl_val) = &raw_acl {
250        out.push_str("/** ACL config */\n");
251        out.push_str("export const acl = ");
252        out.push_str(&format_toml_as_ts(acl_val, 0));
253        out.push_str(" as const;\n\n");
254    } else {
255        out.push_str("/** ACL config */\n");
256        out.push_str("export const acl = {} as const;\n\n");
257    }
258
259    // runtimeConfig
260    let client_actr_type = req.client_actr_type();
261    let target_actr_type = req.target_actr_type();
262    let acl_allow = req.get_acl_allow_types();
263    let acl_allow_js: Vec<String> = acl_allow.iter().map(|t| format!("'{t}'")).collect();
264
265    // package_url: default `.actr` package path under public/packages/.
266    // The basename is always `req.package_name` — there is no longer a
267    // server-vs-client split on the filename, because the guest-bridge
268    // runtime is the only loader path.
269    let package_url = format!("/packages/{}.actr", req.package_name);
270
271    // runtime_wasm_url always points at the shared SW runtime WASM. Every
272    // actor (initiator or acceptor) uses the guest-bridge runtime.
273    let wasm_name = req.wasm_module_name();
274    let runtime_wasm_url = format!("/packages/{wasm_name}_bg.wasm");
275
276    out.push_str("// ── runtimeConfig (passed to Service Worker WASM registration) ──\n\n");
277    out.push_str("/**\n * Service Worker runtime config\n * Passed from main thread to Service Worker via DOM_PORT_INIT\n */\n");
278    out.push_str("export const runtimeConfig: SwRuntimeConfig = {\n");
279    if !req.ais_endpoint.is_empty() {
280        out.push_str(&format!("  ais_endpoint: '{}',\n", req.ais_endpoint));
281    }
282    out.push_str("  signaling_url: system.signaling.url,\n");
283    out.push_str("  realm_id: system.deployment.realm_id,\n");
284    out.push_str(&format!("  client_actr_type: '{client_actr_type}',\n"));
285    out.push_str(&format!("  target_actr_type: '{target_actr_type}',\n"));
286    out.push_str("  service_fingerprint: '',\n");
287    out.push_str(&format!(
288        "  acl_allow_types: [{}],\n",
289        acl_allow_js.join(", ")
290    ));
291    out.push_str(&format!("  package_url: '{package_url}',\n"));
292    out.push_str(&format!("  runtime_wasm_url: '{runtime_wasm_url}',\n"));
293    out.push_str("  // Trust anchors — pubkey_b64 is substituted by deploy tooling.\n");
294    out.push_str("  trust: [{ kind: 'static', pubkey_b64: '__MFR_PUBKEY_PLACEHOLDER__' }],\n");
295    out.push_str("};\n\n");
296
297    // ActorClientConfig
298    // Every actor — whether it typically initiates or accepts — needs the
299    // same peer-side knobs. Role is negotiated per-peer at connect time.
300    out.push_str("// ── ActorClientConfig (passed to createActor) ──\n\n");
301    out.push_str(
302        "/**\n * Actor client config\n * Extracted from system config in actr.toml\n */\n",
303    );
304    out.push_str("export const actrConfig: ActorClientConfig = {\n");
305    out.push_str("  signalingUrl: system.signaling.url,\n");
306    out.push_str("  realm: String(system.deployment.realm_id),\n");
307    out.push_str("  iceServers: [\n");
308    if !req.stun_urls.is_empty() {
309        out.push_str("    ...system.webrtc.stun_urls.map((url) => ({ urls: url })),\n");
310    }
311    if !req.turn_urls.is_empty() {
312        out.push_str("    ...system.webrtc.turn_urls.map((url) => ({ urls: url })),\n");
313    }
314    out.push_str("  ],\n");
315    let ice_transport = if req.force_relay { "relay" } else { "all" };
316    out.push_str(&format!("  iceTransportPolicy: '{ice_transport}',\n"));
317    out.push_str("  serviceWorkerPath: '/actor.sw.js',\n");
318    out.push_str("  autoReconnect: true,\n");
319    out.push_str("  debug: false,\n");
320    out.push_str("  runtimeConfig,\n");
321    out.push_str("};\n");
322
323    Ok(out)
324}
325
326// ═══════════════════════════════════════════════════════════════════
327// ActorRef generation
328// ═══════════════════════════════════════════════════════════════════
329
330fn gen_actor_ref(service: &ServiceInfo, req: &WebCodegenRequest) -> Result<String, String> {
331    let service_name = &service.name;
332
333    let mut out = String::new();
334    out.push_str("/**\n");
335    out.push_str(" * Auto-generated ActorRef\n");
336    out.push_str(&format!(" * Local service: {service_name}\n"));
337    out.push_str(" *\n * DO NOT EDIT this file manually\n */\n\n");
338
339    // Collect imports from remote proto types
340    let mut remote_imports: HashMap<String, Vec<String>> = HashMap::new();
341    for method in &service.methods {
342        for remote in &req.remote_services {
343            for rm in &remote.methods {
344                if rm.input_type == method.input_type || rm.output_type == method.output_type {
345                    let import_path = format!(
346                        "./{}",
347                        to_kebab_case(
348                            &remote
349                                .relative_path
350                                .parent()
351                                .unwrap_or(Path::new(""))
352                                .to_string_lossy()
353                                .replace(std::path::MAIN_SEPARATOR, "/")
354                        )
355                    );
356                    let types = remote_imports.entry(import_path).or_default();
357                    if !types.contains(&method.input_type) {
358                        types.push(method.input_type.clone());
359                    }
360                    if !types.contains(&method.output_type) {
361                        types.push(method.output_type.clone());
362                    }
363                }
364            }
365        }
366    }
367
368    for (path, types) in &remote_imports {
369        let unique_types: Vec<&str> = types.iter().map(|t| t.as_str()).collect();
370        out.push_str(&format!(
371            "import {{ {} }} from '{}';\n",
372            unique_types.join(", "),
373            path
374        ));
375    }
376    if !remote_imports.is_empty() {
377        out.push('\n');
378    }
379
380    out.push_str("/**\n * callRaw compatible interface\n */\n");
381    out.push_str("interface CallRawCapable {\n");
382    out.push_str("  callRaw(routeKey: string, payload: Uint8Array, timeout?: number): Promise<Uint8Array>;\n");
383    out.push_str("}\n\n");
384
385    // ActrType
386    let actr_type = service.actr_type.as_deref().unwrap_or("");
387    if !actr_type.is_empty() {
388        let parts: Vec<&str> = actr_type.splitn(2, ':').collect();
389        let (mfr, name) = if parts.len() == 2 {
390            (parts[0], parts[1])
391        } else {
392            ("", actr_type)
393        };
394        out.push_str("/**\n * ActrType definition\n */\n");
395        out.push_str(&format!("export const {service_name}ActrType = {{\n"));
396        out.push_str(&format!("  manufacturer: '{mfr}',\n"));
397        out.push_str(&format!("  name: '{name}',\n"));
398        out.push_str("};\n\n");
399    }
400
401    // Class
402    out.push_str(&format!(
403        "/**\n * {service_name} ActorRef wrapper\n * Provides type-safe RPC call methods\n */\n"
404    ));
405    out.push_str(&format!("export class {service_name}ActorRef {{\n"));
406    out.push_str("  private actor: CallRawCapable;\n\n");
407    out.push_str("  constructor(actor: CallRawCapable) {\n");
408    out.push_str("    this.actor = actor;\n");
409    out.push_str("  }\n");
410
411    for method in &service.methods {
412        let camel = to_camel_case(&method.name);
413        let input = &method.input_type;
414        let output = &method.output_type;
415        let route = &method.route_key;
416
417        out.push_str(&format!(
418            "\n  /**\n   * Call {} RPC method\n   */\n",
419            method.name
420        ));
421        out.push_str(&format!(
422            "  async {camel}(request: {input}): Promise<{output}> {{\n"
423        ));
424        out.push_str(&format!(
425            "    const encoded = {input}.encode(request).finish();\n"
426        ));
427        out.push_str(&format!(
428            "    const responseData = await this.actor.callRaw('{route}', encoded);\n"
429        ));
430        out.push_str(&format!("    return {output}.decode(responseData);\n"));
431        out.push_str("  }\n");
432    }
433
434    out.push_str("}\n");
435    Ok(out)
436}
437
438// ═══════════════════════════════════════════════════════════════════
439// index.ts
440// ═══════════════════════════════════════════════════════════════════
441
442fn gen_index(req: &WebCodegenRequest) -> Result<String, String> {
443    let mut out = String::new();
444    out.push_str("/**\n * Auto-generated Actr code entry point\n *\n * DO NOT EDIT this file manually\n */\n\n");
445    out.push_str("export {\n");
446    out.push_str("    actrConfig,\n");
447    out.push_str("    runtimeConfig,\n");
448    out.push_str("    actrType,\n");
449    out.push_str("    edition,\n");
450    out.push_str("    exports,\n");
451    out.push_str("    packageInfo,\n");
452    out.push_str("    dependencies,\n");
453    out.push_str("    system,\n");
454    out.push_str("    acl,\n");
455    out.push_str("} from './actr-config';\n");
456
457    for svc in &req.local_services {
458        let file_name = to_kebab_case(&svc.name);
459        out.push_str(&format!("export * from './{file_name}.actorref';\n"));
460    }
461
462    Ok(out)
463}
464
465// ═══════════════════════════════════════════════════════════════════
466// Service Worker
467// ═══════════════════════════════════════════════════════════════════
468
469fn gen_service_worker(req: &WebCodegenRequest) -> Result<String, String> {
470    let wasm_name = req.wasm_module_name();
471
472    let mut out = String::new();
473
474    // Header — the SW boots a guest-bridge runtime that serves both the
475    // initiator and acceptor roles; the concrete workload is attached by
476    // the guest WASM itself, so no role-specific register_fn is called here.
477    out.push_str("/* Actor-RTC Service Worker entry.\n");
478    out.push_str(" *\n * WASM includes: guest-bridge SW runtime\n");
479    out.push_str(" *\n * This file is auto-generated by actr gen — DO NOT EDIT\n */\n\n");
480    out.push_str("/* global wasm_bindgen */\n\n");
481
482    // Console interception
483    out.push_str(CONSOLE_INTERCEPTION);
484    out.push('\n');
485
486    // RUNTIME_CONFIG — received from main thread via DOM_PORT_INIT
487    out.push_str("/** @type {import('@actr/web').SwRuntimeConfig | null} */\n");
488    out.push_str("let RUNTIME_CONFIG = null;\n\n");
489
490    // State variables
491    out.push_str("let wasmReady = false;\n");
492    out.push_str("let wsProbeDone = false;\n");
493    out.push_str("const clientPorts = new Map();\n");
494    out.push_str("const browserToSwClient = new Map();\n\n");
495
496    out.push_str(CLEANUP_STALE_CLIENTS);
497    out.push('\n');
498    out.push_str(EMIT_SW_LOG);
499    out.push('\n');
500
501    // ensureWasmReady
502    out.push_str("async function ensureWasmReady() {\n");
503    out.push_str("  if (wasmReady) return;\n\n");
504    out.push_str("  let runtimeUrl;\n  let wasmUrl;\n  try {\n");
505    out.push_str(&format!(
506        "    runtimeUrl = new URL('{wasm_name}.js', self.location).toString();\n"
507    ));
508    out.push_str(&format!(
509        "    wasmUrl = new URL('{wasm_name}_bg.wasm', self.location).toString();\n"
510    ));
511    out.push_str("    emitSwLog('info', 'runtime_url', runtimeUrl);\n\n");
512
513    // WS probe
514    out.push_str("    if (!wsProbeDone && RUNTIME_CONFIG) {\n");
515    out.push_str("      wsProbeDone = true;\n");
516    out.push_str("      try {\n");
517    out.push_str("        const probe = new WebSocket(RUNTIME_CONFIG.signaling_url);\n");
518    out.push_str("        probe.binaryType = 'arraybuffer';\n");
519    out.push_str("        probe.onopen = () => { emitSwLog('info', 'ws_probe_open', null); probe.close(); };\n");
520    out.push_str(
521        "        probe.onerror = () => { emitSwLog('error', 'ws_probe_error', null); };\n",
522    );
523    out.push_str(
524        "      } catch (error) { emitSwLog('error', 'ws_probe_throw', String(error)); }\n",
525    );
526    out.push_str("    }\n\n");
527
528    // Fetch and load WASM
529    out.push_str("    const runtimeRes = await fetch(runtimeUrl, { cache: 'no-store' });\n");
530    out.push_str("    const wasmRes = await fetch(wasmUrl, { cache: 'no-store' });\n\n");
531    out.push_str("    try {\n");
532    out.push_str("      const runtimeText = await runtimeRes.text();\n");
533    out.push_str("      const patchedText = runtimeText.replace('let wasm_bindgen =', 'self.wasm_bindgen =');\n");
534    out.push_str("      (0, eval)(patchedText);\n");
535    out.push_str("    } catch (error) {\n");
536    out.push_str("      emitSwLog('error', 'eval_failed', String(error));\n");
537    out.push_str("      throw error;\n");
538    out.push_str("    }\n\n");
539    out.push_str("    await wasm_bindgen({ module_or_path: wasmUrl });\n");
540    out.push_str("    wasm_bindgen.init_global();\n\n");
541
542    out.push_str("    wasmReady = true;\n");
543    out.push_str("    emitSwLog('info', 'wasm_ready', null);\n");
544    out.push_str("  } catch (error) {\n");
545    out.push_str("    console.error('[SW] WASM init failed:', error);\n");
546    out.push_str("    emitSwLog('error', 'wasm_init_failed', { error: String(error) });\n");
547    out.push_str("    throw error;\n");
548    out.push_str("  }\n}\n\n");
549
550    // Event listeners
551    out.push_str(SW_EVENT_LISTENERS);
552
553    Ok(out)
554}
555
556// ═══════════════════════════════════════════════════════════════════
557// WASM scaffold
558// ═══════════════════════════════════════════════════════════════════
559
560fn gen_wasm_scaffold(req: &WebCodegenRequest) -> Result<Vec<PathBuf>, String> {
561    let wasm_dir = req.project_root.join("wasm");
562    let wasm_src = wasm_dir.join("src");
563    let wasm_generated = wasm_src.join("generated");
564
565    let mut files = Vec::new();
566    // Codegen topology: provider-only projects emit service handlers;
567    // importer projects emit forwarding stubs. Purely a file-scaffold
568    // concern — the runtime connection role is negotiated per-peer.
569    let provider_only = req.is_service_provider_only();
570    let crate_name = req.wasm_module_name();
571    let pkg_name = format!("{}-wasm", req.package_name);
572
573    // 1. Cargo.toml
574    let cargo_path = wasm_dir.join("Cargo.toml");
575    if !cargo_path.exists() {
576        write_file(&cargo_path, &gen_wasm_cargo_toml(&pkg_name))?;
577        files.push(cargo_path);
578    }
579
580    // 2. build.sh
581    let build_sh = wasm_dir.join("build.sh");
582    if !build_sh.exists() {
583        write_file(&build_sh, &gen_wasm_build_sh(&crate_name))?;
584        make_executable(&build_sh)?;
585        files.push(build_sh);
586    }
587
588    // 3. Generated proto types
589    std::fs::create_dir_all(&wasm_generated)
590        .map_err(|e| format!("Failed to create wasm generated dir: {e}"))?;
591
592    let mut generated_modules = Vec::new();
593    for file_info in &req.files {
594        if !file_info.package.is_empty() {
595            let mod_name = to_snake_case(&file_info.package);
596            if !generated_modules.contains(&mod_name) {
597                generated_modules.push(mod_name.clone());
598                let rs_path = wasm_generated.join(format!("{mod_name}.rs"));
599                let rs_content = gen_proto_rs_types(file_info);
600                write_file(&rs_path, &rs_content)?;
601                files.push(rs_path);
602            }
603        }
604    }
605
606    // generated/mod.rs
607    let mod_rs = wasm_generated.join("mod.rs");
608    let mut mod_content = String::from("//! Auto-generated proto type modules\n\n");
609    for m in &generated_modules {
610        mod_content.push_str(&format!("pub mod {m};\n"));
611    }
612    write_file(&mod_rs, &mod_content)?;
613    files.push(mod_rs);
614
615    // 4. Handler files + lib.rs
616    if provider_only {
617        for svc in &req.local_services {
618            let handler_name = to_snake_case(&svc.name);
619            let handler_path = wasm_src.join(format!("{handler_name}.rs"));
620            if !handler_path.exists() {
621                write_file(&handler_path, &gen_server_handler(svc))?;
622                files.push(handler_path);
623            }
624        }
625        let lib_path = wasm_src.join("lib.rs");
626        if !lib_path.exists() {
627            write_file(&lib_path, &gen_server_lib_rs(req))?;
628            files.push(lib_path);
629        }
630    } else {
631        for svc in &req.local_services {
632            let handler_name = to_snake_case(&svc.name);
633            let handler_path = wasm_src.join(format!("{handler_name}_handler.rs"));
634            if !handler_path.exists() {
635                write_file(&handler_path, &gen_client_handler(svc, req))?;
636                files.push(handler_path);
637            }
638        }
639        let lib_path = wasm_src.join("lib.rs");
640        if !lib_path.exists() {
641            write_file(&lib_path, &gen_client_lib_rs(req))?;
642            files.push(lib_path);
643        }
644    }
645
646    Ok(files)
647}
648
649// ═══════════════════════════════════════════════════════════════════
650// WASM templates
651// ═══════════════════════════════════════════════════════════════════
652
653fn gen_wasm_cargo_toml(pkg_name: &str) -> String {
654    format!(
655        r#"[package]
656name = "{pkg_name}"
657version = "0.1.0"
658edition = "2024"
659rust-version = "1.88"
660
661[workspace]
662
663[lib]
664crate-type = ["cdylib", "rlib"]
665
666[dependencies]
667actr-sw-host = {{ git = "https://github.com/actor-rtc/actr", branch = "web" }}
668actr-web-common = {{ git = "https://github.com/actor-rtc/actr", branch = "web" }}
669
670wasm-bindgen = "0.2"
671wasm-bindgen-futures = "0.4"
672js-sys = "0.3"
673serde-wasm-bindgen = "0.6"
674web-sys = {{ version = "0.3", features = ["console"] }}
675futures = "0.3"
676async-trait = "0.1"
677serde = {{ version = "1.0", features = ["derive"] }}
678serde_json = "1.0"
679prost = "0.14"
680bytes = "1.0"
681log = "0.4"
682wasm-logger = "0.2"
683console_error_panic_hook = "0.1"
684"#
685    )
686}
687
688fn gen_wasm_build_sh(crate_name: &str) -> String {
689    format!(
690        r#"#!/bin/bash
691set -e
692
693echo "🔨 Building WASM..."
694
695TMPDIR="${{TMPDIR:-$(pwd)/.tmp}}"
696export TMPDIR
697mkdir -p "$TMPDIR"
698
699OUT_DIR="../public"
700
701rm -f "$OUT_DIR"/{crate_name}*.wasm "$OUT_DIR"/{crate_name}*.js "$OUT_DIR"/{crate_name}*.d.ts
702
703wasm-pack build \
704  --target no-modules \
705  --out-dir "$OUT_DIR" \
706  --out-name {crate_name} \
707  --release
708
709rm -f "$OUT_DIR/package.json" "$OUT_DIR/.gitignore"
710
711echo ""
712echo "✅ WASM built successfully!"
713echo "📁 Output: $OUT_DIR"
714echo ""
715ls -la "$OUT_DIR"/{crate_name}*
716"#
717    )
718}
719
720fn gen_proto_rs_types(file_info: &FileInfo) -> String {
721    let mut out = String::from(
722        "//! Auto-generated protobuf types\n\nuse prost::Message;\nuse serde::{Deserialize, Serialize};\n\n",
723    );
724
725    // Compile the proto through protoc and walk the resulting descriptor for
726    // structured field information. Failure here degrades to an empty type
727    // body (matching the previous regex parser, which silently emitted no
728    // fields when the file could not be read) so the rest of the scaffold
729    // still writes.
730    let set = match crate::descriptor::compile_to_descriptor_set(
731        std::slice::from_ref(&file_info.proto_file),
732        &[],
733    ) {
734        Ok(set) => set,
735        Err(err) => {
736            tracing::warn!(
737                "{}: protoc descriptor compile failed ({}); emitting empty message bodies",
738                file_info.proto_file.display(),
739                err
740            );
741            prost_types::FileDescriptorSet::default()
742        }
743    };
744    let file_desc = crate::descriptor::find_file(&set, &file_info.proto_file);
745
746    // Fast lookup of service names so we can skip them in the message loop.
747    let service_names: std::collections::HashSet<&str> = file_desc
748        .map(|f| f.service.iter().map(|s| s.name()).collect())
749        .unwrap_or_default();
750
751    for type_name in &file_info.declared_type_names {
752        if service_names.contains(type_name.as_str()) {
753            continue;
754        }
755
756        let fields = file_desc
757            .and_then(|f| crate::descriptor::message_fields_for_scaffold(f, type_name))
758            .unwrap_or_default();
759
760        out.push_str("#[derive(Clone, PartialEq, Message, Serialize, Deserialize)]\n");
761        out.push_str(&format!("pub struct {type_name} {{\n"));
762        for (i, (field_name, field_type)) in fields.iter().enumerate() {
763            let tag = i + 1;
764            let rust_type = proto_type_to_rust(field_type);
765            let prost_attr = proto_type_to_prost_attr(field_type, tag);
766            out.push_str(&format!("    {prost_attr}\n"));
767            out.push_str(&format!("    pub {field_name}: {rust_type},\n"));
768        }
769        out.push_str("}\n\n");
770    }
771
772    out
773}
774
775fn gen_server_handler(service: &ServiceInfo) -> String {
776    let sn = &service.name;
777    let mut out = String::new();
778    out.push_str(&format!("//! {sn} implementation\n\n"));
779    out.push_str("use std::rc::Rc;\nuse actr_sw_host::RuntimeContext;\nuse prost::Message;\n\n");
780
781    let pkg = to_snake_case(&service.package);
782    for m in &service.methods {
783        out.push_str(&format!(
784            "use crate::generated::{pkg}::{{{}, {}}};\n",
785            m.input_type, m.output_type
786        ));
787    }
788    out.push('\n');
789
790    out.push_str(&format!("pub struct {sn};\n\n"));
791    out.push_str(&format!("impl {sn} {{\n"));
792    out.push_str("    pub fn new() -> Self { Self }\n\n");
793
794    for m in &service.methods {
795        out.push_str(&format!(
796            "    pub async fn {}(&self, request: {}, _ctx: Rc<RuntimeContext>) -> Result<{}, String> {{\n",
797            m.snake_name, m.input_type, m.output_type
798        ));
799        out.push_str(&format!(
800            "        log::info!(\"Received {sn} {{}} request\", \"{}\");\n",
801            m.name
802        ));
803        out.push_str("        // TODO: Implement business logic\n");
804        out.push_str(&format!("        Ok({}::default())\n", m.output_type));
805        out.push_str("    }\n\n");
806    }
807    out.push_str("}\n\n");
808
809    out.push_str(&format!(
810        "static SERVICE: std::sync::OnceLock<{sn}> = std::sync::OnceLock::new();\n\n"
811    ));
812    out.push_str(&format!("fn get_service() -> &'static {sn} {{\n"));
813    out.push_str(&format!("    SERVICE.get_or_init({sn}::new)\n}}\n\n"));
814
815    out.push_str("pub async fn handle_request(method: &str, request_bytes: &[u8], ctx: Rc<RuntimeContext>) -> Result<Vec<u8>, String> {\n");
816    out.push_str("    match method {\n");
817    for m in &service.methods {
818        out.push_str(&format!(
819            "        \"{}\" | \"{}\" => {{\n",
820            m.name, m.snake_name
821        ));
822        out.push_str(&format!(
823            "            let request = {}::decode(request_bytes).map_err(|e| format!(\"Decode failed: {{}}\", e))?;\n",
824            m.input_type
825        ));
826        out.push_str(&format!(
827            "            let response = get_service().{}(request, ctx).await?;\n",
828            m.snake_name
829        ));
830        out.push_str("            let mut buf = Vec::with_capacity(response.encoded_len());\n");
831        out.push_str("            response.encode(&mut buf).map_err(|e| format!(\"Encode failed: {}\", e))?;\n");
832        out.push_str("            Ok(buf)\n");
833        out.push_str("        }\n");
834    }
835    out.push_str("        _ => Err(format!(\"Unknown method: {}\", method)),\n");
836    out.push_str("    }\n}\n");
837
838    out
839}
840
841fn gen_client_handler(service: &ServiceInfo, req: &WebCodegenRequest) -> String {
842    let sn = &service.name;
843    let mut out = String::new();
844    out.push_str(&format!(
845        "//! {sn} local handler — forwards requests to remote service\n\n"
846    ));
847    out.push_str("use std::rc::Rc;\nuse std::sync::OnceLock;\n");
848    out.push_str("use actr_sw_host::RuntimeContext;\nuse actr_sw_host::WebContext;\n\n");
849
850    let remote_target = req.dependencies.first().and_then(|d| d.actr_type.as_ref());
851
852    if let Some(target) = remote_target {
853        out.push_str("static TARGET_TYPE: OnceLock<actr_sw_host::actr_protocol::ActrType> = OnceLock::new();\n\n");
854        out.push_str("fn target_type() -> &'static actr_sw_host::actr_protocol::ActrType {\n");
855        out.push_str("    TARGET_TYPE.get_or_init(|| actr_sw_host::actr_protocol::ActrType {\n");
856        out.push_str(&format!(
857            "        manufacturer: \"{}\".to_string(),\n",
858            target.manufacturer
859        ));
860        out.push_str(&format!("        name: \"{}\".to_string(),\n", target.name));
861        out.push_str("        version: \"1.0.0\".to_string(),\n    })\n}\n\n");
862    }
863
864    let remote_route_key = req
865        .remote_services
866        .first()
867        .and_then(|s| s.methods.first())
868        .map(|m| m.route_key.as_str())
869        .unwrap_or("unknown.Unknown.Unknown");
870
871    out.push_str("pub async fn handle_request(method: &str, request_bytes: &[u8], ctx: Rc<RuntimeContext>) -> Result<Vec<u8>, String> {\n");
872    out.push_str("    match method {\n");
873    for m in &service.methods {
874        out.push_str(&format!("        \"{}\" => {{\n", m.name));
875        out.push_str(&format!(
876            "            log::info!(\"[{}] Forwarding to remote...\");\n",
877            sn
878        ));
879        if remote_target.is_some() {
880            out.push_str("            let target = ctx.discover(target_type()).await.map_err(|e| format!(\"Discover failed: {}\", e))?;\n");
881            out.push_str(&format!(
882                "            let response = ctx.call_raw(&target, \"{remote_route_key}\", request_bytes, 30000).await.map_err(|e| format!(\"call_raw failed: {{}}\", e))?;\n"
883            ));
884            out.push_str("            Ok(response)\n");
885        } else {
886            out.push_str("            Err(\"No remote target configured\".to_string())\n");
887        }
888        out.push_str("        }\n");
889    }
890    out.push_str("        _ => Err(format!(\"Unknown method: {}\", method)),\n");
891    out.push_str("    }\n}\n");
892
893    out
894}
895
896fn gen_server_lib_rs(req: &WebCodegenRequest) -> String {
897    let mut handler_mods = Vec::new();
898    let mut register_calls = Vec::new();
899
900    for svc in &req.local_services {
901        let hn = to_snake_case(&svc.name);
902        handler_mods.push(hn.clone());
903        let register_fn = format!("register_{hn}");
904        let service_match = if svc.package.is_empty() {
905            svc.name.clone()
906        } else {
907            format!("{}.{}", svc.package, svc.name)
908        };
909        register_calls.push((register_fn, hn, service_match));
910    }
911
912    let mut out = String::from(
913        "//! WASM entry — Service Worker Runtime + Service Handler\n\nmod generated;\n",
914    );
915    for m in &handler_mods {
916        out.push_str(&format!("mod {m};\n"));
917    }
918    out.push_str("\nuse std::rc::Rc;\nuse wasm_bindgen::prelude::*;\npub use actr_sw_host::*;\n\n");
919    out.push_str("#[wasm_bindgen(start)]\npub fn init() {\n    console_error_panic_hook::set_once();\n    wasm_logger::init(wasm_logger::Config::default());\n    log::info!(\"WASM initialized\");\n}\n\n");
920
921    for (rf, hn, sm) in &register_calls {
922        out.push_str("#[wasm_bindgen]\n");
923        out.push_str(&format!("pub fn {rf}() {{\n"));
924        out.push_str(&format!("    log::info!(\"Registering {hn}...\");\n\n"));
925        out.push_str(
926            "    actr_sw_host::register_service_handler(Rc::new(|route_key, bytes, ctx| {\n",
927        );
928        out.push_str(
929            "        let route_key = route_key.to_string();\n        let bytes = bytes.to_vec();\n",
930        );
931        out.push_str("        Box::pin(async move {\n");
932        out.push_str(
933            "            let (service, method) = if let Some(last_dot) = route_key.rfind('.') {\n",
934        );
935        out.push_str("                (&route_key[..last_dot], &route_key[last_dot + 1..])\n");
936        out.push_str(
937            "            } else {\n                (route_key.as_str(), \"\")\n            };\n",
938        );
939        out.push_str("            match service {\n");
940        out.push_str(&format!(
941            "                \"{sm}\" => {hn}::handle_request(method, &bytes, ctx).await,\n"
942        ));
943        out.push_str("                _ => Err(format!(\"Unknown service: {}\", service)),\n");
944        out.push_str("            }\n        })\n    }));\n\n");
945        out.push_str(&format!("    log::info!(\"{hn} registered\");\n}}\n\n"));
946    }
947
948    out
949}
950
951fn gen_client_lib_rs(req: &WebCodegenRequest) -> String {
952    let mut handler_mods = Vec::new();
953    let mut register_calls = Vec::new();
954
955    for svc in &req.local_services {
956        let hn = to_snake_case(&svc.name);
957        let mn = format!("{hn}_handler");
958        handler_mods.push(mn.clone());
959        let rf = format!("register_{hn}_handler");
960        let sm = if svc.package.is_empty() {
961            svc.name.clone()
962        } else {
963            format!("{}.{}", svc.package, svc.name)
964        };
965        register_calls.push((rf, mn, sm));
966    }
967
968    let mut out =
969        String::from("//! WASM entry — Service Worker Runtime + Local Handler\n\nmod generated;\n");
970    for m in &handler_mods {
971        out.push_str(&format!("mod {m};\n"));
972    }
973    out.push_str("\nuse std::rc::Rc;\nuse wasm_bindgen::prelude::*;\npub use actr_sw_host::*;\n\n");
974    out.push_str("#[wasm_bindgen(start)]\npub fn init() {\n    console_error_panic_hook::set_once();\n    wasm_logger::init(wasm_logger::Config::default());\n    log::info!(\"WASM initialized\");\n}\n\n");
975
976    for (rf, mn, sm) in &register_calls {
977        out.push_str("#[wasm_bindgen]\n");
978        out.push_str(&format!("pub fn {rf}() {{\n"));
979        out.push_str("    log::info!(\"Registering handler...\");\n\n");
980        out.push_str(
981            "    actr_sw_host::register_service_handler(Rc::new(|route_key, bytes, ctx| {\n",
982        );
983        out.push_str(
984            "        let route_key = route_key.to_string();\n        let bytes = bytes.to_vec();\n",
985        );
986        out.push_str("        Box::pin(async move {\n");
987        out.push_str(
988            "            let (service, method) = if let Some(last_dot) = route_key.rfind('.') {\n",
989        );
990        out.push_str("                (&route_key[..last_dot], &route_key[last_dot + 1..])\n");
991        out.push_str(
992            "            } else {\n                (route_key.as_str(), \"\")\n            };\n",
993        );
994        out.push_str("            match service {\n");
995        out.push_str(&format!(
996            "                \"{sm}\" => {mn}::handle_request(method, &bytes, ctx).await,\n"
997        ));
998        out.push_str("                _ => Err(format!(\"Unknown service: {}\", service)),\n");
999        out.push_str("            }\n        })\n    }));\n\n");
1000        out.push_str("    log::info!(\"Handler registered\");\n}\n\n");
1001    }
1002
1003    out
1004}
1005
1006fn gen_root_build_sh() -> String {
1007    r#"#!/bin/bash
1008set -e
1009
1010SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
1011WASM_DIR="$SCRIPT_DIR/wasm"
1012
1013echo "Building WASM..."
1014(
1015  cd "$WASM_DIR"
1016  ./build.sh
1017)
1018
1019echo "WASM artifacts ready in public/"
1020"#
1021    .to_string()
1022}
1023
1024// ═══════════════════════════════════════════════════════════════════
1025// Helpers
1026// ═══════════════════════════════════════════════════════════════════
1027
1028fn write_file(path: &Path, content: &str) -> Result<(), String> {
1029    if let Some(parent) = path.parent() {
1030        std::fs::create_dir_all(parent)
1031            .map_err(|e| format!("Failed to create directory {}: {e}", parent.display()))?;
1032    }
1033    std::fs::write(path, content)
1034        .map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
1035    info!("  📄 {}", path.display());
1036    Ok(())
1037}
1038
1039fn make_executable(path: &Path) -> Result<(), String> {
1040    #[cfg(unix)]
1041    {
1042        use std::os::unix::fs::PermissionsExt;
1043        let mut perms = std::fs::metadata(path)
1044            .map_err(|e| format!("Failed to read metadata: {e}"))?
1045            .permissions();
1046        perms.set_mode(perms.mode() | 0o755);
1047        std::fs::set_permissions(path, perms)
1048            .map_err(|e| format!("Failed to set permissions: {e}"))?;
1049    }
1050    #[cfg(not(unix))]
1051    let _ = path;
1052    Ok(())
1053}
1054
1055fn to_kebab_case(name: &str) -> String {
1056    name.to_kebab_case()
1057}
1058
1059fn to_snake_case(name: &str) -> String {
1060    name.to_snake_case()
1061}
1062
1063fn to_camel_case(name: &str) -> String {
1064    name.to_lower_camel_case()
1065}
1066
1067fn proto_type_to_rust(proto_type: &str) -> String {
1068    match proto_type {
1069        "string" => "String".to_string(),
1070        "bytes" => "Vec<u8>".to_string(),
1071        "bool" => "bool".to_string(),
1072        "int32" | "sint32" | "sfixed32" => "i32".to_string(),
1073        "int64" | "sint64" | "sfixed64" => "i64".to_string(),
1074        "uint32" | "fixed32" => "u32".to_string(),
1075        "uint64" | "fixed64" => "u64".to_string(),
1076        "float" => "f32".to_string(),
1077        "double" => "f64".to_string(),
1078        other => other.to_string(),
1079    }
1080}
1081
1082fn proto_type_to_prost_attr(proto_type: &str, tag: usize) -> String {
1083    match proto_type {
1084        "string" => format!("#[prost(string, tag = \"{tag}\")]"),
1085        "bytes" => format!("#[prost(bytes, tag = \"{tag}\")]"),
1086        "bool" => format!("#[prost(bool, tag = \"{tag}\")]"),
1087        "int32" | "sint32" | "sfixed32" => format!("#[prost(int32, tag = \"{tag}\")]"),
1088        "int64" | "sint64" | "sfixed64" => format!("#[prost(int64, tag = \"{tag}\")]"),
1089        "uint32" | "fixed32" => format!("#[prost(uint32, tag = \"{tag}\")]"),
1090        "uint64" | "fixed64" => format!("#[prost(uint64, tag = \"{tag}\")]"),
1091        "float" => format!("#[prost(float, tag = \"{tag}\")]"),
1092        "double" => format!("#[prost(double, tag = \"{tag}\")]"),
1093        _ => format!("#[prost(message, tag = \"{tag}\")]"),
1094    }
1095}
1096
1097/// Format a TOML value as a TypeScript object literal
1098fn format_toml_as_ts(value: &toml::Value, indent: usize) -> String {
1099    let pad = "  ".repeat(indent);
1100    let inner_pad = "  ".repeat(indent + 1);
1101    match value {
1102        toml::Value::Table(table) => {
1103            if table.is_empty() {
1104                return "{}".to_string();
1105            }
1106            let mut out = "{\n".to_string();
1107            for (key, val) in table {
1108                let ts_key = if key.contains('-') || key.contains('.') {
1109                    format!("'{key}'")
1110                } else {
1111                    key.clone()
1112                };
1113                out.push_str(&format!(
1114                    "{inner_pad}{ts_key}: {},\n",
1115                    format_toml_as_ts(val, indent + 1)
1116                ));
1117            }
1118            out.push_str(&format!("{pad}}}"));
1119            out
1120        }
1121        toml::Value::Array(arr) => {
1122            if arr.is_empty() {
1123                return "[]".to_string();
1124            }
1125            let items: Vec<String> = arr
1126                .iter()
1127                .map(|v| format_toml_as_ts(v, indent + 1))
1128                .collect();
1129            if arr.iter().all(|v| !v.is_table()) {
1130                format!("[{}]", items.join(", "))
1131            } else {
1132                let mut out = "[\n".to_string();
1133                for item in &items {
1134                    out.push_str(&format!("{inner_pad}{item},\n"));
1135                }
1136                out.push_str(&format!("{pad}]"));
1137                out
1138            }
1139        }
1140        toml::Value::String(s) => format!("'{}'", s.replace('\'', "\\'")),
1141        toml::Value::Integer(n) => n.to_string(),
1142        toml::Value::Float(f) => f.to_string(),
1143        toml::Value::Boolean(b) => b.to_string(),
1144        toml::Value::Datetime(dt) => format!("'{dt}'"),
1145    }
1146}
1147
1148// ═══════════════════════════════════════════════════════════════════
1149// Inlined JS constants
1150// ═══════════════════════════════════════════════════════════════════
1151
1152const CONSOLE_INTERCEPTION: &str = r#"(function () {
1153  const _origInfo = console.info;
1154  const _origWarn = console.warn;
1155  const _origError = console.error;
1156  const _origLog = console.log;
1157
1158  function extractMessage(args) {
1159    return Array.from(args)
1160      .filter(a => typeof a === 'string' && !/^\s*(color|background|font-weight|padding)\s*:/.test(a))
1161      .join(' ')
1162      .replace(/%c/g, '')
1163      .trim();
1164  }
1165
1166  function broadcast(data) {
1167    self.clients.matchAll({ type: 'window' }).then(clients => {
1168      for (const client of clients) {
1169        client.postMessage(data);
1170      }
1171    }).catch(() => {});
1172  }
1173
1174  console.info = function (...args) {
1175    _origInfo.apply(console, args);
1176    const msg = extractMessage(args);
1177    if (msg.includes('[SW]') || msg.includes('Echo') || msg.includes('Registering')
1178      || msg.includes('Scheduler') || msg.includes('Dispatcher')) {
1179      broadcast({ type: 'sw_log', level: 'info', message: msg, ts: Date.now() });
1180    }
1181  };
1182
1183  console.warn = function (...args) {
1184    _origWarn.apply(console, args);
1185    const msg = extractMessage(args);
1186    broadcast({ type: 'sw_log', level: 'warn', message: msg, ts: Date.now() });
1187  };
1188
1189  console.error = function (...args) {
1190    _origError.apply(console, args);
1191    const msg = extractMessage(args);
1192    broadcast({ type: 'sw_log', level: 'error', message: msg, ts: Date.now() });
1193  };
1194
1195  console.log = function (...args) {
1196    _origLog.apply(console, args);
1197    const msg = extractMessage(args);
1198    if (msg.includes('[SW]') || msg.includes('[WebRTC]')) {
1199      broadcast({ type: 'sw_log', level: 'info', message: msg, ts: Date.now() });
1200    }
1201  };
1202})();
1203"#;
1204
1205const CLEANUP_STALE_CLIENTS: &str = r#"async function cleanupStaleClients() {
1206  if (!wasmReady) return;
1207  try {
1208    const activeWindows = await self.clients.matchAll({ type: 'window' });
1209    const activeIds = new Set(activeWindows.map(c => c.id));
1210    for (const [browserId, swClientId] of browserToSwClient.entries()) {
1211      if (!activeIds.has(browserId)) {
1212        browserToSwClient.delete(browserId);
1213        clientPorts.delete(swClientId);
1214        try { await wasm_bindgen.unregister_client(swClientId); } catch (_) {}
1215      }
1216    }
1217  } catch (_) {}
1218}
1219"#;
1220
1221const EMIT_SW_LOG: &str = r#"function emitSwLog(level, message, detail) {
1222  for (const port of clientPorts.values()) {
1223    try {
1224      port.postMessage({
1225        type: 'webrtc_event',
1226        payload: { eventType: 'sw_log', data: { level, message, detail } },
1227      });
1228    } catch (_) {}
1229  }
1230}
1231"#;
1232
1233const SW_EVENT_LISTENERS: &str = r#"self.addEventListener('install', (event) => {
1234  event.waitUntil(self.skipWaiting());
1235});
1236
1237self.addEventListener('activate', (event) => {
1238  event.waitUntil(self.clients.claim());
1239});
1240
1241self.addEventListener('message', (event) => {
1242  if (event.data && event.data.type === 'PING') {
1243    if (event.source && event.source.postMessage) {
1244      event.source.postMessage({ type: 'PONG' });
1245    }
1246    return;
1247  }
1248  if (!event.data || event.data.type !== 'DOM_PORT_INIT') return;
1249
1250  const port = event.data.port;
1251  const clientId = event.data.clientId;
1252  if (!port || !clientId) return;
1253
1254  // Receive runtime config from main thread (sourced from actr-config.ts)
1255  if (event.data.runtimeConfig && !RUNTIME_CONFIG) {
1256    RUNTIME_CONFIG = event.data.runtimeConfig;
1257  }
1258
1259  clientPorts.set(clientId, port);
1260  const browserId = event.source && event.source.id;
1261  if (browserId) browserToSwClient.set(browserId, clientId);
1262
1263  cleanupStaleClients();
1264
1265  if (event.source && event.source.postMessage) {
1266    event.source.postMessage({ type: 'sw_ack', message: 'port_ready' });
1267  }
1268
1269  emitSwLog('info', 'sw_env', {
1270    clientId,
1271    location: self.location ? self.location.href : null,
1272    totalClients: clientPorts.size,
1273  });
1274
1275  port.onmessage = async (portEvent) => {
1276    try { await ensureWasmReady(); } catch (_) { return; }
1277    const message = portEvent.data;
1278    if (!message || !message.type) return;
1279
1280    switch (message.type) {
1281      case 'control':
1282        try { await wasm_bindgen.handle_dom_control(clientId, message.payload); } catch (e) {
1283          console.error('[SW] handle_dom_control failed:', e);
1284        }
1285        break;
1286      case 'webrtc_event':
1287        try { await wasm_bindgen.handle_dom_webrtc_event(clientId, message.payload); } catch (e) {
1288          console.error('[SW] handle_dom_webrtc_event failed:', e);
1289        }
1290        break;
1291      case 'fast_path_data':
1292        try { await wasm_bindgen.handle_dom_fast_path(clientId, message.payload); } catch (e) {
1293          console.error('[SW] handle_dom_fast_path failed:', e);
1294        }
1295        break;
1296      case 'register_datachannel_port':
1297        try {
1298          const dcPort = message.payload.port;
1299          const dcPeerId = message.payload.peerId;
1300          if (dcPort && dcPeerId) {
1301            await wasm_bindgen.register_datachannel_port(clientId, dcPeerId, dcPort);
1302          }
1303        } catch (e) {
1304          console.error('[SW] register_datachannel_port failed:', e);
1305        }
1306        break;
1307    }
1308  };
1309
1310  port.start();
1311
1312  ensureWasmReady().then(async () => {
1313    try {
1314      if (!RUNTIME_CONFIG) {
1315        console.error('[SW] RUNTIME_CONFIG not received from main thread');
1316        return;
1317      }
1318      await wasm_bindgen.register_client(clientId, RUNTIME_CONFIG, port);
1319      emitSwLog('info', 'client_registered', { clientId });
1320    } catch (error) {
1321      console.error('[SW] register_client failed:', error);
1322    }
1323  });
1324});
1325"#;