Merge pull request #25 from Space-Banane/add-dotnet-to-dev

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