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) => (
- {img}
+ {getImageDisplayName(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 (
- {img} {isDisabled.message && `(${isDisabled.message})`}
+ {getImageDisplayName(img)}{" "}
+ {isDisabled.message && `(${isDisabled.message})`}
);
})}
@@ -303,12 +303,26 @@ function CreateFunctionModal({
Startup File
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({
Startup File
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 (
- {img} {isDisabled.message && `(${isDisabled.message})`}
+ {getImageDisplayName(img)}{" "}
+ {isDisabled.message && `(${isDisabled.message})`}
);
})}
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 && (
+
+ {dotnetBuildRunning ? "Building..." : ".NET Build"}
+
+ )}
{
+ try {
+ const response = await fetch(`${BASE_URL}/api/function/${id}/dotnet-build`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ credentials: "include",
+ });
+ const data = await response.json();
+ if (data.status !== "OK") {
+ const message = data.message || "Failed to build .NET function";
+ if (typeof data.build_logs === "string" && data.build_logs.trim()) {
+ return `${message}\n\n${data.build_logs.trim()}`;
+ }
+ return message;
+ }
+ return data;
+ } catch (error) {
+ console.error("Error building .NET function:", error);
+ }
+}
+
async function reinstallFfmpeg(
id: number,
): Promise {
@@ -621,6 +646,7 @@ export {
executeFunctionStreaming,
getLogsByFuncId,
installDependencies,
+ buildDotnetFunction,
reinstallFfmpeg,
reinstallOpencv,
getFunctionCorsOrigins,
diff --git a/UI/src/types/Prisma.ts b/UI/src/types/Prisma.ts
index 5f356d4..8f62111 100644
--- a/UI/src/types/Prisma.ts
+++ b/UI/src/types/Prisma.ts
@@ -133,7 +133,10 @@ type Image =
| "golang:1.20"
| "golang:1.21"
| "golang:1.22"
- | "golang:1.23";
+ | "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 ImagesAsArray: Image[] = [
"python:3.9",
@@ -147,9 +150,37 @@ const ImagesAsArray: Image[] = [
"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 ImagesAsArraySet = new Set(ImagesAsArray);
+function getImageDisplayName(image: string): string {
+ switch (image) {
+ case "mcr.microsoft.com/dotnet/sdk:8.0":
+ return ".NET 8";
+ case "mcr.microsoft.com/dotnet/sdk:9.0":
+ return ".NET 9";
+ case "mcr.microsoft.com/dotnet/sdk:10.0":
+ return ".NET 10";
+ default:
+ return image;
+ }
+}
+
+function isDotnetImage(image: string): boolean {
+ return image.startsWith("mcr.microsoft.com/dotnet/sdk:");
+}
+
+function getImageFamily(image: string): string {
+ if (isDotnetImage(image)) {
+ return "dotnet";
+ }
+
+ return image.split(":")[0];
+}
+
type Token = {
id: number;
name: string;
@@ -173,7 +204,13 @@ export type {
TriggerLog,
Token,
};
-export { ImagesAsArray, ImagesAsArraySet };
+export {
+ ImagesAsArray,
+ ImagesAsArraySet,
+ getImageDisplayName,
+ getImageFamily,
+ isDotnetImage,
+};
interface FunctionStorage {
id: number;
name: string;
From b370308eafbc6a9c0effccbe715cce1a650d5f1a Mon Sep 17 00:00:00 2001
From: Space-Banane
Date: Mon, 11 May 2026 17:54:49 +0200
Subject: [PATCH 2/2] Improved Windows Support
---
.gitignore | 3 +-
Backend/package.json | 9 +-
Backend/pnpm-lock.yaml | 208 +++++++++++++++++-
.../__tests__/helpers/FunctionRateLimit.ts | 29 +++
.../src/__tests__/helpers/HttpExecution.ts | 5 +-
Backend/src/__tests__/helpers/StoragePaths.ts | 74 +++++++
Backend/src/lib/FunctionRateLimit.ts | 74 +++++--
Backend/src/lib/GitOps.ts | 8 +-
Backend/src/lib/Runner.ts | 43 ++--
Backend/src/lib/StoragePaths.ts | 35 +++
Backend/src/routes/api/files.ts | 19 +-
README.md | 9 +
UI/package.json | 3 +-
UI/pnpm-lock.yaml | 26 +--
14 files changed, 454 insertions(+), 91 deletions(-)
create mode 100644 Backend/src/__tests__/helpers/StoragePaths.ts
create mode 100644 Backend/src/lib/StoragePaths.ts
diff --git a/.gitignore b/.gitignore
index 99b2d0d..ccd57ea 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,4 +24,5 @@ Backend/dist
.data
-.codex
\ No newline at end of file
+.codex
+shsf_data
\ No newline at end of file
diff --git a/Backend/package.json b/Backend/package.json
index 4c521a7..48a7a4d 100644
--- a/Backend/package.json
+++ b/Backend/package.json
@@ -31,12 +31,15 @@
"scripts": {
"prism": "npx prisma generate && npx prisma db push",
"generate": "npx prisma generate",
- "build": "rm -rf dist && tsc",
+ "build": "rimraf dist && tsc",
"start": "cd dist && node index.js",
"prod": "pnpm build && pnpm start",
- "dev": "rm -rf dist && esbuild `find src \\( -name '*.ts' -o -name '*.tsx' \\)` --platform='node' --sourcemap --ignore-annotations --format='cjs' --target='es2022' --outdir='dist' && cd dist && node index.js",
+ "dev": "rimraf dist && esbuild \"src/**/*.ts\" --platform=node --sourcemap --ignore-annotations --format=cjs --target=es2022 --outdir=dist && cd dist && node index.js",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
+ },
+ "devDependencies": {
+ "rimraf": "^5.0.10"
}
-}
\ No newline at end of file
+}
diff --git a/Backend/pnpm-lock.yaml b/Backend/pnpm-lock.yaml
index 53386ff..34eced1 100644
--- a/Backend/pnpm-lock.yaml
+++ b/Backend/pnpm-lock.yaml
@@ -74,6 +74,10 @@ importers:
vitest:
specifier: ^4.1.2
version: 4.1.2(@types/node@22.19.15)(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.15)(esbuild@0.25.12)(jiti@2.6.1)(yaml@2.8.3))
+ devDependencies:
+ rimraf:
+ specifier: ^5.0.10
+ version: 5.0.10
packages:
@@ -296,6 +300,10 @@ packages:
resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==}
engines: {node: '>=18'}
+ '@isaacs/cliui@8.0.2':
+ resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
+ engines: {node: '>=12'}
+
'@isaacs/fs-minipass@4.0.1':
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
engines: {node: '>=18.0.0'}
@@ -329,6 +337,10 @@ packages:
'@oxc-project/types@0.120.0':
resolution: {integrity: sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==}
+ '@pkgjs/parseargs@0.11.0':
+ resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
+ engines: {node: '>=14'}
+
'@prisma/client@6.19.3':
resolution: {integrity: sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==}
engines: {node: '>=18.18'}
@@ -431,42 +443,36 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.10':
resolution: {integrity: sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10':
resolution: {integrity: sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10':
resolution: {integrity: sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.10':
resolution: {integrity: sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-rc.10':
resolution: {integrity: sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-rc.10':
resolution: {integrity: sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==}
@@ -589,10 +595,18 @@ packages:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
+ ansi-regex@6.2.2:
+ resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
+ engines: {node: '>=12'}
+
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
+ ansi-styles@6.2.3:
+ resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
+ engines: {node: '>=12'}
+
aproba@2.1.0:
resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==}
@@ -636,6 +650,9 @@ packages:
brace-expansion@1.1.13:
resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==}
+ brace-expansion@2.1.0:
+ resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==}
+
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
@@ -754,6 +771,10 @@ packages:
resolution: {integrity: sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==}
engines: {node: '>=18'}
+ cross-spawn@7.0.6:
+ resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
+ engines: {node: '>= 8'}
+
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -807,12 +828,18 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
+ eastasianwidth@0.2.0:
+ resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
+
effect@3.21.0:
resolution: {integrity: sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
+ emoji-regex@9.2.2:
+ resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
+
empathic@2.0.0:
resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==}
engines: {node: '>=14'}
@@ -884,6 +911,10 @@ packages:
debug:
optional: true
+ foreground-child@3.3.1:
+ resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
+ engines: {node: '>=14'}
+
form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
@@ -931,6 +962,11 @@ packages:
resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==}
hasBin: true
+ glob@10.5.0:
+ resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
+ deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
+ hasBin: true
+
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
@@ -999,6 +1035,9 @@ packages:
resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
engines: {node: '>=10'}
+ isexe@2.0.0:
+ resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+
istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
@@ -1011,6 +1050,9 @@ packages:
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'}
+ jackspeak@3.4.3:
+ resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
+
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
@@ -1053,28 +1095,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -1102,6 +1140,9 @@ packages:
long@5.3.2:
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
+ lru-cache@10.4.3:
+ resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
+
lru-cache@11.2.7:
resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==}
engines: {node: 20 || >=22}
@@ -1143,6 +1184,10 @@ packages:
minimatch@3.1.5:
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
+ minimatch@9.0.9:
+ resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
+ engines: {node: '>=16 || 14 >=14.17'}
+
minipass@3.3.6:
resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==}
engines: {node: '>=8'}
@@ -1243,10 +1288,21 @@ packages:
resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==}
engines: {node: '>=10'}
+ package-json-from-dist@1.0.1:
+ resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
+
path-is-absolute@1.0.1:
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
engines: {node: '>=0.10.0'}
+ path-key@3.1.1:
+ resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
+ engines: {node: '>=8'}
+
+ path-scurry@1.11.1:
+ resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
+ engines: {node: '>=16 || 14 >=14.18'}
+
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -1319,6 +1375,10 @@ packages:
deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
+ rimraf@5.0.10:
+ resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==}
+ hasBin: true
+
rjweb-server@9.8.6:
resolution: {integrity: sha512-O73XZL64PUrvbW2spQtBA4ZNcEVthn3SeuStzz+rIUYvOe07c12+dHkdxGHhJGjlmVC03ED+LWAqxvrVRTUbfQ==}
engines: {node: '>=18.0.0'}
@@ -1354,12 +1414,24 @@ packages:
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
+ shebang-command@2.0.0:
+ resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
+ engines: {node: '>=8'}
+
+ shebang-regex@3.0.0:
+ resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
+ engines: {node: '>=8'}
+
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
+ signal-exit@4.1.0:
+ resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
+ engines: {node: '>=14'}
+
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -1381,6 +1453,10 @@ packages:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
+ string-width@5.1.2:
+ resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
+ engines: {node: '>=12'}
+
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
@@ -1388,6 +1464,10 @@ packages:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
+ strip-ansi@7.2.0:
+ resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
+ engines: {node: '>=12'}
+
strtok3@9.1.1:
resolution: {integrity: sha512-FhwotcEqjr241ZbjFzjlIYg6c5/L/s4yBGWSMvJ9UoExiSqL+FnFA/CaeZx17WGaZMS/4SOZp8wH18jSS4R4lw==}
engines: {node: '>=16'}
@@ -1556,6 +1636,11 @@ packages:
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
+ which@2.0.2:
+ resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
+ engines: {node: '>= 8'}
+ hasBin: true
+
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
@@ -1572,6 +1657,10 @@ packages:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
+ wrap-ansi@8.1.0:
+ resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
+ engines: {node: '>=12'}
+
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
@@ -1764,6 +1853,15 @@ snapshots:
'@inquirer/figures@1.0.15': {}
+ '@isaacs/cliui@8.0.2':
+ dependencies:
+ string-width: 5.1.2
+ string-width-cjs: string-width@4.2.3
+ strip-ansi: 7.2.0
+ strip-ansi-cjs: strip-ansi@6.0.1
+ wrap-ansi: 8.1.0
+ wrap-ansi-cjs: wrap-ansi@7.0.0
+
'@isaacs/fs-minipass@4.0.1':
dependencies:
minipass: 7.1.3
@@ -1807,6 +1905,9 @@ snapshots:
'@oxc-project/types@0.120.0': {}
+ '@pkgjs/parseargs@0.11.0':
+ optional: true
+
'@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)':
optionalDependencies:
prisma: 6.19.3(typescript@5.9.3)
@@ -2049,10 +2150,14 @@ snapshots:
ansi-regex@5.0.1: {}
+ ansi-regex@6.2.2: {}
+
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
+ ansi-styles@6.2.3: {}
+
aproba@2.1.0: {}
are-we-there-yet@2.0.0:
@@ -2109,6 +2214,10 @@ snapshots:
balanced-match: 1.0.2
concat-map: 0.0.1
+ brace-expansion@2.1.0:
+ dependencies:
+ balanced-match: 1.0.2
+
buffer@5.7.1:
dependencies:
base64-js: 1.5.1
@@ -2218,6 +2327,12 @@ snapshots:
dependencies:
luxon: 3.7.2
+ cross-spawn@7.0.6:
+ dependencies:
+ path-key: 3.1.1
+ shebang-command: 2.0.0
+ which: 2.0.2
+
debug@4.4.3:
dependencies:
ms: 2.1.3
@@ -2269,6 +2384,8 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
+ eastasianwidth@0.2.0: {}
+
effect@3.21.0:
dependencies:
'@standard-schema/spec': 1.1.0
@@ -2276,6 +2393,8 @@ snapshots:
emoji-regex@8.0.0: {}
+ emoji-regex@9.2.2: {}
+
empathic@2.0.0: {}
end-of-stream@1.4.5:
@@ -2355,6 +2474,11 @@ snapshots:
follow-redirects@1.15.11: {}
+ foreground-child@3.3.1:
+ dependencies:
+ cross-spawn: 7.0.6
+ signal-exit: 4.1.0
+
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
@@ -2422,6 +2546,15 @@ snapshots:
nypm: 0.6.5
pathe: 2.0.3
+ glob@10.5.0:
+ dependencies:
+ foreground-child: 3.3.1
+ jackspeak: 3.4.3
+ minimatch: 9.0.9
+ minipass: 7.1.3
+ package-json-from-dist: 1.0.1
+ path-scurry: 1.11.1
+
glob@7.2.3:
dependencies:
fs.realpath: 1.0.0
@@ -2494,6 +2627,8 @@ snapshots:
is-unicode-supported@0.1.0: {}
+ isexe@2.0.0: {}
+
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1:
@@ -2507,6 +2642,12 @@ snapshots:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.1
+ jackspeak@3.4.3:
+ dependencies:
+ '@isaacs/cliui': 8.0.2
+ optionalDependencies:
+ '@pkgjs/parseargs': 0.11.0
+
jiti@2.6.1: {}
js-tokens@10.0.0: {}
@@ -2569,6 +2710,8 @@ snapshots:
long@5.3.2: {}
+ lru-cache@10.4.3: {}
+
lru-cache@11.2.7: {}
luxon@3.7.2: {}
@@ -2605,6 +2748,10 @@ snapshots:
dependencies:
brace-expansion: 1.1.13
+ minimatch@9.0.9:
+ dependencies:
+ brace-expansion: 2.1.0
+
minipass@3.3.6:
dependencies:
yallist: 4.0.0
@@ -2692,8 +2839,17 @@ snapshots:
strip-ansi: 6.0.1
wcwidth: 1.0.1
+ package-json-from-dist@1.0.1: {}
+
path-is-absolute@1.0.1: {}
+ path-key@3.1.1: {}
+
+ path-scurry@1.11.1:
+ dependencies:
+ lru-cache: 10.4.3
+ minipass: 7.1.3
+
pathe@2.0.3: {}
peek-readable@5.4.2: {}
@@ -2773,6 +2929,10 @@ snapshots:
dependencies:
glob: 7.2.3
+ rimraf@5.0.10:
+ dependencies:
+ glob: 10.5.0
+
rjweb-server@9.8.6(@types/node@22.19.15):
dependencies:
'@rjweb/utils': 1.12.29
@@ -2824,10 +2984,18 @@ snapshots:
set-blocking@2.0.0: {}
+ shebang-command@2.0.0:
+ dependencies:
+ shebang-regex: 3.0.0
+
+ shebang-regex@3.0.0: {}
+
siginfo@2.0.0: {}
signal-exit@3.0.7: {}
+ signal-exit@4.1.0: {}
+
source-map-js@1.2.1: {}
split-ca@1.0.1: {}
@@ -2850,6 +3018,12 @@ snapshots:
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
+ string-width@5.1.2:
+ dependencies:
+ eastasianwidth: 0.2.0
+ emoji-regex: 9.2.2
+ strip-ansi: 7.2.0
+
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
@@ -2858,6 +3032,10 @@ snapshots:
dependencies:
ansi-regex: 5.0.1
+ strip-ansi@7.2.0:
+ dependencies:
+ ansi-regex: 6.2.2
+
strtok3@9.1.1:
dependencies:
'@tokenizer/token': 0.3.0
@@ -2993,6 +3171,10 @@ snapshots:
tr46: 0.0.3
webidl-conversions: 3.0.1
+ which@2.0.2:
+ dependencies:
+ isexe: 2.0.0
+
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
@@ -3014,6 +3196,12 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
+ wrap-ansi@8.1.0:
+ dependencies:
+ ansi-styles: 6.2.3
+ string-width: 5.1.2
+ strip-ansi: 7.2.0
+
wrappy@1.0.2: {}
ws@8.20.0(bufferutil@4.1.0):
diff --git a/Backend/src/__tests__/helpers/FunctionRateLimit.ts b/Backend/src/__tests__/helpers/FunctionRateLimit.ts
index e762b79..ea9cf6c 100644
--- a/Backend/src/__tests__/helpers/FunctionRateLimit.ts
+++ b/Backend/src/__tests__/helpers/FunctionRateLimit.ts
@@ -74,6 +74,35 @@ describe("FunctionRateLimit config parsing", () => {
);
});
+ it("repairs common JS-style object literal config values", async () => {
+ expect(
+ await getRateLimitConfigFromData(
+ "{enabled:true, global:{hits:3, window_ms:1000, penalty_ms:250,},}",
+ ),
+ ).toEqual({
+ enabled: true,
+ global: { hits: 3, window_ms: 1000, penalty_ms: 250 },
+ });
+
+ expect(
+ await getExecutionRateLimitConfigFromData(
+ "{enabled:true, policies:[{name:'Preview', scope:'global', rule:{hits:2, window_ms:500}}]}",
+ ),
+ ).toEqual({
+ enabled: true,
+ policies: [
+ {
+ id: "policy-1",
+ name: "Preview",
+ scope: "global",
+ rule: { hits: 2, window_ms: 500 },
+ mode: "enforce",
+ enabled: true,
+ },
+ ],
+ });
+ });
+
it("preserves valid identities and thresholds", async () => {
const config = await getRateLimitConfigFromData(
JSON.stringify({
diff --git a/Backend/src/__tests__/helpers/HttpExecution.ts b/Backend/src/__tests__/helpers/HttpExecution.ts
index 6f641c4..c0bf502 100644
--- a/Backend/src/__tests__/helpers/HttpExecution.ts
+++ b/Backend/src/__tests__/helpers/HttpExecution.ts
@@ -210,12 +210,13 @@ describe("executeLoadedHttpFunction", () => {
body: "{}",
route: "default",
}),
- {
+ expect.objectContaining({
ratelimit: expect.objectContaining({
configured: false,
blocked: false,
}),
- },
+ mode: "production_execute",
+ }),
);
expect(setFunctionCache).toHaveBeenCalledWith(
10,
diff --git a/Backend/src/__tests__/helpers/StoragePaths.ts b/Backend/src/__tests__/helpers/StoragePaths.ts
new file mode 100644
index 0000000..9c3d56f
--- /dev/null
+++ b/Backend/src/__tests__/helpers/StoragePaths.ts
@@ -0,0 +1,74 @@
+import path from "path";
+import { afterEach, describe, expect, it } from "vitest";
+import {
+ getCacheDir,
+ getFunctionAppDir,
+ getFunctionBaseDir,
+ getFunctionExecutionDir,
+ getFunctionExecutionsDir,
+ getGitRepoDir,
+ getShsfDataRoot,
+} from "../../lib/StoragePaths";
+
+const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
+
+function mockPlatform(platform: NodeJS.Platform) {
+ Object.defineProperty(process, "platform", {
+ value: platform,
+ });
+}
+
+describe("StoragePaths", () => {
+ afterEach(() => {
+ if (originalPlatform) {
+ Object.defineProperty(process, "platform", originalPlatform);
+ }
+ });
+
+ it("uses /opt/shsf_data outside Windows", () => {
+ mockPlatform("linux");
+
+ expect(getShsfDataRoot()).toBe("/opt/shsf_data");
+ expect(getFunctionBaseDir(18)).toBe(
+ path.join("/opt/shsf_data", "functions", "18"),
+ );
+ expect(getFunctionAppDir(18)).toBe(
+ path.join("/opt/shsf_data", "functions", "18", "app"),
+ );
+ expect(getFunctionExecutionsDir(18)).toBe(
+ path.join("/opt/shsf_data", "functions", "18", "executions"),
+ );
+ expect(getFunctionExecutionDir(18, "exec-123")).toBe(
+ path.join("/opt/shsf_data", "functions", "18", "executions", "exec-123"),
+ );
+ expect(getGitRepoDir(18)).toBe(
+ path.join("/opt/shsf_data", "functions", "18", "git_repo"),
+ );
+ expect(getCacheDir("pip", "venv", "function-18")).toBe(
+ path.join("/opt/shsf_data", "cache", "pip", "venv", "function-18"),
+ );
+ });
+
+ it("uses Backend/shsf_data for Windows local development", () => {
+ mockPlatform("win32");
+ const expectedRoot = path.resolve(process.cwd(), "shsf_data");
+
+ expect(getShsfDataRoot()).toBe(expectedRoot);
+ expect(getFunctionBaseDir("18")).toBe(
+ path.join(expectedRoot, "functions", "18"),
+ );
+ expect(getFunctionAppDir("18")).toBe(
+ path.join(expectedRoot, "functions", "18", "app"),
+ );
+ expect(getFunctionExecutionsDir("18")).toBe(
+ path.join(expectedRoot, "functions", "18", "executions"),
+ );
+ expect(getFunctionExecutionDir("18", "exec-123")).toBe(
+ path.join(expectedRoot, "functions", "18", "executions", "exec-123"),
+ );
+ expect(getGitRepoDir("18")).toBe(
+ path.join(expectedRoot, "functions", "18", "git_repo"),
+ );
+ expect(getCacheDir("dotnet")).toBe(path.join(expectedRoot, "cache", "dotnet"));
+ });
+});
diff --git a/Backend/src/lib/FunctionRateLimit.ts b/Backend/src/lib/FunctionRateLimit.ts
index 7757f91..c3f37d8 100644
--- a/Backend/src/lib/FunctionRateLimit.ts
+++ b/Backend/src/lib/FunctionRateLimit.ts
@@ -166,6 +166,37 @@ const ratelimitBucketStore = new Map();
const RatelimitCleanupIntervalMs = 60_000;
let lastRatelimitCleanupAt = 0;
+function parseRateLimitConfigJson(ratelimit: string): unknown | null {
+ try {
+ return JSON.parse(ratelimit);
+ } catch {
+ // Be kind to configs that were pasted/stored as JS-ish object literals.
+ // This intentionally handles only simple JSON-compatible mistakes, not code.
+ const repaired = ratelimit
+ .trim()
+ .replace(/([{,]\s*)([A-Za-z_$][\w$-]*)(\s*:)/g, '$1"$2"$3')
+ .replace(/'/g, '"')
+ .replace(/,\s*([}\]])/g, "$1");
+
+ if (repaired === ratelimit) {
+ return null;
+ }
+
+ try {
+ return JSON.parse(repaired);
+ } catch {
+ return null;
+ }
+ }
+}
+
+function logInvalidRateLimitConfig(context: string, ratelimit: string) {
+ const preview = ratelimit.trim().slice(0, 120);
+ console.warn(
+ `Invalid ${context} rate limit config; using fallback. Stored value starts with: ${preview}`,
+ );
+}
+
function isPlainObject(value: unknown): value is Record {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -448,16 +479,17 @@ export function hasConfiguredRateLimitBuckets(
export async function getRateLimitConfigFromData(
ratelimit: string | null,
): Promise {
- try {
- if (!ratelimit || ratelimit.trim() === "") {
- return DEFAULT_FUNCTION_RATELIMIT_CONFIG;
- }
-
- return normalizeFunctionRateLimitConfig(JSON.parse(ratelimit));
- } catch (error) {
- console.error("Error parsing rate limit config:", error);
+ if (!ratelimit || ratelimit.trim() === "") {
return DEFAULT_FUNCTION_RATELIMIT_CONFIG;
}
+
+ const parsed = parseRateLimitConfigJson(ratelimit);
+ if (parsed === null) {
+ logInvalidRateLimitConfig("function", ratelimit);
+ return DEFAULT_FUNCTION_RATELIMIT_CONFIG;
+ }
+
+ return normalizeFunctionRateLimitConfig(parsed);
}
export async function getExecutionRateLimitConfigFromData(
@@ -467,21 +499,21 @@ export async function getExecutionRateLimitConfigFromData(
return FALLBACK_EXECUTION_RATELIMIT_CONFIG;
}
- try {
- const parsed = JSON.parse(ratelimit);
- const normalized = normalizeFunctionRateLimitConfig(parsed);
- if (
- normalized.enabled === false &&
- (!isPlainObject(parsed) || parsed.enabled !== false)
- ) {
- return FALLBACK_EXECUTION_RATELIMIT_CONFIG;
- }
-
- return normalized;
- } catch (error) {
- console.error("Error parsing execution rate limit config:", error);
+ const parsed = parseRateLimitConfigJson(ratelimit);
+ if (parsed === null) {
+ logInvalidRateLimitConfig("execution", ratelimit);
return FALLBACK_EXECUTION_RATELIMIT_CONFIG;
}
+
+ const normalized = normalizeFunctionRateLimitConfig(parsed);
+ if (
+ normalized.enabled === false &&
+ (!isPlainObject(parsed) || parsed.enabled !== false)
+ ) {
+ return FALLBACK_EXECUTION_RATELIMIT_CONFIG;
+ }
+
+ return normalized;
}
export async function setRateLimitConfig(
diff --git a/Backend/src/lib/GitOps.ts b/Backend/src/lib/GitOps.ts
index 4c0ef35..b4ca635 100644
--- a/Backend/src/lib/GitOps.ts
+++ b/Backend/src/lib/GitOps.ts
@@ -4,6 +4,10 @@ import * as fsSync from "fs";
import { execFile } from "child_process";
import { promisify } from "util";
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
+import {
+ getFunctionAppDir as getStorageFunctionAppDir,
+ getGitRepoDir as getStorageGitRepoDir,
+} from "./StoragePaths";
const execFileAsync = promisify(execFile);
@@ -52,7 +56,7 @@ export function decryptSecret(ciphertext: string, secret: string): string | null
}
export function getFuncAppDir(functionId: number): string {
- return path.join("/opt/shsf_data/functions", String(functionId), "app");
+ return getStorageFunctionAppDir(functionId);
}
/**
@@ -61,7 +65,7 @@ export function getFuncAppDir(functionId: number): string {
* of the specified subdirectory.
*/
export function getGitRepoDir(functionId: number): string {
- return path.join("/opt/shsf_data/functions", String(functionId), "git_repo");
+ return getStorageGitRepoDir(functionId);
}
/**
diff --git a/Backend/src/lib/Runner.ts b/Backend/src/lib/Runner.ts
index 92843a8..4231ab6 100644
--- a/Backend/src/lib/Runner.ts
+++ b/Backend/src/lib/Runner.ts
@@ -12,6 +12,13 @@ import { randomBytes } from "crypto";
import { getLoggingConfigFromData, stripHeadersFromPayload } from "./FunctionLogging";
import { replaceApiBaseInContent } from "./FileHelpers";
import type { LoggedExecutionRateLimitData } from "./FunctionRateLimit";
+import {
+ getCacheDir,
+ getFunctionAppDir,
+ getFunctionBaseDir,
+ getFunctionExecutionDir,
+ getFunctionExecutionsDir,
+} from "./StoragePaths";
interface TimingEntry {
timestamp: number;
@@ -1062,7 +1069,7 @@ export async function executeFunction(
const docker = new Docker();
const functionIdStr = String(functionData.id);
const containerName = `shsf_func_${functionIdStr}`;
- const funcAppDir = path.join("/opt/shsf_data/functions", functionIdStr, "app");
+ const funcAppDir = getFunctionAppDir(functionIdStr);
const runtimeType = getRuntimeType(functionData.image);
const executionMode = options?.mode ?? "dev_execute";
let exitCode = 0;
@@ -1073,12 +1080,7 @@ export async function executeFunction(
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
- const executionDir = path.join(
- "/opt/shsf_data/functions",
- functionIdStr,
- "executions",
- executionId
- );
+ const executionDir = getFunctionExecutionDir(functionIdStr, executionId);
// Define startupFile and initScript here as they are needed for script generation
const startupFile = functionData.startup_file;
@@ -1731,22 +1733,19 @@ echo "[SHSF INIT] .NET setup complete."
if (error.statusCode === 404) {
log("Container not found — creating");
- const baseCacheDir = "/opt/shsf_data/cache";
- const pipCacheHost = path.join(baseCacheDir, "pip");
- const goCacheHost = path.join(baseCacheDir, "go");
- const dotnetCacheHost = path.join(baseCacheDir, "dotnet");
+ const pipCacheHost = getCacheDir("pip");
+ const goCacheHost = getCacheDir("go");
+ const dotnetCacheHost = getCacheDir("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/
- const funcBaseDir = path.join("/opt/shsf_data/functions", functionIdStr);
// Mount /app and /executions separately instead of the old /function_data
let BINDS: string[] = [
- `${funcBaseDir}/app:/app`,
- `${funcBaseDir}/executions:/executions`,
+ `${getFunctionAppDir(functionIdStr)}:/app`,
+ `${getFunctionExecutionsDir(functionIdStr)}:/executions`,
];
if (functionData.docker_mount) {
@@ -2278,10 +2277,10 @@ export async function buildDotnetFunction(
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";
+ const funcBaseDir = getFunctionBaseDir(functionIdStr);
+ const funcAppDir = getFunctionAppDir(functionIdStr);
+ const executionDir = getFunctionExecutionsDir(functionIdStr);
+ const dotnetCacheHost = getCacheDir("dotnet");
try {
await fs.mkdir(funcAppDir, { recursive: true });
@@ -2519,7 +2518,7 @@ export async function deleteContainerForFunction(functionId: number) {
export async function cleanupFunctionContainer(functionId: number) {
const functionIdStr = String(functionId);
const containerName = `shsf_func_${functionIdStr}`;
- const funcAppDir = path.join("/opt/shsf_data/functions", functionIdStr);
+ const funcAppDir = getFunctionBaseDir(functionIdStr);
try {
const docker = new Docker();
@@ -2562,13 +2561,13 @@ export async function cleanupFunctionContainer(functionId: number) {
// Clean up cache directories
try {
// Python venv
- const pipCacheDir = `/opt/shsf_data/cache/pip/venv/function-${functionId}`;
+ const pipCacheDir = getCacheDir("pip", "venv", `function-${functionId}`);
if (fsSync.existsSync(pipCacheDir)) {
await fs.rm(pipCacheDir, { recursive: true, force: true });
}
// Pip hash
- const pipHashDir = `/opt/shsf_data/cache/pip/hashes/function-${functionId}`;
+ const pipHashDir = getCacheDir("pip", "hashes", `function-${functionId}`);
if (fsSync.existsSync(pipHashDir)) {
await fs.rm(pipHashDir, { recursive: true, force: true });
}
diff --git a/Backend/src/lib/StoragePaths.ts b/Backend/src/lib/StoragePaths.ts
new file mode 100644
index 0000000..b14f657
--- /dev/null
+++ b/Backend/src/lib/StoragePaths.ts
@@ -0,0 +1,35 @@
+import path from "path";
+
+const LINUX_DATA_ROOT = "/opt/shsf_data";
+const WINDOWS_DATA_ROOT = path.resolve(__dirname, "../../shsf_data");
+
+export function getShsfDataRoot(): string {
+ return process.platform === "win32" ? WINDOWS_DATA_ROOT : LINUX_DATA_ROOT;
+}
+
+export function getFunctionBaseDir(functionId: number | string): string {
+ return path.join(getShsfDataRoot(), "functions", String(functionId));
+}
+
+export function getFunctionAppDir(functionId: number | string): string {
+ return path.join(getFunctionBaseDir(functionId), "app");
+}
+
+export function getFunctionExecutionsDir(functionId: number | string): string {
+ return path.join(getFunctionBaseDir(functionId), "executions");
+}
+
+export function getFunctionExecutionDir(
+ functionId: number | string,
+ executionId: string,
+): string {
+ return path.join(getFunctionExecutionsDir(functionId), executionId);
+}
+
+export function getGitRepoDir(functionId: number | string): string {
+ return path.join(getFunctionBaseDir(functionId), "git_repo");
+}
+
+export function getCacheDir(...parts: string[]): string {
+ return path.join(getShsfDataRoot(), "cache", ...parts);
+}
diff --git a/Backend/src/routes/api/files.ts b/Backend/src/routes/api/files.ts
index 33d0562..4369945 100644
--- a/Backend/src/routes/api/files.ts
+++ b/Backend/src/routes/api/files.ts
@@ -9,6 +9,7 @@ import Docker from "dockerode";
import path from "path";
import * as fs from "fs/promises";
import { OpenAPITags } from "../../lib/openapi";
+import { getFunctionAppDir } from "../../lib/StoragePaths";
// Add Docker integration
const docker = new Docker();
@@ -170,11 +171,7 @@ export = new fileRouter.Path("/")
// that use a bind mount will see the changes. Replace {{API_BASE}} at write time.
try {
const funcInfo = await getFunctionExecInfo(functionId);
- const funcAppDir = path.join(
- "/opt/shsf_data/functions",
- String(functionId),
- "app",
- );
+ const funcAppDir = getFunctionAppDir(functionId);
await fs.mkdir(funcAppDir, { recursive: true });
let contentToWrite: string | Buffer = data.code;
if (funcInfo && typeof contentToWrite === "string") {
@@ -366,11 +363,7 @@ export = new fileRouter.Path("/")
(fileToDelete.name === "requirements.txt" ||
fileToDelete.name === "package.json")
) {
- const funcAppDir = path.join(
- "/opt/shsf_data/functions",
- String(functionId),
- "app",
- );
+ const funcAppDir = getFunctionAppDir(functionId);
try {
await fs.writeFile(
path.join(funcAppDir, fileToDelete.name),
@@ -503,11 +496,7 @@ export = new fileRouter.Path("/")
data.newFilename === "requirements.txt" ||
data.newFilename === "package.json")
) {
- const funcAppDir = path.join(
- "/opt/shsf_data/functions",
- String(functionId),
- "app",
- );
+ const funcAppDir = getFunctionAppDir(functionId);
try {
// If renaming away from a dependency file, create an empty one
if (
diff --git a/README.md b/README.md
index a38d6dc..c393f1e 100644
--- a/README.md
+++ b/README.md
@@ -43,6 +43,15 @@ SHSF has a web interface, supports Python and Go as main runtimes and allows you
Open your browser and navigate to `http://localhost:3000` (or your configured port)
+### Runtime data directory
+
+SHSF stores function files and runtime caches in an OS-specific data directory:
+
+- Linux/Docker: `/opt/shsf_data`
+- Windows local development: `./shsf_data` inside the `Backend` folder
+
+This keeps Docker bind mounts valid on Windows while preserving the normal `/opt/shsf_data` layout on Linux servers.
+
## Usage
1. **Open the web interface** and register (first user becomes admin).
diff --git a/UI/package.json b/UI/package.json
index 4fd83ce..f56ada1 100644
--- a/UI/package.json
+++ b/UI/package.json
@@ -28,7 +28,7 @@
"web-vitals": "^2.1.4"
},
"scripts": {
- "dev": "PORT=443 react-scripts start",
+ "dev": "cross-env PORT=443 react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
@@ -55,6 +55,7 @@
]
},
"devDependencies": {
+ "cross-env": "^7.0.3",
"eslint": "^8.57.1",
"eslint-config-react-app": "^7.0.1"
}
diff --git a/UI/pnpm-lock.yaml b/UI/pnpm-lock.yaml
index ef14614..07f895a 100644
--- a/UI/pnpm-lock.yaml
+++ b/UI/pnpm-lock.yaml
@@ -78,6 +78,9 @@ importers:
specifier: ^2.1.4
version: 2.1.4
devDependencies:
+ cross-env:
+ specifier: ^7.0.3
+ version: 7.0.3
eslint:
specifier: ^8.57.1
version: 8.57.1
@@ -1088,42 +1091,36 @@ packages:
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
- libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.6':
resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
- libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.6':
resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.6':
resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.6':
resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.6':
resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
- libc: [musl]
'@parcel/watcher-win32-arm64@2.5.6':
resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==}
@@ -1317,28 +1314,24 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.2.2':
resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.2.2':
resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.2.2':
resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
- libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.2.2':
resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==}
@@ -2299,6 +2292,11 @@ packages:
resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==}
engines: {node: '>=10'}
+ cross-env@7.0.3:
+ resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==}
+ engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
+ hasBin: true
+
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -3894,28 +3892,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -8703,6 +8697,10 @@ snapshots:
path-type: 4.0.0
yaml: 1.10.3
+ cross-env@7.0.3:
+ dependencies:
+ cross-spawn: 7.0.6
+
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1