From bf7e105fa6c8cef9e10118785a7ed0b4f8c42ff3 Mon Sep 17 00:00:00 2001 From: Space-Banane Date: Mon, 11 May 2026 17:29:43 +0200 Subject: [PATCH 1/2] [WIP] feat: add .NET runtime support and related documentation --- Backend/src/index.ts | 3 +- Backend/src/lib/HttpExecution.ts | 1 + Backend/src/lib/Runner.ts | 767 +++++++++++++++++- Backend/src/routes/api/ai.ts | 54 +- .../src/routes/api/functions/dotnet-build.ts | 127 +++ Backend/src/routes/api/functions/execute.ts | 6 +- Backend/src/routes/api/functions/manage.ts | 72 +- Backend/src/routes/api/triggers.ts | 1 + UI/src/Routes.tsx | 7 + UI/src/components/cards/FileManagerCard.tsx | 61 +- UI/src/components/modals/AIGenerateModal.tsx | 9 +- .../modals/functions/CreateFunctionModal.tsx | 42 +- .../modals/functions/UpdateFunctionModal.tsx | 31 +- UI/src/pages/docs/dotnet-runtime.tsx | 267 ++++++ UI/src/pages/docs/ffmpeg-install.tsx | 4 +- UI/src/pages/docs/go-runtime.tsx | 8 +- UI/src/pages/functions/FunctionDetail.tsx | 128 +++ UI/src/pages/index/docs.tsx | 12 +- UI/src/services/backend.functions.ts | 26 + UI/src/types/Prisma.ts | 41 +- 20 files changed, 1606 insertions(+), 61 deletions(-) create mode 100644 Backend/src/routes/api/functions/dotnet-build.ts create mode 100644 UI/src/pages/docs/dotnet-runtime.tsx diff --git a/Backend/src/index.ts b/Backend/src/index.ts index d8ec4d0..55be9fc 100644 --- a/Backend/src/index.ts +++ b/Backend/src/index.ts @@ -400,7 +400,8 @@ async function processCrons() { ran_by: "cron", triggerId: cron.id, ...cronExecutionData - }), // ran_by can be cron, user, or exec(api) + }), + { mode: "cron_execute" }, // ran_by can be cron, user, or exec(api) ); executionExitCode = executionResult?.exit_code ?? null; } catch (executionError) { diff --git a/Backend/src/lib/HttpExecution.ts b/Backend/src/lib/HttpExecution.ts index 625704c..f1f8cfd 100644 --- a/Backend/src/lib/HttpExecution.ts +++ b/Backend/src/lib/HttpExecution.ts @@ -265,6 +265,7 @@ export async function executeLoadedHttpFunction( }), { ratelimit: loggedRateLimit, + mode: "production_execute", }, ); diff --git a/Backend/src/lib/Runner.ts b/Backend/src/lib/Runner.ts index 77bbeda..92843a8 100644 --- a/Backend/src/lib/Runner.ts +++ b/Backend/src/lib/Runner.ts @@ -19,6 +19,11 @@ interface TimingEntry { description: string; } +export type FunctionExecutionMode = + | "dev_execute" + | "production_execute" + | "cron_execute"; + export interface PersistedFunctionExecutionLogInput { functionId: number; functionData: Pick; @@ -37,6 +42,8 @@ const FUNCTION_DB_TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours const ServeOnlyFileNotFoundHTML = `File Not Found

404 - File Not Found

The requested HTML file was not found in the function's files.

