[WIP] feat: add .NET runtime support and related documentation
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -265,6 +265,7 @@ export async function executeLoadedHttpFunction(
|
||||
}),
|
||||
{
|
||||
ratelimit: loggedRateLimit,
|
||||
mode: "production_execute",
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+757
-10
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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<T>()</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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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<T>()</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}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user