flora is a fast and secure runtime that lets you write discord bots for your servers, with a rich TypeScript SDK, without worrying about running infrastructure. [mirror]
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor(runtime): switch deployments to prebundled artifacts

+288 -74
+27
apps/runtime/migrations/0007_prebundled_deployments.sql
··· 1 + ALTER TABLE deployments 2 + ADD COLUMN IF NOT EXISTS source_map JSONB; 3 + 4 + DO $$ 5 + BEGIN 6 + IF EXISTS ( 7 + SELECT 1 8 + FROM information_schema.columns 9 + WHERE table_name = 'deployments' 10 + AND column_name = 'files' 11 + ) THEN 12 + EXECUTE $sql$ 13 + UPDATE deployments 14 + SET source_map = ( 15 + SELECT file 16 + FROM jsonb_array_elements(files) AS file 17 + WHERE (file ->> 'path') LIKE '%.map' 18 + LIMIT 1 19 + ) 20 + WHERE source_map IS NULL 21 + $sql$; 22 + END IF; 23 + END 24 + $$; 25 + 26 + ALTER TABLE deployments 27 + DROP COLUMN IF EXISTS files;
+62 -11
apps/runtime/src/deployments.rs
··· 7 7 use tracing::{info, warn}; 8 8 use utoipa::ToSchema; 9 9 10 - use crate::bundler::DeploymentFile; 10 + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] 11 + pub struct DeploymentSourceMapFile { 12 + pub path: String, 13 + pub contents: String, 14 + } 11 15 12 16 /// Stored representation of a guild deployment. 13 17 #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] 14 18 pub struct Deployment { 15 19 pub guild_id: String, 16 20 pub entry: String, 17 - pub files: Vec<DeploymentFile>, 21 + pub source_map: Option<DeploymentSourceMapFile>, 18 22 pub bundle: String, 19 23 pub created_at: DateTime<Utc>, 20 24 pub updated_at: DateTime<Utc>, ··· 31 35 struct DeploymentRow { 32 36 guild_id: String, 33 37 entry: String, 34 - files: sqlx::types::Json<Vec<DeploymentFile>>, 38 + source_map: Option<sqlx::types::Json<DeploymentSourceMapFile>>, 35 39 bundle: String, 36 40 created_at: DateTime<Utc>, 37 41 updated_at: DateTime<Utc>, ··· 54 58 &self, 55 59 guild_id: String, 56 60 entry: String, 57 - files: Vec<DeploymentFile>, 58 61 bundle: String, 62 + source_map: Option<DeploymentSourceMapFile>, 59 63 ) -> Result<Deployment> { 60 64 let record = sqlx::query_as::<_, DeploymentRow>( 61 65 r#" 62 - INSERT INTO deployments (guild_id, entry, files, bundle) 66 + INSERT INTO deployments (guild_id, entry, bundle, source_map) 63 67 VALUES ($1, $2, $3, $4) 64 68 ON CONFLICT (guild_id) DO UPDATE 65 69 SET entry = EXCLUDED.entry, 66 - files = EXCLUDED.files, 67 70 bundle = EXCLUDED.bundle, 71 + source_map = EXCLUDED.source_map, 68 72 updated_at = NOW() 69 - RETURNING guild_id, entry, files, bundle, created_at, updated_at 73 + RETURNING guild_id, entry, source_map, bundle, created_at, updated_at 70 74 "#, 71 75 ) 72 76 .bind(&guild_id) 73 77 .bind(&entry) 74 - .bind(sqlx::types::Json(&files)) 75 78 .bind(&bundle) 79 + .bind(source_map.map(sqlx::types::Json)) 76 80 .fetch_one(&self.db_pool) 77 81 .await?; 78 82 ··· 89 93 90 94 let row = sqlx::query_as::<_, DeploymentRow>( 91 95 r#" 92 - SELECT guild_id, entry, files, bundle, created_at, updated_at 96 + SELECT guild_id, entry, source_map, bundle, created_at, updated_at 93 97 FROM deployments 94 98 WHERE guild_id = $1 95 99 "#, ··· 110 114 pub async fn list_deployments(&self) -> Result<Vec<Deployment>> { 111 115 let rows = sqlx::query_as::<_, DeploymentRow>( 112 116 r#" 113 - SELECT guild_id, entry, files, bundle, created_at, updated_at 117 + SELECT guild_id, entry, source_map, bundle, created_at, updated_at 114 118 FROM deployments 115 119 "#, 116 120 ) ··· 157 161 Ok(Deployment { 158 162 guild_id: row.guild_id, 159 163 entry: row.entry, 160 - files: row.files.0, 164 + source_map: row.source_map.map(|source_map| source_map.0), 161 165 bundle: row.bundle, 162 166 created_at: row.created_at, 163 167 updated_at: row.updated_at, ··· 170 174 format!("guild:{}.bundle.js", self.guild_id) 171 175 } 172 176 } 177 + 178 + #[cfg(test)] 179 + mod tests { 180 + use chrono::Utc; 181 + use serde_json::json; 182 + 183 + use super::{Deployment, DeploymentSourceMapFile}; 184 + 185 + #[test] 186 + fn deployment_json_roundtrip_preserves_source_map() { 187 + let deployment = Deployment { 188 + guild_id: "123".to_string(), 189 + entry: "src/main.ts".to_string(), 190 + source_map: Some(DeploymentSourceMapFile { 191 + path: "bundle.js.map".to_string(), 192 + contents: "{\"version\":3}".to_string(), 193 + }), 194 + bundle: "console.log('hi')".to_string(), 195 + created_at: Utc::now(), 196 + updated_at: Utc::now(), 197 + }; 198 + 199 + let encoded = serde_json::to_string(&deployment).expect("serialize deployment"); 200 + let decoded: Deployment = serde_json::from_str(&encoded).expect("deserialize deployment"); 201 + 202 + assert_eq!( 203 + decoded.source_map.expect("source map").path, 204 + "bundle.js.map" 205 + ); 206 + } 207 + 208 + #[test] 209 + fn deployment_json_allows_missing_source_map() { 210 + let value = json!({ 211 + "guild_id": "123", 212 + "entry": "src/main.ts", 213 + "bundle": "console.log('hi')", 214 + "created_at": Utc::now().to_rfc3339(), 215 + "updated_at": Utc::now().to_rfc3339() 216 + }); 217 + 218 + let deployment: Deployment = 219 + serde_json::from_value(value).expect("deserialize deployment without source map"); 220 + 221 + assert!(deployment.source_map.is_none()); 222 + } 223 + }
+13 -25
apps/runtime/src/discord_handler.rs
··· 1 1 use crate::{ 2 - bundler::{BundleLimits, DeploymentFile, bundle_files}, 3 - deployments::DeploymentService, 2 + deployments::{DeploymentService, DeploymentSourceMapFile}, 4 3 runtime::BotRuntime, 5 4 }; 6 5 use color_eyre::{Report, eyre::eyre}; ··· 19 18 pub runtime: Arc<BotRuntime>, 20 19 pub http: Arc<serenity::http::Http>, 21 20 pub application_id: Arc<std::sync::RwLock<Option<ApplicationId>>>, 22 - pub bundle_limits: BundleLimits, 23 21 pub deployments: DeploymentService, 24 22 } 25 23 ··· 807 805 return Ok(()); 808 806 } 809 807 810 - let files = vec![ 811 - DeploymentFile { 812 - path: DEFAULT_GUILD_ENTRY.to_string(), 813 - contents: DEFAULT_GUILD_SCRIPT.to_string(), 814 - }, 815 - DeploymentFile { 816 - path: "src/utils/reply.ts".to_string(), 817 - contents: DEFAULT_GUILD_UTILS_REPLY.to_string(), 818 - }, 819 - ]; 820 - let bundle_name = format!("guild:{guild_str}.bundle.js"); 821 - let bundle = bundle_files( 822 - &bundle_name, 823 - DEFAULT_GUILD_ENTRY, 824 - &files, 825 - self.bundle_limits, 826 - ) 827 - .map_err(|err| eyre!(err.to_string()))?; 828 808 let deployment = self 829 809 .deployments 830 810 .upsert_deployment( 831 811 guild_str.clone(), 832 812 DEFAULT_GUILD_ENTRY.to_string(), 833 - files, 834 - bundle.code, 813 + DEFAULT_GUILD_BUNDLE.to_string(), 814 + Some(default_guild_source_map()), 835 815 ) 836 816 .await?; 837 817 self.runtime ··· 863 843 864 844 // Ship a minimal starter bot to new guilds without deployments. 865 845 const DEFAULT_GUILD_ENTRY: &str = "src/main.ts"; 866 - const DEFAULT_GUILD_SCRIPT: &str = include_str!("../../../example/src/main.ts"); 867 - const DEFAULT_GUILD_UTILS_REPLY: &str = include_str!("../../../example/src/utils/reply.ts"); 846 + const DEFAULT_GUILD_BUNDLE: &str = include_str!("../../../runtime-dist/default_guild_bundle.js"); 847 + const DEFAULT_GUILD_SOURCE_MAP_CONTENTS: &str = 848 + include_str!("../../../runtime-dist/default_guild_bundle.js.map"); 849 + 850 + fn default_guild_source_map() -> DeploymentSourceMapFile { 851 + DeploymentSourceMapFile { 852 + path: "default_guild_bundle.js.map".to_string(), 853 + contents: DEFAULT_GUILD_SOURCE_MAP_CONTENTS.to_string(), 854 + } 855 + }
+2 -2
apps/runtime/src/handlers/deployments/read.rs
··· 60 60 61 61 ensure_guild_admin(&state, &identity, &guild_id).await?; 62 62 63 - let files = deployment.files.clone(); 63 + let source_map = deployment.source_map.clone(); 64 64 let bundle = deployment.bundle.clone(); 65 - let mut response = DeploymentResponse::from(deployment).with_files(files); 65 + let mut response = DeploymentResponse::from(deployment).with_source_map(source_map); 66 66 if query.include_bundle { 67 67 response = response.with_bundle(bundle); 68 68 }
+92 -27
apps/runtime/src/handlers/deployments/upsert.rs
··· 8 8 use utoipa::ToSchema; 9 9 10 10 use crate::{ 11 - bundler::{DeploymentFile, SourceMapMode, bundle_files_with_sourcemap_mode}, 12 - deployments::Deployment, 11 + deployments::{Deployment, DeploymentSourceMapFile}, 13 12 handlers::{ 14 13 auth::{ensure_guild_admin, require_identity}, 15 14 error::ApiError, ··· 23 22 pub struct DeploymentRequest { 24 23 /// Entry point path for the bundle (e.g. src/main.ts). 25 24 pub entry: String, 26 - /// Files included in this deployment. 27 - pub files: Vec<DeploymentFile>, 28 - /// How to emit source maps for the bundled output. 29 - #[serde(default)] 30 - pub source_map_mode: SourceMapMode, 25 + /// Prebuilt JavaScript bundle source. 26 + pub bundle: String, 27 + /// Optional source map file for the prebuilt bundle. 28 + #[serde(default, skip_serializing_if = "Option::is_none")] 29 + pub source_map: Option<DeploymentSourceMapFile>, 31 30 } 32 31 33 32 /// API representation of a deployment. ··· 38 37 pub updated_at: String, 39 38 pub entry: String, 40 39 #[serde(skip_serializing_if = "Option::is_none")] 41 - pub files: Option<Vec<DeploymentFile>>, 40 + pub source_map: Option<DeploymentSourceMapFile>, 42 41 #[serde(skip_serializing_if = "Option::is_none")] 43 42 pub bundle: Option<String>, 44 43 } ··· 50 49 created_at: value.created_at.to_rfc3339(), 51 50 updated_at: value.updated_at.to_rfc3339(), 52 51 entry: value.entry, 53 - files: None, 52 + source_map: None, 54 53 bundle: None, 55 54 } 56 55 } 57 56 } 58 57 59 58 impl DeploymentResponse { 60 - pub fn with_files(mut self, files: Vec<DeploymentFile>) -> Self { 61 - self.files = Some(files); 59 + pub fn with_source_map(mut self, source_map: Option<DeploymentSourceMapFile>) -> Self { 60 + self.source_map = source_map; 62 61 self 63 62 } 64 63 ··· 68 67 } 69 68 } 70 69 70 + fn validate_request(request: &DeploymentRequest) -> Result<(), ApiError> { 71 + if request.entry.trim().is_empty() { 72 + return Err(ApiError::bad_request("entry must not be empty")); 73 + } 74 + 75 + if request.bundle.trim().is_empty() { 76 + return Err(ApiError::bad_request("bundle must not be empty")); 77 + } 78 + 79 + if let Some(source_map) = &request.source_map { 80 + if source_map.path.trim().is_empty() { 81 + return Err(ApiError::bad_request("source_map.path must not be empty")); 82 + } 83 + 84 + if source_map.contents.trim().is_empty() { 85 + return Err(ApiError::bad_request( 86 + "source_map.contents must not be empty", 87 + )); 88 + } 89 + 90 + if !source_map.path.ends_with(".map") { 91 + return Err(ApiError::bad_request("source_map.path must end with .map")); 92 + } 93 + } 94 + 95 + Ok(()) 96 + } 97 + 71 98 /// Create or update a deployment for a guild. 72 99 #[utoipa::path( 73 100 post, ··· 91 118 let identity = require_identity(&state, &headers).await?; 92 119 ensure_guild_admin(&state, &identity, &guild_id).await?; 93 120 94 - let bundle_name = format!("guild:{guild_id}.bundle.js"); 95 - let bundled = bundle_files_with_sourcemap_mode( 96 - &bundle_name, 97 - &request.entry, 98 - &request.files, 99 - state.bundle_limits, 100 - request.source_map_mode, 101 - ) 102 - .map_err(|err| ApiError::bad_request(err.to_string()))?; 103 - 104 - let mut files = request.files; 105 - if let Some(source_map_file) = bundled.source_map_file { 106 - files.retain(|file| file.path != source_map_file.path); 107 - files.push(source_map_file); 108 - } 121 + validate_request(&request)?; 109 122 110 123 let deployment = state 111 124 .deployments 112 - .upsert_deployment(guild_id.clone(), request.entry, files, bundled.code) 125 + .upsert_deployment( 126 + guild_id.clone(), 127 + request.entry, 128 + request.bundle, 129 + request.source_map, 130 + ) 113 131 .await 114 132 .map_err(|err| { 115 133 error!(target: "flora:api", guild_id, ?err, "failed to upsert deployment"); ··· 127 145 128 146 Ok(ApiJson(Json(deployment.into()))) 129 147 } 148 + 149 + #[cfg(test)] 150 + mod tests { 151 + use super::{DeploymentRequest, validate_request}; 152 + use crate::{deployments::DeploymentSourceMapFile, handlers::error::ApiError}; 153 + 154 + #[test] 155 + fn validate_request_rejects_empty_bundle() { 156 + let request = DeploymentRequest { 157 + entry: "src/main.ts".to_string(), 158 + bundle: " ".to_string(), 159 + source_map: None, 160 + }; 161 + 162 + let result = validate_request(&request); 163 + assert!(matches!(result, Err(ApiError::BadRequest { .. }))); 164 + } 165 + 166 + #[test] 167 + fn validate_request_rejects_invalid_source_map() { 168 + let request = DeploymentRequest { 169 + entry: "src/main.ts".to_string(), 170 + bundle: "console.log('ok')".to_string(), 171 + source_map: Some(DeploymentSourceMapFile { 172 + path: "source-map.txt".to_string(), 173 + contents: "{}".to_string(), 174 + }), 175 + }; 176 + 177 + let result = validate_request(&request); 178 + assert!(matches!(result, Err(ApiError::BadRequest { .. }))); 179 + } 180 + 181 + #[test] 182 + fn validate_request_accepts_bundle_with_source_map() { 183 + let request = DeploymentRequest { 184 + entry: "src/main.ts".to_string(), 185 + bundle: "console.log('ok')".to_string(), 186 + source_map: Some(DeploymentSourceMapFile { 187 + path: "bundle.js.map".to_string(), 188 + contents: "{}".to_string(), 189 + }), 190 + }; 191 + 192 + validate_request(&request).expect("request should be valid"); 193 + } 194 + }
-4
apps/runtime/src/main.rs
··· 1 1 mod auth; 2 - mod bundler; 3 2 mod deployments; 4 3 mod discord_handler; 5 4 mod handlers; ··· 141 140 let app_info = http.get_current_application_info().await?; 142 141 http.set_application_id(app_info.id); 143 142 144 - let bundle_limits = bundler::BundleLimits::from_config(&config.runtime); 145 143 let runtime = Arc::new(BotRuntime::new( 146 144 http.clone(), 147 145 kv_service.clone(), ··· 173 171 runtime: runtime.clone(), 174 172 http: http.clone(), 175 173 application_id: Arc::new(std::sync::RwLock::new(Some(app_info.id))), 176 - bundle_limits, 177 174 deployments: deployment_service.clone(), 178 175 }); 179 176 ··· 188 185 tokens: token_service.clone(), 189 186 kv: kv_service.clone(), 190 187 secrets: secret_service.clone(), 191 - bundle_limits, 192 188 http: http.clone(), 193 189 }; 194 190
+1 -1
apps/runtime/src/runtime/tests.rs
··· 117 117 Deployment { 118 118 guild_id: GUILD_ID.to_string(), 119 119 entry: "main.js".to_string(), 120 - files: Vec::new(), 120 + source_map: None, 121 121 bundle, 122 122 created_at: Utc::now(), 123 123 updated_at: Utc::now(),
+2 -4
apps/runtime/src/state.rs
··· 1 1 use crate::{ 2 - auth::AuthService, bundler::BundleLimits, deployments::DeploymentService, kv::KvService, 3 - runtime::BotRuntime, secrets::SecretService, tokens::TokenService, 2 + auth::AuthService, deployments::DeploymentService, kv::KvService, runtime::BotRuntime, 3 + secrets::SecretService, tokens::TokenService, 4 4 }; 5 5 use serenity::http::Http; 6 6 use std::sync::Arc; ··· 20 20 pub kv: KvService, 21 21 /// Secret storage and encryption. 22 22 pub secrets: SecretService, 23 - /// Deployment bundle size/count limits. 24 - pub bundle_limits: BundleLimits, 25 23 /// Bot HTTP client for guild permission checks. 26 24 pub http: Arc<Http>, 27 25 }
+75
runtime-dist/default_guild_bundle.js
··· 1 + function buildPong(args) { 2 + const suffix = args.length > 0 ? ` (${args.join(' ')})` : '' 3 + return `pong${suffix}` 4 + } 5 + 6 + const ping = defineCommand({ 7 + name: 'ping', 8 + description: 'Respond with pong', 9 + run: async ctx => { 10 + await ctx.reply(buildPong(ctx.args)) 11 + } 12 + }) 13 + 14 + const hello = defineSlashCommand({ 15 + name: 'hello', 16 + description: 'Say hello', 17 + options: [ 18 + { 19 + name: 'name', 20 + description: 'Who to greet', 21 + type: 'string', 22 + required: false 23 + } 24 + ], 25 + run: async ctx => { 26 + const name = ctx.options.name || 'world' 27 + await ctx.reply({ 28 + content: `Hello, ${name}!`, 29 + ephemeral: true 30 + }) 31 + } 32 + }) 33 + 34 + const counter = defineSlashCommand({ 35 + name: 'counter', 36 + description: 'A simple counter using KV storage', 37 + subcommands: [ 38 + { 39 + name: 'get', 40 + description: 'Get current count', 41 + run: async ctx => { 42 + const store = kv.store('counters') 43 + const count = await store.get('main') 44 + await ctx.reply(`Current count: ${count || 0}`) 45 + } 46 + }, 47 + { 48 + name: 'increment', 49 + description: 'Increment the counter', 50 + run: async ctx => { 51 + const store = kv.store('counters') 52 + const current = parseInt((await store.get('main')) || '0', 10) 53 + const newCount = current + 1 54 + await store.set('main', String(newCount)) 55 + await ctx.reply(`Count is now: ${newCount}`) 56 + } 57 + }, 58 + { 59 + name: 'reset', 60 + description: 'Reset the counter', 61 + run: async ctx => { 62 + const store = kv.store('counters') 63 + await store.set('main', '0') 64 + await ctx.reply('Counter reset to 0') 65 + } 66 + } 67 + ] 68 + }) 69 + 70 + createBot({ 71 + prefix: '!', 72 + commands: [ping], 73 + slashCommands: [hello, counter] 74 + }) 75 + // # sourceMappingURL=default_guild_bundle.js.map
+14
runtime-dist/default_guild_bundle.js.map
··· 1 + { 2 + "version": 3, 3 + "file": "default_guild_bundle.js", 4 + "sources": [ 5 + "src/main.ts", 6 + "src/utils/reply.ts" 7 + ], 8 + "sourcesContent": [ 9 + "import { buildPong } from './utils/reply'\n\n// Prefix command example\nconst ping = defineCommand({\n name: 'ping',\n description: 'Respond with pong',\n run: async (ctx) => {\n await ctx.reply(buildPong(ctx.args))\n }\n})\n\n// Slash command example\nconst hello = defineSlashCommand({\n name: 'hello',\n description: 'Say hello',\n options: [\n {\n name: 'name',\n description: 'Who to greet',\n type: 'string',\n required: false\n }\n ],\n run: async (ctx) => {\n const name = (ctx.options.name as string) || 'world'\n await ctx.reply({\n content: `Hello, ${name}!`,\n ephemeral: true\n })\n }\n})\n\n// Slash command with subcommands\nconst counter = defineSlashCommand({\n name: 'counter',\n description: 'A simple counter using KV storage',\n subcommands: [\n {\n name: 'get',\n description: 'Get current count',\n run: async (ctx) => {\n const store = kv.store('counters')\n const count = await store.get('main')\n await ctx.reply(`Current count: ${count || 0}`)\n }\n },\n {\n name: 'increment',\n description: 'Increment the counter',\n run: async (ctx) => {\n const store = kv.store('counters')\n const current = parseInt(await store.get('main') || '0', 10)\n const newCount = current + 1\n await store.set('main', String(newCount))\n await ctx.reply(`Count is now: ${newCount}`)\n }\n },\n {\n name: 'reset',\n description: 'Reset the counter',\n run: async (ctx) => {\n const store = kv.store('counters')\n await store.set('main', '0')\n await ctx.reply('Counter reset to 0')\n }\n }\n ]\n})\n\n// Register the bot\ncreateBot({\n prefix: '!',\n commands: [ping],\n slashCommands: [hello, counter]\n})\n", 10 + "export function buildPong(args: string[]): string {\n const suffix = args.length > 0 ? ` (${args.join(' ')})` : ''\n return `pong${suffix}`\n}\n" 11 + ], 12 + "names": [], 13 + "mappings": "" 14 + }