`; const DB_FIELD_LIMIT = 10000; +const SHSF_FUNCTION_RESULT_START = "SHSF_FUNCTION_RESULT_START"; +const SHSF_FUNCTION_RESULT_END = "SHSF_FUNCTION_RESULT_END"; function truncateDbField(value: string): string { return value.length > DB_FIELD_LIMIT @@ -44,6 +51,225 @@ function truncateDbField(value: string): string { : value; } +function appendLogOutput(existing: string, next: string): string { + if (!next.trim()) { + return existing; + } + + if (!existing.trim()) { + return next.trim(); + } + + return `${existing.trimEnd()}\n${next.trim()}`; +} + +function isDotnetImage(image: string): boolean { + return image.startsWith("mcr.microsoft.com/dotnet/sdk:"); +} + +function getRuntimeType(image: string): "python" | "golang" | "dotnet" | string { + if (isDotnetImage(image)) { + return "dotnet"; + } + + return image.split(":")[0]; +} + +async function findFilesByExtension( + rootDir: string, + extension: string, +): Promise { + const matches: string[] = []; + const entries = await fs.readdir(rootDir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(rootDir, entry.name); + + if (entry.isDirectory()) { + if (entry.name === ".git") { + continue; + } + matches.push(...(await findFilesByExtension(fullPath, extension))); + continue; + } + + if (entry.isFile() && entry.name.toLowerCase().endsWith(extension)) { + matches.push(fullPath); + } + } + + return matches; +} + +class DotnetProjectResolutionError extends Error {} + +interface DotnetProjectCandidate { + absolutePath: string; + relativePath: string; + depth: number; + inSolution: boolean; + isRunnable: boolean; + isTestProject: boolean; +} + +function normalizeDotnetProjectPath(projectPath: string): string { + return path.normalize(projectPath.replace(/\\/g, path.sep)); +} + +async function readSolutionProjectPaths( + funcAppDir: string, + slnFiles: string[], +): Promise> { + const projectPaths = new Set(); + + for (const slnFile of slnFiles) { + const content = await fs.readFile(slnFile, "utf8"); + const projectMatches = content.matchAll( + /Project\([^)]*\)\s*=\s*"[^"]+",\s*"([^"]+\.csproj)"/gi, + ); + + for (const match of projectMatches) { + const rawProjectPath = match[1]; + if (!rawProjectPath) { + continue; + } + + const absolutePath = path.resolve( + path.dirname(slnFile), + normalizeDotnetProjectPath(rawProjectPath), + ); + projectPaths.add(path.relative(funcAppDir, absolutePath)); + } + } + + return projectPaths; +} + +async function readDotnetProjectCandidate( + funcAppDir: string, + csprojPath: string, + solutionProjectPaths: Set, +): Promise { + const relativePath = path.relative(funcAppDir, csprojPath); + const content = await fs.readFile(csprojPath, "utf8"); + const outputTypeMatch = content.match( + /\s*([^<\s]+)\s*<\/OutputType>/i, + ); + const sdkMatch = content.match(/]*\bSdk="([^"]+)"/i); + const outputType = outputTypeMatch?.[1]?.trim().toLowerCase() ?? ""; + const projectSdk = sdkMatch?.[1]?.trim().toLowerCase() ?? ""; + const isTestProject = + /\s*true\s*<\/IsTestProject>/i.test(content) || + /Microsoft\.NET\.Test\.Sdk/i.test(content); + const isRunnable = + outputType === "exe" || + outputType === "winexe" || + projectSdk.includes("microsoft.net.sdk.web"); + + return { + absolutePath: csprojPath, + relativePath, + depth: relativePath.split(path.sep).length, + inSolution: solutionProjectPaths.has(relativePath), + isRunnable, + isTestProject, + }; +} + +function selectSingleDotnetProjectCandidate( + candidates: DotnetProjectCandidate[], + errorMessage: string, +): DotnetProjectCandidate | null { + if (candidates.length === 0) { + return null; + } + + if (candidates.length === 1) { + return candidates[0]; + } + + const candidateList = candidates + .map((candidate) => candidate.relativePath) + .sort((left, right) => left.localeCompare(right)) + .join(", "); + throw new DotnetProjectResolutionError(`${errorMessage} Candidates: ${candidateList}`); +} + +async function resolveDotnetProjectPath(funcAppDir: string): Promise { + const csprojFiles = await findFilesByExtension(funcAppDir, ".csproj"); + const slnFiles = await findFilesByExtension(funcAppDir, ".sln"); + + if (csprojFiles.length === 0) { + if (slnFiles.length > 0) { + throw new DotnetProjectResolutionError( + "Found a .sln file but no .csproj file. Add at least one runnable .csproj to execute this .NET function.", + ); + } + + throw new DotnetProjectResolutionError( + "No .csproj file found. Add a runnable .NET project before executing this function.", + ); + } + + const solutionProjectPaths = + slnFiles.length > 0 + ? await readSolutionProjectPaths(funcAppDir, slnFiles) + : new Set(); + const candidates = await Promise.all( + csprojFiles.map((csprojPath) => + readDotnetProjectCandidate(funcAppDir, csprojPath, solutionProjectPaths), + ), + ); + + const solutionRunnableCandidate = selectSingleDotnetProjectCandidate( + candidates.filter( + (candidate) => candidate.inSolution && candidate.isRunnable && !candidate.isTestProject, + ), + "Multiple runnable .csproj files were found in the solution. Keep one runnable entry project in the solution or remove the ambiguity.", + ); + if (solutionRunnableCandidate) { + return solutionRunnableCandidate.relativePath; + } + + const runnableCandidate = selectSingleDotnetProjectCandidate( + candidates.filter((candidate) => candidate.isRunnable && !candidate.isTestProject), + "Multiple runnable .csproj files were found. Keep one runnable entry project or configure the repository so only one executable project is detected.", + ); + if (runnableCandidate) { + return runnableCandidate.relativePath; + } + + const solutionNonTestCandidate = selectSingleDotnetProjectCandidate( + candidates.filter((candidate) => candidate.inSolution && !candidate.isTestProject), + "Multiple non-test .csproj files were found in the solution, but none was clearly runnable. Mark the startup project as executable or remove the ambiguity.", + ); + if (solutionNonTestCandidate) { + return solutionNonTestCandidate.relativePath; + } + + const nonTestCandidates = candidates.filter((candidate) => !candidate.isTestProject); + if (nonTestCandidates.length === 1) { + return nonTestCandidates[0].relativePath; + } + + if (nonTestCandidates.length > 1) { + throw new DotnetProjectResolutionError( + "No runnable .csproj could be identified automatically. Mark one project as executable with Exe or use Microsoft.NET.Sdk.Web, and keep test/support projects non-runnable.", + ); + } + + throw new DotnetProjectResolutionError( + "Only test projects were found. Add or include one runnable .csproj for this .NET function.", + ); +} + +function getDotnetProjectDirectory( + funcAppDir: string, + dotnetProjectPath: string, +): string { + return path.join(funcAppDir, path.dirname(dotnetProjectPath)); +} + export async function persistFunctionExecutionLog( input: PersistedFunctionExecutionLogInput, ) { @@ -562,6 +788,183 @@ func (db *Database) Exists(storageName, key string) bool { } `; +const DbComScriptCS = `// Database Communication Script in C# +// GENERATED ON THE FLY - DO NOT EDIT - THIS WILL BE OVERWRITTEN ON THE NEXT RUN +using System; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Tasks; + +namespace SHSF; + +public sealed class DatabaseError : Exception +{ + public DatabaseError(string message) : base(message) { } +} + +public sealed class Database +{ + private static readonly HttpClient Client = new(); + private readonly string _baseUrl = "{{API}}".TrimEnd('/'); + private readonly string _accessKey = "{{AUTHKEY}}"; + + private async Task MakeRequestAsync(HttpMethod method, string path, object? payload = null) + { + using var request = new HttpRequestMessage(method, _baseUrl + path); + request.Headers.TryAddWithoutValidation("X-Access-Key", _accessKey); + + if (payload is not null) + { + request.Content = new StringContent( + JsonSerializer.Serialize(payload), + Encoding.UTF8, + "application/json" + ); + } + + using var response = await Client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + JsonNode? parsed = null; + + if (!string.IsNullOrWhiteSpace(body)) + { + parsed = JsonNode.Parse(body); + } + + if (parsed is JsonObject obj && obj["status"] is not null) + { + var status = obj["status"]?.GetValue(); + if (!string.Equals(status, "OK", StringComparison.OrdinalIgnoreCase)) + { + throw new DatabaseError(obj["message"]?.GetValue() ?? "Unknown error"); + } + + return obj["data"] ?? parsed; + } + + if (!response.IsSuccessStatusCode) + { + throw new DatabaseError($"HTTP {(int)response.StatusCode}: {body}"); + } + + return parsed; + } + + public Task CreateStorage(string name, string purpose = "") => + MakeRequestAsync(HttpMethod.Post, "/api/storage", new { name, purpose }); + + public Task ListStorages() => + MakeRequestAsync(HttpMethod.Get, "/api/storage"); + + public Task DeleteStorage(string storageName) => + MakeRequestAsync(HttpMethod.Delete, $"/api/storage/{Uri.EscapeDataString(storageName)}"); + + public Task Clear(string storageName) => + MakeRequestAsync(HttpMethod.Delete, $"/api/storage/{Uri.EscapeDataString(storageName)}/items"); + + public Task Set(string storageName, string key, object? value, string? expiresAt = null) => + MakeRequestAsync( + HttpMethod.Post, + $"/api/storage/{Uri.EscapeDataString(storageName)}/item", + expiresAt is null + ? new { key, value } + : new { key, value, expiresAt } + ); + + public async Task Get(string storageName, string key) + { + var result = await MakeRequestAsync( + HttpMethod.Get, + $"/api/storage/{Uri.EscapeDataString(storageName)}/item/{Uri.EscapeDataString(key)}" + ); + + if (result is JsonObject obj && obj["value"] is not null) + { + return obj["value"]; + } + + return result; + } + + public Task GetItem(string storageName, string key) => + MakeRequestAsync( + HttpMethod.Get, + $"/api/storage/{Uri.EscapeDataString(storageName)}/item/{Uri.EscapeDataString(key)}" + ); + + public Task ListItems(string storageName) => + MakeRequestAsync(HttpMethod.Get, $"/api/storage/{Uri.EscapeDataString(storageName)}/items"); + + public Task DeleteItem(string storageName, string key) => + MakeRequestAsync( + HttpMethod.Delete, + $"/api/storage/{Uri.EscapeDataString(storageName)}/item/{Uri.EscapeDataString(key)}" + ); + + public async Task Exists(string storageName, string key) + { + try + { + await Get(storageName, key); + return true; + } + catch (DatabaseError) + { + return false; + } + } +} +`; + +const ShsfRuntimeScriptCS = `// SHSF runtime helper for C# +// GENERATED ON THE FLY - DO NOT EDIT - THIS WILL BE OVERWRITTEN ON THE NEXT RUN +using System; +using System.IO; +using System.Runtime.CompilerServices; +using System.Text.Json; + +namespace SHSF; + +internal static class RuntimeBootstrap +{ + internal static readonly TextWriter OriginalStdout = new StreamWriter(Console.OpenStandardOutput()) + { + AutoFlush = true + }; + + [ModuleInitializer] + internal static void Initialize() + { + Console.SetOut(Console.Error); + } +} + +public static class Runtime +{ + public static string PayloadPath => + Environment.GetEnvironmentVariable("SHSF_PAYLOAD_PATH") + ?? (Environment.GetCommandLineArgs().Length > 1 + ? Environment.GetCommandLineArgs()[1] + : throw new InvalidOperationException("SHSF payload path not provided.")); + + public static string LoadPayload() => File.ReadAllText(PayloadPath); + + public static T? LoadPayloadJson() => + JsonSerializer.Deserialize(LoadPayload()); + + public static void Return(object? value) + { + RuntimeBootstrap.OriginalStdout.WriteLine("${SHSF_FUNCTION_RESULT_START}"); + RuntimeBootstrap.OriginalStdout.Write(JsonSerializer.Serialize(value)); + RuntimeBootstrap.OriginalStdout.WriteLine(); + RuntimeBootstrap.OriginalStdout.Write("${SHSF_FUNCTION_RESULT_END}"); + RuntimeBootstrap.OriginalStdout.Flush(); + } +} +`; + async function getOrCreateFunctionDbToken(userId: number): Promise { const tokenName = `__function_db_access__`; @@ -606,8 +1009,9 @@ export async function executeFunction( | { enabled: true; onChunk: (data: string) => void } | { enabled: false }, payload: string, - metadata?: { + options?: { ratelimit?: LoggedExecutionRateLimitData; + mode?: FunctionExecutionMode; }, ) { const starting_time = Date.now(); @@ -659,7 +1063,8 @@ export async function executeFunction( const functionIdStr = String(functionData.id); const containerName = `shsf_func_${functionIdStr}`; const funcAppDir = path.join("/opt/shsf_data/functions", functionIdStr, "app"); - const runtimeType = functionData.image.split(":")[0]; + const runtimeType = getRuntimeType(functionData.image); + const executionMode = options?.mode ?? "dev_execute"; let exitCode = 0; // Generate a unique execution ID for this request to avoid race conditions @@ -693,6 +1098,7 @@ export async function executeFunction( await Promise.all( files.map(async (file) => { const filePath = path.join(funcAppDir, file.name); + await fs.mkdir(path.dirname(filePath), { recursive: true }); let content: string | Buffer = file.content as any; if (typeof content === "string") { content = replaceApiBaseInContent(content, functionData.namespaceId, functionData.executionId); @@ -706,6 +1112,12 @@ export async function executeFunction( mark(`Skip DB file writes (git_url set)`); } + let dotnetProjectPath: string | null = null; + if (runtimeType === "dotnet") { + dotnetProjectPath = await resolveDotnetProjectPath(funcAppDir); + log(`Resolved .NET project: ${dotnetProjectPath}`); + } + // For Go runtime, generate the runner wrapper file and go.mod if needed if (runtimeType === "golang") { const runnerWrapperCode = `package main @@ -1005,6 +1417,61 @@ fi await fs.writeFile(wrapperPath, wrapperContent); await fs.chmod(wrapperPath, "755"); log("Go runner script written"); // intermediate — mark fires after init.sh below + } else if (runtimeType === "dotnet") { + const wrapperPath = path.join(funcAppDir, "_runner.sh"); + const wrapperContent = `#!/bin/sh +if [ -f /app/.shsf_env ]; then + . /app/.shsf_env + echo "[SHSF RUNNER] Sourced environment from /app/.shsf_env" >&2 +else + echo "[SHSF RUNNER] Warning: No .shsf_env file found" >&2 +fi + +if [ $# -lt 2 ]; then + echo "Error: Missing payload file path or execution mode" >&2 + exit 1 +fi + +PAYLOAD_PATH="$1" +EXECUTION_MODE="$2" +export SHSF_PAYLOAD_PATH="$PAYLOAD_PATH" +export SHSF_EXECUTION_MODE="$EXECUTION_MODE" +PROJECT_PATH="${dotnetProjectPath ?? ""}" + +if [ -z "$PROJECT_PATH" ]; then + echo "Error: No runnable .NET project could be resolved." >&2 + exit 1 +fi + +cd /app + +if [ "$EXECUTION_MODE" = "dev_execute" ]; then + exec dotnet run --project "$PROJECT_PATH" -- "$PAYLOAD_PATH" +fi + +ENTRY_PATH_FILE="/app/.shsf_dotnet_entry" +BUILT_TARGET="" + +if [ -f "$ENTRY_PATH_FILE" ]; then + BUILT_TARGET="$(cat "$ENTRY_PATH_FILE" 2>/dev/null)" +fi + +if [ -z "$BUILT_TARGET" ] || [ ! -f "$BUILT_TARGET" ]; then + PROJECT_DIR="$(dirname "$PROJECT_PATH")" + ASSEMBLY_NAME="$(basename "$PROJECT_PATH" .csproj)" + BUILT_TARGET="$(find "/app/$PROJECT_DIR/bin" -type f -name "$ASSEMBLY_NAME.dll" ! -path "*/ref/*" ! -path "*/obj/*" | sort | tail -n 1)" +fi + +if [ -z "$BUILT_TARGET" ] || [ ! -f "$BUILT_TARGET" ]; then + echo "Error: No built .NET assembly found for $PROJECT_PATH. Run .NET Build before using production execution routes." >&2 + exit 1 +fi + +exec dotnet "$BUILT_TARGET" "$PAYLOAD_PATH" +`; + await fs.writeFile(wrapperPath, wrapperContent); + await fs.chmod(wrapperPath, "755"); + log(".NET runner script written"); } else { console.warn( `[executeFunction] Runner script generation skipped: Unsupported runtime type '${runtimeType}' for function ${functionData.id}.` @@ -1159,6 +1626,18 @@ echo "export GOCACHE=$GO_PKG_CACHE_DIR" > /app/.shsf_env echo "export GOMODCACHE=$GO_PKG_CACHE_DIR/mod" >> /app/.shsf_env echo "export PATH=/app:\$PATH" >> /app/.shsf_env echo "[SHSF INIT] Go setup complete." +`; + } else if (runtimeType === "dotnet") { + initScript += ` +echo "[SHSF INIT] Setting up .NET environment for function ${functionData.id}" +DOTNET_CACHE_DIR="/dotnet-cache/function-${functionData.id}" +NUGET_PACKAGES_DIR="$DOTNET_CACHE_DIR/nuget" +DOTNET_CLI_HOME_DIR="$DOTNET_CACHE_DIR/cli-home" +mkdir -p "$NUGET_PACKAGES_DIR" "$DOTNET_CLI_HOME_DIR" +echo "export NUGET_PACKAGES=$NUGET_PACKAGES_DIR" > /app/.shsf_env +echo "export DOTNET_CLI_HOME=$DOTNET_CLI_HOME_DIR" >> /app/.shsf_env +echo "export PATH=/app:\$PATH" >> /app/.shsf_env +echo "[SHSF INIT] .NET setup complete." `; } else { // This was already checked for runner script, but as a safeguard for init.sh: @@ -1175,7 +1654,20 @@ echo "[SHSF INIT] Go setup complete." await fs.chmod(path.join(funcAppDir, "init.sh"), "755"); mark("Generate scripts"); // runner script(s) + init.sh - const requiresDbCom = files.some((f) => f.content.includes("_db_com")); + if (runtimeType === "dotnet") { + const dotnetProjectPath = await resolveDotnetProjectPath(funcAppDir); + const dotnetProjectDir = getDotnetProjectDirectory( + funcAppDir, + dotnetProjectPath, + ); + await fs.writeFile( + path.join(dotnetProjectDir, "SHSF.Runtime.cs"), + ShsfRuntimeScriptCS, + ); + } + + const requiresDbCom = + runtimeType === "dotnet" || files.some((f) => f.content.includes("_db_com")); if (requiresDbCom) { const dbToken = await getOrCreateFunctionDbToken(functionData.userId); if (runtimeType === "python") { @@ -1186,6 +1678,14 @@ echo "[SHSF INIT] Go setup complete." const dbScript = DbComScriptGO.replace("{{API}}", API_URL!).replace("{{AUTHKEY}}", dbToken); await fs.writeFile(path.join(funcAppDir, "_db_com.go"), dbScript); await fs.chmod(path.join(funcAppDir, "_db_com.go"), "755"); + } else if (runtimeType === "dotnet") { + const dotnetProjectPath = await resolveDotnetProjectPath(funcAppDir); + const dotnetProjectDir = getDotnetProjectDirectory( + funcAppDir, + dotnetProjectPath, + ); + const dbScript = DbComScriptCS.replace("{{API}}", API_URL!).replace("{{AUTHKEY}}", dbToken); + await fs.writeFile(path.join(dotnetProjectDir, "_db_com.cs"), dbScript); } mark("DB token + script"); } @@ -1234,9 +1734,11 @@ echo "[SHSF INIT] Go setup complete." const baseCacheDir = "/opt/shsf_data/cache"; const pipCacheHost = path.join(baseCacheDir, "pip"); const goCacheHost = path.join(baseCacheDir, "go"); + const dotnetCacheHost = path.join(baseCacheDir, "dotnet"); await Promise.all([ fs.mkdir(pipCacheHost, { recursive: true }), fs.mkdir(goCacheHost, { recursive: true }), + fs.mkdir(dotnetCacheHost, { recursive: true }), ]); // Mount the base function directory which contains both app/ and executions/ @@ -1255,6 +1757,8 @@ echo "[SHSF INIT] Go setup complete." BINDS.push(`${pipCacheHost}:/pip-cache`); // Mount persistent pip cache } else if (runtimeType === "golang") { BINDS.push(`${goCacheHost}:/go-cache`); // Mount persistent go cache + } else if (runtimeType === "dotnet") { + BINDS.push(`${dotnetCacheHost}:/dotnet-cache`); } else { throw new Error( `Unsupported runtime type for container BIND setup: ${runtimeType}` @@ -1314,6 +1818,12 @@ echo "[SHSF INIT] Go setup complete." // Now, execute the function logic using docker exec await fs.writeFile(path.join(executionDir, "payload.json"), payload); + const dotnetPayloadDir = path.join(funcAppDir, ".shsf-executions"); + const dotnetPayloadPath = path.join(dotnetPayloadDir, `${executionId}.json`); + if (runtimeType === "dotnet") { + await fs.mkdir(dotnetPayloadDir, { recursive: true }); + await fs.writeFile(dotnetPayloadPath, payload); + } const execEnv: string[] = []; // Add function-specific env vars to exec as well, in case they are needed by the runner script directly @@ -1338,6 +1848,13 @@ echo "[SHSF INIT] Go setup complete." execCmd = ["/bin/sh", "/app/_runner.py", containerPayloadPath]; } else if (runtimeType === "golang") { execCmd = ["/bin/sh", "/app/_runner.sh", containerPayloadPath]; + } else if (runtimeType === "dotnet") { + execCmd = [ + "/bin/sh", + "/app/_runner.sh", + `/app/.shsf-executions/${executionId}.json`, + executionMode, + ]; } else { throw new Error( `Unsupported runtime type for exec command: ${runtimeType}` @@ -1454,8 +1971,8 @@ echo "[SHSF INIT] Go setup complete." if (exitCode === 0 && func_result) { try { // Look for the function result markers - const startMarker = "SHSF_FUNCTION_RESULT_START"; - const endMarker = "SHSF_FUNCTION_RESULT_END"; + const startMarker = SHSF_FUNCTION_RESULT_START; + const endMarker = SHSF_FUNCTION_RESULT_END; const startIdx = func_result.indexOf(startMarker); const endIdx = func_result.lastIndexOf(endMarker); @@ -1466,18 +1983,25 @@ echo "[SHSF INIT] Go setup complete." .substring(startIdx + startMarker.length, endIdx) .trim(); - // Content before or after markers in stdout is now unexpected, but log it as a warning if it occurs. const prefix = func_result.substring(0, startIdx).trim(); if (prefix) { - logs += `\n[Runner Warning] Unexpected content before result marker in stdout: ${prefix}`; + logs = appendLogOutput(logs, prefix); } const suffix = func_result.substring(endIdx + endMarker.length).trim(); if (suffix) { - logs += `\n[Runner Warning] Unexpected content after result marker in stdout: ${suffix}`; + logs = appendLogOutput(logs, suffix); } + func_result = actualResult; parsedResult = JSON.parse(actualResult); + } else if (runtimeType === "dotnet") { + if (func_result.trim()) { + logs = appendLogOutput( + logs, + `Stdout content (missing ${SHSF_FUNCTION_RESULT_START}/${SHSF_FUNCTION_RESULT_END} markers):\n${func_result.trim()}` + ); + } } else { // If no markers are found, or they are in the wrong order, // treat the entire stdout as potential logging output. @@ -1485,7 +2009,10 @@ echo "[SHSF INIT] Go setup complete." `[executeFunction] Function result markers not found or in wrong order in stdout. Treating stdout as logs.` ); if (func_result.trim()) { - logs += `\nStdout content (no valid markers found):\n${func_result.trim()}`; + logs = appendLogOutput( + logs, + `Stdout content (no valid markers found):\n${func_result.trim()}` + ); } // No parsedResult, leave it as null } @@ -1526,6 +2053,11 @@ echo "[SHSF INIT] Go setup complete." } finally { try { await fs.rm(executionDir, { recursive: true, force: true }); + if (runtimeType === "dotnet") { + await fs.rm(path.join(funcAppDir, ".shsf-executions", `${executionId}.json`), { + force: true, + }); + } mark("Cleanup"); } catch (cleanupError: any) { if (cleanupError.code !== "ENOENT") { @@ -1563,7 +2095,7 @@ echo "[SHSF INIT] Go setup complete." payload, exit_code: exitCode, tooks, - ...(metadata?.ratelimit ? { ratelimit: metadata.ratelimit } : {}), + ...(options?.ratelimit ? { ratelimit: options.ratelimit } : {}), }); } catch (error) { console.error("Error creating trigger log:", error); @@ -1727,6 +2259,221 @@ export async function installDependencies( } } +export async function buildDotnetFunction( + functionId: number, + functionData: Function, + files: FunctionFile[], +): Promise< + | { status: "success" } + | { status: "container_missing" } + | { status: "build_failed"; message: string; buildLogs?: string } +> { + if (getRuntimeType(functionData.image) !== "dotnet") { + return { + status: "build_failed", + message: ".NET build is only available for .NET SDK functions.", + }; + } + + const docker = new Docker(); + const functionIdStr = String(functionId); + const containerName = `shsf_func_${functionIdStr}`; + const funcBaseDir = path.join("/opt/shsf_data/functions", functionIdStr); + const funcAppDir = path.join(funcBaseDir, "app"); + const executionDir = path.join(funcBaseDir, "executions"); + const dotnetCacheHost = "/opt/shsf_data/cache/dotnet"; + + try { + await fs.mkdir(funcAppDir, { recursive: true }); + await fs.mkdir(executionDir, { recursive: true }); + await fs.mkdir(dotnetCacheHost, { recursive: true }); + + if (!functionData.git_url) { + await Promise.all( + files.map(async (file) => { + const filePath = path.join(funcAppDir, file.name); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + let content: string | Buffer = file.content as any; + if (typeof content === "string") { + content = replaceApiBaseInContent( + content, + functionData.namespaceId, + functionData.executionId, + ); + } + await fs.writeFile(filePath, content as any); + }), + ); + } + + const dotnetProjectPath = await resolveDotnetProjectPath(funcAppDir); + const dotnetProjectDir = getDotnetProjectDirectory( + funcAppDir, + dotnetProjectPath, + ); + const initScript = `#!/bin/sh +set -e +echo "[SHSF INIT] Setting up .NET environment..." +DOTNET_CACHE_DIR="/dotnet-cache/function-${functionId}" +NUGET_PACKAGES_DIR="$DOTNET_CACHE_DIR/nuget" +DOTNET_CLI_HOME_DIR="$DOTNET_CACHE_DIR/cli-home" +mkdir -p "$NUGET_PACKAGES_DIR" "$DOTNET_CLI_HOME_DIR" +echo "export NUGET_PACKAGES=$NUGET_PACKAGES_DIR" > /app/.shsf_env +echo "export DOTNET_CLI_HOME=$DOTNET_CLI_HOME_DIR" >> /app/.shsf_env +echo "export PATH=/app:\\$PATH" >> /app/.shsf_env +echo "[SHSF INIT] .NET environment ready." +`; + await fs.writeFile(path.join(funcAppDir, "init.sh"), initScript); + await fs.chmod(path.join(funcAppDir, "init.sh"), "755"); + await fs.writeFile( + path.join(dotnetProjectDir, "SHSF.Runtime.cs"), + ShsfRuntimeScriptCS, + ); + + const dbToken = await getOrCreateFunctionDbToken(functionData.userId); + const dbScript = DbComScriptCS.replace("{{API}}", API_URL!).replace( + "{{AUTHKEY}}", + dbToken, + ); + await fs.writeFile(path.join(dotnetProjectDir, "_db_com.cs"), dbScript); + + let container = docker.getContainer(containerName); + try { + const inspectInfo = await container.inspect(); + if (!inspectInfo.State.Running) { + await container.start(); + } + } catch (error: any) { + if (error.statusCode !== 404) { + throw error; + } + + const imageExists = await docker.listImages({ + filters: JSON.stringify({ reference: [functionData.image] }), + }); + if (imageExists.length === 0) { + const pullStream = await docker.pull(functionData.image); + await new Promise((resolve, reject) => { + docker.modem.followProgress(pullStream, (pullError) => + pullError ? reject(pullError) : resolve(null), + ); + }); + } + + container = await docker.createContainer({ + Image: functionData.image, + name: containerName, + Env: functionData.env + ? JSON.parse(functionData.env).map( + (env: { name: string; value: any }) => `${env.name}=${env.value}`, + ) + : [], + HostConfig: { + Binds: [ + `${funcAppDir}:/app`, + `${executionDir}:/executions`, + `${dotnetCacheHost}:/dotnet-cache`, + ...(functionData.docker_mount + ? ["/var/run/docker.sock:/var/run/docker.sock"] + : []), + ], + AutoRemove: false, + Memory: (functionData.max_ram || 128) * 1024 * 1024, + }, + Cmd: [ + "/bin/sh", + "-c", + "/app/init.sh && echo '[SHSF] Container ready.' && tail -f /dev/null", + ], + Tty: false, + }); + await container.start(); + } + + const exec = await container.exec({ + Cmd: [ + "/bin/sh", + "-c", + `cd /app && . /app/.shsf_env && rm -f /app/.shsf_dotnet_entry && dotnet build "${dotnetProjectPath}" && PROJECT_DIR="$(dirname "${dotnetProjectPath}")" && ASSEMBLY_NAME="$(basename "${dotnetProjectPath}" .csproj)" && TARGET_PATH="$(find "/app/$PROJECT_DIR/bin" -type f -name "$ASSEMBLY_NAME.dll" ! -path "*/ref/*" ! -path "*/obj/*" | sort | tail -n 1)" && if [ -z "$TARGET_PATH" ] || [ ! -f "$TARGET_PATH" ]; then echo "Failed to resolve built .NET assembly for ${dotnetProjectPath}" >&2; exit 1; fi && printf '%s' "$TARGET_PATH" > /app/.shsf_dotnet_entry`, + ], + Env: functionData.env + ? JSON.parse(functionData.env).map( + (env: { name: string; value: any }) => `${env.name}=${env.value}`, + ) + : [], + AttachStdout: true, + AttachStderr: true, + Tty: false, + }); + + const execStream = await exec.start({ hijack: true, stdin: false }); + const buildOutput = { stdout: "", stderr: "" }; + const stdoutMultiplex = new PassThrough(); + const stderrMultiplex = new PassThrough(); + + stdoutMultiplex.on("data", (chunk) => { + buildOutput.stdout += chunk.toString("utf8"); + }); + stderrMultiplex.on("data", (chunk) => { + buildOutput.stderr += chunk.toString("utf8"); + }); + + docker.modem.demuxStream(execStream, stdoutMultiplex, stderrMultiplex); + + await new Promise((resolve, reject) => { + execStream.on("end", resolve); + execStream.on("error", reject); + }); + + const inspect = await exec.inspect(); + const buildLogs = [buildOutput.stderr.trim(), buildOutput.stdout.trim()] + .filter(Boolean) + .join("\n"); + if (inspect.ExitCode !== 0) { + console.error( + `Error building .NET function: ${buildLogs || "dotnet build failed without output"}`, + ); + + const persistedBuildLogs = appendLogOutput( + "[SHSF] .NET build failed.", + buildLogs || "dotnet build failed without output", + ); + try { + await persistFunctionExecutionLog({ + functionId, + functionData, + logs: persistedBuildLogs, + output: JSON.stringify(null), + exit_code: inspect.ExitCode ?? 1, + error_type: "dotnet_build", + force: true, + }); + } catch (persistError) { + console.error("Error persisting .NET build failure logs:", persistError); + } + + return { + status: "build_failed", + message: "dotnet build failed.", + buildLogs, + }; + } + return { status: "success" }; + } catch (error) { + console.error("Error building .NET function:", error); + if (error instanceof DotnetProjectResolutionError) { + return { + status: "build_failed", + message: error.message, + }; + } + return { + status: "build_failed", + message: "Failed to build .NET function", + }; + } +} + // Helper function to clean up container when deleting a function export async function deleteContainerForFunction(functionId: number) { const functionIdStr = String(functionId); diff --git a/Backend/src/routes/api/ai.ts b/Backend/src/routes/api/ai.ts index c480cef..902ca52 100644 --- a/Backend/src/routes/api/ai.ts +++ b/Backend/src/routes/api/ai.ts @@ -15,6 +15,9 @@ const Images: string[] = [ "golang:1.21", "golang:1.22", "golang:1.23", + "mcr.microsoft.com/dotnet/sdk:8.0", + "mcr.microsoft.com/dotnet/sdk:9.0", + "mcr.microsoft.com/dotnet/sdk:10.0", ]; const DisallowedFiles = ["_runner.py", "_runner.js", "init.sh"]; @@ -48,6 +51,18 @@ func main_user(args interface{}) (interface{}, error) { • Dependencies go in a \`go.mod\` file (auto-downloaded by the runtime). • Supported Go versions: 1.20 / 1.21 / 1.22 / 1.23 +**.NET / C#** (project-based runtime) +\`\`\`csharp +using SHSF; + +var args = Runtime.LoadPayloadJson>(); +Runtime.Return(new { hello = "world" }); +\`\`\` +• .NET functions are project-based: include a runnable \`.csproj\` and C# source files. +• Do NOT use \`func.startup_file\` for .NET. The startup file should be an empty string. +• Your code can use \`Runtime.LoadPayload()\`, \`Runtime.LoadPayloadJson()\`, and \`Runtime.Return(object)\` from the auto-provisioned \`SHSF.Runtime.cs\` helper. +• Supported .NET SDK images: 8.0 / 9.0 / 10.0 + --- ### 2. The \`args\` object @@ -93,6 +108,20 @@ func main_user(args interface{}) (interface{}, error) { } \`\`\` +C# example: +\`\`\`csharp +using System.Text.Json; +using SHSF; + +var payload = Runtime.LoadPayloadJson>() ?? new(); +var body = payload.TryGetValue("body", out var rawBody) && rawBody is JsonElement value && value.ValueKind == JsonValueKind.String + ? JsonSerializer.Deserialize>(value.GetString() ?? "{}") ?? new() + : new Dictionary(); + +var name = body.TryGetValue("name", out var providedName) ? providedName : "stranger"; +Runtime.Return(new { greeting = $"Hello {name}" }); +\`\`\` + --- ### 3. Custom responses (SHSF v2 protocol) @@ -180,6 +209,11 @@ import "os" apiKey := os.Getenv("MY_API_KEY") \`\`\` +C#: +\`\`\`csharp +var apiKey = Environment.GetEnvironmentVariable("MY_API_KEY") ?? ""; +\`\`\` + --- ### 6. Persistent storage @@ -360,6 +394,7 @@ def main(args): |---------|-----------------|-------------------------------------------| | Python | requirements.txt | pip-installed before first run | | Go | go.mod + go.sum | module dependencies, auto-downloaded | +| .NET | .csproj | NuGet restore/build handled by dotnet CLI | Python requirements.txt example: \`\`\` @@ -390,6 +425,7 @@ require ( - Never write partial files or placeholder comments like "# ... rest of code" - Never hard-code secrets — always use environment variables (§5) - Go entry-point is main_user(), never main() +- .NET functions must include a runnable .csproj and return results via Runtime.Return(...) - Never invent SHSF-specific APIs that are not documented in this reference - **Always \`import json\` and call \`json.loads(args.get("body", "{}"))\` in Python before accessing body fields** `; @@ -501,7 +537,7 @@ export = new fileRouter.Path("/") Based on the user's description and chosen runtime, suggest: 1. A concise, professional name for the function (alphanumeric, max 128 chars). 2. A clear, helpful description. -3. The most appropriate startup file name (e.g., "main.py" for Python, "main_user.go" for Go). +3. The most appropriate startup file name (e.g., "main.py" for Python, "main_user.go" for Go, or an empty string for .NET project-based functions). Return ONLY a JSON object with the following structure: { @@ -513,6 +549,7 @@ Return ONLY a JSON object with the following structure: Platform Rules: - Go functions MUST use "main_user.go" as the startup file. - Python functions should typically use "main.py". +- .NET functions MUST return an empty startup_file string. - Available runtimes: ${Images.join(", ")}`, }, { @@ -531,12 +568,18 @@ Platform Rules: const rawContent = typeof content === "string" ? content : JSON.stringify(content); const jsonMatch = rawContent.match(/\{[\s\S]*\}/); const config = JSON.parse(jsonMatch ? jsonMatch[0] : rawContent); + const fallbackStartupFile = body.image.startsWith("python") + ? "main.py" + : body.image.startsWith("golang") + ? "main_user.go" + : ""; + return ctr.print({ status: "OK", data: { name: config.name || "My Function", description: config.description || body.prompt, - startup_file: config.startup_file || (body.image.startsWith("python") ? "main.py" : "main_user.go"), + startup_file: config.startup_file ?? fallbackStartupFile, }, }); } catch (e) { @@ -685,10 +728,15 @@ Function context: Entry-point conventions: Python → def main(args): ... return result Go → func main_user(args interface{}) (interface{}, error) { ... } + .NET → use a runnable .csproj; read input via SHSF.Runtime.cs and finish with Runtime.Return(...) Rules you MUST follow: 1. Use the write_file tool for EVERY file you produce. Do NOT just describe code. -2. Always include the startup file "${func.startup_file}". +2. ${ + func.image.startsWith("mcr.microsoft.com/dotnet/sdk:") + ? 'Do NOT rely on func.startup_file for .NET. Instead, always include a runnable .csproj and the C# source files it needs.' + : `Always include the startup file "${func.startup_file}".` + } 3. You may write at most ${maxFiles} files total. 4. These filenames are FORBIDDEN (never use them): ${DisallowedFiles.join(", ")}. 5. Write the FULL content of each file — no TODOs, no placeholders, no "…existing code…" markers. diff --git a/Backend/src/routes/api/functions/dotnet-build.ts b/Backend/src/routes/api/functions/dotnet-build.ts new file mode 100644 index 0000000..00164c2 --- /dev/null +++ b/Backend/src/routes/api/functions/dotnet-build.ts @@ -0,0 +1,127 @@ +import { API_KEY_HEADER, COOKIE, fileRouter, prisma } from "../../.."; +import { checkAuthentication } from "../../../lib/Authentication"; +import { buildDotnetFunction } from "../../../lib/Runner"; +import { OpenAPITags } from "../../../lib/openapi"; + +export = new fileRouter.Path("/").http( + "POST", + "/api/function/{id}/dotnet-build", + (http) => + http + .document({ + description: "Build a .NET function for production-style execution", + tags: ["Functions"] as OpenAPITags[], + operationId: "buildDotnetFunction", + responses: { + 200: { + description: ".NET build completed successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + status: { type: "string" }, + message: { type: "string" }, + build_logs: { type: "string" }, + }, + }, + }, + }, + }, + }, + }) + .onRequest(async (ctr) => { + const id = ctr.params.get("id"); + if (!id) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Missing function id", + }); + } + + const functionId = parseInt(id); + if (isNaN(functionId)) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Invalid function id", + }); + } + + const authCheck = await checkAuthentication( + ctr.cookies.get(COOKIE), + ctr.headers.get(API_KEY_HEADER), + ); + + if (!authCheck.success) { + return ctr.print({ + status: 401, + message: authCheck.message, + }); + } + + const functionData = await prisma.function.findFirst({ + where: { + id: functionId, + userId: authCheck.user.id, + }, + }); + if (!functionData) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: 404, + message: "Function not found", + }); + } + + if (!functionData.image.startsWith("mcr.microsoft.com/dotnet/sdk:")) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: ".NET build is only available for .NET SDK functions", + }); + } + + const files = functionData.git_url + ? [] + : await prisma.functionFile.findMany({ + where: { + functionId: functionData.id, + }, + }); + if (!functionData.git_url && (!files || files.length === 0)) { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: 404, + message: "Function has no files", + }); + } + + try { + const result = await buildDotnetFunction(functionId, functionData, files); + + if (result.status === "container_missing") { + return ctr.status(ctr.$status.NOT_FOUND).print({ + status: 404, + message: "Function runtime container could not be prepared", + }); + } + + if (result.status === "build_failed") { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: result.message, + ...(result.buildLogs + ? { build_logs: result.buildLogs } + : {}), + }); + } + + return ctr.print({ + status: "OK", + }); + } catch (error: any) { + return ctr.status(ctr.$status.INTERNAL_SERVER_ERROR).print({ + status: 500, + message: "Failed to build .NET function", + error: error.message, + }); + } + }), +); diff --git a/Backend/src/routes/api/functions/execute.ts b/Backend/src/routes/api/functions/execute.ts index 423a855..ec5141b 100644 --- a/Backend/src/routes/api/functions/execute.ts +++ b/Backend/src/routes/api/functions/execute.ts @@ -177,7 +177,8 @@ export = new fileRouter.Path("/") JSON.stringify({ ran_by: "user", ...runPayload, - }) + }), + { mode: "dev_execute" }, ) .then(async (result) => { await print( @@ -215,7 +216,8 @@ export = new fileRouter.Path("/") JSON.stringify({ ran_by: "user", ...runPayload, - }) + }), + { mode: "dev_execute" }, ); if (functionData.cache_enabled && result?.result) { diff --git a/Backend/src/routes/api/functions/manage.ts b/Backend/src/routes/api/functions/manage.ts index fc536da..d943c93 100644 --- a/Backend/src/routes/api/functions/manage.ts +++ b/Backend/src/routes/api/functions/manage.ts @@ -22,6 +22,9 @@ const Images: string[] = [ "golang:1.21", "golang:1.22", "golang:1.23", + "mcr.microsoft.com/dotnet/sdk:8.0", + "mcr.microsoft.com/dotnet/sdk:9.0", + "mcr.microsoft.com/dotnet/sdk:10.0", ]; const deprecatedImages: string[] = [ "python:3.9", @@ -30,6 +33,14 @@ const deprecatedImages: string[] = [ "golang:1.21", ]; +function isDotnetImage(image: string): boolean { + return image.startsWith("mcr.microsoft.com/dotnet/sdk:"); +} + +function getImageFamily(image: string): string { + return isDotnetImage(image) ? "dotnet" : image.split(":")[0]; +} + // Create Docker client instance for container management const docker = new Docker(); @@ -204,7 +215,7 @@ export = new fileRouter.Path("/") name: z.string().min(1).max(128), description: z.string().min(3).max(128), image: z.enum(Images as any), - startup_file: z.string().min(1).max(256), + startup_file: z.string().max(256), docker_mount: z.boolean().optional(), ffmpeg_install: z.boolean().optional(), opencv_install: z.boolean().optional(), @@ -264,6 +275,17 @@ export = new fileRouter.Path("/") }); } + if (!isDotnetImage(data.image) && !data.startup_file.trim()) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Startup file is required for this runtime", + }); + } + + const normalizedStartupFile = isDotnetImage(data.image) + ? "" + : data.startup_file.trim(); + const authCheck = await checkAuthentication( ctr.cookies.get(COOKIE), ctr.headers.get(API_KEY_HEADER), @@ -324,7 +346,7 @@ export = new fileRouter.Path("/") namespaceId: data.namespaceId, name: data.name, image: data.image, - startup_file: data.startup_file, + startup_file: normalizedStartupFile, tags: data.settings?.tags?.join(",") || "", allow_http: data.settings?.allow_http, max_ram: data.settings?.max_ram, @@ -350,13 +372,17 @@ export = new fileRouter.Path("/") opencv_install: data.opencv_install || false, cors_origins: data.cors_origins, executionAlias: data.executionAlias, - files: { - // create first file when function is created. - create: { - name: data.startup_file, - content: (await getFirstFileByLanguage(data.image.split(":")[0])) ?? "", - }, - }, + ...(normalizedStartupFile + ? { + files: { + create: { + name: normalizedStartupFile, + content: + (await getFirstFileByLanguage(getImageFamily(data.image))) ?? "", + }, + }, + } + : {}), }, }); @@ -831,7 +857,7 @@ export = new fileRouter.Path("/") name: z.string().min(1).max(128).optional(), description: z.string().min(3).max(128).optional(), image: z.enum(Images as any).optional(), - startup_file: z.string().min(1).max(256).optional(), + startup_file: z.string().max(256).optional(), executionAlias: z .string() .min(8) @@ -900,6 +926,19 @@ export = new fileRouter.Path("/") }); } + const nextImage = data.image ?? existingFunction.image; + + if ( + data.startup_file !== undefined && + !isDotnetImage(nextImage) && + !data.startup_file.trim() + ) { + return ctr.status(ctr.$status.BAD_REQUEST).print({ + status: 400, + message: "Startup file is required for this runtime", + }); + } + // Check for duplicate executionAlias before updating if (data.executionAlias !== undefined) { const aliasExists = await prisma.function.findFirst({ @@ -950,11 +989,20 @@ export = new fileRouter.Path("/") namespaceChange = namespaceRecord.id; } + const normalizedStartupFile = + data.startup_file !== undefined + ? isDotnetImage(nextImage) + ? "" + : data.startup_file.trim() + : undefined; + const updatedData: any = { ...(data.name && { name: data.name }), ...(data.description && { description: data.description }), ...(data.image && { image: data.image }), - ...(data.startup_file && { startup_file: data.startup_file }), + ...(normalizedStartupFile !== undefined && { + startup_file: normalizedStartupFile, + }), ...(data.settings?.tags && { tags: data.settings.tags.join(","), }), @@ -1013,7 +1061,7 @@ export = new fileRouter.Path("/") const changes: string[] = []; if (data.image && data.image !== existingFunction.image) { changes.push(`image: ${existingFunction.image} -> ${data.image}`); - if (data.image.split(":")[0] !== existingFunction.image.split(":")[0]) { + if (getImageFamily(data.image) !== getImageFamily(existingFunction.image)) { // We prohibit language changes due to absolute nightmares of edge cases. return ctr.status(ctr.$status.BAD_REQUEST).print({ status: 400, diff --git a/Backend/src/routes/api/triggers.ts b/Backend/src/routes/api/triggers.ts index 542f444..af91312 100644 --- a/Backend/src/routes/api/triggers.ts +++ b/Backend/src/routes/api/triggers.ts @@ -826,6 +826,7 @@ export = new fileRouter.Path("/") func.files, { enabled: false }, payload, + { mode: "production_execute" }, ); } catch (error) { console.error(`[runFunctionTriggerNow] executeFunction failed for function ${func.id}:`, error); diff --git a/UI/src/Routes.tsx b/UI/src/Routes.tsx index 67102ad..21509b4 100644 --- a/UI/src/Routes.tsx +++ b/UI/src/Routes.tsx @@ -32,6 +32,7 @@ import { GuestUsersDocPage } from "./pages/docs/guest-users"; import GuestUsersPage from "./pages/GuestUsers"; import GuestAccessPage from "./pages/Guest-Access"; import { FfmpegInstallPage } from "./pages/docs/ffmpeg-install"; +import { DocsDotnetRuntime } from "./pages/docs/dotnet-runtime"; import { DocsGoRuntime } from "./pages/docs/go-runtime"; import { DOCSKICKOFF } from "./pages/docs/kickoff"; import { DocsVersionControl } from "./pages/docs/version-control"; @@ -206,6 +207,12 @@ export const routes: AppRoute[] = [ name: "Go Runtime", requireAuth: false, }, + { + path: "/docs/dotnet-runtime", + component: DocsDotnetRuntime, + name: ".NET Runtime", + requireAuth: false, + }, { path: "/docs/kickoff", component: DOCSKICKOFF, diff --git a/UI/src/components/cards/FileManagerCard.tsx b/UI/src/components/cards/FileManagerCard.tsx index 6abba0e..7dfe6ef 100644 --- a/UI/src/components/cards/FileManagerCard.tsx +++ b/UI/src/components/cards/FileManagerCard.tsx @@ -1,3 +1,4 @@ +import { useState, type DragEvent } from "react"; import { FunctionFile } from "../../types/Prisma"; import { ActionButton } from "../buttons/ActionButton"; @@ -9,6 +10,7 @@ export function FileManagerCard({ onDownloadFile, onRenameFile, onDeleteFile, + onDropFiles, onAIGenerate, disabled = false, disabledReason, @@ -20,15 +22,60 @@ export function FileManagerCard({ onDownloadFile: (file: FunctionFile) => void; onRenameFile: (file: FunctionFile) => void; onDeleteFile: (file: FunctionFile) => void; + onDropFiles?: (files: File[]) => void | Promise; onAIGenerate?: () => void; disabled?: boolean; disabledReason?: string; }) { + const [isDragOver, setIsDragOver] = useState(false); + + const handleDragEnter = (event: DragEvent) => { + if (!onDropFiles) return; + event.preventDefault(); + setIsDragOver(true); + }; + + const handleDragOver = (event: DragEvent) => { + if (!onDropFiles) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + if (!isDragOver) { + setIsDragOver(true); + } + }; + + const handleDragLeave = (event: DragEvent) => { + if (!onDropFiles) return; + if (event.currentTarget.contains(event.relatedTarget as Node | null)) { + return; + } + setIsDragOver(false); + }; + + const handleDrop = (event: DragEvent) => { + if (!onDropFiles) return; + event.preventDefault(); + setIsDragOver(false); + const droppedFiles = Array.from(event.dataTransfer.files || []); + if (droppedFiles.length === 0) { + return; + } + void onDropFiles(droppedFiles); + }; + return (

📁 @@ -94,6 +141,18 @@ export function FileManagerCard({ )}

+ {onDropFiles && ( +
+ {isDragOver ? "Drop files to create them here" : "Drag files here to add them"} +
+ )} + {ImagesAsArray.map((img) => ( ))} diff --git a/UI/src/components/modals/functions/CreateFunctionModal.tsx b/UI/src/components/modals/functions/CreateFunctionModal.tsx index 7aa7d60..c466109 100644 --- a/UI/src/components/modals/functions/CreateFunctionModal.tsx +++ b/UI/src/components/modals/functions/CreateFunctionModal.tsx @@ -4,8 +4,13 @@ import { createFunction, getDeprecatedImages, } from "../../../services/backend.functions"; -import { Image, ImagesAsArray, Namespace } from "../../../types/Prisma"; -import { InlineCode } from "../../InlineCode"; +import { + getImageDisplayName, + Image, + ImagesAsArray, + isDotnetImage, + Namespace, +} from "../../../types/Prisma"; interface CreateFunctionModalProps { isOpen: boolean; @@ -37,6 +42,7 @@ function CreateFunctionModal({ const [corsOrigins, setCorsOrigins] = useState(""); const [corsOriginInput, setCorsOriginInput] = useState(""); const [deprecatedImages, setDeprecatedImages] = useState([]); + const isDotnetRuntime = isDotnetImage(image); useEffect(() => { const fetchDeprecatedImages = async () => { @@ -103,7 +109,7 @@ function CreateFunctionModal({ description, image, namespaceId, - startup_file: startupFile, + startup_file: isDotnetRuntime ? "" : startupFile, docker_mount: dockerMount, ffmpeg_install: ffmpegInstall, opencv_install: opencvInstall, @@ -279,16 +285,10 @@ function CreateFunctionModal({ message: "This image is deprecated and cannot be selected", }; } - if (img.split(":")[0] !== image.split(":")[0]) { - isDisabled = { - state: true, - message: "Changing language/runtime is not allowed", - }; - } - return ( ); })} @@ -303,12 +303,26 @@ function CreateFunctionModal({ setStartupFile(e.target.value)} - className="w-full p-3 bg-gray-800/50 border border-gray-600/50 text-white rounded-lg focus:border-primary/50 focus:ring-2 focus:ring-primary/20 focus:outline-none transition-all duration-300" - disabled={isLoading} + className={`w-full p-3 bg-gray-800/50 border border-gray-600/50 text-white rounded-lg focus:border-primary/50 focus:ring-2 focus:ring-primary/20 focus:outline-none transition-all duration-300 ${ + isLoading || isDotnetRuntime + ? "opacity-50 cursor-not-allowed" + : "" + }`} + disabled={isLoading || isDotnetRuntime} /> + {isDotnetRuntime && ( +

+ .NET functions resolve the runnable project from your + `.csproj` and `.sln` files. This field stays empty on purpose. +

+ )} diff --git a/UI/src/components/modals/functions/UpdateFunctionModal.tsx b/UI/src/components/modals/functions/UpdateFunctionModal.tsx index c9508f3..fcd979f 100644 --- a/UI/src/components/modals/functions/UpdateFunctionModal.tsx +++ b/UI/src/components/modals/functions/UpdateFunctionModal.tsx @@ -10,8 +10,11 @@ import { } from "../../../services/backend.functions"; import { getNamespaces } from "../../../services/backend.namespaces"; import { + getImageDisplayName, + getImageFamily, Image, ImagesAsArray, + isDotnetImage, TriggerLog, XFunction, Namespace, @@ -138,6 +141,7 @@ function UpdateFunctionModal({ const namespaceSelectValue = namespaces.length > 0 ? (selectedNamespaceId ?? "") : ""; + const isDotnetRuntime = isDotnetImage(image); const addCorsOrigin = () => { const val = corsOriginInput.trim(); @@ -191,7 +195,7 @@ function UpdateFunctionModal({ name: name.trim() || undefined, description: description.trim() || undefined, image, - startup_file: startupFile?.trim() || undefined, + startup_file: isDotnetRuntime ? "" : startupFile?.trim() || undefined, docker_mount: dockerMount, ffmpeg_install: ffmpegInstall, opencv_install: opencv_install, @@ -403,12 +407,26 @@ function UpdateFunctionModal({ setStartupFile(e.target.value)} - className="w-full p-3 bg-gray-800/50 border border-gray-600/50 text-white rounded-lg focus:border-primary/50 focus:ring-2 focus:ring-primary/20 focus:outline-none transition-all duration-300" - disabled={isLoading} + className={`w-full p-3 bg-gray-800/50 border border-gray-600/50 text-white rounded-lg focus:border-primary/50 focus:ring-2 focus:ring-primary/20 focus:outline-none transition-all duration-300 ${ + isLoading || isDotnetRuntime + ? "opacity-50 cursor-not-allowed" + : "" + }`} + disabled={isLoading || isDotnetRuntime} /> + {isDotnetRuntime && ( +

+ .NET functions keep this empty and resolve the runnable + project from your `.csproj` and `.sln` files. +

+ )} @@ -495,7 +513,7 @@ function UpdateFunctionModal({ > {ImagesAsArray.map((img) => { let isDisabled: { state?: boolean; message?: string } = {}; - if (img.split(":")[0] !== image.split(":")[0]) { + if (getImageFamily(img) !== getImageFamily(image)) { isDisabled = { state: true, message: "Changing language/runtime is not allowed", @@ -510,7 +528,8 @@ function UpdateFunctionModal({ return ( ); })} diff --git a/UI/src/pages/docs/dotnet-runtime.tsx b/UI/src/pages/docs/dotnet-runtime.tsx new file mode 100644 index 0000000..a443a7e --- /dev/null +++ b/UI/src/pages/docs/dotnet-runtime.tsx @@ -0,0 +1,267 @@ +import { DocsContentShell } from "./DocsContentShell"; + +export const DocsDotnetRuntime = () => { + return ( + +

.NET Runtime

+

+ Learn how to build SHSF functions in C# with the generated{" "} + SHSF helpers for payload loading, responses, and database + communication. +

+ +

Overview

+

+ SHSF supports the .NET SDK runtime for multi-file C# projects. You can save + your .cs, .csproj, and .sln files + directly in the editor, then run them inside a .NET SDK container. +

+ +
+ Important: SHSF resolves the runnable project from your{" "} + .csproj files automatically. The startup-file field is disabled + for .NET functions on purpose. +
+ +

+ Supported .NET Versions +

+
    +
  • .NET 8
  • +
  • .NET 9
  • +
  • .NET 10
  • +
+ +

+ How SHSF Runs .NET Functions +

+
    +
  • + Development UI runs use dotnet run. +
  • +
  • + HTTP routes, cron jobs, and production-style trigger execution use{" "} + dotnet run --no-build. +
  • +
  • + Use the .NET Build button in Function Detail after changing + project files and before relying on production routes. +
  • +
  • + SHSF only treats text between{" "} + SHSF_FUNCTION_RESULT_START and{" "} + SHSF_FUNCTION_RESULT_END as the function response. +
  • +
+ +

+ Generated SHSF Helpers +

+

+ Every .NET function gets generated helper classes under the{" "} + SHSF namespace: +

+
    +
  • + SHSF.Runtime for payload loading and response output +
  • +
  • + SHSF.Database for persistent storage communication +
  • +
+ +

+ Example Project Files +

+ +

1. Example .csproj

+
+				{`
+  
+    Exe
+    net10.0
+    enable
+    enable
+  
+`}
+			
+ +

+ 2. Basic Program.cs +

+

+ Use SHSF.Runtime.LoadPayload() to read the raw payload file and{" "} + SHSF.Runtime.Return(...) to send the response back to SHSF. +

+
+				{`using SHSF;
+
+var payload = Runtime.LoadPayload();
+Console.Error.WriteLine("Raw payload length: " + payload.Length);
+
+Runtime.Return(new
+{
+    message = "Hello from .NET",
+    payload
+});`}
+			
+ +

+ 3. Deserialize JSON Payloads +

+

+ Use LoadPayloadJson<T>() when you expect JSON input. +

+
+				{`using SHSF;
+
+public sealed class RunPayload
+{
+    public string? Name { get; set; }
+    public string? Route { get; set; }
+}
+
+var payload = Runtime.LoadPayloadJson() ?? new RunPayload();
+
+Runtime.Return(new
+{
+    greeting = $"Hello, {payload.Name ?? "world"}!",
+    route = payload.Route ?? "default"
+});`}
+			
+ +

+ 4. Custom HTTP Responses +

+

+ You can still return SHSF custom response envelopes from C#. +

+
+				{`using SHSF;
+
+Runtime.Return(new
+{
+    _shsf = "v2",
+    _code = 201,
+    _headers = new Dictionary
+    {
+        ["Content-Type"] = "application/json",
+        ["X-Powered-By"] = "SHSF .NET"
+    },
+    _res = new
+    {
+        status = "created",
+        runtime = ".NET"
+    }
+});`}
+			
+ +

+ Using The Database Helper +

+

+ SHSF generates a SHSF.Database class for .NET functions so you + can read and write persistent data without building your own HTTP client. +

+ +

+ Create and Write Data +

+
+				{`using SHSF;
+
+var db = new Database();
+
+await db.CreateStorage("users", "Stores user profile data");
+await db.Set("users", "alice", new
+{
+    name = "Alice",
+    tier = "pro"
+});
+
+Runtime.Return(new { status = "saved" });`}
+			
+ +

Read Data

+
+				{`using SHSF;
+
+var db = new Database();
+var user = await db.Get("users", "alice");
+
+Runtime.Return(new
+{
+    user
+});`}
+			
+ +

+ Check If A Key Exists +

+
+				{`using SHSF;
+
+var db = new Database();
+var exists = await db.Exists("users", "alice");
+
+Runtime.Return(new
+{
+    exists
+});`}
+			
+ +

+ List and Delete Items +

+
+				{`using SHSF;
+
+var db = new Database();
+var items = await db.ListItems("users");
+await db.DeleteItem("users", "alice");
+
+Runtime.Return(new
+{
+    items
+});`}
+			
+ +

+ Logging and Responses +

+

+ Write normal logs with Console.WriteLine or{" "} + Console.Error.WriteLine. Return the actual function response + only with SHSF.Runtime.Return(...). +

+
+				{`using SHSF;
+
+Console.WriteLine("Starting request processing...");
+Console.Error.WriteLine("This is also captured as a log line.");
+
+Runtime.Return(new
+{
+    ok = true
+});`}
+			
+ +
+

+ 🚀 Next Step - Kickoff +

+

+ Now that you know the .NET runtime contract, you can generate starter + files faster with SHSF Kickoff. +

+ + #22 Kickoff + + +
+
+ ); +}; diff --git a/UI/src/pages/docs/ffmpeg-install.tsx b/UI/src/pages/docs/ffmpeg-install.tsx index 4ca8c4c..ab7bad8 100644 --- a/UI/src/pages/docs/ffmpeg-install.tsx +++ b/UI/src/pages/docs/ffmpeg-install.tsx @@ -212,10 +212,10 @@ export const FfmpegInstallPage = () => { and use the Go runtime in SHSF.

- #20 Go Runtime + #21 .NET Runtime diff --git a/UI/src/pages/docs/go-runtime.tsx b/UI/src/pages/docs/go-runtime.tsx index a8bf9f9..060bbc4 100644 --- a/UI/src/pages/docs/go-runtime.tsx +++ b/UI/src/pages/docs/go-runtime.tsx @@ -333,16 +333,16 @@ func main_user(args interface{}) (interface{}, error) {

- 🚀 Next Step - KICKOFF + 🚀 Next Step - .NET Runtime

- Interested in starting a new project with SHSF? Learn how an AI might help you kick it off faster. + Want to build C# functions too? Learn how the SHSF .NET runtime handles payloads, responses, and storage helpers.

- #21 KICKOFF + #21 .NET Runtime
diff --git a/UI/src/pages/functions/FunctionDetail.tsx b/UI/src/pages/functions/FunctionDetail.tsx index e9f4edf..3bf99b5 100644 --- a/UI/src/pages/functions/FunctionDetail.tsx +++ b/UI/src/pages/functions/FunctionDetail.tsx @@ -22,6 +22,8 @@ import HtmlResultModal from "../../components/modals/functionDetail/HtmlResultMo import ImageResultModal from "../../components/modals/functionDetail/ImageResultModal"; import { FunctionFile, + getImageDisplayName, + isDotnetImage, XFunction, Trigger, Namespace, @@ -34,6 +36,7 @@ import { updateFunction, getLogsByFuncId, installDependencies, + buildDotnetFunction, } from "../../services/backend.functions"; import { getFiles, @@ -121,6 +124,7 @@ function FunctionDetail() { const [showTriggersDetails, setShowTriggersDetails] = useState(false); const [showLogsModal, setShowLogsModal] = useState(false); const [pipRunning, setPipRunning] = useState(false); + const [dotnetBuildRunning, setDotnetBuildRunning] = useState(false); const [showPopup, setShowPopup] = useState(false); const [popupContent, setPopupContent] = useState<{ headers: Record; @@ -177,6 +181,7 @@ function FunctionDetail() { .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") || "my-func"} --force` : ""; + const isDotnetRuntime = Boolean(functionData && isDotnetImage(functionData.image)); useBeforeUnload( (event) => { @@ -288,6 +293,11 @@ function FunctionDetail() { ".md": "markdown", ".go": "go", ".mod": "go", + ".cs": "csharp", + ".csproj": "xml", + ".props": "xml", + ".targets": "xml", + ".sln": "plaintext", ".rs": "rust", ".lua": "lua", }; @@ -722,6 +732,47 @@ function FunctionDetail() { } }; + const handleDotnetBuild = async () => { + if (!id) return; + + setDotnetBuildRunning(true); + try { + const response = await buildDotnetFunction(parseInt(id)); + if ( + response && + typeof response === "object" && + "status" in response && + response.status === "OK" + ) { + setDepModalContent({ + title: ".NET Build Completed", + message: + "Build finished successfully. HTTP routes and cron triggers can now run the compiled assembly.", + success: true, + }); + setShowDepModal(true); + } else { + setDepModalContent({ + title: ".NET Build Failed", + message: "Build failed: " + String(response), + success: false, + }); + setShowDepModal(true); + } + } catch (error) { + console.error("Error building .NET function:", error); + setDepModalContent({ + title: ".NET Build Failed", + message: "An error occurred while building the .NET function.", + success: false, + }); + setShowDepModal(true); + } finally { + fetchLogs(); + setDotnetBuildRunning(false); + } + }; + useEffect(() => { loadData(); }, [id]); @@ -754,6 +805,52 @@ function FunctionDetail() { } }; + const handleDropFiles = async (droppedFiles: File[]) => { + if (!id) { + toast.error("Function ID is missing."); + return; + } + + const existingNames = new Set(files.map((file) => file.name)); + const createdNames: string[] = []; + const skippedNames: string[] = []; + + for (const droppedFile of droppedFiles) { + if (existingNames.has(droppedFile.name)) { + skippedNames.push(droppedFile.name); + continue; + } + + try { + const content = await droppedFile.text(); + const created = await handleCreateFile(droppedFile.name, content); + if (created) { + existingNames.add(droppedFile.name); + createdNames.push(droppedFile.name); + } + } catch (error) { + console.error("Error reading dropped file:", error); + toast.error(`Failed to read ${droppedFile.name}.`); + } + } + + if (createdNames.length > 0) { + toast.success( + createdNames.length === 1 + ? `Created ${createdNames[0]}` + : `Created ${createdNames.length} files`, + ); + } + + if (skippedNames.length > 0) { + toast.error( + skippedNames.length === 1 + ? `${skippedNames[0]} already exists.` + : `${skippedNames.length} files were skipped because they already exist.`, + ); + } + }; + const handleRenameFile = async (newFilename: string): Promise => { if (!id || !selectedFile) return false; @@ -1362,6 +1459,7 @@ function FunctionDetail() { setSelectedFile(file); setShowDeleteModal(true); }} + onDropFiles={handleDropFiles} onAIGenerate={() => setShowAIModal(true)} disabled={Boolean(functionData.git_url)} disabledReason="Git source active — file manager disabled. Use Version Control to manage files." @@ -1445,6 +1543,11 @@ function FunctionDetail() { ? `${activeFileLanguage || "plaintext"} file` : "Select a file from the sidebar to start editing"}

+ {functionData && ( +

+ Runtime: {getImageDisplayName(functionData.image)} +

+ )} @@ -1523,6 +1626,22 @@ function FunctionDetail() { + {isDotnetRuntime && ( +
+ Development UI runs use dotnet run and can be much + slower. HTTP routes and cron triggers use{" "} + dotnet run --no-build, so run .NET Build + after changing project files before production-style execution. + Use SHSF.Runtime.LoadPayload() or{" "} + SHSF.Runtime.LoadPayloadJson<T>() to read the + payload file, and use SHSF.Runtime.Return(...) for + the response. SHSF only treats text between{" "} + SHSF_FUNCTION_RESULT_START and{" "} + SHSF_FUNCTION_RESULT_END as the response. Everything + else is logged. +
+ )} +
{/* Show Pip Install button if requirements.txt exists or if it's a git-based function (since we don't know the files) */} {(files.find((file) => file.name === "requirements.txt") || Boolean(functionData.git_url)) && ( @@ -1534,6 +1653,15 @@ function FunctionDetail() { {pipRunning ? "Installing..." : "Install requirements.txt"} )} + {isDotnetRuntime && ( + + )}