[WIP] feat: add .NET runtime support and related documentation

This commit is contained in:
Space-Banane
2026-05-11 17:29:43 +02:00
parent 532fa2ebe7
commit bf7e105fa6
20 changed files with 1606 additions and 61 deletions
+2 -1
View File
@@ -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) {
+1
View File
@@ -265,6 +265,7 @@ export async function executeLoadedHttpFunction(
}),
{
ratelimit: loggedRateLimit,
mode: "production_execute",
},
);
+757 -10
View File
@@ -19,6 +19,11 @@ interface TimingEntry {
description: string;
}
export type FunctionExecutionMode =
| "dev_execute"
| "production_execute"
| "cron_execute";
export interface PersistedFunctionExecutionLogInput {
functionId: number;
functionData: Pick<Function, "logging" | "startup_file">;
@@ -37,6 +42,8 @@ const FUNCTION_DB_TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
const ServeOnlyFileNotFoundHTML = `<html><head><title>File Not Found</title></head><body><h1>404 - File Not Found</h1><p>The requested HTML file was not found in the function's files.</p></body></html>`;
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<string[]> {
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<Set<string>> {
const projectPaths = new Set<string>();
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<string>,
): Promise<DotnetProjectCandidate> {
const relativePath = path.relative(funcAppDir, csprojPath);
const content = await fs.readFile(csprojPath, "utf8");
const outputTypeMatch = content.match(
/<OutputType>\s*([^<\s]+)\s*<\/OutputType>/i,
);
const sdkMatch = content.match(/<Project[^>]*\bSdk="([^"]+)"/i);
const outputType = outputTypeMatch?.[1]?.trim().toLowerCase() ?? "";
const projectSdk = sdkMatch?.[1]?.trim().toLowerCase() ?? "";
const isTestProject =
/<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<string> {
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<string>();
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 <OutputType>Exe</OutputType> 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<JsonNode?> 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<string>();
if (!string.Equals(status, "OK", StringComparison.OrdinalIgnoreCase))
{
throw new DatabaseError(obj["message"]?.GetValue<string>() ?? "Unknown error");
}
return obj["data"] ?? parsed;
}
if (!response.IsSuccessStatusCode)
{
throw new DatabaseError($"HTTP {(int)response.StatusCode}: {body}");
}
return parsed;
}
public Task<JsonNode?> CreateStorage(string name, string purpose = "") =>
MakeRequestAsync(HttpMethod.Post, "/api/storage", new { name, purpose });
public Task<JsonNode?> ListStorages() =>
MakeRequestAsync(HttpMethod.Get, "/api/storage");
public Task<JsonNode?> DeleteStorage(string storageName) =>
MakeRequestAsync(HttpMethod.Delete, $"/api/storage/{Uri.EscapeDataString(storageName)}");
public Task<JsonNode?> Clear(string storageName) =>
MakeRequestAsync(HttpMethod.Delete, $"/api/storage/{Uri.EscapeDataString(storageName)}/items");
public Task<JsonNode?> 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<JsonNode?> 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<JsonNode?> GetItem(string storageName, string key) =>
MakeRequestAsync(
HttpMethod.Get,
$"/api/storage/{Uri.EscapeDataString(storageName)}/item/{Uri.EscapeDataString(key)}"
);
public Task<JsonNode?> ListItems(string storageName) =>
MakeRequestAsync(HttpMethod.Get, $"/api/storage/{Uri.EscapeDataString(storageName)}/items");
public Task<JsonNode?> DeleteItem(string storageName, string key) =>
MakeRequestAsync(
HttpMethod.Delete,
$"/api/storage/{Uri.EscapeDataString(storageName)}/item/{Uri.EscapeDataString(key)}"
);
public async Task<bool> 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<T>() =>
JsonSerializer.Deserialize<T>(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<string> {
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<void>((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);
+51 -3
View File
@@ -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<Dictionary<string, JsonElement?>>();
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<T>()\`, 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<Dictionary<string, JsonElement?>>() ?? new();
var body = payload.TryGetValue("body", out var rawBody) && rawBody is JsonElement value && value.ValueKind == JsonValueKind.String
? JsonSerializer.Deserialize<Dictionary<string, string>>(value.GetString() ?? "{}") ?? new()
: new Dictionary<string, string>();
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.
@@ -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,
});
}
}),
);
+4 -2
View File
@@ -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) {
+60 -12
View File
@@ -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,
+1
View File
@@ -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);
+7
View File
@@ -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,
+60 -1
View File
@@ -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<void>;
onAIGenerate?: () => void;
disabled?: boolean;
disabledReason?: string;
}) {
const [isDragOver, setIsDragOver] = useState(false);
const handleDragEnter = (event: DragEvent<HTMLDivElement>) => {
if (!onDropFiles) return;
event.preventDefault();
setIsDragOver(true);
};
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
if (!onDropFiles) return;
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
if (!isDragOver) {
setIsDragOver(true);
}
};
const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
if (!onDropFiles) return;
if (event.currentTarget.contains(event.relatedTarget as Node | null)) {
return;
}
setIsDragOver(false);
};
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
if (!onDropFiles) return;
event.preventDefault();
setIsDragOver(false);
const droppedFiles = Array.from(event.dataTransfer.files || []);
if (droppedFiles.length === 0) {
return;
}
void onDropFiles(droppedFiles);
};
return (
<div
className={`bg-gradient-to-br from-gray-900/50 to-gray-800/50 border border-primary/20 rounded-lg p-4 relative ${
className={`bg-gradient-to-br from-gray-900/50 to-gray-800/50 border rounded-lg p-4 relative transition-all duration-200 ${
isDragOver
? "border-primary shadow-[0_0_0_1px_rgba(34,211,238,0.5),0_0_24px_rgba(34,211,238,0.18)] bg-primary/5"
: "border-primary/20"
} ${
disabled ? "opacity-50 pointer-events-none select-none grayscale" : ""
}`}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<h2 className="text-lg font-bold text-primary mb-3 flex items-center gap-2">
<span>📁</span>
@@ -94,6 +141,18 @@ export function FileManagerCard({
)}
</div>
{onDropFiles && (
<div
className={`mb-3 rounded-lg border border-dashed px-3 py-2 text-center text-xs transition-all duration-200 ${
isDragOver
? "border-primary/60 bg-primary/10 text-primary"
: "border-primary/20 bg-background/20 text-text/60"
}`}
>
{isDragOver ? "Drop files to create them here" : "Drag files here to add them"}
</div>
)}
<ActionButton
icon=""
label="New File"
+7 -2
View File
@@ -1,5 +1,10 @@
import React, { useState, useRef } from "react";
import { FunctionFile, Image, ImagesAsArray } from "../../types/Prisma";
import {
FunctionFile,
getImageDisplayName,
Image,
ImagesAsArray,
} from "../../types/Prisma";
import { generateWithAI, generateConfigWithAI, type AIMode } from "../../services/backend.ai";
import { createFunction } from "../../services/backend.functions";
import Modal from "./Modal";
@@ -283,7 +288,7 @@ function AIGenerateModal({
>
{ImagesAsArray.map((img) => (
<option key={img} value={img} className="bg-[#0a0a0f]">
{img}
{getImageDisplayName(img)}
</option>
))}
</select>
@@ -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<string>("");
const [corsOriginInput, setCorsOriginInput] = useState<string>("");
const [deprecatedImages, setDeprecatedImages] = useState<string[]>([]);
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 (
<option key={img} value={img} disabled={isDisabled.state}>
{img} {isDisabled.message && `(${isDisabled.message})`}
{getImageDisplayName(img)}{" "}
{isDisabled.message && `(${isDisabled.message})`}
</option>
);
})}
@@ -303,12 +303,26 @@ function CreateFunctionModal({
<label className="text-sm font-medium text-gray-300">Startup File</label>
<input
type="text"
placeholder="main.py, index.js, etc."
placeholder={
isDotnetRuntime
? ".NET functions auto-detect the runnable project"
: "main.py, index.js, etc."
}
value={startupFile || ""}
onChange={(e) => 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 && (
<p className="text-xs text-cyan-300">
.NET functions resolve the runnable project from your
`.csproj` and `.sln` files. This field stays empty on purpose.
</p>
)}
</div>
</div>
</div>
@@ -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({
<label className="text-sm font-medium text-gray-300">Startup File</label>
<input
type="text"
placeholder="main.py, index.js, etc."
placeholder={
isDotnetRuntime
? ".NET functions auto-detect the runnable project"
: "main.py, index.js, etc."
}
value={startupFile || ""}
onChange={(e) => 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 && (
<p className="text-xs text-cyan-300">
.NET functions keep this empty and resolve the runnable
project from your `.csproj` and `.sln` files.
</p>
)}
</div>
</div>
@@ -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 (
<option key={img} value={img} disabled={isDisabled.state}>
{img} {isDisabled.message && `(${isDisabled.message})`}
{getImageDisplayName(img)}{" "}
{isDisabled.message && `(${isDisabled.message})`}
</option>
);
})}
+267
View File
@@ -0,0 +1,267 @@
import { DocsContentShell } from "./DocsContentShell";
export const DocsDotnetRuntime = () => {
return (
<DocsContentShell>
<h1 className="text-3xl font-bold text-primary mb-2">.NET Runtime</h1>
<p className="mt-3 text-lg text-text/90 mb-8">
Learn how to build SHSF functions in C# with the generated{" "}
<code>SHSF</code> helpers for payload loading, responses, and database
communication.
</p>
<h2 className="text-2xl font-bold text-primary mt-8 mb-6">Overview</h2>
<p className="mb-6 text-text/90">
SHSF supports the .NET SDK runtime for multi-file C# projects. You can save
your <code>.cs</code>, <code>.csproj</code>, and <code>.sln</code> files
directly in the editor, then run them inside a .NET SDK container.
</p>
<div className="mb-6 p-4 bg-cyan-900/20 border-l-4 border-cyan-400 rounded">
<b>Important:</b> SHSF resolves the runnable project from your{" "}
<code>.csproj</code> files automatically. The startup-file field is disabled
for .NET functions on purpose.
</div>
<h2 className="text-2xl font-bold text-primary mt-8 mb-6">
Supported .NET Versions
</h2>
<ul className="list-disc list-inside mb-6 text-text/90 space-y-2">
<li>.NET 8</li>
<li>.NET 9</li>
<li>.NET 10</li>
</ul>
<h2 className="text-2xl font-bold text-primary mt-8 mb-6">
How SHSF Runs .NET Functions
</h2>
<ul className="list-disc list-inside mb-6 text-text/90 space-y-2">
<li>
Development UI runs use <code>dotnet run</code>.
</li>
<li>
HTTP routes, cron jobs, and production-style trigger execution use{" "}
<code>dotnet run --no-build</code>.
</li>
<li>
Use the <code>.NET Build</code> button in Function Detail after changing
project files and before relying on production routes.
</li>
<li>
SHSF only treats text between{" "}
<code>SHSF_FUNCTION_RESULT_START</code> and{" "}
<code>SHSF_FUNCTION_RESULT_END</code> as the function response.
</li>
</ul>
<h2 className="text-2xl font-bold text-primary mt-8 mb-6">
Generated SHSF Helpers
</h2>
<p className="mb-4 text-text/90">
Every .NET function gets generated helper classes under the{" "}
<code>SHSF</code> namespace:
</p>
<ul className="list-disc list-inside mb-6 text-text/90 space-y-2">
<li>
<code>SHSF.Runtime</code> for payload loading and response output
</li>
<li>
<code>SHSF.Database</code> for persistent storage communication
</li>
</ul>
<h2 className="text-2xl font-bold text-primary mt-8 mb-6">
Example Project Files
</h2>
<h3 className="text-xl font-semibold text-primary mb-4">1. Example .csproj</h3>
<pre className="bg-gray-900 p-4 rounded-lg overflow-x-auto text-sm mb-6">
<code>{`<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>`}</code>
</pre>
<h3 className="text-xl font-semibold text-primary mb-4">
2. Basic Program.cs
</h3>
<p className="mb-4 text-text/90">
Use <code>SHSF.Runtime.LoadPayload()</code> to read the raw payload file and{" "}
<code>SHSF.Runtime.Return(...)</code> to send the response back to SHSF.
</p>
<pre className="bg-gray-900 p-4 rounded-lg overflow-x-auto text-sm mb-6">
<code>{`using SHSF;
var payload = Runtime.LoadPayload();
Console.Error.WriteLine("Raw payload length: " + payload.Length);
Runtime.Return(new
{
message = "Hello from .NET",
payload
});`}</code>
</pre>
<h3 className="text-xl font-semibold text-primary mb-4">
3. Deserialize JSON Payloads
</h3>
<p className="mb-4 text-text/90">
Use <code>LoadPayloadJson&lt;T&gt;()</code> when you expect JSON input.
</p>
<pre className="bg-gray-900 p-4 rounded-lg overflow-x-auto text-sm mb-6">
<code>{`using SHSF;
public sealed class RunPayload
{
public string? Name { get; set; }
public string? Route { get; set; }
}
var payload = Runtime.LoadPayloadJson<RunPayload>() ?? new RunPayload();
Runtime.Return(new
{
greeting = $"Hello, {payload.Name ?? "world"}!",
route = payload.Route ?? "default"
});`}</code>
</pre>
<h3 className="text-xl font-semibold text-primary mb-4">
4. Custom HTTP Responses
</h3>
<p className="mb-4 text-text/90">
You can still return SHSF custom response envelopes from C#.
</p>
<pre className="bg-gray-900 p-4 rounded-lg overflow-x-auto text-sm mb-6">
<code>{`using SHSF;
Runtime.Return(new
{
_shsf = "v2",
_code = 201,
_headers = new Dictionary<string, string>
{
["Content-Type"] = "application/json",
["X-Powered-By"] = "SHSF .NET"
},
_res = new
{
status = "created",
runtime = ".NET"
}
});`}</code>
</pre>
<h2 className="text-2xl font-bold text-primary mt-8 mb-6">
Using The Database Helper
</h2>
<p className="mb-4 text-text/90">
SHSF generates a <code>SHSF.Database</code> class for .NET functions so you
can read and write persistent data without building your own HTTP client.
</p>
<h3 className="text-xl font-semibold text-primary mb-4">
Create and Write Data
</h3>
<pre className="bg-gray-900 p-4 rounded-lg overflow-x-auto text-sm mb-6">
<code>{`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" });`}</code>
</pre>
<h3 className="text-xl font-semibold text-primary mb-4">Read Data</h3>
<pre className="bg-gray-900 p-4 rounded-lg overflow-x-auto text-sm mb-6">
<code>{`using SHSF;
var db = new Database();
var user = await db.Get("users", "alice");
Runtime.Return(new
{
user
});`}</code>
</pre>
<h3 className="text-xl font-semibold text-primary mb-4">
Check If A Key Exists
</h3>
<pre className="bg-gray-900 p-4 rounded-lg overflow-x-auto text-sm mb-6">
<code>{`using SHSF;
var db = new Database();
var exists = await db.Exists("users", "alice");
Runtime.Return(new
{
exists
});`}</code>
</pre>
<h3 className="text-xl font-semibold text-primary mb-4">
List and Delete Items
</h3>
<pre className="bg-gray-900 p-4 rounded-lg overflow-x-auto text-sm mb-6">
<code>{`using SHSF;
var db = new Database();
var items = await db.ListItems("users");
await db.DeleteItem("users", "alice");
Runtime.Return(new
{
items
});`}</code>
</pre>
<h2 className="text-2xl font-bold text-primary mt-8 mb-6">
Logging and Responses
</h2>
<p className="mb-4 text-text/90">
Write normal logs with <code>Console.WriteLine</code> or{" "}
<code>Console.Error.WriteLine</code>. Return the actual function response
only with <code>SHSF.Runtime.Return(...)</code>.
</p>
<pre className="bg-gray-900 p-4 rounded-lg overflow-x-auto text-sm mb-6">
<code>{`using SHSF;
Console.WriteLine("Starting request processing...");
Console.Error.WriteLine("This is also captured as a log line.");
Runtime.Return(new
{
ok = true
});`}</code>
</pre>
<div className="mt-12 p-6 bg-gradient-to-r from-blue-900/20 to-purple-900/20 border border-primary/30 rounded-xl">
<h2 className="text-xl font-bold text-primary mb-3">
🚀 Next Step - Kickoff
</h2>
<p className="text-text/90 mb-4">
Now that you know the .NET runtime contract, you can generate starter
files faster with SHSF Kickoff.
</p>
<a
href="/docs/kickoff"
className="inline-flex items-center gap-2 text-blue-400 hover:text-blue-300 font-medium transition-colors"
>
#22 Kickoff
<span className="text-lg"></span>
</a>
</div>
</DocsContentShell>
);
};
+2 -2
View File
@@ -212,10 +212,10 @@ export const FfmpegInstallPage = () => {
and use the Go runtime in SHSF.
</p>
<a
href="/docs/go-runtime"
href="/docs/dotnet-runtime"
className="inline-flex items-center gap-2 text-blue-400 hover:text-blue-300 font-medium transition-colors"
>
#20 Go Runtime
#21 .NET Runtime
<span className="text-lg"></span>
</a>
</div>
+4 -4
View File
@@ -333,16 +333,16 @@ func main_user(args interface{}) (interface{}, error) {
<div className="mt-12 p-6 bg-gradient-to-r from-blue-900/20 to-purple-900/20 border border-primary/30 rounded-xl">
<h2 className="text-xl font-bold text-primary mb-3">
🚀 Next Step - KICKOFF
🚀 Next Step - .NET Runtime
</h2>
<p className="text-text/90 mb-4">
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.
</p>
<a
href="/docs/kickoff"
href="/docs/dotnet-runtime"
className="inline-flex items-center gap-2 text-blue-400 hover:text-blue-300 font-medium transition-colors"
>
#21 KICKOFF
#21 .NET Runtime
<span className="text-lg"></span>
</a>
</div>
+128
View File
@@ -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<boolean>(false);
const [showLogsModal, setShowLogsModal] = useState<boolean>(false);
const [pipRunning, setPipRunning] = useState<boolean>(false);
const [dotnetBuildRunning, setDotnetBuildRunning] = useState<boolean>(false);
const [showPopup, setShowPopup] = useState<boolean>(false);
const [popupContent, setPopupContent] = useState<{
headers: Record<string, string>;
@@ -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<boolean> => {
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"}
</p>
{functionData && (
<p className="text-xs text-text/45">
Runtime: {getImageDisplayName(functionData.image)}
</p>
)}
</div>
</div>
@@ -1523,6 +1626,22 @@ function FunctionDetail() {
</button>
</div>
{isDotnetRuntime && (
<div className="rounded-xl border border-cyan-400/20 bg-cyan-500/5 p-3 text-sm text-cyan-100">
Development UI runs use <code>dotnet run</code> and can be much
slower. HTTP routes and cron triggers use{" "}
<code>dotnet run --no-build</code>, so run <code>.NET Build</code>
after changing project files before production-style execution.
Use <code>SHSF.Runtime.LoadPayload()</code> or{" "}
<code>SHSF.Runtime.LoadPayloadJson&lt;T&gt;()</code> to read the
payload file, and use <code>SHSF.Runtime.Return(...)</code> for
the response. SHSF only treats text between{" "}
<code>SHSF_FUNCTION_RESULT_START</code> and{" "}
<code>SHSF_FUNCTION_RESULT_END</code> as the response. Everything
else is logged.
</div>
)}
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-primary/10 pt-4">
{/* 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"}
</button>
)}
{isDotnetRuntime && (
<button
className="h-9 px-3 text-sm rounded-lg bg-cyan-600/90 text-white hover:bg-cyan-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-300"
onClick={handleDotnetBuild}
disabled={dotnetBuildRunning || running || saving}
>
{dotnetBuildRunning ? "Building..." : ".NET Build"}
</button>
)}
<button
className="h-9 px-3 text-sm rounded-lg bg-background/45 border border-primary/20 text-primary hover:border-primary/40 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-300"
onClick={handleLoadDefault}
+10 -2
View File
@@ -275,8 +275,16 @@ const lessons: {
link: "/docs/go-runtime",
},
{
key: "kickoff",
key: "dotnet-runtime",
identifier: "#21",
title: ".NET Runtime",
description:
"Build C# functions with SHSF.Runtime payload helpers and SHSF.Database storage access.",
link: "/docs/dotnet-runtime",
},
{
key: "kickoff",
identifier: "#22",
title: "Kickoff",
description:
"KICKOFF your new functions with AI-powered code generation.",
@@ -284,7 +292,7 @@ const lessons: {
},
{
key: "version-control",
identifier: "#22",
identifier: "#23",
title: "VERSION // CONTROL",
description:
"Deploy functions directly from a Git repository. Clone, pull, and keep your code in sync — automatically or on demand.",
+26
View File
@@ -243,6 +243,31 @@ async function installDependencies(
}
}
async function buildDotnetFunction(
id: number,
): Promise<OKResponse | string | undefined> {
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<OKResponse | string | undefined> {
@@ -621,6 +646,7 @@ export {
executeFunctionStreaming,
getLogsByFuncId,
installDependencies,
buildDotnetFunction,
reinstallFfmpeg,
reinstallOpencv,
getFunctionCorsOrigins,
+39 -2
View File
@@ -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;