1use crate::request::{FileInfo, ServiceInfo, WebCodegenRequest, WebCodegenResponse};
12use heck::{ToKebabCase, ToLowerCamelCase, ToSnakeCase};
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15use tracing::info;
16
17pub 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 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 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 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 let wasm_files = gen_wasm_scaffold(req)?;
59 generated.extend(wasm_files);
60
61 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 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
79fn 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 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 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 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 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 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 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 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 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 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 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 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 let package_url = format!("/packages/{}.actr", req.package_name);
270
271 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 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
326fn 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 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 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 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
438fn 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
465fn 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 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 out.push_str(CONSOLE_INTERCEPTION);
484 out.push('\n');
485
486 out.push_str("/** @type {import('@actr/web').SwRuntimeConfig | null} */\n");
488 out.push_str("let RUNTIME_CONFIG = null;\n\n");
489
490 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 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 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 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 out.push_str(SW_EVENT_LISTENERS);
552
553 Ok(out)
554}
555
556fn 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 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 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 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 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 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 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
649fn 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 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 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 ®ister_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 ®ister_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
1024fn 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
1097fn 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
1148const 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"#;