mod native;
pub(crate) use native::{cleanup_deleted_native_service, compose_processes, rename_native_capabilities};
pub(crate) use native::{
dependency_in_use, ensure_dependency as ensure_native_dependency, release_dependency,
};
use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
};
use anyhow::{Context, Result, bail};
pub(crate) mod email;
pub(crate) mod sms;
pub(crate) mod whatsapp;
pub(crate) mod voice;
pub(crate) mod payments;
pub(crate) mod models;
use super::in_place;
use crate::core::docker::{DependencyCondition, DependsOn, DockerCompose, DockerService};
pub(crate) struct Capability {
pub(crate) id: &'static str,
pub(crate) resource_type: &'static str,
pub(crate) summary: &'static str,
pub(crate) registration_key: &'static str,
pub(crate) receives_events: bool,
pub(crate) workers: bool,
pub(crate) add: fn(&mut CapabilityEdit) -> Result<()>,
pub(crate) remove: fn(&mut CapabilityEdit) -> Result<()>,
}
pub(crate) static CAPABILITIES: &[&Capability] = &[
&email::EMAIL,
&sms::SMS,
&whatsapp::WHATSAPP,
&voice::VOICE,
&payments::PAYMENTS,
&models::MODELS,
];
pub(crate) fn apply(edit: &mut CapabilityEdit, capability: &Capability, adding: bool) -> Result<()> {
if edit.native {
return native::apply(edit, capability, adding);
}
if adding {
(capability.add)(edit)?;
edit.ensure_gateway_wiring(capability.id)
} else {
(capability.remove)(edit)?;
if capability.receives_events {
edit.remove_platform_events(capability.id)?;
}
edit.release_gateway_wiring(capability.id)
}
}
pub(crate) fn find(id: &str) -> Option<&'static Capability> {
CAPABILITIES.iter().copied().find(|c| c.id == id)
}
pub(crate) fn resource_type(id: &str) -> &str {
find(id).map(|c| c.resource_type).unwrap_or(id)
}
pub(crate) const LOCAL_INSTANCE_HMAC_KEY: &str = "local-dev-instance-key";
pub(crate) const LOCAL_INSTANCE_ID: &str = "local-instance";
pub(crate) const GATEWAY_MOCK_SERVICE: &str = "gateway-mock";
pub(crate) const GATEWAY_MOCK_HOST_PORT: &str = "18088";
pub(crate) struct CapabilityEdit {
pub(crate) native: bool,
pub(crate) format_rust: bool,
pub(crate) app_root: PathBuf,
pub(crate) app_name: String,
pub(crate) service_name: String,
pub(crate) service_path: PathBuf,
pub(crate) service_capabilities: Vec<String>,
pub(crate) app_capabilities: Vec<String>,
compose_keys: Vec<String>,
staged: BTreeMap<PathBuf, Option<String>>,
}
impl CapabilityEdit {
pub(crate) fn new(
app_root: &Path,
app_name: &str,
service_name: &str,
service_path: &Path,
service_capabilities: Vec<String>,
app_capabilities: Vec<String>,
) -> Self {
Self {
native: false,
format_rust: false,
app_root: app_root.to_path_buf(),
app_name: app_name.to_string(),
service_name: service_name.to_string(),
service_path: service_path.to_path_buf(),
service_capabilities,
app_capabilities,
compose_keys: vec![service_name.to_string()],
staged: BTreeMap::new(),
}
}
pub(crate) fn for_worker(mut self) -> Self {
self.compose_keys = vec![
format!("{}-worker", self.service_name),
format!("{}-server", self.service_name),
];
self
}
pub(crate) fn read(&self, path: &Path) -> Result<Option<String>> {
if let Some(staged) = self.staged.get(path) {
return Ok(staged.clone());
}
if path.exists() {
return Ok(Some(
fs::read_to_string(path).with_context(|| format!("reading {path:?}"))?,
));
}
Ok(None)
}
pub(crate) fn write(&mut self, path: PathBuf, content: String) {
let content = if self.format_rust && path.extension().is_some_and(|extension| extension == "rs") {
crate::init::rust_project::format_rust_source(&path, &content)
} else {
content
};
self.staged.insert(path, Some(content));
}
pub(crate) fn delete(&mut self, path: PathBuf) {
self.staged.insert(path, None);
}
pub(crate) fn registrations_path(&self) -> PathBuf {
self.service_path.join("registrations.ts")
}
pub(crate) fn changed_paths(&self) -> Vec<(PathBuf, bool)> {
self.staged
.iter()
.map(|(path, content)| (path.clone(), content.is_some()))
.collect()
}
pub(crate) fn commit(self) -> Result<()> {
for (path, content) in self.staged {
match content {
Some(content) => {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&path, content).with_context(|| format!("writing {path:?}"))?;
}
None => {
if path.exists() {
fs::remove_file(&path).with_context(|| format!("removing {path:?}"))?;
if let Some(parent) = path.parent()
&& fs::read_dir(parent).is_ok_and(|mut entries| entries.next().is_none())
{
fs::remove_dir(parent)?;
}
}
}
}
}
Ok(())
}
pub(crate) fn is_generated(&self, path: &Path, text: &str, template: &str) -> bool {
text == template
|| (path.extension().is_some_and(|extension| extension == "rs")
&& text == crate::init::rust_project::format_rust_source(path, template))
}
fn display<'a>(&self, path: &'a Path) -> std::path::Display<'a> {
path.strip_prefix(&self.app_root).unwrap_or(path).display()
}
pub(crate) fn note_kept(&self, path: &Path) {
eprintln!("note: kept {} (edited since `infra add`)", self.display(path));
}
pub(crate) fn note_kept_setting(&self, path: &Path, what: &str) {
eprintln!(
"note: kept {what} in {} (changed since `infra add`)",
self.display(path)
);
}
pub(crate) fn release_env_local(&mut self, pairs: &[(&str, &str)]) -> Result<()> {
let path = self.service_path.join(".env.local");
let Some(text) = self.read(&path)? else {
return Ok(());
};
let kept = in_place::remove_env_pairs(&text, pairs);
let kept_names: Vec<String> = kept
.lines()
.filter_map(in_place::env_key)
.filter(|name| pairs.iter().any(|(added, _)| added == name))
.map(str::to_owned)
.collect();
for name in &kept_names {
self.note_kept_setting(&path, name);
}
if kept == text {
return Ok(());
}
if kept.trim().is_empty() {
self.delete(path);
} else {
self.write(path, kept);
}
Ok(())
}
pub(crate) fn inject_registration(
&mut self,
import_line: &str,
import_source: &str,
env_block: Option<&str>,
runtime_block: Option<&str>,
) -> Result<()> {
let path = self.registrations_path();
let before = self
.read(&path)?
.with_context(|| format!("{path:?} not found"))?;
let mut text = before.clone();
let names = named_imports(import_line);
if !names.is_empty() {
let names: Vec<&str> = names.iter().map(String::as_str).collect();
text = in_place::add_named_imports(&text, import_source, &names);
} else if let Some(name) = default_import(import_line) {
text = in_place::add_default_import(&text, &name, import_source);
}
for (block, declaration) in [
(env_block, "environmentConfig"),
(runtime_block, "runtimeDependencies"),
] {
if let Some(block) = block {
text = in_place::add_config_entries(&text, declaration, block)?;
}
}
if text != before {
self.write(path, text);
}
Ok(())
}
pub(crate) fn remove_registration(
&mut self,
import_source: Option<&str>,
env_keys: &[&str],
runtime_keys: &[&str],
) -> Result<()> {
let path = self.registrations_path();
let Some(before) = self.read(&path)? else {
return Ok(());
};
let mut text = in_place::remove_config_entries(&before, "environmentConfig", env_keys)?;
text = in_place::remove_config_entries(&text, "runtimeDependencies", runtime_keys)?;
text = in_place::drop_orphaned_imports(&before, &text, ADDED_IMPORTS);
if let Some(source) = import_source {
if let Some((_, _, names)) = find_import(&text, source) {
let names: Vec<&str> = names.iter().map(String::as_str).collect();
text = in_place::remove_named_imports(&text, source, &names);
}
}
if text != before {
self.write(path, text);
}
Ok(())
}
pub(crate) fn ensure_gateway_wiring(&mut self, feature: &str) -> Result<()> {
let registrations = self.read(&self.registrations_path())?.unwrap_or_default();
if !registrations.contains("INSTANCE_HMAC_KEY:") {
self.inject_registration(
"import { getEnvVar } from \"@forklaunch/core/services\";",
"@forklaunch/core/services",
Some(GATEWAY_ENV_BLOCK),
None,
)?;
}
let pairs = local_gateway_env();
let pairs: Vec<(&str, &str)> = pairs.iter().map(|(k, v)| (*k, v.as_str())).collect();
self.ensure_env_local(&pairs)?;
let keys = self.compose_keys.clone();
self.update_compose(|compose| {
let missing: Vec<&str> = keys
.iter()
.map(String::as_str)
.filter(|k| !compose.services.contains_key(*k))
.collect();
if !missing.is_empty() {
bail!(
"no {} service in docker-compose (needs: {})",
missing
.iter()
.map(|k| format!("'{k}'"))
.collect::<Vec<_>>()
.join(", "),
keys.join(", ")
);
}
let events = find(feature).is_none_or(|c| c.receives_events);
for (i, key) in keys.iter().enumerate() {
ensure_gateway_mock(compose, key, feature, events && i + 1 == keys.len())?;
}
Ok(())
})
}
pub(crate) fn release_gateway_wiring(&mut self, feature: &str) -> Result<()> {
if self.service_capabilities.is_empty() {
let path = self.registrations_path();
if let Some(text) = self.read(&path)? {
let keys = in_place::snippet_keys(GATEWAY_ENV_BLOCK)?;
let ours: Vec<&str> = keys
.iter()
.map(String::as_str)
.filter(|key| {
in_place::entry_matches_snippet(
&text,
"environmentConfig",
key,
GATEWAY_ENV_BLOCK,
)
})
.collect();
let removed = in_place::remove_config_entries(&text, "environmentConfig", &ours)?;
let unread: Vec<&str> = ours
.into_iter()
.filter(|key| !in_place::binding_used(&removed, key))
.collect();
if !unread.is_empty() {
self.remove_registration(None, &unread, &[])?;
}
}
let env_local = self.service_path.join(".env.local");
if let Some(text) = self.read(&env_local)? {
let pairs = local_gateway_env();
let pairs: Vec<(&str, &str)> =
pairs.iter().map(|(k, v)| (*k, v.as_str())).collect();
let changed = in_place::remove_env_pairs(&text, &pairs);
if changed != text {
if changed.trim().is_empty() {
self.delete(env_local);
} else {
self.write(env_local, changed);
}
}
}
}
let keys = self.compose_keys.clone();
let service_has_none_left = self.service_capabilities.is_empty();
let app_has_none_left = self.app_capabilities.is_empty();
let events = find(feature).is_none_or(|c| c.receives_events);
self.update_compose(|compose| {
for (i, key) in keys.iter().enumerate() {
release_gateway_mock(
compose,
key,
feature,
events && i + 1 == keys.len(),
service_has_none_left,
app_has_none_left,
);
}
Ok(())
})
}
fn update_compose(
&mut self,
change: impl FnOnce(&mut DockerCompose) -> Result<()>,
) -> Result<()> {
let path = self.app_root.join("docker-compose.yaml");
let Some(text) = self.read(&path)? else {
return Ok(());
};
let mut compose: DockerCompose = serde_yml::from_str(&text)?;
let before = serde_yml::to_string(&compose)?;
change(&mut compose)?;
let after = serde_yml::to_string(&compose)?;
if after != before {
let header: String = text
.split_inclusive('\n')
.take_while(|l| l.starts_with('#') || l.trim().is_empty())
.collect();
self.write(path, format!("{header}{after}"));
}
Ok(())
}
pub(crate) fn ensure_env_local(&mut self, pairs: &[(&str, &str)]) -> Result<()> {
let path = self.service_path.join(".env.local");
let mut text = self.read(&path)?.unwrap_or_default();
let mut changed = false;
for (key, value) in pairs {
let present = text
.lines()
.any(|line| in_place::env_key(line) == Some(*key));
if !present {
if !text.is_empty() && !text.ends_with('\n') {
text.push('\n');
}
text.push_str(&format!("{key}={value}\n"));
changed = true;
}
}
if changed {
self.write(path, text);
}
Ok(())
}
pub(crate) fn ensure_platform_events(
&mut self,
feature: &str,
handler_stub: &str,
) -> Result<()> {
let routes = self
.service_path
.join("api/routes/platformEvents.routes.ts");
if self.read(&routes)?.is_none() {
self.write(routes, render(PLATFORM_EVENTS_ROUTES, &self.app_name));
self.write(
self.service_path
.join("api/controllers/platformEvents.controller.ts"),
render(PLATFORM_EVENTS_CONTROLLER, &self.app_name),
);
self.mount_platform_events_router()?;
}
let handler = self
.service_path
.join(format!("api/platformEvents/{feature}.ts"));
if self.read(&handler)?.is_none() {
self.write(handler, handler_stub.to_string());
}
self.write_platform_events_index()?;
Ok(())
}
pub(crate) fn remove_platform_events(&mut self, feature: &str) -> Result<()> {
self.delete(
self.service_path
.join(format!("api/platformEvents/{feature}.ts")),
);
let remaining = self.event_features();
if remaining.is_empty() {
for file in [
"api/routes/platformEvents.routes.ts",
"api/controllers/platformEvents.controller.ts",
"api/platformEvents/index.ts",
] {
self.delete(self.service_path.join(file));
}
let server = self.service_path.join("server.ts");
if let Some(text) = self.read(&server)? {
let kept: String = text
.split_inclusive('\n')
.filter(|line| !is_platform_events_mount(line))
.collect();
if kept != text {
self.write(server, kept);
}
}
} else {
self.write_platform_events_index()?;
}
Ok(())
}
fn event_features(&self) -> Vec<String> {
self.service_capabilities
.iter()
.filter(|c| find(c).is_some_and(|c| c.receives_events))
.cloned()
.collect()
}
fn write_platform_events_index(&mut self) -> Result<()> {
let features = self.event_features();
let mut imports = String::new();
let mut entries = String::new();
for feature in &features {
let ident = camel(feature);
imports.push_str(&format!(
"import {{ handle as {ident} }} from './{feature}';\n"
));
entries.push_str(&format!(" {feature}: {ident},\n"));
}
let content = format!(
"/**\n * Platform event handlers by feature. Generated by `forklaunch infra add`;\n * edit the handler files, not this list.\n */\nimport type {{ PlatformEvent }} from '@forklaunch/core/http';\n{imports}\nexport const platformEventHandlers: Record<\n string,\n (event: PlatformEvent) => Promise<void>\n> = {{\n{entries}}};\n"
);
self.write(
self.service_path.join("api/platformEvents/index.ts"),
content,
);
Ok(())
}
fn mount_platform_events_router(&mut self) -> Result<()> {
let server = self.service_path.join("server.ts");
let Some(text) = self.read(&server)? else {
bail!("{server:?} not found; mount platformEventsRouter by hand");
};
if text.contains("platformEventsRouter") {
return Ok(());
}
let q = in_place::quote_of(&text);
let lines: Vec<&str> = text.split_inclusive('\n').collect();
let import_ends: Vec<&str> = lines
.iter()
.copied()
.filter(|l| l.starts_with("import ") || l.starts_with("} from "))
.filter(|l| l.contains(" from "))
.collect();
let semi = if import_ends.is_empty() || import_ends.iter().any(|l| l.trim_end().ends_with(';')) {
";"
} else {
""
};
let last_import = lines
.iter()
.rposition(|l| l.starts_with("import ") || l.starts_with("} from "));
let import = format!("import {{ platformEventsRouter }} from {q}./api/routes/platformEvents.routes{q}{semi}\n");
let mut out = String::new();
if last_import.is_none() {
out.push_str(&import);
}
let mut used = false;
for (i, line) in lines.iter().enumerate() {
out.push_str(line);
let after = if Some(i) == last_import {
Some(import.clone())
} else if !used && line.trim_start().starts_with("app.use(") {
used = true;
let indent: String = line.chars().take_while(|c| *c == ' ' || *c == '\t').collect();
Some(format!("{indent}app.use(platformEventsRouter){semi}\n"))
} else {
None
};
if let Some(after) = after {
if !line.ends_with('\n') {
out.push('\n');
}
out.push_str(&after);
}
}
if !used {
bail!("no app.use( in {server:?}; mount platformEventsRouter by hand");
}
self.write(server, out);
Ok(())
}
}
fn is_platform_events_mount(line: &str) -> bool {
let line = line.trim();
let line = line.strip_suffix(';').unwrap_or(line).replace('"', "'");
line == "app.use(platformEventsRouter)"
|| line == "import { platformEventsRouter } from './api/routes/platformEvents.routes'"
}
fn local_gateway_env() -> Vec<(&'static str, String)> {
vec![
(
"PLATFORM_GATEWAY_URL",
format!("http://localhost:{GATEWAY_MOCK_HOST_PORT}"),
),
("INSTANCE_ID", LOCAL_INSTANCE_ID.to_string()),
("INSTANCE_HMAC_KEY", LOCAL_INSTANCE_HMAC_KEY.to_string()),
]
}
fn named_imports(import_line: &str) -> Vec<String> {
let Some(open) = import_line.find('{') else {
return Vec::new();
};
let Some(close) = import_line[open..].find('}') else {
return Vec::new();
};
import_line[open + 1..open + close]
.split(',')
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty())
.collect()
}
fn default_import(import_line: &str) -> Option<String> {
regex::Regex::new(r"^\s*import\s+([A-Za-z_$][\w$]*)\s+from\b")
.unwrap()
.captures(import_line)
.map(|c| c[1].to_string())
}
pub(crate) fn find_import(text: &str, source: &str) -> Option<(usize, usize, Vec<String>)> {
let pattern = format!(
r#"import\s*\{{([^}}]*)\}}\s*from\s*["']{}["'];?"#,
regex::escape(source)
);
let re = regex::Regex::new(&pattern).ok()?;
let m = re.captures(text)?;
let whole = m.get(0)?;
let names = m[1]
.split(',')
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty())
.collect();
Some((whole.start(), whole.end(), names))
}
pub(crate) fn add_named_imports(text: &str, source: &str, names: &[&str]) -> String {
in_place::add_named_imports(text, source, names)
}
#[cfg(test)]
pub(crate) fn remove_named_imports(text: &str, source: &str, names: &[&str]) -> String {
in_place::remove_named_imports(text, source, names)
}
fn camel(feature: &str) -> String {
let mut out = String::new();
let mut upper = false;
for c in feature.chars() {
if c == '-' || c == '_' {
upper = true;
} else if upper {
out.extend(c.to_uppercase());
upper = false;
} else {
out.push(c);
}
}
format!("{out}Events")
}
fn render(template: &str, app_name: &str) -> String {
template.replace("{{app_name}}", app_name)
}
fn events_var(feature: &str) -> String {
format!("MOCK_EVENTS_URL_{}", feature.to_uppercase().replace('-', "_"))
}
fn events_url(compose: &DockerCompose, service_key: &str) -> Option<String> {
let service = compose.services.get(service_key)?;
let port = service
.environment
.as_ref()
.and_then(|e| e.get("PORT").cloned())
.unwrap_or_else(|| "8000".to_string());
Some(format!("http://{service_key}:{port}"))
}
fn url_list(value: Option<&String>) -> Vec<String> {
value
.map(|v| {
v.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.collect()
})
.unwrap_or_default()
}
pub(crate) fn generated_gateway_mock(networks: Option<Vec<String>>) -> DockerService {
DockerService {
hostname: Some(GATEWAY_MOCK_SERVICE.to_string()),
image: Some("node:22-alpine".to_string()),
command: Some(crate::core::docker::Command::Multiple(vec![
"npx".to_string(),
"-y".to_string(),
"-p".to_string(),
format!(
"@forklaunch/core@{}",
crate::core::package_json::package_json_constants::CORE_VERSION
),
"forklaunch-gateway-mock".to_string(),
])),
ports: Some(vec![format!("{GATEWAY_MOCK_HOST_PORT}:8080")]),
networks,
environment: Some(
[
("PORT".to_string(), "8080".to_string()),
(
"MOCK_INSTANCE_HMAC_KEY".to_string(),
LOCAL_INSTANCE_HMAC_KEY.to_string(),
),
]
.into_iter()
.collect(),
),
..Default::default()
}
}
pub(crate) fn ensure_gateway_mock(
compose: &mut DockerCompose,
service_key: &str,
feature: &str,
receives_events: bool,
) -> Result<()> {
let service = compose
.services
.get(service_key)
.with_context(|| format!("no '{service_key}' service in docker-compose"))?
.clone();
let port = service
.environment
.as_ref()
.and_then(|e| e.get("PORT").cloned())
.unwrap_or_else(|| "8000".to_string());
let mock = compose
.services
.entry(GATEWAY_MOCK_SERVICE.to_string())
.or_insert_with(|| generated_gateway_mock(service.networks.clone()));
if receives_events {
let url = format!("http://{service_key}:{port}");
let env = mock.environment.get_or_insert_with(Default::default);
let mut urls = url_list(env.get(&events_var(feature)));
if !urls.contains(&url) {
urls.push(url);
}
env.insert(events_var(feature), urls.join(","));
}
let target = compose.services.get_mut(service_key).unwrap();
let env = target.environment.get_or_insert_with(Default::default);
for (key, value) in compose_gateway_env() {
match env.get(key) {
None => {
env.insert(key.to_string(), value);
}
Some(existing) if *existing != value => eprintln!(
"note: docker-compose.yaml sets {key}={existing:?} for {service_key}; kept (the gateway mock expects {value:?})"
),
Some(_) => {}
}
}
target
.depends_on
.get_or_insert_with(Default::default)
.entry(GATEWAY_MOCK_SERVICE.to_string())
.or_insert(DependsOn {
condition: DependencyCondition::ServiceStarted,
..Default::default()
});
Ok(())
}
pub(crate) fn release_gateway_mock(
compose: &mut DockerCompose,
service_key: &str,
feature: &str,
receives_events: bool,
service_has_none_left: bool,
app_has_none_left: bool,
) {
let own_url = events_url(compose, service_key);
if let (true, Some(mock)) = (
receives_events,
compose.services.get_mut(GATEWAY_MOCK_SERVICE),
) {
if let Some(env) = mock.environment.as_mut() {
let urls: Vec<String> = url_list(env.get(&events_var(feature)))
.into_iter()
.filter(|u| Some(u) != own_url.as_ref())
.collect();
if urls.is_empty() {
env.shift_remove(&events_var(feature));
} else {
env.insert(events_var(feature), urls.join(","));
}
}
}
if service_has_none_left {
if let Some(target) = compose.services.get_mut(service_key) {
if let Some(env) = target.environment.as_mut() {
for (key, value) in compose_gateway_env() {
if env.get(key) == Some(&value) {
env.shift_remove(key);
}
}
}
if let Some(deps) = target.depends_on.as_mut() {
deps.shift_remove(GATEWAY_MOCK_SERVICE);
if deps.is_empty() {
target.depends_on = None;
}
}
}
}
if app_has_none_left {
compose.services.shift_remove(GATEWAY_MOCK_SERVICE);
}
}
pub(crate) const ADDED_IMPORTS: &[&str] = &[
"getEnvVar",
"type",
"createEmailClient",
"EmailClient",
"createSmsClient",
"SmsClient",
"createWhatsAppClient",
"createVoiceClient",
"VoiceClient",
"createStripeClient",
"Stripe",
"createModelGatewayClient",
"ModelGatewayClient",
];
fn compose_gateway_env() -> [(&'static str, String); 3] {
[
(
"PLATFORM_GATEWAY_URL",
format!("http://{GATEWAY_MOCK_SERVICE}:8080"),
),
("INSTANCE_ID", LOCAL_INSTANCE_ID.to_string()),
("INSTANCE_HMAC_KEY", LOCAL_INSTANCE_HMAC_KEY.to_string()),
]
}
const GATEWAY_ENV_BLOCK: &str = "const configInjector = createConfigInjector(SchemaValidator(), {
PLATFORM_GATEWAY_URL: {
lifetime: Lifetime.Singleton,
type: optional(string),
value: getEnvVar('PLATFORM_GATEWAY_URL')
},
INSTANCE_ID: {
lifetime: Lifetime.Singleton,
type: optional(string),
value: getEnvVar('INSTANCE_ID')
},
INSTANCE_HMAC_KEY: {
lifetime: Lifetime.Singleton,
type: optional(string),
value: getEnvVar('INSTANCE_HMAC_KEY')
}
});";
const PLATFORM_EVENTS_ROUTES: &str = "import { forklaunchRouter, schemaValidator } from '@{{app_name}}/core';
import { ci, tokens } from '../../bootstrapper';
import { receivePlatformEvent } from '../controllers/platformEvents.controller';
const openTelemetryCollector = ci.resolve(tokens.OtelCollector);
/** Events the ForkLaunch platform delivers (payments, email, sms, …). */
export const platformEventsRouter = forklaunchRouter(
'/platform-events',
schemaValidator,
openTelemetryCollector
);
export const receivePlatformEventRoute = platformEventsRouter.post(
'/:feature',
receivePlatformEvent
);
";
const PLATFORM_EVENTS_CONTROLLER: &str = "import { handlers, schemaValidator, string } from '@{{app_name}}/core';
import {
PlatformEventVerificationError,
verifyPlatformEvent
} from '@forklaunch/core/http';
import { platformEventHandlers } from '../platformEvents';
/**
* Receives an event the ForkLaunch platform delivered, verifies it was signed
* with this instance's key, and hands it to the feature's handler
* (api/platformEvents/<feature>.ts). Deliveries can repeat: handlers dedupe
* on event.id. A non-2xx answer makes the platform retry.
*/
export const receivePlatformEvent = handlers.post(
schemaValidator,
'/:feature',
{
name: 'ReceivePlatformEvent',
access: 'public',
summary: 'Receives a signed event from the ForkLaunch platform',
params: { feature: string },
body: schemaValidator.unknown,
responses: { 200: string, 401: string, 404: string }
},
async (req, res) => {
const handler = platformEventHandlers[req.params.feature];
if (!handler) {
res.status(404).send(`No handler for ${req.params.feature} events`);
return;
}
let event;
try {
event = verifyPlatformEvent({
method: 'POST',
path: `/platform-events/${req.params.feature}`,
headers: req.headers as Record<string, string | string[] | undefined>,
body: (req as { _rawBody?: Buffer })._rawBody ?? req.body
});
} catch (error) {
if (error instanceof PlatformEventVerificationError) {
res.status(401).send(error.message);
return;
}
throw error;
}
await handler(event);
res.status(200).send('ok');
}
);
";
#[cfg(test)]
mod tests;