Compare commits

..
4 Commits
Author SHA1 Message Date
Akizon77 f0cc0fba38 fix: pin 1.5.1 frontend release source 2026-09-24 23:21:05 +08:00
Akizon77 1d972c42dc fix: pin 1.5.1-rc1 frontend version 2026-09-24 17:25:06 +08:00
Akizon f089b08b3a 裁剪1下 2026-09-24 17:21:44 +08:00
Akizon 6e70263c50 重定向旧版本的主题接口? 2026-09-24 17:21:37 +08:00
240 changed files with 8484 additions and 12032 deletions

No files matched your search

+3 -1
View File
@@ -24,7 +24,9 @@ jobs:
TAG: ${{ github.event.release.tag_name }}
run: |
frontend_ref=""
if [[ "$TAG" =~ ^(.+)-fix[0-9]+$ ]]; then
if [[ "$TAG" == "1.5.1" || "$TAG" == "1.5.1-rc1" ]]; then
frontend_ref="1.5.0-fix1"
elif [[ "$TAG" =~ ^(.+)-fix[0-9]+$ ]]; then
frontend_ref="${BASH_REMATCH[1]}"
fi
echo "frontend_ref=$frontend_ref" >> "$GITHUB_OUTPUT"
-57
View File
@@ -1,57 +0,0 @@
name: Test
on:
pull_request:
branches:
- main
push:
branches:
- dev
workflow_dispatch:
concurrency:
group: test-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Download dependencies
run: go mod download
# NOTE: the repository stores Go sources with CRLF line endings, which
# gofmt normalises to LF, so "gofmt -l" currently lists ~327 files. Until
# that is normalised repo-wide this step is advisory only; flip it to a
# hard failure once the tree is clean.
- name: Formatting report (advisory)
run: |
count=$(gofmt -l . | grep -v '^web/node_modules' | wc -l)
echo "gofmt reports $count file(s) needing formatting."
gofmt -l . | grep -v '^web/node_modules' | head -20 || true
- name: Vet
run: go vet ./...
- name: Test
run: go test ./... -count=1 -timeout 10m
- name: Test (repeat for isolation)
run: go test ./... -count=2 -shuffle=on -timeout 15m
- name: Race detector
run: go test -race ./pkg/metric/ ./internal/metricstore/ ./internal/plugin/ ./web/connection/ ./web/api/terminal/ -count=1 -timeout 15m
- name: Coverage
run: |
go test ./... -count=1 -coverpkg=./... -coverprofile=coverage.out -timeout 15m
go tool cover -func=coverage.out | tail -1
+2 -3
View File
@@ -15,7 +15,6 @@ komari.db
/utils/geoip/data
.vscode/
/backup
/cache
# Test binary, built with `go test -c`
*.test
@@ -37,5 +36,5 @@ go.work.sum
komari
/dist/
.codex
.gocache/
/web/filemanager/data
/web/filemanager/data
-1
View File
@@ -12,7 +12,6 @@ COPY --chmod=755 komari-${TARGETOS}-${TARGETARCH} /app/komari
ENV GIN_MODE=release
ENV KOMARI_LISTEN=0.0.0.0:25774
ENV GODEBUG=disablethp=1
EXPOSE 25774
+3 -4
View File
@@ -48,7 +48,6 @@ Interested in sponsoring Komari? Contact the developer via [email](mailto:komari
| Sponsor | Description |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <a href="https://mooproxy.net/?utm_source=komari"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/Mooproxy-logo-transparent.png" alt="mooproxy.net" width="180"></a> | [MooProxy · Rotating residential proxies from $0.20/GB. Sign up for 500MB of free traffic and try it today!](https://mooproxy.net/?utm_source=komari) |
| <a href="https://axisnow.io/zh?utm=komari"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/AxisNow.jpg" alt="AxisNow" width="180"></a> | [Self-Hosted Private CDN \| Subscription-Based CDN-Like Service \| A Fully Controlled, Flexible, Modular CDN Network](https://axisnow.io/zh?utm=komari) |
| <a href="https://whmcs.as211392.com/aff.php?aff=110"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/dreamcloud.png" alt="Dream Cloud" width="180"></a> | Cost-effective Asia-Pacific hosting with direct connectivity and robust DDoS protection, backed by transparent capacity claims. |
| <a href="https://sharon.io"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp" alt="Sharon Networks" width="180"></a> | Premium China-optimized connectivity from Asia-Pacific data centers, featuring low latency, high bandwidth, and Tbps-scale local DDoS mitigation. Join the [Telegram community](https://t.me/SharonNetwork) to participate in charitable initiatives and community giveaways. |
@@ -63,6 +62,6 @@ Thanks to everyone who has contributed code, themes, plugins, documentation, tra
If Komari has been useful to you, consider buying me a coffee. Thank you for your support!
| TRON Network |
| ------------------------------------------------------------------------------------------- |
| ![TRON Network QR code](https://b2.akz.moe/awesome-pictures/PixPin_2026-08-07_15-16-52.png) |
| WeChat Pay | TRON Network |
| ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
| ![WeChat Pay QR code](https://b2.akz.moe/awesome-pictures/%E5%BE%AE%E4%BF%A1%E8%B5%9E%E8%B5%8F%E7%A0%81.png) | ![TRON Network QR code](https://b2.akz.moe/awesome-pictures/PixPin_2026-08-07_15-16-52.png) |
+3 -4
View File
@@ -48,7 +48,6 @@ Docker、二进制文件、源码构建和更新说明,请参阅 [安装指南
| 赞助商 | 描述 |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <a href="https://mooproxy.net/?utm_source=komari"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/Mooproxy-logo-transparent.png" alt="mooproxy.net" width="180"></a> | [MooProxy · 动态住宅代理IP低至 $0.20/GB,注册即送 500MB 免费流量,立即体验!](https://mooproxy.net/?utm_source=komari) |
| <a href="https://axisnow.io/zh?utm=komari"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/AxisNow.jpg" alt="AxisNow" width="180"></a> | [自建私有部署CDN \| 订阅式高仿CDN \| 自主可控、灵活组合的CDN网络](https://axisnow.io/zh?utm=komari) |
| <a href="https://whmcs.as211392.com/aff.php?aff=110"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/dreamcloud.png" alt="Dream Cloud" width="180"></a> | 极高性价比解锁直连亚太高防,真高防,不虚标,打死退款 |
| <a href="https://sharon.io"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp" alt="Sharon Networks" width="180"></a> | 亚太数据中心提供顶级的中国优化网络接入 · 低延时&高带宽&提供Tbps级本地清洗高防服务, 为您的业务保驾护航, 为您的客户提供极致体验. 加入社区 [Telegram群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用 |
@@ -63,6 +62,6 @@ Docker、二进制文件、源码构建和更新说明,请参阅 [安装指南
如果 Komari 对你有所帮助,欢迎请作者喝一杯奶茶。感谢你的支持!
| TRON Network |
| --------------------------------------------------------------------------- |
| ![TRON](https://b2.akz.moe/awesome-pictures/PixPin_2026-08-07_15-16-52.png) |
| 微信赞赏码 | TRON Network |
| ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------- |
| ![wechat](https://b2.akz.moe/awesome-pictures/%E5%BE%AE%E4%BF%A1%E8%B5%9E%E8%B5%8F%E7%A0%81.png) | ![TRON](https://b2.akz.moe/awesome-pictures/PixPin_2026-08-07_15-16-52.png) |
+20
View File
@@ -0,0 +1,20 @@
package flags
import "testing"
func TestNormalizeDatabaseType(t *testing.T) {
tests := map[string]string{
"": DatabaseTypeSQLite,
"sqlite": DatabaseTypeSQLite,
" SQLite ": DatabaseTypeSQLite,
"SQLITE": DatabaseTypeSQLite,
"postgres": "postgres",
" postgres": "postgres",
}
for input, want := range tests {
if got := NormalizeDatabaseType(input); got != want {
t.Fatalf("NormalizeDatabaseType(%q) = %q, want %q", input, got, want)
}
}
}
-5
View File
@@ -6,7 +6,6 @@ import (
appserver "github.com/komari-monitor/komari/internal/server"
logger "github.com/komari-monitor/komari/utils/log"
frontendpublic "github.com/komari-monitor/komari/web/public"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/spf13/cobra"
@@ -33,10 +32,6 @@ func init() {
// 具体各阶段的职责与顺序见 internal/server.App。这里只负责串联:
// 任一初始化阶段失败即中止启动,避免在半初始化状态下对外提供服务。
func RunServer() {
if err := frontendpublic.PrepareDefaultDist(); err != nil {
logger.Fatalf("server", "prepare default frontend failed: %v", err)
}
app := appserver.New(appserver.Options{ListenAddr: flags.Listen})
if err := app.Bootstrap(); err != nil {
_ = app.Shutdown()
+13 -3
View File
@@ -35,11 +35,21 @@ func Enable2Fa(uuid, secret string) error {
func Verify2Fa(uuid, code string) (bool, error) {
db := dbcore.GetDBInstance()
var user models.User
if err := db.Where("uuid = ?", uuid).First(&user).Error; err != nil {
err := db.Where("uuid = ?", uuid).First(&user).Error
if err != nil {
return false, err
}
// 用户未启用2FA时视为校验失败
return user.TwoFactor != "" && totp.Validate(code, user.TwoFactor), nil
if user.TwoFactor == "" {
return false, nil // 用户未启用2FA
}
valid := totp.Validate(code, user.TwoFactor)
if !valid {
return false, nil
}
return true, nil
}
func Disable2Fa(uuid string) error {
+56 -24
View File
@@ -14,12 +14,15 @@ import (
const constantSalt = "06Wm4Jv1Hkxx"
// CheckPassword 检查密码,正确时返回用户 UUID 和 true
func CheckPassword(username, passwd string) (uuid string, ok bool) {
// CheckPassword 检查密码是否正确
//
// 如果密码正确,返回用户的 UUID 和 true;否则返回空字符串和 false
func CheckPassword(username, passwd string) (uuid string, success bool) {
db := dbcore.GetDBInstance()
var user models.User
result := db.Where("username = ?", username).First(&user)
if result.Error != nil {
// 静默处理错误,不显示日志
return "", false
}
if hashPasswd(passwd) != user.Passwd {
@@ -29,7 +32,7 @@ func CheckPassword(username, passwd string) (uuid string, ok bool) {
}
// ForceResetPassword 强制重置用户密码
func ForceResetPassword(username, passwd string) error {
func ForceResetPassword(username, passwd string) (err error) {
db := dbcore.GetDBInstance()
result := db.Model(&models.User{}).Where("username = ?", username).Update("passwd", hashPasswd(passwd))
if result.Error != nil {
@@ -43,54 +46,82 @@ func ForceResetPassword(username, passwd string) error {
// hashPasswd 对密码进行加盐哈希
func hashPasswd(passwd string) string {
h := sha256.Sum256([]byte(passwd + constantSalt))
return base64.StdEncoding.EncodeToString(h[:])
saltedPassword := passwd + constantSalt
hash := sha256.New()
hash.Write([]byte(saltedPassword))
hashedPassword := base64.StdEncoding.EncodeToString(hash.Sum(nil))
return hashedPassword
}
func CreateAccount(username, passwd string) (user models.User, err error) {
return CreateAccountWithDB(dbcore.GetDBInstance(), username, passwd)
}
func CreateAccountWithDB(db *gorm.DB, username, passwd string) (models.User, error) {
user := models.User{
func CreateAccountWithDB(db *gorm.DB, username, passwd string) (user models.User, err error) {
hashedPassword := hashPasswd(passwd)
user = models.User{
UUID: uuid.New().String(),
Username: username,
Passwd: hashPasswd(passwd),
Passwd: hashedPassword,
}
return user, db.Create(&user).Error
err = db.Create(&user).Error
if err != nil {
return models.User{}, err
}
return user, nil
}
func DeleteAccountByUsername(username string) (err error) {
return DeleteAccountByUsernameWithDB(dbcore.GetDBInstance(), username)
}
func DeleteAccountByUsernameWithDB(db *gorm.DB, username string) error {
return db.Where("username = ?", username).Delete(&models.User{}).Error
func DeleteAccountByUsernameWithDB(db *gorm.DB, username string) (err error) {
err = db.Where("username = ?", username).Delete(&models.User{}).Error
if err != nil {
return err
}
return nil
}
func GetUserByUUID(uuid string) (models.User, error) {
var user models.User
return user, dbcore.GetDBInstance().Where("uuid = ?", uuid).First(&user).Error
}
// GetUserBySSO 通过 SSO 信息获取用户
func GetUserBySSO(ssoID string) (models.User, error) {
func GetUserByUUID(uuid string) (user models.User, err error) {
db := dbcore.GetDBInstance()
var user models.User
if err := db.Where("sso_id = ?", ssoID).First(&user).Error; err != nil {
return models.User{}, fmt.Errorf("用户不存在:%s", ssoID)
err = db.Where("uuid = ?", uuid).First(&user).Error
if err != nil {
return models.User{}, err
}
return user, nil
}
// 通过 SSO 信息获取用户
func GetUserBySSO(ssoID string) (user models.User, err error) {
db := dbcore.GetDBInstance()
// 首先尝试查找已存在的用户
err = db.Where("sso_id = ?", ssoID).First(&user).Error
if err == nil {
return user, nil
}
// 如果找不到用户,返回明确的错误信息
return models.User{}, fmt.Errorf("用户不存在:%s", ssoID)
}
func BindingExternalAccount(uuid string, sso_id string) error {
db := dbcore.GetDBInstance()
return db.Model(&models.User{}).Where("uuid = ?", uuid).Update("sso_id", sso_id).Error
err := db.Model(&models.User{}).Where("uuid = ?", uuid).Update("sso_id", sso_id).Error
if err != nil {
return err
}
return nil
}
func UnbindExternalAccount(uuid string) error {
db := dbcore.GetDBInstance()
return db.Model(&models.User{}).Where("uuid = ?", uuid).Update("sso_id", "").Error
err := db.Model(&models.User{}).Where("uuid = ?", uuid).Update("sso_id", "").Error
if err != nil {
return err
}
return nil
}
func UpdateUser(uuid string, name, password, sso_type *string) error {
@@ -112,7 +143,8 @@ func UpdateUser(uuid string, name, password, sso_type *string) error {
updates["sso_type"] = *sso_type
}
updates["updated_at"] = time.Now().UTC()
if err := db.Model(&models.User{}).Where("uuid = ?", uuid).Updates(updates).Error; err != nil {
err := db.Model(&models.User{}).Where("uuid = ?", uuid).Updates(updates).Error
if err != nil {
return err
}
if password != nil {
-220
View File
@@ -1,220 +0,0 @@
package accounts
import (
"testing"
"time"
"github.com/pquerna/otp/totp"
)
// TestHashPasswordIsSaltedAndStable pins the password hash contract: stable
// for the same input, different for different passwords, and never equal to
// the plaintext.
func TestHashPasswordIsSaltedAndStable(t *testing.T) {
a := hashPasswd("correct horse battery staple")
b := hashPasswd("correct horse battery staple")
if a != b {
t.Fatal("hash is not deterministic for the same password")
}
if hashPasswd("other password") == a {
t.Fatal("different passwords produced the same hash")
}
if a == "correct horse battery staple" {
t.Fatal("hash equals the plaintext")
}
if a == hashPasswd("correct horse battery staplex") {
t.Fatal("hash is not sensitive to a one-character change")
}
}
// TestCheckPassword covers the credential check used by the login endpoint.
func TestCheckPassword(t *testing.T) {
resetAccountsState(t)
user, err := CreateAccount("alice", "Strong-password1")
if err != nil {
t.Fatalf("create account: %v", err)
}
if uuid, ok := CheckPassword("alice", "Strong-password1"); !ok || uuid != user.UUID {
t.Fatalf("correct password rejected: uuid=%q ok=%v", uuid, ok)
}
if _, ok := CheckPassword("alice", "wrong-password"); ok {
t.Fatal("wrong password accepted")
}
if _, ok := CheckPassword("nobody", "Strong-password1"); ok {
t.Fatal("unknown user accepted")
}
}
// TestForceResetPasswordChangesCredential guards the admin recovery path.
func TestForceResetPasswordChangesCredential(t *testing.T) {
resetAccountsState(t)
if _, err := CreateAccount("bob", "Old-password1"); err != nil {
t.Fatalf("create account: %v", err)
}
if err := ForceResetPassword("bob", "New-password1"); err != nil {
t.Fatalf("force reset: %v", err)
}
if _, ok := CheckPassword("bob", "Old-password1"); ok {
t.Fatal("old password still works after reset")
}
if _, ok := CheckPassword("bob", "New-password1"); !ok {
t.Fatal("new password does not work after reset")
}
if err := ForceResetPassword("nobody", "New-password1"); err == nil {
t.Fatal("reset of a missing user should fail")
}
}
// TestSessionLifecycle covers creation, lookup and deletion.
func TestSessionLifecycle(t *testing.T) {
resetAccountsState(t)
user, err := CreateAccount("carol", "Strong-password1")
if err != nil {
t.Fatalf("create account: %v", err)
}
session, err := CreateSession(user.UUID, 3600, "ua", "127.0.0.1", "password")
if err != nil {
t.Fatalf("create session: %v", err)
}
if got, err := GetSession(session); err != nil || got != user.UUID {
t.Fatalf("GetSession = %q, %v; want %q", got, err, user.UUID)
}
if err := DeleteSession(session); err != nil {
t.Fatalf("delete session: %v", err)
}
if _, err := GetSession(session); err == nil {
t.Fatal("deleted session still resolves")
}
}
// TestExpiredSessionIsRejectedAndRemoved verifies GetSession both refuses an
// expired session and cleans it up.
func TestExpiredSessionIsRejectedAndRemoved(t *testing.T) {
resetAccountsState(t)
user, err := CreateAccount("dave", "Strong-password1")
if err != nil {
t.Fatalf("create account: %v", err)
}
// expires=-1 puts the record in the past.
session, err := CreateSession(user.UUID, -1, "ua", "127.0.0.1", "password")
if err != nil {
t.Fatalf("create session: %v", err)
}
if _, err := GetSession(session); err == nil {
t.Fatal("expired session resolved successfully")
}
if got := countSessions(t, "session = ?", session); got != 0 {
t.Fatalf("expired session was not removed, %d rows remain", got)
}
}
// TestRemoveExpiredSessionsOnlyDropsExpired pins the janitor's selectivity.
func TestRemoveExpiredSessionsOnlyDropsExpired(t *testing.T) {
resetAccountsState(t)
user, err := CreateAccount("erin", "Strong-password1")
if err != nil {
t.Fatalf("create account: %v", err)
}
live, err := CreateSession(user.UUID, 3600, "ua", "127.0.0.1", "password")
if err != nil {
t.Fatalf("create live session: %v", err)
}
stale, err := CreateSession(user.UUID, -1, "ua", "127.0.0.1", "password")
if err != nil {
t.Fatalf("create stale session: %v", err)
}
if err := RemoveExpiredSessions(); err != nil {
t.Fatalf("remove expired sessions: %v", err)
}
if _, err := GetSession(live); err != nil {
t.Fatalf("live session was removed: %v", err)
}
if _, err := GetSession(stale); err == nil {
t.Fatal("stale session survived the janitor")
}
}
// TestUpdateUserRotatesPasswordAndDropsSessions pins the rule that changing a
// password invalidates every existing session.
func TestUpdateUserRotatesPasswordAndDropsSessions(t *testing.T) {
resetAccountsState(t)
user, err := CreateAccount("frank", "Strong-password1")
if err != nil {
t.Fatalf("create account: %v", err)
}
session, err := CreateSession(user.UUID, 3600, "ua", "127.0.0.1", "password")
if err != nil {
t.Fatalf("create session: %v", err)
}
newPassword := "Rotated-password1"
if err := UpdateUser(user.UUID, nil, &newPassword, nil); err != nil {
t.Fatalf("update user: %v", err)
}
if _, err := GetSession(session); err == nil {
t.Fatal("session survived a password rotation")
}
if _, ok := CheckPassword("frank", newPassword); !ok {
t.Fatal("rotated password does not work")
}
if err := UpdateUser("missing-uuid", nil, &newPassword, nil); err == nil {
t.Fatal("updating a missing user should fail")
}
}
// TestTwoFactorLifecycle covers enable, verify and disable.
func TestTwoFactorLifecycle(t *testing.T) {
resetAccountsState(t)
user, err := CreateAccount("grace", "Strong-password1")
if err != nil {
t.Fatalf("create account: %v", err)
}
// Without a secret, verification must fail closed.
if ok, err := Verify2Fa(user.UUID, "123456"); err != nil || ok {
t.Fatalf("Verify2Fa before enabling = %v, %v; want false, nil", ok, err)
}
secret, img, err := Generate2Fa()
if err != nil {
t.Fatalf("generate 2fa: %v", err)
}
if secret == "" || img == nil {
t.Fatal("Generate2Fa returned an empty secret or image")
}
if err := Enable2Fa(user.UUID, secret); err != nil {
t.Fatalf("enable 2fa: %v", err)
}
code, err := totp.GenerateCode(secret, time.Now())
if err != nil {
t.Fatalf("generate totp code: %v", err)
}
if ok, err := Verify2Fa(user.UUID, code); err != nil || !ok {
t.Fatalf("Verify2Fa with a valid code = %v, %v; want true, nil", ok, err)
}
if ok, err := Verify2Fa(user.UUID, "000000"); err != nil || ok {
t.Fatalf("Verify2Fa with a wrong code = %v, %v; want false, nil", ok, err)
}
if err := Disable2Fa(user.UUID); err != nil {
t.Fatalf("disable 2fa: %v", err)
}
if ok, err := Verify2Fa(user.UUID, code); err != nil || ok {
t.Fatalf("Verify2Fa after disabling = %v, %v; want false, nil", ok, err)
}
}
// TestVerify2FaUnknownUserIsAnError distinguishes "no such user" from "wrong
// code" so callers do not silently treat a lookup failure as a pass.
func TestVerify2FaUnknownUserIsAnError(t *testing.T) {
resetAccountsState(t)
if _, err := Verify2Fa("missing-uuid", "123456"); err == nil {
t.Fatal("Verify2Fa for a missing user should return an error")
}
}
-47
View File
@@ -1,47 +0,0 @@
package accounts
import (
"os"
"testing"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
)
// TestMain wires a shared in-memory SQLite database for the accounts tests.
func TestMain(m *testing.M) {
flags.DatabaseType = flags.DatabaseTypeSQLite
flags.DatabaseFile = "file:komari_accounts_test?mode=memory&cache=shared"
db := dbcore.GetDBInstance()
if sqlDB, err := db.DB(); err == nil {
sqlDB.SetMaxOpenConns(1)
}
os.Exit(m.Run())
}
// resetAccountsState clears users and sessions so each test starts clean.
func resetAccountsState(t *testing.T) {
t.Helper()
clear := func() {
db := dbcore.GetDBInstance()
if err := db.Where("1 = 1").Delete(&models.User{}).Error; err != nil {
t.Fatalf("clear users: %v", err)
}
if err := db.Where("1 = 1").Delete(&models.Session{}).Error; err != nil {
t.Fatalf("clear sessions: %v", err)
}
}
clear()
t.Cleanup(clear)
}
// countSessions returns how many session rows match the query.
func countSessions(t *testing.T, query string, args ...any) int64 {
t.Helper()
var count int64
if err := dbcore.GetDBInstance().Model(&models.Session{}).Where(query, args...).Count(&count).Error; err != nil {
t.Fatalf("count sessions: %v", err)
}
return count
}
+33 -11
View File
@@ -16,10 +16,13 @@ import (
)
// GetAllSessions 获取所有会话
func GetAllSessions() ([]models.Session, error) {
func GetAllSessions() (sessions []models.Session, err error) {
db := dbcore.GetDBInstance()
var sessions []models.Session
return sessions, db.Find(&sessions).Error
err = db.Find(&sessions).Error
if err != nil {
return nil, err
}
return sessions, nil
}
// CreateSession 创建新会话
@@ -54,14 +57,19 @@ func CreateSession(uuid string, expires int, userAgent, ip, login_method string)
}
}()
return session, db.Create(&sessionRecord).Error
err := db.Create(&sessionRecord).Error
if err != nil {
return "", err
}
return session, nil
}
// GetSession 根据会话 ID 获取 UUID
func GetSession(session string) (string, error) {
func GetSession(session string) (uuid string, err error) {
db := dbcore.GetDBInstance()
var sessionRecord models.Session
if err := db.Where("session = ?", session).First(&sessionRecord).Error; err != nil {
err = db.Where("session = ?", session).First(&sessionRecord).Error
if err != nil {
return "", err
}
@@ -75,22 +83,32 @@ func GetSession(session string) (string, error) {
}
func GetUserBySession(session string) (models.User, error) {
db := dbcore.GetDBInstance()
var sessionRecord models.Session
if err := dbcore.GetDBInstance().Where("session = ?", session).First(&sessionRecord).Error; err != nil {
err := db.Where("session = ?", session).First(&sessionRecord).Error
if err != nil {
return models.User{}, err
}
return GetUserByUUID(sessionRecord.UUID)
}
// DeleteSession 删除指定会话
func DeleteSession(session string) error {
func DeleteSession(session string) (err error) {
db := dbcore.GetDBInstance()
return db.Where("session = ?", session).Delete(&models.Session{}).Error
result := db.Where("session = ?", session).Delete(&models.Session{})
if result.Error != nil {
return result.Error
}
return nil
}
func DeleteAllSessions() error {
db := dbcore.GetDBInstance()
return db.Where("1 = 1").Delete(&models.Session{}).Error
result := db.Where("1 = 1").Delete(&models.Session{})
if result.Error != nil {
return result.Error
}
return nil
}
func UpdateLatest(session, useragent, ip string) error {
@@ -104,5 +122,9 @@ func UpdateLatest(session, useragent, ip string) error {
func RemoveExpiredSessions() error {
db := dbcore.GetDBInstance()
return db.Where("expires < ?", time.Now().UTC()).Delete(&models.Session{}).Error
result := db.Where("expires < ?", time.Now().UTC()).Delete(&models.Session{})
if result.Error != nil {
return result.Error
}
return nil
}
+54 -13
View File
@@ -16,7 +16,12 @@ import (
)
func DeleteClient(clientUuid string) error {
return dbcore.GetDBInstance().Delete(&models.Client{}, "uuid = ?", clientUuid).Error
db := dbcore.GetDBInstance()
err := db.Delete(&models.Client{}, "uuid = ?", clientUuid).Error
if err != nil {
return err
}
return nil
}
func SaveClientInfo(update map[string]interface{}) error {
@@ -26,6 +31,11 @@ func SaveClientInfo(update map[string]interface{}) error {
return fmt.Errorf("invalid client UUID")
}
// 确保更新的字段不为空
if len(update) == 0 {
return fmt.Errorf("no fields to update")
}
update["updated_at"] = time.Now().UTC()
toFloat64 := func(value interface{}) (float64, bool) {
@@ -104,7 +114,11 @@ func SaveClientInfo(update map[string]interface{}) error {
return err
}
return db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(update).Error
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(update).Error
if err != nil {
return err
}
return nil
}
// CreateClient 创建新客户端
@@ -121,7 +135,8 @@ func CreateClient() (clientUUID, token string, err error) {
UpdatedAt: time.Now().UTC(),
}
if err := db.Create(&client).Error; err != nil {
err = db.Create(&client).Error
if err != nil {
return "", "", err
}
if err := tasks.AddDefaultOnClientUUID(clientUUID); err != nil {
@@ -145,7 +160,8 @@ func CreateClientWithName(name string) (clientUUID, token string, err error) {
UpdatedAt: time.Now().UTC(),
}
if err := db.Create(&client).Error; err != nil {
err = db.Create(&client).Error
if err != nil {
return "", "", err
}
if err := tasks.AddDefaultOnClientUUID(clientUUID); err != nil {
@@ -154,25 +170,41 @@ func CreateClientWithName(name string) (clientUUID, token string, err error) {
return clientUUID, token, nil
}
func GetClientByUUID(uuid string) (models.Client, error) {
var client models.Client
if err := dbcore.GetDBInstance().Where("uuid = ?", uuid).First(&client).Error; err != nil {
/*
// GetAllClients 获取所有客户端配置
func getAllClients() (clients []models.Client, err error) {
db := dbcore.GetDBInstance()
err = db.Find(&clients).Error
if err != nil {
return nil, err
}
return clients, nil
}
*/
func GetClientByUUID(uuid string) (client models.Client, err error) {
db := dbcore.GetDBInstance()
err = db.Where("uuid = ?", uuid).First(&client).Error
if err != nil {
return models.Client{}, err
}
return client, nil
}
func GetClientTokenByUUID(uuid string) (string, error) {
func GetClientTokenByUUID(uuid string) (token string, err error) {
db := dbcore.GetDBInstance()
var client models.Client
if err := dbcore.GetDBInstance().Where("uuid = ?", uuid).First(&client).Error; err != nil {
err = db.Where("uuid = ?", uuid).First(&client).Error
if err != nil {
return "", err
}
return client.Token, nil
}
func GetAllClientBasicInfo() ([]models.Client, error) {
var clients []models.Client
if err := dbcore.GetDBInstance().Find(&clients).Error; err != nil {
func GetAllClientBasicInfo() (clients []models.Client, err error) {
db := dbcore.GetDBInstance()
err = db.Find(&clients).Error
if err != nil {
return nil, err
}
return clients, nil
@@ -185,6 +217,11 @@ func SaveClient(updates map[string]interface{}) error {
return fmt.Errorf("invalid client UUID")
}
// 确保更新的字段不为空
if len(updates) == 0 {
return fmt.Errorf("no fields to update")
}
if v, exists := updates["traffic_limit"]; exists {
if val, ok := v.(float64); ok {
if val < 0 || val > math.MaxInt64-1 {
@@ -217,5 +254,9 @@ func SaveClient(updates map[string]interface{}) error {
updates["updated_at"] = time.Now().UTC()
return db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(updates).Error
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(updates).Error
if err != nil {
return err
}
return nil
}
+11 -4
View File
@@ -9,16 +9,19 @@ import (
v2 "github.com/komari-monitor/komari/protocol/v2"
)
func GetClientUUIDByToken(token string) (string, error) {
func GetClientUUIDByToken(token string) (clientUUID string, err error) {
db := dbcore.GetDBInstance()
var client models.Client
if err := dbcore.GetDBInstance().Where("token = ?", token).First(&client).Error; err != nil {
err = db.Where("token = ?", token).First(&client).Error
if err != nil {
return "", err
}
return client.UUID, nil
}
// ReportVerify 检查上报数据,防止异常数据导致数据库损坏
// 检查数据防止异常数据导致数据库损坏
func ReportVerify(report v2.Report) error {
// 防止输入不合理范围
if report.CPU.Usage < 0 || report.CPU.Usage > 100 {
return fmt.Errorf("CPU.Usage must be between 0 and 100")
}
@@ -53,24 +56,28 @@ func ReportVerify(report v2.Report) error {
}
// [int64] 防止数据溢出
// Ram 验证
if err := checkInt64("Ram.Used", report.Ram.Used); err != nil {
return err
}
if err := checkInt64("Ram.Total", report.Ram.Total); err != nil {
return err
}
// Swap 验证
if err := checkInt64("Swap.Used", report.Swap.Used); err != nil {
return err
}
if err := checkInt64("Swap.Total", report.Swap.Total); err != nil {
return err
}
// Disk 验证
if err := checkInt64("Disk.Used", report.Disk.Used); err != nil {
return err
}
if err := checkInt64("Disk.Total", report.Disk.Total); err != nil {
return err
}
// Network 验证
if err := checkInt64("Network.Up", report.Network.Up); err != nil {
return err
}
@@ -83,7 +90,7 @@ func ReportVerify(report v2.Report) error {
if err := checkInt64("Network.TotalDown", report.Network.TotalDown); err != nil {
return err
}
// 拒绝所有负数 Int
// 拒绝所有负数Int
if report.Process < 0 {
return fmt.Errorf("Process must be non-negative: %d", report.Process)
}
+64
View File
@@ -0,0 +1,64 @@
package clipboard
import (
"time"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
)
// CreateClipboard 创建剪贴板记录
func CreateClipboard(cb *models.Clipboard) error {
db := dbcore.GetDBInstance()
cb.CreatedAt = time.Now().UTC()
cb.UpdatedAt = cb.CreatedAt
return db.Create(cb).Error
}
// GetClipboardByID 根据ID获取剪贴板记录
func GetClipboardByID(id int) (*models.Clipboard, error) {
var cb models.Clipboard
db := dbcore.GetDBInstance()
if err := db.First(&cb, id).Error; err != nil {
return nil, err
}
return &cb, nil
}
// UpdateClipboardFields 更新剪贴板记录
func UpdateClipboardFields(id int, fields map[string]interface{}) error {
db := dbcore.GetDBInstance()
delete(fields, "created_at")
fields["updated_at"] = time.Now().UTC()
return db.Model(&models.Clipboard{}).Where("id = ?", id).Updates(fields).Error
}
// DeleteClipboard 删除剪贴板记录
func DeleteClipboard(id int) error {
db := dbcore.GetDBInstance()
// Check if record exists first
var cb models.Clipboard
if err := db.First(&cb, id).Error; err != nil {
return err // Record not found or other error
}
return db.Delete(&cb).Error
}
// DeleteClipboardBatch 批量删除剪贴板记录
func DeleteClipboardBatch(ids []int) error {
if len(ids) == 0 {
return nil
}
db := dbcore.GetDBInstance()
return db.Where("id IN ?", ids).Delete(&models.Clipboard{}).Error
}
// ListClipboard 列出所有剪贴板记录
func ListClipboard() ([]models.Clipboard, error) {
var list []models.Clipboard
db := dbcore.GetDBInstance()
if err := db.Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
+6 -15
View File
@@ -186,13 +186,6 @@ const (
// versionID 是当前构建的版本标识,由 SetVersionID 在 Initialize 前注入。
var versionID string
// nil means a fresh install; an empty value means an unmarked legacy install.
var lastRunVersion *string
func LastRunVersion() *string {
return lastRunVersion
}
// dbFileExistedAtStartup 记录本次进程启动、打开数据库之前 komari.db 是否已存在,
// 用于区分“全新安装”与“从旧版升级(无版本标记)”。在 doInitialize 打开数据库
// 之前采集。
@@ -228,18 +221,14 @@ func resolveDatabaseFile() string {
//
// 备份失败不阻止启动,但打印明确错误;备份成功(或无需备份)后写入/更新版本。
func backupOnVersionUpgrade() {
prevVersion, readErr := config.GetAs[string](SystemVersionKey)
prevVersion = strings.TrimSpace(prevVersion)
versionRecorded := readErr == nil && prevVersion != ""
lastRunVersion = nil
if versionRecorded || dbFileExistedAtStartup {
lastRunVersion = &prevVersion
}
if versionID == "" {
return
}
prevVersion, readErr := config.GetAs[string](SystemVersionKey)
prevVersion = strings.TrimSpace(prevVersion)
versionRecorded := readErr == nil && prevVersion != ""
// 版本未变化,无需备份。
if versionRecorded && prevVersion == versionID {
return
@@ -461,6 +450,8 @@ func doInitialize() error {
&models.User{},
&models.Client{},
&models.Log{},
&models.Clipboard{},
&models.LoadNotification{},
&models.OfflineNotification{},
&models.PingTask{},
&models.OidcProvider{},
+13
View File
@@ -0,0 +1,13 @@
package models
import "time"
type Clipboard struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement;unique"`
Text string `json:"text" gorm:"type:longtext"`
Name string `json:"name" gorm:"type:varchar(255)"`
Weight int `json:"weight" gorm:"type:int"`
Remark string `json:"remark" gorm:"type:text"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+12
View File
@@ -12,6 +12,18 @@ type OfflineNotification struct {
LastNotified *time.Time `json:"last_notified"` // 上次通知时间
}
// LoadNotification 定义了基于资源占用达标时间比的负载通知规则
type LoadNotification struct {
Id uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" gorm:"type:varchar(255)"`
Clients StringArray `json:"clients" gorm:"type:longtext"`
Metric string `json:"metric" gorm:"type:varchar(50);not null;default:'cpu'"` // 监控指标,如 cpu, ram, load
Threshold float32 `json:"threshold" gorm:"type:decimal(5,2);not null;default:80.00"` // 阈值百分比
Ratio float32 `json:"ratio" gorm:"type:decimal(5,2);not null;default:0.80"` // 达标时间比
Interval int `json:"interval" gorm:"type:int;not null;default:15"` // 监测间隔(分钟)
LastNotified *time.Time `json:"last_notified"` // 上次通知时间
}
// TrafficReportNotification 定义了流量定时报告的数据库模型
type TrafficReportNotification struct {
Client string `json:"client" gorm:"type:varchar(36);not null;index;unique;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
+2 -2
View File
@@ -13,7 +13,7 @@ import (
)
func TestTimeFieldsStoreAndSerializeAsUTCWithNanoseconds(t *testing.T) {
dsn := fmt.Sprintf("file:%s-%d?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"), time.Now().UnixNano())
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
NowFunc: func() time.Time { return time.Now().UTC() },
@@ -48,7 +48,7 @@ func TestTimeFieldsStoreAndSerializeAsUTCWithNanoseconds(t *testing.T) {
}
func TestNullableTimeFieldReadsAndSerializesNull(t *testing.T) {
dsn := fmt.Sprintf("file:%s-%d?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"), time.Now().UnixNano())
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("open database: %v", err)
+63
View File
@@ -0,0 +1,63 @@
package notification
import (
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/notifier"
"gorm.io/gorm"
)
func AddLoadNotification(clients []string, name string, metric string, threshold float32, ratio float32, interval int) (uint, error) {
db := dbcore.GetDBInstance()
notification := models.LoadNotification{
Clients: clients,
Name: name,
Metric: metric,
Threshold: threshold,
Ratio: ratio,
Interval: interval,
}
if err := db.Create(&notification).Error; err != nil {
return 0, err
}
return notification.Id, ReloadLoadNotificationSchedule()
}
func DeleteLoadNotification(id []uint) error {
db := dbcore.GetDBInstance()
result := db.Where("id IN ?", id).Delete(&models.LoadNotification{})
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return ReloadLoadNotificationSchedule()
}
func EditLoadNotification(notifications []*models.LoadNotification) error {
db := dbcore.GetDBInstance()
for _, notification := range notifications {
result := db.Model(&models.LoadNotification{}).Where("id = ?", notification.Id).Updates(notification)
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
}
return ReloadLoadNotificationSchedule()
}
func GetAllLoadNotifications() ([]models.LoadNotification, error) {
db := dbcore.GetDBInstance()
var notifications []models.LoadNotification
if err := db.Find(&notifications).Error; err != nil {
return nil, err
}
return notifications, nil
}
func ReloadLoadNotificationSchedule() error {
db := dbcore.GetDBInstance()
var loadNotifications []models.LoadNotification
if err := db.Find(&loadNotifications).Error; err != nil {
return err
}
return notifier.ReloadLoadNotificationSchedule(loadNotifications)
}
+5 -1
View File
@@ -20,5 +20,9 @@ func DeleteOidcConfigByName(name string) error {
}
func SaveOidcConfig(config *models.OidcProvider) error {
return dbcore.GetDBInstance().Save(config).Error
db := dbcore.GetDBInstance()
if err := db.Save(config).Error; err != nil {
return err
}
return nil
}
-63
View File
@@ -1,63 +0,0 @@
package records
import "github.com/komari-monitor/komari/database/models"
// LoadProjection is the field subset selected by a load_type request.
type LoadProjection struct {
Cpu *float32 `json:"cpu,omitempty"`
Gpu *float32 `json:"gpu,omitempty"`
Ram *int64 `json:"ram,omitempty"`
RamTotal *int64 `json:"ram_total,omitempty"`
Swap *int64 `json:"swap,omitempty"`
SwapTotal *int64 `json:"swap_total,omitempty"`
Load *float32 `json:"load,omitempty"`
Temp *float32 `json:"temp,omitempty"`
Disk *int64 `json:"disk,omitempty"`
DiskTotal *int64 `json:"disk_total,omitempty"`
NetIn *int64 `json:"net_in,omitempty"`
NetOut *int64 `json:"net_out,omitempty"`
NetTotalUp *int64 `json:"net_total_up,omitempty"`
NetTotalDown *int64 `json:"net_total_down,omitempty"`
Process *int `json:"process,omitempty"`
Connections *int `json:"connections,omitempty"`
ConnectionsUdp *int `json:"connections_udp,omitempty"`
}
// ProjectLoad returns the load_type projection for one record.
func ProjectLoad(r models.Record, loadType string) LoadProjection {
var p LoadProjection
switch loadType {
case "cpu":
p.Cpu = ptr(r.Cpu)
case "gpu":
p.Gpu = ptr(r.Gpu)
case "ram":
p.Ram = ptr(r.Ram)
p.RamTotal = ptr(r.RamTotal)
case "swap":
p.Swap = ptr(r.Swap)
p.SwapTotal = ptr(r.SwapTotal)
case "load":
p.Load = ptr(r.Load)
case "temp":
p.Temp = ptr(r.Temp)
case "disk":
p.Disk = ptr(r.Disk)
p.DiskTotal = ptr(r.DiskTotal)
case "network":
p.NetIn = ptr(r.NetIn)
p.NetOut = ptr(r.NetOut)
p.NetTotalUp = ptr(r.NetTotalUp)
p.NetTotalDown = ptr(r.NetTotalDown)
case "process":
p.Process = ptr(r.Process)
case "connections":
p.Connections = ptr(r.Connections)
p.ConnectionsUdp = ptr(r.ConnectionsUdp)
default:
p.Cpu = ptr(r.Cpu)
}
return p
}
func ptr[T any](v T) *T { return &v }
+9 -65
View File
@@ -4,8 +4,8 @@ import (
"context"
"time"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/internal/metricstore"
"github.com/komari-monitor/komari/database/models"
)
// 历史监控数据已完全迁移到 metric store(默认 SQLite ./data/metrics.db,或配置的
@@ -19,77 +19,21 @@ func DeleteAll() error {
return metricstore.DeleteAllRecords(context.Background())
}
// QueryGPURecords returns GPU records using the caller's cancellation context.
func QueryGPURecords(ctx context.Context, uuid string, start, end time.Time) ([]models.GPURecord, error) {
return metricstore.GetGPURecordsByClientAndTime(ctx, uuid, start, end)
}
// GetGPURecordsByClientAndTime is a compatibility-only wrapper without cancellation.
// New request handlers should use QueryGPURecords.
// GetGPURecordsByClientAndTime 获取 GPU 记录数据。
func GetGPURecordsByClientAndTime(uuid string, start, end time.Time) ([]models.GPURecord, error) {
return QueryGPURecords(context.Background(), uuid, start, end)
return metricstore.GetGPURecordsByClientAndTime(context.Background(), uuid, start, end)
}
// RecordQueryOptions narrows metric selection and the response point budget.
type RecordQueryOptions = metricstore.RecordQueryOptions
// LoadTypeMetricNames maps the RPC load_type to the stored metrics that back
// that field. An empty result means "all load metrics" (including temp and
// unknown values, which stay on the compatible full-query path).
func LoadTypeMetricNames(loadType string) []string {
var names []string
switch loadType {
case "cpu":
return []string{metricstore.MetricCPU}
case "gpu":
names = []string{metricstore.MetricGPU}
case "ram":
names = []string{metricstore.MetricRAM}
case "swap":
names = []string{metricstore.MetricSwap}
case "load":
names = []string{metricstore.MetricLoad}
case "disk":
names = []string{metricstore.MetricDisk}
case "network":
names = []string{metricstore.MetricNetIn, metricstore.MetricNetOut, metricstore.MetricNetTotalUp, metricstore.MetricNetTotalDown}
case "process":
names = []string{metricstore.MetricProcess}
case "connections":
names = []string{metricstore.MetricConnections, metricstore.MetricConnectionsUDP}
default:
return nil
}
// CPU is the common presence anchor: the previous implementation built one
// record per timestamp where any load metric existed. Including CPU keeps
// those timestamps (and zero values for a missing selected metric) without
// going back to all fifteen metrics.
return append(names, metricstore.MetricCPU)
func GetRecordsByClientAndTime(uuid string, start, end time.Time) ([]models.Record, error) {
return metricstore.GetRecordsByClientAndTime(context.Background(), uuid, start, end)
}
// QueryRecords returns one client's records, or all clients when uuid is empty.
// Request handlers should use this context-aware entry point.
func QueryRecords(ctx context.Context, uuid string, start, end time.Time, opts ...RecordQueryOptions) ([]models.Record, error) {
if uuid == "" {
return metricstore.GetRecordsByTime(ctx, start, end, opts...)
}
return metricstore.GetRecordsByClientAndTime(ctx, uuid, start, end, opts...)
}
// GetRecordsByClientAndTime is a compatibility-only wrapper without cancellation.
// New request handlers should use QueryRecords.
func GetRecordsByClientAndTime(uuid string, start, end time.Time, opts ...RecordQueryOptions) ([]models.Record, error) {
return metricstore.GetRecordsByClientAndTime(context.Background(), uuid, start, end, opts...)
}
// GetRecordMetricMaxByClientAndTime is a compatibility-only wrapper without cancellation.
// New request handlers should use metricstore.GetRecordMetricMaxByClientAndTime.
// GetRecordMetricMaxByClientAndTime 获取单项监控指标在各时间桶内的最大值。
func GetRecordMetricMaxByClientAndTime(uuid, metricName string, start, end time.Time) ([]models.Record, error) {
return metricstore.GetRecordMetricMaxByClientAndTime(context.Background(), uuid, metricName, start, end)
}
// GetRecordsByTime is a compatibility-only wrapper without cancellation.
// New request handlers should use QueryRecords with an empty uuid.
func GetRecordsByTime(start, end time.Time, opts ...RecordQueryOptions) ([]models.Record, error) {
return metricstore.GetRecordsByTime(context.Background(), start, end, opts...)
// GetRecordsByTime 获取所有客户端在时间范围内的记录。
func GetRecordsByTime(start, end time.Time) ([]models.Record, error) {
return metricstore.GetRecordsByTime(context.Background(), start, end)
}
+33 -5
View File
@@ -2,8 +2,8 @@ package tasks
import (
"context"
"slices"
"sort"
"time"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
@@ -28,8 +28,17 @@ func AddPingTask(clients []string, defaultOn bool, name string, target, task_typ
if err := tx.Create(&task).Error; err != nil {
return err
}
// Append by id to avoid races between concurrent create requests.
return tx.Model(&models.PingTask{}).Where("id = ?", task.Id).Update("weight", int(task.Id)).Error
result := tx.Model(&models.PingTask{}).Where("id = ?", task.Id).Update("weight", int(task.Id))
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
})
if err != nil {
return 0, err
@@ -129,8 +138,10 @@ func UpdatePingTaskOrder(order map[uint]int) error {
}
for _, id := range ids {
if err := tx.Model(&models.PingTask{}).Where("id = ?", id).Update("weight", order[id]).Error; err != nil {
return err
weight := order[id]
result := tx.Model(&models.PingTask{}).Where("id = ?", id).Update("weight", weight)
if result.Error != nil {
return result.Error
}
}
return nil
@@ -167,14 +178,27 @@ func ReloadPingSchedule() error {
// AddDefaultOnClientUUID 在新客户端注册后,把该 UUID 追加到所有 default_on=true 的任务的 clients 中(去重)。
func AddDefaultOnClientUUID(uuid string) error {
if uuid == "" {
return nil
}
db := dbcore.GetDBInstance()
var tasks []models.PingTask
if err := db.Where("all_clients = ?", true).Find(&tasks).Error; err != nil {
return err
}
if len(tasks) == 0 {
return nil
}
changed := false
for _, task := range tasks {
if slices.Contains(task.Clients, uuid) {
exists := false
for _, c := range task.Clients {
if c == uuid {
exists = true
break
}
}
if exists {
continue
}
next := append(models.StringArray{}, task.Clients...)
@@ -189,3 +213,7 @@ func AddDefaultOnClientUUID(uuid string) error {
}
return nil
}
func GetPingRecords(uuid string, taskId int, start, end time.Time) ([]models.PingRecord, error) {
return metricstore.GetPingRecords(context.Background(), uuid, taskId, start, end)
}
-180
View File
@@ -1,180 +0,0 @@
package tasks
import (
"math"
"sort"
"time"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/internal/sampling"
)
// PingClientSummary is the per-client aggregate shared by ping RPC adapters.
type PingClientSummary struct {
Total int
Loss int
Min int
Max int
LossPercent float64
}
// PingTaskSummary is the per-task aggregate shared by ping RPC adapters.
type PingTaskSummary struct {
Total int
Loss int
Valid int
Min int
Max int
Avg int
Latest int
P50 int
P99 int
LossPercent float64
P99P50Ratio float64
}
// PingSummary aggregates records once so legacy and public ping handlers only
// differ in response shape.
type PingSummary struct {
Clients map[string]PingClientSummary
Tasks map[uint]PingTaskSummary
}
type pingClientAccumulator struct {
total, loss, min, max int
}
type pingTaskAccumulator struct {
total, loss, min, max, sum, valid int
latest int
latestTime time.Time
latencies []int
}
// SummarizePingRecords computes client and task aggregates in one pass.
func SummarizePingRecords(recs []models.PingRecord) PingSummary {
clients := make(map[string]*pingClientAccumulator)
taskMap := make(map[uint]*pingTaskAccumulator)
for _, r := range recs {
cs := clients[r.Client]
if cs == nil {
cs = &pingClientAccumulator{}
clients[r.Client] = cs
}
cs.total++
ts := taskMap[r.TaskId]
if ts == nil {
ts = &pingTaskAccumulator{latest: -1}
taskMap[r.TaskId] = ts
}
ts.total++
if r.Value < 0 {
cs.loss++
ts.loss++
continue
}
if cs.min == 0 || r.Value < cs.min {
cs.min = r.Value
}
if r.Value > cs.max {
cs.max = r.Value
}
ts.valid++
ts.sum += r.Value
if ts.min == 0 || r.Value < ts.min {
ts.min = r.Value
}
if r.Value > ts.max {
ts.max = r.Value
}
if ts.latestTime.IsZero() || r.Time.After(ts.latestTime) {
ts.latestTime = r.Time
ts.latest = r.Value
}
ts.latencies = append(ts.latencies, r.Value)
}
out := PingSummary{Clients: make(map[string]PingClientSummary, len(clients)), Tasks: make(map[uint]PingTaskSummary, len(taskMap))}
for client, v := range clients {
loss := 0.0
if v.total > 0 {
loss = float64(v.loss) / float64(v.total) * 100
}
out.Clients[client] = PingClientSummary{Total: v.total, Loss: v.loss, Min: v.min, Max: v.max, LossPercent: loss}
}
for id, v := range taskMap {
loss := 0.0
if v.total > 0 {
loss = float64(v.loss) / float64(v.total) * 100
}
avg := 0
if v.valid > 0 {
avg = v.sum / v.valid
}
p50, p99 := pingPercentiles(v.latencies)
ratio := 0.0
if p50 > 0 && p99 >= p50 {
adjustedBase := math.Max(math.Min(float64(p50), 50.0), 10.0)
ratio = float64(p99-p50) / adjustedBase
}
out.Tasks[id] = PingTaskSummary{
Total: v.total, Loss: v.loss, Valid: v.valid, Min: v.min, Max: v.max,
Avg: avg, Latest: v.latest, P50: p50, P99: p99, LossPercent: loss, P99P50Ratio: ratio,
}
}
return out
}
// SamplePingRecordsByTask applies the legacy ping maxCount policy: allocate
// points proportionally by task, sample each task evenly and return ascending
// by time. Statistics must be computed from the unsampled records.
func SamplePingRecordsByTask(recs []models.PingRecord, maxCount int) []models.PingRecord {
if maxCount <= 0 || len(recs) <= maxCount {
return recs
}
groups := make(map[uint][]models.PingRecord)
for _, r := range recs {
groups[r.TaskId] = append(groups[r.TaskId], r)
}
meta := make([]sampling.Group[uint], 0, len(groups))
for id, points := range groups {
sort.Slice(points, func(i, j int) bool { return points[i].Time.Before(points[j].Time) })
groups[id] = points
meta = append(meta, sampling.Group[uint]{Key: id, Length: len(points)})
}
targets := sampling.AllocateTargets(meta, maxCount)
out := make([]models.PingRecord, 0, maxCount)
for id, points := range groups {
out = append(out, sampling.SampleEvenly(points, targets[id])...)
}
sort.Slice(out, func(i, j int) bool { return out[i].Time.Before(out[j].Time) })
return out
}
func pingPercentiles(values []int) (int, int) {
if len(values) == 0 {
return 0, 0
}
sort.Ints(values)
return pingPercentile(values, 0.50), pingPercentile(values, 0.99)
}
func pingPercentile(values []int, percentile float64) int {
if len(values) == 0 {
return 0
}
if percentile <= 0 {
return values[0]
}
if percentile >= 1 {
return values[len(values)-1]
}
pos := float64(len(values)-1) * percentile
lo := int(math.Floor(pos))
hi := int(math.Ceil(pos))
if lo == hi {
return values[lo]
}
frac := pos - float64(lo)
return int(math.Round(float64(values[lo]) + (float64(values[hi])-float64(values[lo]))*frac))
}
+4 -3
View File
@@ -9,6 +9,7 @@ import (
func CreateTask(taskId string, clients []string, command string) error {
db := dbcore.GetDBInstance()
// Create a new task in the database with clients as JSON array
task := models.Task{
TaskId: taskId,
Clients: models.StringArray(clients),
@@ -28,10 +29,10 @@ func CreateTask(taskId string, clients []string, command string) error {
CreatedAt: time.Now().UTC(),
})
}
if len(taskResults) == 0 {
return nil
if len(taskResults) > 0 {
return db.Create(&taskResults).Error
}
return db.Create(&taskResults).Error
return nil
}
func GetTaskByTaskId(taskId string) (*models.Task, error) {
var task models.Task
+8 -58
View File
@@ -1,7 +1,6 @@
package tasks
import (
"os"
"testing"
"time"
@@ -11,7 +10,8 @@ import (
)
func TestClearTaskResultsByTimeBeforeUsesUTCTimeValue(t *testing.T) {
resetTaskTables(t)
flags.DatabaseType = flags.DatabaseTypeSQLite
flags.DatabaseFile = "file:task_cleanup_time?mode=memory&cache=shared"
db := dbcore.GetDBInstance()
taskID := "task-cleanup-time"
@@ -42,7 +42,8 @@ func TestClearTaskResultsByTimeBeforeUsesUTCTimeValue(t *testing.T) {
}
func TestGetAllPingTasksOrdersByWeightThenID(t *testing.T) {
resetTaskTables(t)
flags.DatabaseType = flags.DatabaseTypeSQLite
flags.DatabaseFile = "file:ping_task_order?mode=memory&cache=shared"
db := dbcore.GetDBInstance()
tasks := []models.PingTask{
@@ -67,7 +68,8 @@ func TestGetAllPingTasksOrdersByWeightThenID(t *testing.T) {
}
func TestUpdatePingTaskOrderAllowsUnchangedWeights(t *testing.T) {
resetTaskTables(t)
flags.DatabaseType = flags.DatabaseTypeSQLite
flags.DatabaseFile = "file:ping_task_update_order?mode=memory&cache=shared"
db := dbcore.GetDBInstance()
items := []models.PingTask{
@@ -98,7 +100,8 @@ func TestUpdatePingTaskOrderAllowsUnchangedWeights(t *testing.T) {
}
func TestGetPingTasksByClientOrdersByWeightThenID(t *testing.T) {
resetTaskTables(t)
flags.DatabaseType = flags.DatabaseTypeSQLite
flags.DatabaseFile = "file:ping_task_client_order?mode=memory&cache=shared"
db := dbcore.GetDBInstance()
items := []models.PingTask{
@@ -115,56 +118,3 @@ func TestGetPingTasksByClientOrdersByWeightThenID(t *testing.T) {
t.Fatalf("ping task order = %#v, want ids [%d %d]", ordered, items[1].Id, items[0].Id)
}
}
// TestMain pins a single shared in-memory database. dbcore.Initialize runs
// once per process, so per-test DSN assignment would be ignored and leave the
// tests fighting over whichever DSN happened to win.
func TestMain(m *testing.M) {
flags.DatabaseType = flags.DatabaseTypeSQLite
flags.DatabaseFile = "file:komari_tasks_test?mode=memory&cache=shared"
db := dbcore.GetDBInstance()
if sqlDB, err := db.DB(); err == nil {
sqlDB.SetMaxOpenConns(1)
}
os.Exit(m.Run())
}
// resetTaskTables clears the shared in-memory tables so repeated runs and
// shuffled execution start from a clean slate.
func resetTaskTables(t *testing.T) {
t.Helper()
db := dbcore.GetDBInstance()
for _, model := range []any{&models.Task{}, &models.TaskResult{}, &models.PingTask{}} {
if err := db.Where("1 = 1").Delete(model).Error; err != nil {
t.Fatalf("reset %T: %v", model, err)
}
}
t.Cleanup(func() {
db := dbcore.GetDBInstance()
for _, model := range []any{&models.Task{}, &models.TaskResult{}, &models.PingTask{}} {
_ = db.Where("1 = 1").Delete(model).Error
}
})
}
+1
View File
@@ -98,6 +98,7 @@ func GetPublicInfo() (map[string]interface{}, error) {
"record_preserve_time": retention.MaxDays * 24,
"ping_record_preserve_time": retention.MaxDays * 24,
"private_site": cst.PrivateSite,
"visitor_audit_enabled": cst.VisitorAuditEnabled,
"theme": cst.Theme,
"theme_settings": tc_data,
}, nil
+10 -4
View File
@@ -3,7 +3,8 @@ module github.com/komari-monitor/komari
go 1.25.0
require (
github.com/Calcium-Ion/moejs v0.1.0-alpha.9
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf
github.com/gin-gonic/gin v1.10.0
github.com/go-sql-driver/mysql v1.10.0
github.com/google/uuid v1.6.0
@@ -15,13 +16,13 @@ require (
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pquerna/otp v1.5.0
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.12.1
github.com/stretchr/testify v1.11.1
golang.org/x/sys v0.33.0
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.26.1
)
require go.yaml.in/yaml/v3 v3.0.5 // indirect
require github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 // indirect
// Misuse of ServerConfig.PublicKeyCallback may cause authorization bypass in golang.org/x/crypto #1
// golang.org/x/crypto Vulnerable to Denial of Service (DoS) via Slow or Incomplete Key Exchange #3
@@ -29,7 +30,7 @@ require golang.org/x/crypto v0.39.0
// HTTP Proxy bypass using IPv6 Zone IDs in golang.org/x/net #2
// golang.org/x/net vulnerable to Cross-site Scripting #4
require golang.org/x/net v0.41.0
require golang.org/x/net v0.41.0 // indirect
require (
filippo.io/edwards25519 v1.2.0 // indirect
@@ -37,13 +38,17 @@ require (
github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -58,6 +63,7 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.8.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+21 -6
View File
@@ -1,7 +1,7 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/Calcium-Ion/moejs v0.1.0-alpha.9 h1:WY1ebChegtafhPjHSQx3Q+gfnrjqYwHewHuO9k+v7e0=
github.com/Calcium-Ion/moejs v0.1.0-alpha.9/go.mod h1:9YqHj36EV7pzV/vlEYSw49f3oggeJFdmqcjM9TmDpHs=
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
@@ -17,6 +17,14 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 h1:16iT9CBDOniJwFGPI41MbUDfEk74hFaKTqudrX8kenY=
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217/go.mod h1:eIb+f24U+eWQCIsj9D/ah+MD9UP+wdxuqzsdLD+mhGM=
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6 h1:6dE1TmjqkY6tehR4A67gDNhvDtuZ54ocu7ab4K9o540=
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf h1:gbmvliZnCut4NjaPSNOQlfqBoZ9C5Dpf72mHMMYhgVE=
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
@@ -31,6 +39,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
@@ -38,6 +48,8 @@ github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PU
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k=
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -89,6 +101,7 @@ github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTK
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
@@ -110,14 +123,14 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
@@ -138,6 +151,8 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-1
View File
@@ -1196,7 +1196,6 @@ After=network.target
[Service]
Type=simple
Environment="GODEBUG=disablethp=1"
ExecStart=${BINARY_PATH} server -l 0.0.0.0:${port}
WorkingDirectory=${DATA_DIR}
Restart=always
+3
View File
@@ -16,6 +16,7 @@ type Settings struct {
AutoDiscoveryKey string `json:"auto_discovery_key" default:""` // 自动发现密钥
ScriptDomain string `json:"script_domain" default:""` // 自定义脚本域名
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest" default:"false"` // 是否向访客页面发送 IP 地址,默认 false
VisitorAuditEnabled bool `json:"visitor_audit_enabled" default:"false"` // 是否允许公开访客事件写入审计日志,默认 false
SSRFProtectionEnabled bool `json:"ssrf_protection_enabled" default:"false"` // 是否启用 SSRF 防护,默认关闭
EulaAccepted bool `json:"eula_accepted" default:"false"`
BaseScriptsURLKey string `json:"base_scripts_url" default:""`
@@ -54,6 +55,7 @@ const (
AutoDiscoveryKeyKey = "auto_discovery_key"
ScriptDomainKey = "script_domain"
SendIpAddrToGuestKey = "send_ip_addr_to_guest"
VisitorAuditEnabledKey = "visitor_audit_enabled"
SSRFProtectionEnabledKey = "ssrf_protection_enabled"
EulaAcceptedKey = "eula_accepted"
BaseScriptsURLKey = "base_scripts_url"
@@ -73,6 +75,7 @@ const (
LoginNotificationKey = "login_notification"
TrafficLimitPercentageKey = "traffic_limit_percentage"
UpdatedAtKey = "updated_at"
XtermjsSettingsKey = "xtermjs_settings"
ThemeMarketSourcesKey = "theme_market_sources"
PluginMarketSourcesKey = "plugin_market_sources"
)
+7
View File
@@ -65,6 +65,9 @@ func targetFingerprint(cfg *MetricStoreConfig) string {
// autoMigrate 控制是否在 Open 时自动建表:正式初始化/热加载时为 true,
// 仅做连接测试时为 false(不写入 schema,避免对目标库产生副作用)。
func buildMetricConfig(cfg *MetricStoreConfig, autoMigrate bool) (metric.Config, error) {
if cfg == nil {
return metric.Config{}, fmt.Errorf("metric store config is nil")
}
driver := ResolveDriverFromConfig(cfg.Driver, cfg.DSN)
tablePrefix := cfg.TablePrefix
@@ -128,6 +131,10 @@ func defaultRollupPolicy() metric.RollupPolicy {
}
func rollupPolicyFromConfig(cfg *MetricStoreConfig) (metric.RollupPolicy, error) {
if cfg == nil {
return metric.RollupPolicy{}, fmt.Errorf("metric store config is nil")
}
minuteRetention := cfg.RollupMinuteRetentionMinutes
fiveMinuteRetention := cfg.RollupFiveMinuteRetentionMinutes
hourRetention := cfg.RollupHourRetentionHours
+116 -110
View File
@@ -10,32 +10,14 @@ import (
"github.com/komari-monitor/komari/pkg/metric"
)
// RecordQueryOptions narrows the record reconstruction work. MetricNames
// limits which stored load metrics are queried; empty means all load metrics.
// MaxPoints is a total response-point budget; it is applied before aggregation,
// so an all-client query does not materialize thousands of points per client
// only to sample them afterwards.
type RecordQueryOptions struct {
MetricNames []string
MaxPoints int
}
// GetRecordsByClientAndTime 从 metric store 查询记录并重构为 models.Record。
// opts 为空表示兼容旧行为;有值时用于指标选择和点数预算。
func GetRecordsByClientAndTime(ctx context.Context, clientUUID string, start, end time.Time, opts ...RecordQueryOptions) ([]models.Record, error) {
// GetRecordsByClientAndTime 从 metric store 查询记录并重构为 models.Record
func GetRecordsByClientAndTime(ctx context.Context, clientUUID string, start, end time.Time) ([]models.Record, error) {
s := GetStore()
if s == nil {
return nil, fmt.Errorf("metric store not enabled")
}
return getRecordsFromSeries(ctx, s, clientUUID, start, end, false, firstRecordOptions(opts))
}
func firstRecordOptions(opts []RecordQueryOptions) RecordQueryOptions {
if len(opts) == 0 {
return RecordQueryOptions{}
}
return opts[0]
return getRecordsByClientAndTimeFromSeries(ctx, s, clientUUID, start, end)
}
// GetRecordMetricMaxByClientAndTime 查询单项监控指标在各时间桶内的最大值。
@@ -48,17 +30,28 @@ func GetRecordMetricMaxByClientAndTime(ctx context.Context, clientUUID, recordMe
return getRecordMetricMaxByClientAndTimeFromSeries(ctx, s, clientUUID, recordMetric, start, end)
}
// GetRecordsByTime 从 metric store 查询所有客户端在时间范围内的记录。
// opts 为空表示兼容旧行为;有值时用于指标选择和总点数预算。
func GetRecordsByTime(ctx context.Context, start, end time.Time, opts ...RecordQueryOptions) ([]models.Record, error) {
// GetRecordsByTime 从 metric store 查询所有客户端在时间范围内的记录
func GetRecordsByTime(ctx context.Context, start, end time.Time) ([]models.Record, error) {
s := GetStore()
if s == nil {
return nil, fmt.Errorf("metric store not enabled")
}
// Query all entities together. Entity-only grouping retains the old per-client
// aggregation across tags without repeating the tier scan for every client.
return getRecordsFromSeries(ctx, s, "", start, end, true, firstRecordOptions(opts))
interval := recordSeriesInterval(s, start, end, time.Now().UTC())
entityIDs, err := listRecordEntityIDs(ctx, s, start, end, interval)
if err != nil {
return nil, err
}
var records []models.Record
for _, entityID := range entityIDs {
items, err := getRecordsByClientAndTimeFromSeries(ctx, s, entityID, start, end)
if err != nil {
return nil, err
}
records = append(records, items...)
}
sortRecords(records)
return records, nil
}
type recordSeriesKey struct {
@@ -66,58 +59,25 @@ type recordSeriesKey struct {
ts int64
}
func getRecordsFromSeries(ctx context.Context, s *metric.Store, clientUUID string, start, end time.Time, allEntities bool, opts RecordQueryOptions) ([]models.Record, error) {
metricNames := opts.MetricNames
if len(metricNames) == 0 {
metricNames = loadRecordMetricNames
}
specs := make([]MetricSpec, 0, len(metricNames))
for _, name := range metricNames {
specs = append(specs, MetricSpec{Name: name, Aggregations: []metric.Aggregation{recordMetricAggregation(name)}})
}
entities := EntityScope{All: allEntities}
grouping := GroupMerged
if allEntities {
grouping = GroupEntity
} else {
entities.IDs = []string{clientUUID}
}
rounding := RoundFloor
if opts.MaxPoints > 0 {
rounding = RoundCeil
}
loaded, err := QueryMetrics(ctx, MetricsQuery{
Store: s, Mode: QuerySeries, Source: SourceRollup,
OmitTags: true, OmitLabels: true, CompactDefinitions: true,
Entities: entities, Specs: specs, Start: start, End: end,
Order: metric.OrderAsc, Grouping: grouping,
Budget: PointBudget{PerSeries: 500, Total: opts.MaxPoints},
Rounding: rounding, AllowMissing: true,
})
if err != nil {
return nil, fmt.Errorf("failed to query metrics: %w", err)
}
return assembleRecordsFromSeries(loaded.Values, clientUUID), nil
}
func getRecordsByClientAndTimeFromSeries(ctx context.Context, s *metric.Store, clientUUID string, start, end time.Time) ([]models.Record, error) {
now := time.Now().UTC()
interval := recordSeriesInterval(s, start, end, now)
recordMap := make(map[recordSeriesKey]*models.Record)
// assembleRecordsFromSeries unions metric buckets without allocating a record per key.
func assembleRecordsFromSeries(values map[string]map[metric.Aggregation][]metric.AggregatePoint, clientUUID string) []models.Record {
// Aligned metrics normally share buckets: the largest selected metric is a
// conservative capacity estimate, not the sum of the metric fanout. Missing
// buckets can grow the union beyond it (and duplicate seconds can shrink it).
capacity := 0
for name, aggregations := range values {
if n := len(aggregations[recordMetricAggregation(name)]); n > capacity {
capacity = n
}
}
records := make([]models.Record, 0, capacity)
recordIndex := make(map[recordSeriesKey]int, capacity)
for metricName, aggMap := range values {
agg := recordMetricAggregation(metricName)
points, ok := aggMap[agg]
if !ok {
continue
for _, metricName := range loadRecordMetricNames {
points, err := s.Series(ctx, metric.AggregateQuery{
Query: metric.Query{
MetricName: metricName,
EntityID: clientUUID,
Start: start,
End: end,
Order: metric.OrderAsc,
},
Aggregation: recordMetricAggregation(metricName),
Interval: interval,
}, now)
if err != nil {
return nil, fmt.Errorf("failed to query metric %s: %w", metricName, err)
}
for _, point := range points {
entityID := point.EntityID
@@ -125,22 +85,22 @@ func assembleRecordsFromSeries(values map[string]map[metric.Aggregation][]metric
entityID = clientUUID
}
key := recordSeriesKey{client: entityID, ts: point.Bucket.Unix()}
index, exists := recordIndex[key]
if !exists {
index = len(records)
recordIndex[key] = index
records = append(records, models.Record{
if recordMap[key] == nil {
recordMap[key] = &models.Record{
Client: entityID,
Time: point.Bucket.UTC(),
})
}
}
// Resolve the address after append: the backing array may have moved.
applyRecordMetricValue(&records[index], metricName, point.Value)
applyRecordMetricValue(recordMap[key], metricName, point.Value)
}
}
records := make([]models.Record, 0, len(recordMap))
for _, rec := range recordMap {
records = append(records, *rec)
}
sortRecords(records)
return records
return records, nil
}
func getRecordMetricMaxByClientAndTimeFromSeries(ctx context.Context, s *metric.Store, clientUUID, recordMetric string, start, end time.Time) ([]models.Record, error) {
@@ -149,19 +109,22 @@ func getRecordMetricMaxByClientAndTimeFromSeries(ctx context.Context, s *metric.
return nil, fmt.Errorf("unsupported record metric %q", recordMetric)
}
loaded, err := QueryMetrics(ctx, MetricsQuery{
Store: s, Mode: QuerySeries, Source: SourceRollup,
OmitTags: true, OmitLabels: true, CompactDefinitions: true,
Entities: EntityScope{IDs: []string{clientUUID}},
Specs: []MetricSpec{{Name: metricName, Aggregations: []metric.Aggregation{metric.AggMax}}},
Start: start, End: end, Order: metric.OrderAsc, Grouping: GroupMerged,
Budget: PointBudget{PerSeries: 500}, Rounding: RoundFloor, AllowMissing: true,
})
now := time.Now().UTC()
points, err := s.Series(ctx, metric.AggregateQuery{
Query: metric.Query{
MetricName: metricName,
EntityID: clientUUID,
Start: start,
End: end,
Order: metric.OrderAsc,
},
Aggregation: metric.AggMax,
Interval: recordSeriesInterval(s, start, end, now),
}, now)
if err != nil {
return nil, fmt.Errorf("failed to query metric %s: %w", metricName, err)
}
points := loaded.Values[metricName][metric.AggMax]
records := make([]models.Record, 0, len(points))
for _, point := range points {
entityID := point.EntityID
@@ -186,6 +149,49 @@ func recordMetricAggregation(metricName string) metric.Aggregation {
}
}
func recordSeriesInterval(s *metric.Store, start, end, now time.Time) time.Duration {
interval := recordDownsampleInterval(end.Sub(start), 500)
return s.CompatibleSeriesInterval(start, now, interval)
}
func recordDownsampleInterval(rangeDuration time.Duration, maxPoints int) time.Duration {
if maxPoints <= 0 {
maxPoints = 500
}
nanos := rangeDuration.Nanoseconds()
if nanos <= 0 {
return time.Second
}
interval := time.Duration((nanos + int64(maxPoints) - 1) / int64(maxPoints))
if interval < time.Second {
return time.Second
}
return metric.FloorStandardInterval(interval)
}
func listRecordEntityIDs(ctx context.Context, s *metric.Store, start, end time.Time, interval time.Duration) ([]string, error) {
seen := make(map[string]struct{})
for _, metricName := range loadRecordMetricNames {
ids, err := s.EntityIDs(ctx, metric.Query{
MetricName: metricName,
Start: start.Add(-interval),
End: end,
})
if err != nil {
return nil, err
}
for _, id := range ids {
seen[id] = struct{}{}
}
}
out := make([]string, 0, len(seen))
for id := range seen {
out = append(out, id)
}
sort.Strings(out)
return out, nil
}
func applyRecordMetricValue(rec *models.Record, metricName string, value float64) {
switch metricName {
case MetricCPU:
@@ -247,21 +253,17 @@ func GetGPURecordsByClientAndTime(ctx context.Context, clientUUID string, start,
}
recordMap := make(map[gpuKey]*models.GPURecord)
specs := make([]MetricSpec, 0, len(gpuMetrics))
for _, name := range gpuMetrics {
specs = append(specs, MetricSpec{Name: name})
}
loaded, err := QueryMetrics(ctx, MetricsQuery{
Store: s, Mode: QueryPoints, Source: SourceRaw,
OmitLabels: true, CompactDefinitions: true,
Entities: EntityScope{IDs: []string{clientUUID}}, Specs: specs,
Start: start, End: end, Order: metric.OrderAsc, AllowMissing: true,
})
if err != nil {
return nil, fmt.Errorf("failed to query GPU metrics: %w", err)
}
for _, metricName := range gpuMetrics {
points := loaded.Raw[metricName]
points, err := s.Query(ctx, metric.Query{
MetricName: metricName,
EntityID: clientUUID,
Start: start,
End: end,
Order: metric.OrderAsc,
})
if err != nil {
continue // GPU 数据可能不存在
}
for _, p := range points {
deviceIndex := 0
@@ -305,3 +307,7 @@ func GetGPURecordsByClientAndTime(ctx context.Context, clientUUID string, start,
return records, nil
}
// GetPingRecords 从 metric store 查询兼容旧接口的 ping 记录。
//
// 旧接口过去直接读取 ping_records。这里使用与 queryMetrics 相同的 Series
+31 -11
View File
@@ -62,33 +62,39 @@ func writePingRecords(ctx context.Context, records []models.PingRecord) error {
return s.WriteBatch(ctx, points)
}
// GetPingRecords returns legacy-shaped ping records, newest first.
func GetPingRecords(ctx context.Context, clientUUID string, taskID int, start, end time.Time) ([]models.PingRecord, error) {
s := GetStore()
if s == nil {
return nil, fmt.Errorf("metric store not enabled")
}
query := MetricsQuery{
Store: s, Mode: QuerySeries, Source: SourceRollup,
CompactDefinitions: true,
Entities: EntityScope{All: clientUUID == ""},
Specs: []MetricSpec{{Name: MetricPingLatency, Aggregations: []metric.Aggregation{metric.AggLast}}},
Start: start, End: end, Order: metric.OrderAsc, Grouping: GroupSeries,
Budget: PointBudget{PerSeries: 4000}, Rounding: RoundFloor, AllowMissing: true,
query := metric.Query{
MetricName: MetricPingLatency,
Start: start,
End: end,
Order: metric.OrderAsc,
}
if clientUUID != "" {
query.Entities.IDs = []string{clientUUID}
query.EntityID = clientUUID
}
if taskID >= 0 {
query.Tags = map[string]string{"task_id": fmt.Sprintf("%d", taskID)}
}
loaded, err := QueryMetrics(ctx, query)
interval := pingQueryInterval(end.Sub(start), 4000)
interval = s.CompatibleSeriesInterval(start, time.Now().UTC(), interval)
points, err := s.Series(ctx, metric.AggregateQuery{
Query: query,
Aggregation: metric.AggLast,
Interval: interval,
PreserveSeries: true,
}, time.Now().UTC())
if err != nil {
return nil, err
}
points := loaded.Values[MetricPingLatency][metric.AggLast]
records := make([]models.PingRecord, 0, len(points))
for _, p := range points {
taskIDVal := uint(0)
@@ -111,3 +117,17 @@ func GetPingRecords(ctx context.Context, clientUUID string, taskID int, start, e
return records, nil
}
func pingQueryInterval(rangeDuration time.Duration, maxPoints int) time.Duration {
if maxPoints <= 0 {
maxPoints = 4000
}
if rangeDuration <= 0 {
return time.Second
}
interval := time.Duration((rangeDuration.Nanoseconds() + int64(maxPoints) - 1) / int64(maxPoints))
if interval < time.Second {
return time.Second
}
return metric.FloorStandardInterval(interval)
}
-766
View File
@@ -1,766 +0,0 @@
package metricstore
import (
"context"
"fmt"
"sort"
"strings"
"time"
"github.com/komari-monitor/komari/pkg/metric"
)
// QueryMode selects exact samples, adaptive series, or exclusive-boundary lookups.
type QueryMode string
const (
QueryPoints QueryMode = "points"
QuerySeries QueryMode = "series"
QueryLatestBefore QueryMode = "latest_before"
)
type QuerySource string
const (
SourceAuto QuerySource = "auto"
SourceRaw QuerySource = "raw"
SourceRollup QuerySource = "rollup"
)
type QueryGrouping string
const (
GroupMerged QueryGrouping = "merged"
GroupEntity QueryGrouping = "entity"
GroupSeries QueryGrouping = "series"
)
type IntervalRounding string
const (
RoundFloor IntervalRounding = "floor"
RoundCeil IntervalRounding = "ceil"
)
// EntityScope deliberately distinguishes an empty authorized set from all entities.
// All and IDs are mutually exclusive. IDs are deduplicated without modifying callers.
type EntityScope struct {
All bool
IDs []string
}
type MetricSpec struct {
Name string
Aggregations []metric.Aggregation
// Positive intervals are explicit and are preserved, even for historical data.
Interval time.Duration
// Zero inherits Budget.PerSeries, then 500. -1 disables adaptive downsampling:
// a zero Interval then selects the finest compatible tier, not a whole-window bucket.
MaxPoints int
}
// PointBudget controls adaptive interval planning, not a raw-row SQL LIMIT.
// Total divides the point target across entities (metrics may be columns of one
// legacy record); it is not a hard cap across metrics, aggregations or tag variants.
// Raw queries and explicit intervals retain their exact historical semantics.
// Response adapters can impose their existing final sampling policy separately.
type PointBudget struct {
Total int
PerSeries int
}
type FillPolicy struct {
Gaps bool
PingNegativeAsNull bool
}
// QueryProjection controls retained result representations. The default keeps all
// source data for compatibility; chart requires Fill and retains only Filled.
type QueryProjection string
const (
ProjectionFull QueryProjection = "full"
ProjectionChart QueryProjection = "chart"
)
type MetricsQuery struct {
// Output omissions are opt-in; filtering and aggregation identity are unchanged.
OmitTags bool
OmitLabels bool
CompactDefinitions bool
Projection QueryProjection
Store *metric.Store
Mode QueryMode
Source QuerySource
Entities EntityScope
Specs []MetricSpec
Start, End time.Time
Tags map[string]string
Order metric.Order
Grouping QueryGrouping
Budget PointBudget
Rounding IntervalRounding
Fill FillPolicy
AllowMissing bool
Now time.Time
}
type MetricsResult struct {
Definitions map[string]metric.Definition
Raw map[string][]metric.Point
Values map[string]map[metric.Aggregation][]metric.AggregatePoint
Intervals map[string]time.Duration
IsRaw bool
Filled []MetricSeries
// Latest is keyed by metric name, then entity; missing samples are absent.
Latest map[string]map[string]metric.Point
}
type MetricSeries struct {
MetricName, EntityID string
Aggregation metric.Aggregation
Tags map[string]string
Interval time.Duration
Points []MetricValue
}
type MetricValue struct {
Time time.Time
Value *float64
Count int
Tags, Labels map[string]string
}
// DownsampleInterval returns a standard interval that keeps rangeDuration
// within about maxPoints buckets, never finer than one second. roundUp is used
// by point budgets: flooring can exceed the budget, while compatibility queries
// preserve the historical floor behavior.
func DownsampleInterval(rangeDuration time.Duration, maxPoints int, roundUp bool) time.Duration {
if maxPoints < 1 {
maxPoints = 1
}
points := time.Duration(maxPoints)
interval := (rangeDuration + points - 1) / points
if interval < time.Second {
return time.Second
}
if roundUp {
return metric.CeilStandardInterval(interval)
}
return metric.FloorStandardInterval(interval)
}
// QueryMetrics is the shared query policy boundary. Raw and aggregate slices are
// transferred directly from batch primitives; nullable chart data is allocated only
// when explicitly requested by Fill. All work uses ctx and one captured clock.
func QueryMetrics(ctx context.Context, req MetricsQuery) (MetricsResult, error) {
if ctx == nil {
return MetricsResult{}, fmt.Errorf("%w: context is required", metric.ErrInvalidArgument)
}
if err := ctx.Err(); err != nil {
return MetricsResult{}, err
}
result := MetricsResult{Definitions: map[string]metric.Definition{}, Raw: map[string][]metric.Point{}, Values: map[string]map[metric.Aggregation][]metric.AggregatePoint{}, Intervals: map[string]time.Duration{}, Latest: map[string]map[string]metric.Point{}}
if req.Mode == "" {
req.Mode = QuerySeries
}
if req.Source == "" {
req.Source = SourceAuto
}
if req.Grouping == "" {
req.Grouping = GroupMerged
}
if req.Order == "" {
req.Order = metric.OrderAsc
}
if req.Rounding == "" {
req.Rounding = RoundFloor
}
if err := validateMetricsQuery(req); err != nil {
return result, err
}
now := req.Now
if now.IsZero() {
now = time.Now()
}
now = now.UTC()
req.Start, req.End = req.Start.UTC(), req.End.UTC()
req.Entities.IDs = uniqueQueryIDs(req.Entities.IDs)
// Never let the primitive's empty entity filter broaden an empty authorized set.
s := req.Store
if s == nil {
s = GetStore()
}
if s == nil {
return result, fmt.Errorf("metric store not enabled")
}
names := make([]string, 0, len(req.Specs))
for _, spec := range req.Specs {
names = append(names, spec.Name)
}
loadDefinitions := s.GetMetrics
if req.CompactDefinitions {
loadDefinitions = s.GetMetricsCompact
}
if !req.Entities.All && len(req.Entities.IDs) == 0 {
definitions, err := loadDefinitions(ctx, names)
if err != nil {
return result, err
}
result.Definitions = definitions
result.IsRaw = req.Source == SourceRaw || (req.Source == SourceAuto && (req.Mode == QueryPoints || UsesRawWindow(req.Start, req.End, now)))
if err := checkQueryDefinitions(names, definitions, req.AllowMissing); err != nil {
return result, err
}
return result, ctx.Err()
}
if req.Mode == QueryLatestBefore {
definitions, err := loadDefinitions(ctx, names)
if err != nil {
return result, err
}
result.Definitions = definitions
if err := checkQueryDefinitions(names, definitions, req.AllowMissing); err != nil {
return result, err
}
for _, name := range names {
if _, ok := definitions[name]; !ok {
continue
}
ids := req.Entities.IDs
for _, id := range ids {
if err := ctx.Err(); err != nil {
return result, err
}
point, found, err := s.LatestBefore(ctx, name, id, req.End)
if err != nil {
return result, err
}
if found {
if result.Latest[name] == nil {
result.Latest[name] = map[string]metric.Point{}
}
result.Latest[name][id] = point
}
}
}
return result, ctx.Err()
}
result.IsRaw = req.Source == SourceRaw || (req.Source == SourceAuto && (req.Mode == QueryPoints || UsesRawWindow(req.Start, req.End, now)))
if result.IsRaw {
definitions, err := loadDefinitions(ctx, names)
if err != nil {
return result, err
}
result.Definitions = definitions
if err := checkQueryDefinitions(names, definitions, req.AllowMissing); err != nil {
return result, err
}
known := make([]string, 0, len(names))
for _, name := range names {
if _, ok := definitions[name]; ok {
known = append(known, name)
result.Intervals[name] = 0
}
}
if len(known) > 0 {
result.Raw, err = s.QueryBatch(ctx, metric.BatchQuery{MetricNames: known, EntityIDs: req.Entities.IDs, Start: req.Start, End: req.End, Tags: req.Tags, Order: req.Order, OmitTags: req.OmitTags, OmitLabels: req.OmitLabels, IncludeSeriesIdentity: req.Fill.Gaps || req.Fill.PingNegativeAsNull})
if err != nil {
return result, err
}
}
} else {
entityCount := len(req.Entities.IDs)
if needsTotalEntityCount(req) {
if req.Entities.All {
count, err := s.CountEntities(ctx, names)
if err != nil {
return result, err
}
entityCount = count
}
// Explicit IDs are a conservative dictionary-count upper bound; never
// scan every metric/tier simply to plan a response budget.
}
if entityCount < 1 {
entityCount = 1
}
specs := make([]metric.BatchSeriesSpec, 0, len(req.Specs))
for _, spec := range req.Specs {
interval := spec.Interval
if interval == 0 {
points := spec.MaxPoints
if points == 0 {
points = req.Budget.PerSeries
}
if points == 0 {
points = 500
}
if points == 500 && spec.MaxPoints == 0 && req.Budget.PerSeries == 0 && req.Budget.Total == -1 {
points = -1
}
interval = time.Second
if points != -1 {
if req.Budget.Total > 0 {
budget := req.Budget.Total / entityCount
if budget < 1 {
budget = 1
}
if budget < points {
points = budget
}
}
interval = DownsampleInterval(req.End.Sub(req.Start), points, req.Rounding == RoundCeil)
}
interval = s.CompatibleSeriesInterval(req.Start, now, interval)
if interval <= 0 {
interval = time.Second
}
}
aggregations := spec.Aggregations
if len(aggregations) == 0 {
aggregations = []metric.Aggregation{metric.AggAvg}
}
specs = append(specs, metric.BatchSeriesSpec{MetricName: spec.Name, Aggregations: aggregations, Interval: interval, PreserveSeries: req.Grouping == GroupSeries, GroupByEntity: req.Grouping == GroupEntity})
result.Intervals[spec.Name] = interval
}
batch, err := s.SeriesBatch(ctx, metric.BatchSeriesQuery{Specs: specs, EntityIDs: req.Entities.IDs, Start: req.Start, End: req.End, Tags: req.Tags, Order: req.Order, OmitTags: req.OmitTags, CompactDefinitions: req.CompactDefinitions, IncludeSeriesIdentity: req.Fill.Gaps || req.Fill.PingNegativeAsNull}, now)
if err != nil {
return result, err
}
result.Definitions, result.Values = batch.Definitions, batch.Values
// SeriesBatch already loads metadata. Validate its result rather than issue a
// second metadata query; unknown metrics otherwise look like empty series.
if err := checkQueryDefinitions(names, batch.Definitions, req.AllowMissing); err != nil {
return result, err
}
for _, name := range names {
if _, ok := batch.Definitions[name]; !ok {
delete(result.Intervals, name)
delete(result.Values, name)
}
}
}
if req.Fill.Gaps || req.Fill.PingNegativeAsNull {
filled, err := buildQuerySeriesOwned(ctx, req, result, req.Projection == ProjectionChart)
if err != nil {
return result, err
}
result.Filled = filled
if req.Projection == ProjectionChart {
result.Raw, result.Values = nil, nil
}
}
return result, ctx.Err()
}
// needsTotalEntityCount excludes explicit intervals and unlimited specs: neither
// consumes the total adaptive budget. Keep its precedence aligned with planning.
func needsTotalEntityCount(req MetricsQuery) bool {
if req.Budget.Total <= 0 {
return false
}
for _, spec := range req.Specs {
if spec.Interval != 0 {
continue
}
points := spec.MaxPoints
if points == 0 {
points = req.Budget.PerSeries
}
if points != -1 {
return true
}
}
return false
}
func validateMetricsQuery(req MetricsQuery) error {
invalid := func(message string) error { return fmt.Errorf("%w: %s", metric.ErrInvalidArgument, message) }
if req.OmitTags && (req.Fill.Gaps || req.Fill.PingNegativeAsNull) {
return invalid("omitting tags with filled output is unsupported: series identity must be preserved")
}
if req.Mode == QueryLatestBefore && (req.OmitTags || req.OmitLabels) {
return invalid("latest-before output omissions are unsupported")
}
switch req.Mode {
case QueryPoints, QuerySeries, QueryLatestBefore:
default:
return invalid("unsupported query mode")
}
switch req.Source {
case SourceAuto, SourceRaw, SourceRollup:
default:
return invalid("unsupported query source")
}
switch req.Grouping {
case GroupMerged, GroupEntity, GroupSeries:
default:
return invalid("unsupported query grouping")
}
switch req.Projection {
case "", ProjectionFull:
case ProjectionChart:
if req.Mode == QueryLatestBefore || (!req.Fill.Gaps && !req.Fill.PingNegativeAsNull) {
return invalid("chart projection requires fill and does not support latest-before")
}
default:
return invalid("unsupported query projection")
}
switch req.Rounding {
case RoundFloor, RoundCeil:
default:
return invalid("unsupported interval rounding")
}
if req.Order != metric.OrderAsc && req.Order != metric.OrderDesc {
return invalid("unsupported query order")
}
if req.Entities.All && len(req.Entities.IDs) > 0 {
return invalid("all entities and explicit IDs are mutually exclusive")
}
for _, id := range req.Entities.IDs {
if strings.TrimSpace(id) == "" {
return invalid("entity ID cannot be empty")
}
}
if req.End.IsZero() {
return invalid("end time is required")
}
if req.Mode != QueryLatestBefore && (req.Start.IsZero() || req.End.Before(req.Start)) {
return invalid("valid start and end times are required")
}
if req.Mode == QueryLatestBefore && (len(req.Tags) > 0 || req.Source != SourceAuto) {
return invalid("latest-before requires auto source and does not support tag filters")
}
if req.Mode == QueryPoints && req.Source == SourceRollup {
return invalid("points mode requires raw or auto source")
}
if req.Mode == QueryLatestBefore && req.Entities.All {
return invalid("latest-before requires explicit entity IDs")
}
if req.Budget.Total < -1 || req.Budget.PerSeries < -1 {
return invalid("invalid point budget")
}
if len(req.Specs) == 0 {
return invalid("at least one metric specification is required")
}
seen := map[string]bool{}
for _, spec := range req.Specs {
if strings.TrimSpace(spec.Name) == "" || seen[spec.Name] {
return invalid("metric names must be nonempty and unique")
}
seen[spec.Name] = true
if spec.Interval < 0 || spec.MaxPoints < -1 {
return invalid("invalid metric interval or point budget")
}
for _, agg := range spec.Aggregations {
// Reuse primitive aggregation validation even when the selected source is raw.
if err := (metric.AggregateQuery{Query: metric.Query{MetricName: spec.Name, Start: req.End, End: req.End}, Aggregation: agg, Interval: time.Second}).Validate(); err != nil {
return err
}
}
}
return nil
}
// UsesRawWindow selects the public adaptive raw window; its end boundary is strict.
func UsesRawWindow(start, end, now time.Time) bool {
return end.Sub(start) <= 10*time.Minute && end.After(now.Add(-10*time.Minute))
}
func uniqueQueryIDs(ids []string) []string {
out := make([]string, 0, len(ids))
seen := map[string]bool{}
for _, id := range ids {
if !seen[id] {
out = append(out, id)
seen[id] = true
}
}
return out
}
func checkQueryDefinitions(names []string, definitions map[string]metric.Definition, allowMissing bool) error {
if !allowMissing {
for _, name := range names {
if _, ok := definitions[name]; !ok {
return fmt.Errorf("%w: metric %q", metric.ErrNotFound, name)
}
}
}
return nil
}
// querySeriesKey consumes storage's immutable, collision-free output identity.
// Manually constructed points (and low-level queries without the opt-in) retain
// the old canonical fallback. Neither grouping nor ordering uses a hash.
func querySeriesKey(entity string, tags map[string]string, identity *metric.SeriesIdentity) string {
if key := identity.Key(); key != "" {
return key
}
return metric.NewSeriesIdentity(entity, tags).Key()
}
func buildQuerySeries(ctx context.Context, req MetricsQuery, result MetricsResult) ([]MetricSeries, error) {
return buildQuerySeriesOwned(ctx, req, result, false)
}
// detach is only valid for QueryMetrics-owned source maps. Release each source
// array after conversion, before allocating gaps or processing the next metric.
func buildQuerySeriesOwned(ctx context.Context, req MetricsQuery, result MetricsResult, detach bool) ([]MetricSeries, error) {
out := []MetricSeries{}
for _, spec := range req.Specs {
if err := ctx.Err(); err != nil {
return nil, err
}
if _, ok := result.Definitions[spec.Name]; !ok {
continue
}
aggregations := spec.Aggregations
if result.IsRaw {
aggregations = []metric.Aggregation{""}
} else if len(aggregations) == 0 {
aggregations = []metric.Aggregation{metric.AggAvg}
}
for aggIndex, agg := range aggregations {
if err := ctx.Err(); err != nil {
return nil, err
}
var seenEntities map[string]bool
if len(req.Entities.IDs) > 0 && (result.IsRaw || req.Grouping != GroupMerged) {
seenEntities = make(map[string]bool, len(req.Entities.IDs))
}
groups := map[string]*MetricSeries{}
add := func(entity string, tags map[string]string, identity *metric.SeriesIdentity, value MetricValue) {
// Exact samples always preserve identity; aggregate identity is determined by
// the requested primitive grouping and must not be collapsed again here.
key := querySeriesKey(entity, tags, identity)
series := groups[key]
if series == nil {
series = &MetricSeries{MetricName: spec.Name, EntityID: entity, Aggregation: agg, Tags: tags, Interval: result.Intervals[spec.Name]}
groups[key] = series
if seenEntities != nil {
seenEntities[entity] = true
}
}
if req.Fill.PingNegativeAsNull && (spec.Name == MetricPingLatency || spec.Name == MetricPingLoss) && value.Value != nil && *value.Value == -1 {
value.Value = nil
}
series.Points = append(series.Points, value)
}
if result.IsRaw {
for i, p := range result.Raw[spec.Name] {
if i%256 == 0 {
if err := ctx.Err(); err != nil {
return nil, err
}
}
v := p.Value
add(p.EntityID, p.Tags, p.SeriesIdentity, MetricValue{Time: p.Timestamp, Value: &v, Count: 1, Tags: p.Tags, Labels: p.Labels})
}
} else {
for i, p := range result.Values[spec.Name][agg] {
if i%256 == 0 {
if err := ctx.Err(); err != nil {
return nil, err
}
}
v := p.Value
add(p.EntityID, p.Tags, p.SeriesIdentity, MetricValue{Time: p.Bucket, Value: &v, Count: p.Count, Tags: p.Tags})
}
}
if detach {
if result.IsRaw {
delete(result.Raw, spec.Name)
} else {
// Duplicate aggregation specs historically produce duplicate series.
needed := false
for _, next := range aggregations[aggIndex+1:] {
needed = needed || next == agg
}
if !needed {
delete(result.Values[spec.Name], agg)
}
}
}
// Seed missing entities directly; do not scan all groups for each ID or
// allocate a placeholder point only to discard it.
seed := func(id string) {
groups[querySeriesKey(id, req.Tags, nil)] = &MetricSeries{MetricName: spec.Name, EntityID: id, Aggregation: agg, Tags: req.Tags, Interval: result.Intervals[spec.Name]}
if seenEntities != nil {
seenEntities[id] = true
}
}
if result.IsRaw || req.Grouping != GroupMerged {
for i, id := range req.Entities.IDs {
if i%256 == 0 {
if err := ctx.Err(); err != nil {
return nil, err
}
}
if !seenEntities[id] {
seed(id)
}
}
}
if len(groups) == 0 {
ids := req.Entities.IDs
if len(ids) == 0 || (!result.IsRaw && req.Grouping == GroupMerged) {
ids = []string{""}
}
for _, id := range ids {
seed(id)
}
}
keys := make([]string, 0, len(groups))
for key := range groups {
if len(keys)%256 == 0 {
if err := ctx.Err(); err != nil {
return nil, err
}
}
keys = append(keys, key)
}
if err := ctx.Err(); err != nil {
return nil, err
}
sort.Strings(keys)
for _, key := range keys {
if err := ctx.Err(); err != nil {
return nil, err
}
series := *groups[key]
sort.SliceStable(series.Points, func(i, j int) bool { return series.Points[i].Time.Before(series.Points[j].Time) })
if err := ctx.Err(); err != nil {
return nil, err
}
if req.Fill.Gaps {
var err error
series, err = fillMetricSeriesOwned(ctx, series, req.Start, req.End)
if err != nil {
return nil, err
}
}
if req.Order == metric.OrderDesc {
for i, j := 0, len(series.Points)-1; i < j; i, j = i+1, j-1 {
if i%256 == 0 {
if err := ctx.Err(); err != nil {
return nil, err
}
}
series.Points[i], series.Points[j] = series.Points[j], series.Points[i]
}
}
out = append(out, series)
}
}
}
return out, ctx.Err()
}
// FillMetricSeries matches the public chart's adaptive lower-quartile gap policy.
func FillMetricSeries(series MetricSeries, start, end time.Time) MetricSeries {
filled, _ := fillMetricSeries(context.Background(), series, start, end)
return filled
}
func fillMetricSeries(ctx context.Context, series MetricSeries, start, end time.Time) (MetricSeries, error) {
if err := ctx.Err(); err != nil {
return series, err
}
// Public callers retain ownership, including spare capacity. Never write there.
series.Points = append([]MetricValue(nil), series.Points...)
return fillMetricSeriesOwned(ctx, series, start, end)
}
// fillMetricSeriesOwned inserts gaps backwards into exclusively owned storage.
// Dense series reuse their point array; sparse series grow only when capacity
// cannot accommodate the exact number of gaps, rather than always copying into
// a second full point array. The public wrapper above remains non-mutating.
func fillMetricSeriesOwned(ctx context.Context, series MetricSeries, start, end time.Time) (MetricSeries, error) {
if err := ctx.Err(); err != nil {
return series, err
}
deltas := make([]time.Duration, 0, len(series.Points))
for i := 1; i < len(series.Points); i++ {
if i%256 == 0 {
if err := ctx.Err(); err != nil {
return series, err
}
}
if d := series.Points[i].Time.Sub(series.Points[i-1].Time); d > 0 {
deltas = append(deltas, d)
}
}
if err := ctx.Err(); err != nil {
return series, err
}
expected := series.Interval
if len(deltas) >= 2 {
sort.Slice(deltas, func(i, j int) bool { return deltas[i] < deltas[j] })
if observed := deltas[(len(deltas)-1)/4]; observed > expected {
expected = observed
}
}
if err := ctx.Err(); err != nil {
return series, err
}
if expected > 0 {
series.Interval = expected
}
null := func(at time.Time) MetricValue { return MetricValue{Time: at.UTC(), Tags: series.Tags} }
n := len(series.Points)
if n == 0 {
series.Points = append(series.Points, null(start), null(end))
return series, ctx.Err()
}
leading := start.Before(series.Points[0].Time)
gap := func(i int) bool {
return i > 0 && expected > 0 && series.Points[i-1].Value != nil && series.Points[i].Value != nil && series.Points[i].Time.Sub(series.Points[i-1].Time) > expected+expected/2
}
extra := 0
if leading {
extra++
}
for i := 1; i < n; i++ {
if i%256 == 0 {
if err := ctx.Err(); err != nil {
return series, err
}
}
if gap(i) {
extra++
}
}
if extra == 0 {
return series, ctx.Err()
}
if cap(series.Points) < n+extra {
points := make([]MetricValue, n+extra)
copy(points, series.Points)
series.Points = points
} else {
series.Points = series.Points[:n+extra]
}
write := n + extra - 1
for i := n - 1; i >= 0; i-- {
if i%256 == 0 {
if err := ctx.Err(); err != nil {
return series, err
}
}
// Decide before moving: write may equal i, but never precedes it.
insert := gap(i)
var at time.Time
if insert {
at = series.Points[i-1].Time.Add(expected)
}
series.Points[write] = series.Points[i]
write--
if insert {
series.Points[write] = null(at)
write--
}
}
if leading {
series.Points[0] = null(start)
}
return series, ctx.Err()
}
+69 -59
View File
@@ -25,25 +25,17 @@ type reportTrafficValues struct {
timestamp time.Time
hasUp bool
totalUp int64
totalUpAt time.Time
hasDown bool
totalDown int64
totalDownAt time.Time
}
var (
reportTrafficStates sync.Map
reportWriteMu sync.Mutex
)
var reportTrafficStates sync.Map
const (
reportBatchInterval = 3 * time.Second
reportBatchQueueSize = 512
pingBatchQueueSize = 4096
reportBatchQueueSize = 4096
pingBatchMaxRecords = 512
reportBatchWriteTimeout = 10 * time.Second
// Longer gaps have no reliable per-bucket traffic attribution.
maxTrafficReportGap = 15 * time.Minute
)
var (
@@ -82,7 +74,7 @@ func StartReportBatcher() {
}
worker := &reportBatchWorker{
queue: make(chan v2.Report, reportBatchQueueSize),
pingQueue: make(chan models.PingRecord, pingBatchQueueSize),
pingQueue: make(chan models.PingRecord, reportBatchQueueSize),
requests: make(chan reportBatchRequest, 1),
done: make(chan struct{}),
}
@@ -213,8 +205,8 @@ func (w *reportBatchWorker) run() {
for {
select {
case request := <-w.requests:
pending = append(pending, drainQueue(w.queue, reportBatchQueueSize)...)
pendingPings = append(pendingPings, drainQueue(w.pingQueue, pingBatchQueueSize)...)
pending = append(pending, drainReportQueue(w.queue, reportBatchQueueSize)...)
pendingPings = append(pendingPings, drainPingQueue(w.pingQueue, reportBatchQueueSize)...)
err := errors.Join(
writePendingReports(request.ctx, &pending),
writePendingPingRecords(request.ctx, &pendingPings),
@@ -229,11 +221,11 @@ func (w *reportBatchWorker) run() {
}
request.done <- err
case <-ticker.C:
pendingPings = append(pendingPings, drainQueue(w.pingQueue, pingBatchQueueSize)...)
pendingPings = append(pendingPings, drainPingQueue(w.pingQueue, reportBatchQueueSize)...)
if err := writePendingPingRecords(context.Background(), &pendingPings); err != nil {
logger.Errorf("metricstore", "failed to flush ping batch: %v", err)
}
pending = append(pending, drainQueue(w.queue, reportBatchQueueSize)...)
pending = append(pending, drainReportQueue(w.queue, reportBatchQueueSize)...)
if err := writePendingReports(context.Background(), &pending); err != nil {
logger.Errorf("metricstore", "failed to flush metric report batch: %v", err)
}
@@ -258,36 +250,69 @@ func (w *reportBatchWorker) enqueuePing(ctx context.Context, record models.PingR
}
}
// drainQueue takes up to limit queued items without blocking.
func drainQueue[T any](queue <-chan T, limit int) []T {
n := min(len(queue), limit)
if n <= 0 {
func drainReportQueue(queue <-chan v2.Report, limit int) []v2.Report {
if limit <= 0 {
return nil
}
items := make([]T, 0, n)
for len(items) < limit {
capacity := len(queue)
if capacity > limit {
capacity = limit
}
if capacity == 0 {
return nil
}
reports := make([]v2.Report, 0, capacity)
for len(reports) < limit {
select {
case item := <-queue:
items = append(items, item)
case report := <-queue:
reports = append(reports, report)
default:
return items
return reports
}
}
return items
return reports
}
func drainPingQueue(queue <-chan models.PingRecord, limit int) []models.PingRecord {
if limit <= 0 {
return nil
}
capacity := len(queue)
if capacity > limit {
capacity = limit
}
if capacity == 0 {
return nil
}
records := make([]models.PingRecord, 0, capacity)
for len(records) < limit {
select {
case record := <-queue:
records = append(records, record)
default:
return records
}
}
return records
}
// writePendingReports commits all pending reports as one batch and keeps them
// for the next attempt when the write fails.
func writePendingReports(ctx context.Context, pending *[]v2.Report) error {
if len(*pending) == 0 {
return nil
}
writeCtx, cancel := context.WithTimeout(ctx, reportBatchWriteTimeout)
defer cancel()
if _, err := writeReportBatch(writeCtx, *pending); err != nil {
return err
batchSize := len(*pending)
for len(*pending) > 0 {
if batchSize > len(*pending) {
batchSize = len(*pending)
}
writeCtx, cancel := context.WithTimeout(ctx, reportBatchWriteTimeout)
_, err := writeReportBatch(writeCtx, (*pending)[:batchSize])
cancel()
if err != nil {
return err
}
*pending = (*pending)[batchSize:]
}
*pending = (*pending)[:0]
return nil
}
@@ -317,11 +342,6 @@ func writeReportBatch(ctx context.Context, reports []v2.Report) ([]v2.Report, er
}
defer storeOperations.ReleaseShared()
// Serialize baseline reads and commits, including direct writes without
// the background batcher.
reportWriteMu.Lock()
defer reportWriteMu.Unlock()
s := GetStore()
if s == nil {
return nil, fmt.Errorf("metric store not enabled")
@@ -341,65 +361,55 @@ func writeReportBatch(ctx context.Context, reports []v2.Report) ([]v2.Report, er
state.mu.Unlock()
}
if !values.initialized {
totalUp, hasUp, err := latestReportPoint(ctx, s, MetricNetTotalUp, report.UUID, report.UpdatedAt)
totalUp, hasUp, err := latestReportCounter(ctx, s, MetricNetTotalUp, report.UUID, report.UpdatedAt)
if err != nil {
logger.Errorf("metricstore", "failed to restore previous upload counter for %s: %v", report.UUID, err)
} else {
values.totalUp = int64(totalUp.Value)
values.totalUpAt = totalUp.Timestamp
values.totalUp = totalUp
values.hasUp = hasUp
}
totalDown, hasDown, err := latestReportPoint(ctx, s, MetricNetTotalDown, report.UUID, report.UpdatedAt)
totalDown, hasDown, err := latestReportCounter(ctx, s, MetricNetTotalDown, report.UUID, report.UpdatedAt)
if err != nil {
logger.Errorf("metricstore", "failed to restore previous download counter for %s: %v", report.UUID, err)
} else {
values.totalDown = int64(totalDown.Value)
values.totalDownAt = totalDown.Timestamp
values.totalDown = totalDown
values.hasDown = hasDown
}
values.initialized = true
// Keep only the restored baseline on failure. WriteBatch may have
// accepted raw samples before a rollup flush fails, so restoring
// again on retry could otherwise read this uncommitted batch.
state.mu.Lock()
state.reportTrafficValues = values
state.mu.Unlock()
}
if !values.timestamp.IsZero() && !report.UpdatedAt.After(values.timestamp) {
report.UpdatedAt = values.timestamp.Add(time.Millisecond)
}
trafficUp := int64(0)
if values.hasUp && !values.totalUpAt.IsZero() && report.UpdatedAt.Sub(values.totalUpAt) <= maxTrafficReportGap {
if values.hasUp {
trafficUp = TrafficCounterDelta(report.Network.TotalUp, values.totalUp)
}
trafficDown := int64(0)
if values.hasDown && !values.totalDownAt.IsZero() && report.UpdatedAt.Sub(values.totalDownAt) <= maxTrafficReportGap {
if values.hasDown {
trafficDown = TrafficCounterDelta(report.Network.TotalDown, values.totalDown)
}
points = append(points, reportMetricPoints(report, trafficUp, trafficDown)...)
values.timestamp = report.UpdatedAt
values.hasUp = true
values.totalUp = report.Network.TotalUp
values.totalUpAt = report.UpdatedAt
values.hasDown = true
values.totalDown = report.Network.TotalDown
values.totalDownAt = report.UpdatedAt
pendingStates[state] = values
prepared[i] = report
}
if err := s.WriteBatch(ctx, points); err != nil {
return nil, err
}
// Advance counters and timestamps only after success. Retried batches
// must replace the same samples, not manufacture resets or new timestamps.
// Persist the restored per-report traffic state even if the write below
// fails, so a slow or failing database does not re-issue the previous-
// counter queries on every batch.
for state, values := range pendingStates {
state.mu.Lock()
state.reportTrafficValues = values
state.mu.Unlock()
}
if err := s.WriteBatch(ctx, points); err != nil {
return nil, err
}
return prepared, nil
}
+1 -16
View File
@@ -49,23 +49,8 @@ func reportMetricPoints(report v2.Report, trafficUp, trafficDown int64) []metric
return points
}
// latestReportPoint preserves independent counter restoration errors: one failed
// direction must not prevent the other from restoring its baseline.
func latestReportPoint(ctx context.Context, s *metric.Store, metricName, entityID string, before time.Time) (metric.Point, bool, error) {
loaded, err := QueryMetrics(ctx, MetricsQuery{
Store: s, Mode: QueryLatestBefore, Source: SourceAuto,
Entities: EntityScope{IDs: []string{entityID}},
Specs: []MetricSpec{{Name: metricName}}, End: before, AllowMissing: true,
})
if err != nil {
return metric.Point{}, false, err
}
point, ok := loaded.Latest[metricName][entityID]
return point, ok, nil
}
func latestReportCounter(ctx context.Context, s *metric.Store, metricName, entityID string, before time.Time) (int64, bool, error) {
point, ok, err := latestReportPoint(ctx, s, metricName, entityID, before)
point, ok, err := s.LatestBefore(ctx, metricName, entityID, before)
if err != nil {
return 0, false, err
}
-74
View File
@@ -51,7 +51,6 @@ func useReportTestStore(t *testing.T, policy *metric.RollupPolicy) *metric.Store
type reportCounterFault struct {
remaining atomic.Int32
denied atomic.Int32
failWrite atomic.Bool
}
func (f *reportCounterFault) denyRollupRead() bool {
@@ -89,9 +88,6 @@ func useReportCounterFailureStore(t *testing.T) (*metric.Store, *reportCounterFa
driver := &sqlite3.SQLiteDriver{
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
conn.RegisterAuthorizer(func(op int, arg1, _, _ string) int {
if op == sqlite3.SQLITE_TRANSACTION && arg1 == "BEGIN" && fault.failWrite.Load() {
return sqlite3.SQLITE_DENY
}
if op == sqlite3.SQLITE_READ && arg1 == "metric_rollups" && fault.denyRollupRead() {
return sqlite3.SQLITE_DENY
}
@@ -170,76 +166,6 @@ func TestReportBatchCounterRestoreFailureInitializesStateOnce(t *testing.T) {
assertMetricValues(t, s, MetricTrafficDown, first.UUID, base.Add(-time.Second), base.Add(2*time.Second), []float64{0, 60})
}
func TestReportBatchRetryDoesNotCountHistoricalTraffic(t *testing.T) {
for _, existingBaseline := range []bool{false, true} {
t.Run(fmt.Sprintf("existing-baseline-%v", existingBaseline), func(t *testing.T) {
s, fault := useReportCounterFailureStore(t)
fault.remaining.Store(0)
ctx := context.Background()
base := time.Now().UTC().Truncate(time.Minute).Add(-2 * time.Minute)
report := v2.Report{
UUID: "retry-node", UpdatedAt: base,
Network: v2.NetworkReport{TotalUp: 12 << 30, TotalDown: 30 << 30},
}
if existingBaseline {
if _, err := writeReportBatch(ctx, []v2.Report{report}); err != nil {
t.Fatal(err)
}
}
first := report
first.UpdatedAt = base.Add(3 * time.Second)
first.Network.TotalUp += 100
first.Network.TotalDown += 200
second := first
second.UpdatedAt = base.Add(6 * time.Second)
second.Network.TotalUp += 50
second.Network.TotalDown += 60
pending := []v2.Report{first, second}
fault.failWrite.Store(true)
for attempt := 0; attempt < 2; attempt++ {
if err := writePendingReports(ctx, &pending); err == nil {
t.Fatal("expected injected rollup write failure")
}
if len(pending) != 2 {
t.Fatal("failed reports were removed from pending batch")
}
value, _ := reportTrafficStates.Load(report.UUID)
state := value.(*reportTrafficState)
state.mu.Lock()
baseline := state.reportTrafficValues
state.mu.Unlock()
if baseline.hasUp != existingBaseline || baseline.hasDown != existingBaseline ||
(existingBaseline && (baseline.totalUp != report.Network.TotalUp ||
baseline.totalDown != report.Network.TotalDown || !baseline.timestamp.Equal(base))) {
t.Fatal("failed write advanced the committed counter baseline")
}
}
fault.failWrite.Store(false)
if err := writePendingReports(ctx, &pending); err != nil {
t.Fatalf("retry failed: %v", err)
}
if len(pending) != 0 {
t.Fatal("successful reports remained pending")
}
wantUp, wantDown := []float64{0, 50}, []float64{0, 60}
if existingBaseline {
wantUp, wantDown = []float64{0, 100, 50}, []float64{0, 200, 60}
}
assertMetricValues(t, s, MetricTrafficUp, report.UUID, base.Add(-time.Second), base.Add(time.Minute), wantUp)
assertMetricValues(t, s, MetricTrafficDown, report.UUID, base.Add(-time.Second), base.Add(time.Minute), wantDown)
var sumUp, sumDown float64
for i := range wantUp {
sumUp += wantUp[i]
sumDown += wantDown[i]
}
assertMetricAggregate(t, s, MetricTrafficUp, report.UUID, base.Add(-time.Second), base.Add(time.Minute), metric.AggSum, sumUp, len(wantUp))
assertMetricAggregate(t, s, MetricTrafficDown, report.UUID, base.Add(-time.Second), base.Add(time.Minute), metric.AggSum, sumDown, len(wantDown))
})
}
}
func TestWriteReportStoresMinuteMetricsAndResetAwareTraffic(t *testing.T) {
ctx := context.Background()
policy := defaultRollupPolicy()
+59 -27
View File
@@ -37,67 +37,99 @@ func StructureUpgradeRequired(ctx context.Context) (bool, error) {
}
func structureUpgradeRequiredForConfig(ctx context.Context, cfg *MetricStoreConfig) (bool, error) {
s, err := openWithoutMigration(ctx, cfg)
metricCfg, err := buildMetricConfig(cfg, false)
if err != nil {
return false, err
}
defer s.Close()
return s.NeedsRestructure(ctx)
}
// openWithoutMigration opens cfg without creating or altering any table.
func openWithoutMigration(ctx context.Context, cfg *MetricStoreConfig) (*metric.Store, error) {
metricCfg, err := buildMetricConfig(cfg, false)
store, err := metric.Open(ctx, metricCfg)
if err != nil {
return nil, err
return false, err
}
return metric.Open(ctx, metricCfg)
defer store.Close()
return store.NeedsRestructure(ctx)
}
// RestructureConfiguredStore performs the explicit data copy, table swap and
// one physical reclaim pass used by the upgrade guide.
func RestructureConfiguredStore(ctx context.Context, report func(RestructureProgress)) (RestructureResult, error) {
return rebuildConfiguredStore(ctx, report, (*metric.Store).Restructure)
cfg, err := config.GetManyAs[MetricStoreConfig]()
if err != nil {
return RestructureResult{}, err
}
metricCfg, err := buildMetricConfig(cfg, false)
if err != nil {
return RestructureResult{}, err
}
store, err := metric.Open(ctx, metricCfg)
if err != nil {
return RestructureResult{}, err
}
defer store.Close()
before, err := store.LegacyStorageSize(ctx)
if err != nil {
return RestructureResult{}, fmt.Errorf("measure legacy metric storage: %w", err)
}
result, err := store.Restructure(ctx, func(progress metric.RestructureProgress) {
if report == nil {
return
}
report(RestructureProgress{Phase: progress.Phase, CurrentMetric: progress.Current, RowsDone: progress.RowsDone, RowsTotal: progress.RowsTotal, MetricsDone: progress.MetricsDone, MetricsTotal: progress.MetricsTotal})
})
if err != nil {
return RestructureResult{}, err
}
if report != nil {
report(RestructureProgress{Phase: "reclaiming", RowsDone: result.RowsCopied, RowsTotal: result.RowsCopied, MetricsDone: result.Metrics, MetricsTotal: result.Metrics})
}
if err := store.ReclaimSpace(ctx); err != nil {
return RestructureResult{}, fmt.Errorf("reclaim metric storage: %w", err)
}
after, err := store.StorageSize(ctx)
if err != nil {
return RestructureResult{}, fmt.Errorf("measure rebuilt metric storage: %w", err)
}
return RestructureResult{BeforeBytes: before, AfterBytes: after, RowsCopied: result.RowsCopied, Metrics: result.Metrics}, nil
}
// DiscardConfiguredStoreHistory completes the same schema upgrade as
// RestructureConfiguredStore but drops every historical sample instead of
// copying it. Metric definitions remain available for new ingest.
func DiscardConfiguredStoreHistory(ctx context.Context, report func(RestructureProgress)) (RestructureResult, error) {
return rebuildConfiguredStore(ctx, report, (*metric.Store).DiscardHistory)
}
type storeRebuild func(*metric.Store, context.Context, func(metric.RestructureProgress)) (metric.RestructureResult, error)
func rebuildConfiguredStore(ctx context.Context, report func(RestructureProgress), rebuild storeRebuild) (RestructureResult, error) {
if report == nil {
report = func(RestructureProgress) {}
}
cfg, err := config.GetManyAs[MetricStoreConfig]()
if err != nil {
return RestructureResult{}, err
}
s, err := openWithoutMigration(ctx, cfg)
metricCfg, err := buildMetricConfig(cfg, false)
if err != nil {
return RestructureResult{}, err
}
defer s.Close()
store, err := metric.Open(ctx, metricCfg)
if err != nil {
return RestructureResult{}, err
}
defer store.Close()
before, err := s.LegacyStorageSize(ctx)
before, err := store.LegacyStorageSize(ctx)
if err != nil {
return RestructureResult{}, fmt.Errorf("measure legacy metric storage: %w", err)
}
result, err := rebuild(s, ctx, func(progress metric.RestructureProgress) {
result, err := store.DiscardHistory(ctx, func(progress metric.RestructureProgress) {
if report == nil {
return
}
report(RestructureProgress{Phase: progress.Phase, CurrentMetric: progress.Current, RowsDone: progress.RowsDone, RowsTotal: progress.RowsTotal, MetricsDone: progress.MetricsDone, MetricsTotal: progress.MetricsTotal})
})
if err != nil {
return RestructureResult{}, err
}
report(RestructureProgress{Phase: "reclaiming", RowsDone: result.RowsCopied, RowsTotal: result.RowsCopied, MetricsDone: result.Metrics, MetricsTotal: result.Metrics})
if err := s.ReclaimSpace(ctx); err != nil {
if report != nil {
report(RestructureProgress{Phase: "reclaiming", RowsDone: result.RowsCopied, RowsTotal: result.RowsCopied, MetricsDone: result.Metrics, MetricsTotal: result.Metrics})
}
if err := store.ReclaimSpace(ctx); err != nil {
return RestructureResult{}, fmt.Errorf("reclaim metric storage: %w", err)
}
after, err := s.StorageSize(ctx)
after, err := store.StorageSize(ctx)
if err != nil {
return RestructureResult{}, fmt.Errorf("measure rebuilt metric storage: %w", err)
}
+69 -36
View File
@@ -53,6 +53,13 @@ func openStoreWithDefaultRetention(ctx context.Context, cfg *MetricStoreConfig,
return s, nil
}
// OpenStore opens an isolated metric store using the supplied configuration.
// It is used by the pre-start upgrade flow before the process-wide store is
// initialized. The caller owns the returned store and must close it.
// func OpenStore(ctx context.Context, cfg *MetricStoreConfig) (*metric.Store, error) {
// return openStore(ctx, cfg)
// }
// OpenStoreForMigration opens an isolated target and uses the legacy data span
// as the initial retention for definitions that do not exist yet. Existing
// definitions keep their configured retention, including an explicit zero.
@@ -66,11 +73,17 @@ func OpenStoreForMigration(ctx context.Context, cfg *MetricStoreConfig, legacyRe
// TestConnection 使用给定配置尝试连接 metrics 数据库(不影响当前运行的 store)。
// 仅打开连接并 Ping,不执行自动建表,连接成功后立即关闭。失败时返回可读错误。
func TestConnection(ctx context.Context, cfg *MetricStoreConfig) error {
s, err := openWithoutMigration(ctx, cfg)
metricCfg, err := buildMetricConfig(cfg, false)
if err != nil {
return err
}
s, err := metric.Open(ctx, metricCfg)
if err != nil {
return err
}
defer s.Close()
return s.Ping(ctx)
}
@@ -103,7 +116,10 @@ func InitializeStore() error {
return err
}
activateStore(s, targetFingerprint(cfg), "initialization")
storeMu.Lock()
store = s
storeFingerprint = targetFingerprint(cfg)
storeMu.Unlock()
clearStoreClosing()
logger.Infof("metricstore", "Metric store initialized successfully (driver=%s)", ResolveDriverFromConfig(cfg.Driver, cfg.DSN))
@@ -114,6 +130,10 @@ func InitializeStore() error {
// from the restricted recovery page. It records that target as the current
// manual-migration source, but does not copy data from the unavailable store.
func RecoverStore(ctx context.Context, cfg *MetricStoreConfig) error {
if cfg == nil {
return fmt.Errorf("metric store recovery config is nil")
}
storeInitMu.Lock()
defer storeInitMu.Unlock()
if err := storeOperations.Acquire(ctx); err != nil {
@@ -127,14 +147,18 @@ func RecoverStore(ctx context.Context, cfg *MetricStoreConfig) error {
recovered := *cfg
recovered.DSN = strings.TrimSpace(recovered.DSN)
recovered.Driver = string(ResolveDriverFromConfig(recovered.Driver, recovered.DSN))
target := targetFingerprint(&recovered)
restructureRequired, err := structureUpgradeRequiredForConfig(ctx, &recovered)
if err != nil {
return err
}
if restructureRequired {
if err := saveRecoveredTarget(&recovered, target); err != nil {
return err
target := targetFingerprint(&recovered)
if err := config.SetMany(map[string]any{
MetricDBDriverKey: recovered.Driver,
MetricDBDSNKey: recovered.DSN,
MigrationTargetKey: target,
}); err != nil {
return fmt.Errorf("save recovered metric store config: %w", err)
}
logger.Infof("metricstore", "Metric store connection recovered; structure upgrade is required (driver=%s)", recovered.Driver)
return nil
@@ -143,43 +167,31 @@ func RecoverStore(ctx context.Context, cfg *MetricStoreConfig) error {
if err != nil {
return err
}
if err := saveRecoveredTarget(&recovered, target); err != nil {
_ = s.Close()
return err
}
activateStore(s, target, "recovery")
clearStoreClosing()
logger.Infof("metricstore", "Metric store recovered successfully (driver=%s)", recovered.Driver)
return nil
}
// saveRecoveredTarget persists the recovered connection and records it as the
// default source for the next manual store migration.
func saveRecoveredTarget(cfg *MetricStoreConfig, target string) error {
target := targetFingerprint(&recovered)
if err := config.SetMany(map[string]any{
MetricDBDriverKey: cfg.Driver,
MetricDBDSNKey: cfg.DSN,
MetricDBDriverKey: recovered.Driver,
MetricDBDSNKey: recovered.DSN,
MigrationTargetKey: target,
}); err != nil {
_ = s.Close()
return fmt.Errorf("save recovered metric store config: %w", err)
}
return nil
}
// activateStore publishes s as the active store, then closes the store it
// replaced. Callers must hold the exclusive operation gate or storeInitMu.
func activateStore(s *metric.Store, fingerprint, reason string) {
storeMu.Lock()
old := store
store = s
storeFingerprint = fingerprint
storeFingerprint = target
storeMu.Unlock()
if old == nil {
return
}
if err := old.Close(); err != nil {
logger.Errorf("metricstore", "Failed to close previous metric store during %s: %v", reason, err)
clearStoreClosing()
if old != nil {
if closeErr := old.Close(); closeErr != nil {
logger.Errorf("metricstore", "Failed to close previous metric store during recovery: %v", closeErr)
}
}
logger.Infof("metricstore", "Metric store recovered successfully (driver=%s)", recovered.Driver)
return nil
}
// Reload 根据最新配置热重载 metric store,无需重启进程。
@@ -206,7 +218,16 @@ func Reload(ctx context.Context) error {
// point-backed schema. That schema must go through the authenticated
// structure-upgrade guide first; otherwise index creation can reference
// columns such as resolution_id that do not exist yet.
needsRestructure, err := structureUpgradeRequiredForConfig(ctx, cfg)
checkCfg, err := buildMetricConfig(cfg, false)
if err != nil {
return err
}
checkStore, err := metric.Open(ctx, checkCfg)
if err != nil {
return err
}
needsRestructure, err := checkStore.NeedsRestructure(ctx)
_ = checkStore.Close()
if err != nil {
return fmt.Errorf("inspect metric store structure: %w", err)
}
@@ -220,7 +241,17 @@ func Reload(ctx context.Context) error {
return err
}
activateStore(s, targetFingerprint(cfg), "reload")
storeMu.Lock()
old := store
store = s
storeFingerprint = targetFingerprint(cfg)
storeMu.Unlock()
if old != nil {
if cerr := old.Close(); cerr != nil {
logger.Errorf("metricstore", "Failed to close previous metric store on reload: %v", cerr)
}
}
logger.Infof("metricstore", "Metric store reloaded successfully (driver=%s)", ResolveDriverFromConfig(cfg.Driver, cfg.DSN))
return nil
}
@@ -235,7 +266,7 @@ func GetStore() *metric.Store {
// CloseStoreContext stops the asynchronous store migration before taking the
// store write lock, so shutdown cannot wait forever on the migration's lease.
func CloseStoreContext(ctx context.Context) error {
if err := StopStoreMigrationForShutdown(ctx); err != nil {
if err := stopStoreMigrationForClose(ctx); err != nil {
clearStoreClosing()
return err
}
@@ -247,11 +278,13 @@ func CloseStoreContext(ctx context.Context) error {
storeMu.Lock()
defer storeMu.Unlock()
var err error
if store != nil {
err = store.Close()
err := store.Close()
store = nil
storeFingerprint = ""
return err
}
storeFingerprint = ""
return err
return nil
}
+35 -17
View File
@@ -224,30 +224,23 @@ func finishStoreMigration(status string, err error) {
func CancelStoreMigration() error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
storeMigMu.Lock()
stop, done := storeMigCancel, storeMigDone
storeMigMu.Unlock()
if stop == nil {
return fmt.Errorf("no store migration is currently running")
}
return awaitStoreMigrationStop(ctx, stop, done)
return stopStoreMigration(ctx, true)
}
// StopStoreMigrationForShutdown prevents a new store migration and waits for
// an active one to release its exclusive lease before report batches flush.
func StopStoreMigrationForShutdown(ctx context.Context) error {
func stopStoreMigration(ctx context.Context, requireRunning bool) error {
storeMigMu.Lock()
storeClosing = true
stop, done := storeMigCancel, storeMigDone
cancel := storeMigCancel
done := storeMigDone
storeMigMu.Unlock()
if stop == nil {
if cancel == nil {
if requireRunning {
return fmt.Errorf("no store migration is currently running")
}
return nil
}
return awaitStoreMigrationStop(ctx, stop, done)
}
cancel()
func awaitStoreMigrationStop(ctx context.Context, stop context.CancelFunc, done <-chan struct{}) error {
stop()
select {
case <-done:
return nil
@@ -256,6 +249,31 @@ func awaitStoreMigrationStop(ctx context.Context, stop context.CancelFunc, done
}
}
func stopStoreMigrationForClose(ctx context.Context) error {
storeMigMu.Lock()
storeClosing = true
cancel := storeMigCancel
done := storeMigDone
storeMigMu.Unlock()
if cancel == nil {
return nil
}
cancel()
select {
case <-done:
return nil
case <-ctx.Done():
return fmt.Errorf("wait for store migration to cancel: %w", ctx.Err())
}
}
// StopStoreMigrationForShutdown prevents a new store migration and waits for
// an active one to release its exclusive lease before report batches flush.
func StopStoreMigrationForShutdown(ctx context.Context) error {
return stopStoreMigrationForClose(ctx)
}
func clearStoreClosing() {
storeMigMu.Lock()
storeClosing = false
+11
View File
@@ -112,6 +112,9 @@ func Run(ctx Context) error {
if err := migrateLegacyClientInfo(db); err != nil {
return err
}
if err := migrateLegacyLoadNotification(db); err != nil {
return err
}
if err := migrateLegacyPingAllClientsExpansion(db); err != nil {
return err
}
@@ -172,6 +175,14 @@ func hasTableColumn(db *gorm.DB, tableName, columnName string) bool {
return false
}
func migrateLegacyLoadNotification(db *gorm.DB) error {
if db.Migrator().HasColumn(&models.LoadNotification{}, "client") {
logger.InfoArgs("migration", "[>0.1.4] Rebuilding LoadNotification table....")
return db.Migrator().DropTable(&models.LoadNotification{})
}
return nil
}
func migrateLegacyOidcConfig(db *gorm.DB) error {
if db.Migrator().HasTable(&models.OidcProvider{}) {
return nil
+1 -2
View File
@@ -2,7 +2,6 @@ package migrations
import (
"encoding/json"
"fmt"
"strings"
"testing"
"time"
@@ -16,7 +15,7 @@ import (
func openTestDB(t *testing.T, name string) *gorm.DB {
t.Helper()
dsn := fmt.Sprintf("file:%s-%d?mode=memory&cache=shared", strings.ReplaceAll(name, " ", "_"), time.Now().UnixNano())
dsn := "file:" + strings.ReplaceAll(name, " ", "_") + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite test db: %v", err)
+3
View File
@@ -35,7 +35,10 @@ var legacyTimestampColumns = [...]timestampColumn{
{table: "sessions", column: "expires"},
{table: "sessions", column: "created_at"},
{table: "logs", column: "time"},
{table: "clipboards", column: "created_at"},
{table: "clipboards", column: "updated_at"},
{table: "offline_notifications", column: "last_notified"},
{table: "load_notifications", column: "last_notified"},
{table: "task_results", column: "finished_at"},
{table: "task_results", column: "created_at"},
{table: "records", column: "time"},
+1 -1
View File
@@ -61,7 +61,7 @@ func TestParseLegacyTimestampAcceptsHistoricalEpochUnits(t *testing.T) {
func openTimestampMigrationDB(t *testing.T) *gorm.DB {
t.Helper()
dsn := fmt.Sprintf("file:%s-%d?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"), time.Now().UnixNano())
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("open database: %v", err)
-64
View File
@@ -1,64 +0,0 @@
// Package notifications wires the built-in notification channels into the
// shared messageSender registry.
package notifications
import (
"encoding/json"
"errors"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/internal/managedconfig"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/messageSender/javascript"
"github.com/komari-monitor/komari/utils/messageSender/webhook"
)
// Initialize registers the built-in channels and seeds their defaults.
func Initialize() (err error) {
if err := webhook.Register(); err != nil {
return err
}
registered := []string{"webhook"}
defer func() {
if err != nil {
for _, id := range registered {
err = errors.Join(err, messageSender.UnregisterNotificationChannel(id))
}
}
}()
if err := javascript.Register(); err != nil {
return err
}
registered = append(registered, javascript.ChannelID)
for _, item := range messageSender.ListNotificationChannels() {
if _, err := database.GetMessageSenderConfigByName(item.ID); err == nil {
continue
}
values := map[string]any{}
for _, field := range managedconfig.Items(item.Configuration) {
if field.Key != "" {
values[field.Key] = managedconfig.DefaultValue(field)
}
}
raw, err := json.Marshal(values)
if err != nil {
return err
}
if err := database.SaveMessageSenderConfig(&models.MessageSenderProvider{
Name: item.ID,
Addition: string(raw),
}); err != nil {
return err
}
}
return nil
}
// Shutdown unregisters the built-in channels and releases their runtimes.
func Shutdown() error {
return errors.Join(
messageSender.UnregisterNotificationChannel(javascript.ChannelID),
messageSender.UnregisterNotificationChannel("webhook"),
)
}
+19 -21
View File
@@ -5,10 +5,8 @@ import (
"net/http"
"strings"
"github.com/Calcium-Ion/moejs"
"github.com/Calcium-Ion/moejs/engine"
"github.com/dop251/goja"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/pkg/jsruntime/native"
"github.com/komari-monitor/komari/pkg/rpc"
)
@@ -19,41 +17,41 @@ const principalContextKey = "principal"
// routeRequestContext builds the JS request context with the caller identity
// resolved by IdentityMiddleware. Raw credentials (session/client tokens) are
// intentionally not exposed.
func routeRequestContext(vm *moejs.Runtime, c *gin.Context) *engine.Object {
ctx := vm.Realm().NewObject()
func routeRequestContext(vm *goja.Runtime, c *gin.Context) *goja.Object {
ctx := vm.NewObject()
principal := rpcPrincipalFromGin(c)
p := vm.Realm().NewObject()
p := vm.NewObject()
if principal != nil {
_ = native.Set(vm, p, "type", principalTypeString(principal.Type))
_ = native.Set(vm, p, "roles", principal.Roles)
_ = native.Set(vm, p, "user_uuid", principal.UserUUID)
_ = native.Set(vm, p, "client_uuid", principal.ClientUUID)
_ = native.Set(vm, p, "is_api_key", principal.IsAPIKey)
_ = p.Set("type", principalTypeString(principal.Type))
_ = p.Set("roles", principal.Roles)
_ = p.Set("user_uuid", principal.UserUUID)
_ = p.Set("client_uuid", principal.ClientUUID)
_ = p.Set("is_api_key", principal.IsAPIKey)
}
_ = native.Set(vm, ctx, "principal", p)
_ = ctx.Set("principal", p)
if role, ok := c.Get("role"); ok {
if s, ok := role.(string); ok {
_ = native.Set(vm, ctx, "role", s)
_ = ctx.Set("role", s)
}
}
if uuid, ok := c.Get("uuid"); ok {
_ = native.Set(vm, ctx, "user_uuid", uuid)
_ = ctx.Set("user_uuid", uuid)
}
if clientUUID, ok := c.Get("client_uuid"); ok {
_ = native.Set(vm, ctx, "client_uuid", clientUUID)
_ = ctx.Set("client_uuid", clientUUID)
}
_ = native.Set(vm, ctx, "remote_ip", c.ClientIP())
_ = native.Set(vm, ctx, "user_agent", c.GetHeader("User-Agent"))
_ = ctx.Set("remote_ip", c.ClientIP())
_ = ctx.Set("user_agent", c.GetHeader("User-Agent"))
return ctx
}
// hookRequestContext builds the JS request context at the raw HTTP layer
// (before the gin identity middleware runs): only network metadata is
// available there.
func hookRequestContext(vm *moejs.Runtime, r *http.Request) *engine.Object {
ctx := vm.Realm().NewObject()
_ = native.Set(vm, ctx, "remote_ip", remoteIP(r))
_ = native.Set(vm, ctx, "user_agent", r.Header.Get("User-Agent"))
func hookRequestContext(vm *goja.Runtime, r *http.Request) *goja.Object {
ctx := vm.NewObject()
_ = ctx.Set("remote_ip", remoteIP(r))
_ = ctx.Set("user_agent", r.Header.Get("User-Agent"))
return ctx
}
+7 -8
View File
@@ -5,9 +5,8 @@ import (
"fmt"
"sync/atomic"
"github.com/Calcium-Ion/moejs"
"github.com/dop251/goja"
"github.com/komari-monitor/komari/internal/scheduler"
"github.com/komari-monitor/komari/pkg/jsruntime"
)
// cronJobSeq gives every registered cron job a process-unique id. Job names
@@ -21,7 +20,7 @@ var cronJobSeq atomic.Uint64
// evaluation and takes the manager lock itself; the critical section is short
// and never waits on the event loop, so no lock cycle is possible. The job is
// removed automatically when the plugin is unloaded or fails to load.
func (m *Manager) registerCron(short, spec string, fn moejs.Value) error {
func (m *Manager) registerCron(short, spec string, fn goja.Callable) error {
m.mu.Lock()
defer m.mu.Unlock()
inst, ok := m.instances[short]
@@ -38,7 +37,7 @@ func (m *Manager) registerCron(short, spec string, fn moejs.Value) error {
name := fmt.Sprintf("plugin:%s:%d", short, cronJobSeq.Add(1))
if err := scheduler.AddContextFunc(name, spec, false, func(ctx context.Context) {
m.runCronJob(short, name, fn, host)
m.runCronJob(short, name, fn)
}); err != nil {
return err
}
@@ -52,7 +51,7 @@ func (m *Manager) registerCron(short, spec string, fn moejs.Value) error {
// on a scheduler goroutine and drops the fire when the plugin was unloaded
// (the job is cancelled with the instance, but a fire may already be queued).
// Errors thrown by the handler are reported to the plugin log by RunJob.
func (m *Manager) runCronJob(short, name string, fn moejs.Value, owner *jsruntime.Host) {
func (m *Manager) runCronJob(short, name string, fn goja.Callable) {
inst := m.instanceFor(short)
if inst == nil {
return
@@ -61,12 +60,12 @@ func (m *Manager) runCronJob(short, name string, fn moejs.Value, owner *jsruntim
host := inst.host
alive := inst.runtime != nil
inst.mu.RUnlock()
if !alive || host == nil || host != owner {
if !alive || host == nil {
return
}
host.RunOnLoop(func(vm *moejs.Runtime) {
host.RunOnLoop(func(vm *goja.Runtime) {
_ = host.RunJob(vm, "plugin cron "+name, func() error {
_, err := vm.CallFunction(fn, moejs.Undefined())
_, err := fn(goja.Undefined())
return err
})
})
+53
View File
@@ -14,6 +14,8 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/rpc"
)
@@ -399,6 +401,57 @@ func TestGetConfigurationMergesManifestDefaults(t *testing.T) {
}
}
func TestGetConfigurationResolvesSelectorValues(t *testing.T) {
withTempDataDir(t)
if err := dbcore.GetDBInstance().Create(&models.Client{UUID: "node-a"}).Error; err != nil {
t.Fatal(err)
}
if err := dbcore.GetDBInstance().Create(&models.PingTask{Id: 8}).Error; err != nil {
t.Fatal(err)
}
zipPath := writePluginZip(t, map[string]string{
"komari-plugin.json": `{"name":"Cfg","short":"cfg","version":"1.0.0","configuration":{"type":"managed","data":[
{"key":"nodes","name":"Nodes","type":"nodes"},
{"key":"tasks","name":"Tasks","type":"pingtasks"}
]}}`,
"script.js": `function load() {}`,
})
if _, err := InstallZip(zipPath); err != nil {
t.Fatal(err)
}
if err := SaveConfiguration("cfg", map[string]any{
"nodes": `["node-a","deleted-node"]`,
"tasks": `[8,7]`,
}); err != nil {
t.Fatal(err)
}
values, err := GetConfiguration("cfg")
if err != nil {
t.Fatal(err)
}
nodes, ok := values["nodes"].([]string)
if !ok || len(nodes) != 1 || nodes[0] != "node-a" {
t.Fatalf("nodes = %#v", values["nodes"])
}
tasks, ok := values["tasks"].([]uint)
if !ok || len(tasks) != 1 || tasks[0] != 8 {
t.Fatalf("tasks = %#v", values["tasks"])
}
var stored models.PluginConfiguration
if err := dbcore.GetDBInstance().Where("short = ?", "cfg").First(&stored).Error; err != nil {
t.Fatal(err)
}
var raw map[string]any
if err := json.Unmarshal([]byte(stored.Data), &raw); err != nil {
t.Fatal(err)
}
if raw["nodes"] != `["node-a","deleted-node"]` || raw["tasks"] != `[8,7]` {
t.Fatalf("stored = %#v", raw)
}
}
func TestGetConfigFromScript(t *testing.T) {
withTempDataDir(t)
gin.SetMode(gin.TestMode)
+37 -39
View File
@@ -14,11 +14,9 @@ import (
"sync"
"time"
"github.com/Calcium-Ion/moejs"
"github.com/Calcium-Ion/moejs/engine"
"github.com/dop251/goja"
"github.com/komari-monitor/komari/pkg/jsruntime"
"github.com/komari-monitor/komari/pkg/jsruntime/httpbody"
"github.com/komari-monitor/komari/pkg/jsruntime/native"
)
// hookKind identifies the HTTP hook phase.
@@ -33,7 +31,7 @@ const (
type hookEntry struct {
short string
kind hookKind
fn moejs.Value
fn goja.Callable
host *jsruntime.Host
matcher *hookMatcher // nil matches every request
bodyLimit int64 // plugin-declared maxHTTPBodyBytes (0 = default)
@@ -213,36 +211,36 @@ func (m *Manager) wrapHandler(next http.Handler) http.Handler {
func (m *Manager) runRequestHook(r *http.Request, body []byte, h *hookEntry) ([]byte, error) {
nextBody := body
var hookErr error
queued, timedOut := runHookTurn(h.host, "plugin request hook "+h.short, func(vm *moejs.Runtime) {
queued, timedOut := runHookTurn(h.host, "plugin request hook "+h.short, func(vm *goja.Runtime) {
reqObj := hookRequestObject(vm, r, nextBody)
hookErr = h.host.RunJob(vm, "plugin request hook "+h.short, func() error {
_, err := vm.CallFunction(h.fn, moejs.Undefined(), engine.ObjectValue(reqObj))
_, err := h.fn(goja.Undefined(), reqObj)
return err
})
if hookErr != nil {
return
}
if value := native.Get(vm, reqObj, "method"); !value.IsUndefined() {
if method := strings.ToUpper(strings.TrimSpace(native.String(vm, value))); method != "" {
if value := reqObj.Get("method"); value != nil && !goja.IsUndefined(value) {
if method := strings.ToUpper(strings.TrimSpace(value.String())); method != "" {
r.Method = method
}
}
if value := native.Get(vm, reqObj, "url"); !value.IsUndefined() {
if raw := strings.TrimSpace(native.String(vm, value)); raw != "" {
if value := reqObj.Get("url"); value != nil && !goja.IsUndefined(value) {
if raw := strings.TrimSpace(value.String()); raw != "" {
if parsed, err := url.Parse(raw); err == nil {
r.URL = parsed
r.RequestURI = raw
}
}
}
if value := native.Get(vm, reqObj, "headers"); !value.IsUndefined() {
if value := reqObj.Get("headers"); value != nil && !goja.IsUndefined(value) {
var raw map[string]any
if err := native.ExportTo(vm, value, &raw); err == nil {
if err := vm.ExportTo(value, &raw); err == nil {
r.Header = normalizeHeader(raw)
}
}
if value := native.Get(vm, reqObj, "body"); !value.IsUndefined() {
nextBody = []byte(native.String(vm, value))
if value := reqObj.Get("body"); value != nil && !goja.IsUndefined(value) {
nextBody = []byte(value.String())
}
})
if !queued {
@@ -258,29 +256,29 @@ func (m *Manager) runRequestHook(r *http.Request, body []byte, h *hookEntry) ([]
// the JS-side modifications to the captured response.
func (m *Manager) runResponseHook(r *http.Request, bw *bufferedResponseWriter, h *hookEntry) error {
var hookErr error
queued, timedOut := runHookTurn(h.host, "plugin response hook "+h.short, func(vm *moejs.Runtime) {
queued, timedOut := runHookTurn(h.host, "plugin response hook "+h.short, func(vm *goja.Runtime) {
resObj := hookResponseObject(vm, bw)
hookErr = h.host.RunJob(vm, "plugin response hook "+h.short, func() error {
_, err := vm.CallFunction(h.fn, moejs.Undefined(), engine.ObjectValue(hookRequestObject(vm, r, nil)), engine.ObjectValue(resObj))
_, err := h.fn(goja.Undefined(), hookRequestObject(vm, r, nil), resObj)
return err
})
if hookErr != nil {
return
}
status := bw.status()
if value := native.Get(vm, resObj, "statusCode"); !value.IsUndefined() {
status = int(native.Int(vm, value))
if value := resObj.Get("statusCode"); value != nil && !goja.IsUndefined(value) {
status = int(value.ToInteger())
}
header := bw.Header().Clone()
if value := native.Get(vm, resObj, "headers"); !value.IsUndefined() {
if value := resObj.Get("headers"); value != nil && !goja.IsUndefined(value) {
var raw map[string]any
if err := native.ExportTo(vm, value, &raw); err == nil {
if err := vm.ExportTo(value, &raw); err == nil {
header = normalizeHeader(raw)
}
}
responseBody := bw.bodyBytes()
if value := native.Get(vm, resObj, "body"); !value.IsUndefined() {
responseBody = []byte(native.String(vm, value))
if value := resObj.Get("body"); value != nil && !goja.IsUndefined(value) {
responseBody = []byte(value.String())
}
bw.apply(status, header, responseBody)
})
@@ -300,16 +298,16 @@ func (m *Manager) runResponseHook(r *http.Request, bw *bufferedResponseWriter, h
// runHookTurn queues one hook callback on the plugin event loop and waits
// for it with the runtime timeout. It reports whether the job was queued and
// whether it timed out.
func runHookTurn(host *jsruntime.Host, name string, job func(vm *moejs.Runtime)) (queued, timedOut bool) {
func runHookTurn(host *jsruntime.Host, name string, job func(vm *goja.Runtime)) (queued, timedOut bool) {
return runHookTurnTimeout(host, name, host.Timeout(), job)
}
// runHookTurnTimeout is runHookTurn with an explicit wait budget. The
// callback keeps running on the plugin loop after a timeout; the waiter gives
// up and the caller falls back to passing the message through.
func runHookTurnTimeout(host *jsruntime.Host, name string, timeout time.Duration, job func(vm *moejs.Runtime)) (queued, timedOut bool) {
func runHookTurnTimeout(host *jsruntime.Host, name string, timeout time.Duration, job func(vm *goja.Runtime)) (queued, timedOut bool) {
done := make(chan struct{})
if !host.RunOnLoop(func(vm *moejs.Runtime) {
if !host.RunOnLoop(func(vm *goja.Runtime) {
defer close(done)
job(vm)
}) {
@@ -328,29 +326,29 @@ func (m *Manager) logHookError(h *hookEntry, err error) {
}
// hookRequestObject builds the mutable JS request object.
func hookRequestObject(vm *moejs.Runtime, r *http.Request, body []byte) *engine.Object {
req := vm.Realm().NewObject()
_ = native.Set(vm, req, "method", r.Method)
_ = native.Set(vm, req, "url", r.URL.RequestURI())
_ = native.Set(vm, req, "headers", headerToMap(r.Header))
func hookRequestObject(vm *goja.Runtime, r *http.Request, body []byte) *goja.Object {
req := vm.NewObject()
_ = req.Set("method", r.Method)
_ = req.Set("url", r.URL.RequestURI())
_ = req.Set("headers", headerToMap(r.Header))
query := make(map[string]string, len(r.URL.Query()))
for name, values := range r.URL.Query() {
query[name] = strings.Join(values, ",")
}
_ = native.Set(vm, req, "query", query)
_ = native.Set(vm, req, "body", string(body))
_ = native.Set(vm, req, "context", hookRequestContext(vm, r))
_ = req.Set("query", query)
_ = req.Set("body", string(body))
_ = req.Set("context", hookRequestContext(vm, r))
return req
}
// hookResponseObject builds the mutable JS response object from the captured
// response.
func hookResponseObject(vm *moejs.Runtime, bw *bufferedResponseWriter) *engine.Object {
res := vm.Realm().NewObject()
_ = native.Set(vm, res, "statusCode", bw.status())
_ = native.Set(vm, res, "statusMessage", "")
_ = native.Set(vm, res, "headers", headerToMap(bw.Header()))
_ = native.Set(vm, res, "body", string(bw.bodyBytes()))
func hookResponseObject(vm *goja.Runtime, bw *bufferedResponseWriter) *goja.Object {
res := vm.NewObject()
_ = res.Set("statusCode", bw.status())
_ = res.Set("statusMessage", "")
_ = res.Set("headers", headerToMap(bw.Header()))
_ = res.Set("body", string(bw.bodyBytes()))
return res
}
+2 -19
View File
@@ -8,7 +8,6 @@ import (
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
)
func TestMain(m *testing.M) {
@@ -21,9 +20,8 @@ func TestMain(m *testing.M) {
os.Exit(m.Run())
}
// withTempDataDir redirects DataDir and StorageDir, resets the global
// manager, and clears the plugin configuration rows shared through the
// process-wide test database, so each test starts from a clean slate.
// withTempDataDir redirects DataDir and StorageDir and resets the global
// manager for the duration of a test.
func withTempDataDir(t *testing.T) {
t.Helper()
oldDir := DataDir
@@ -31,11 +29,6 @@ func withTempDataDir(t *testing.T) {
oldGlobal := global
DataDir = t.TempDir()
StorageDir = t.TempDir()
// SaveConfiguration writes to the shared in-memory database created by
// TestMain, so leftover rows would leak into later tests (and repeated
// runs). Clear them before and after each test.
clearPluginConfigurations(t)
t.Cleanup(func() { clearPluginConfigurations(t) })
mgr := &Manager{
instances: make(map[string]*Instance),
routes: make(map[string]map[string]bool),
@@ -50,16 +43,6 @@ func withTempDataDir(t *testing.T) {
})
}
// clearPluginConfigurations removes every stored plugin configuration row.
func clearPluginConfigurations(t *testing.T) {
t.Helper()
if err := dbcore.GetDBInstance().
Where("1 = 1").
Delete(&models.PluginConfiguration{}).Error; err != nil {
t.Fatalf("clear plugin configurations: %v", err)
}
}
// writePluginZip creates a plugin ZIP from a name -> content map.
func writePluginZip(t *testing.T, files map[string]string) string {
t.Helper()
-163
View File
@@ -1,163 +0,0 @@
package plugin
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"github.com/Calcium-Ion/moejs"
"github.com/Calcium-Ion/moejs/engine"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/jsruntime"
"github.com/komari-monitor/komari/pkg/jsruntime/native"
"github.com/komari-monitor/komari/utils/messageSender"
)
// registerNotificationChannel registers one channel owned by a plugin. The
// channel is tied to the current runtime, so unloading the plugin removes it.
func (m *Manager) registerNotificationChannel(
short string,
id string,
configuration models.Configuration,
handler moejs.Value,
host *jsruntime.Host,
) error {
inst := m.instanceFor(short)
if inst == nil {
return fmt.Errorf("plugin %q is not loaded", short)
}
if host == nil {
return fmt.Errorf("plugin %q notification runtime is unavailable", short)
}
notificationChannel := &pluginNotificationChannel{
short: short,
host: host,
handler: handler,
}
inst.mu.Lock()
defer inst.mu.Unlock()
if inst.runtime == nil {
return fmt.Errorf("plugin %q notification runtime is closed", short)
}
if err := messageSender.RegisterNotificationChannel(id, configuration, notificationChannel); err != nil {
return err
}
if inst.notificationChannels == nil {
inst.notificationChannels = make(map[string]struct{})
}
inst.notificationChannels[id] = struct{}{}
return nil
}
type pluginNotificationChannel struct {
short string
host *jsruntime.Host
handler moejs.Value
}
func (c *pluginNotificationChannel) Send(ctx context.Context, notification messageSender.Notification, config map[string]any) error {
if c.host == nil || c.handler.IsUndefined() {
return fmt.Errorf("plugin %q notification channel is closed", c.short)
}
result := make(chan error, 1)
var once sync.Once
finish := func(err error) {
once.Do(func() { result <- err })
}
queued := c.host.RunOnLoop(func(vm *moejs.Runtime) {
var value moejs.Value
runErr := c.host.RunJob(vm, "plugin notification "+c.short, func() error {
raw, err := json.Marshal(notification)
if err != nil {
return err
}
var notificationValue map[string]any
if err := json.Unmarshal(raw, &notificationValue); err != nil {
return err
}
var callErr error
value, callErr = vm.CallFunction(c.handler,
moejs.Undefined(),
native.From(vm, notificationValue),
native.From(vm, config),
)
return callErr
})
if runErr != nil {
finish(runErr)
return
}
_, _, ok := moejs.PromiseResult(value)
if !ok {
finish(nil)
return
}
then := native.Get(vm, native.Object(vm, value), "then")
if !engine.IsCallable(then) {
finish(fmt.Errorf("plugin %q notification channel returned a Promise without then", c.short))
return
}
onFulfilled := native.Bind(vm, "notificationFulfilled", 0, func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
finish(nil)
return moejs.Undefined(), nil
})
onRejected := native.Bind(vm, "notificationRejected", 1, func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
finish(fmt.Errorf("plugin %q notification channel failed: %s", c.short, native.String(vm, moejs.Arg(args, 0))))
return moejs.Undefined(), nil
})
if _, err := vm.CallFunction(then, value, onFulfilled, onRejected); err != nil {
finish(fmt.Errorf("plugin %q notification channel Promise failed: %w", c.short, err))
}
})
if !queued {
return fmt.Errorf("plugin %q notification runtime is closed", c.short)
}
timeout := c.host.Timeout()
if deadline, ok := ctx.Deadline(); ok {
if remaining := time.Until(deadline); remaining < timeout {
timeout = remaining
}
}
if timeout <= 0 {
return context.DeadlineExceeded
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case err := <-result:
return err
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return fmt.Errorf("plugin %q notification channel timed out", c.short)
}
}
func (c *pluginNotificationChannel) Unload() error {
return nil
}
func (m *Manager) unregisterNotificationChannels(inst *Instance) error {
if inst == nil {
return nil
}
inst.mu.Lock()
ids := make([]string, 0, len(inst.notificationChannels))
for id := range inst.notificationChannels {
ids = append(ids, id)
}
inst.notificationChannels = nil
inst.mu.Unlock()
var firstErr error
for _, id := range ids {
if err := messageSender.UnregisterNotificationChannel(id); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
+15 -23
View File
@@ -29,12 +29,12 @@ import (
"sync"
"time"
"github.com/Calcium-Ion/moejs"
"github.com/dop251/goja"
"github.com/dop251/goja_nodejs/require"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/internal/scheduler"
"github.com/komari-monitor/komari/pkg/jsruntime"
"github.com/komari-monitor/komari/pkg/jsruntime/require"
"github.com/komari-monitor/komari/pkg/rpc"
"github.com/komari-monitor/komari/web/connection"
)
@@ -113,16 +113,15 @@ type Manager struct {
// Instance is one loaded plugin runtime plus its host bindings.
type Instance struct {
mu sync.RWMutex
info models.Plugin
dir string
runtime *jsruntime.Runtime
host *jsruntime.Host
handlers map[string]moejs.Value // "METHOD path" -> current route handler
statics map[string]*staticConfig // mount path -> static folder config
rpcMethods map[string]moejs.Value // registered RPC method -> JS handler
cronJobs []string // scheduler job names, removed on unload
notificationChannels map[string]struct{}
mu sync.RWMutex
info models.Plugin
dir string
runtime *jsruntime.Runtime
host *jsruntime.Host
handlers map[string]goja.Callable // "METHOD path" -> current route handler
statics map[string]*staticConfig // mount path -> static folder config
rpcMethods map[string]goja.Callable // registered RPC method -> JS handler
cronJobs []string // scheduler job names, removed on unload
}
// global is the process-wide plugin manager.
@@ -262,7 +261,7 @@ func (m *Manager) load(short string) error {
logs.Reset()
_, _ = logs.Write([]byte("[plugin] loading " + short + "\n"))
inst := &Instance{info: info, dir: dir, handlers: make(map[string]moejs.Value), statics: make(map[string]*staticConfig)}
inst := &Instance{info: info, dir: dir, handlers: make(map[string]goja.Callable), statics: make(map[string]*staticConfig)}
m.mu.Lock()
if _, ok := m.instances[short]; ok {
m.mu.Unlock()
@@ -306,8 +305,8 @@ func (m *Manager) load(short string) error {
if rt.HasFunction("load") {
if err := rt.CallVoid("load"); err != nil {
m.dropInstance(short, inst)
rt.Close()
m.dropInstance(short, inst)
return fmt.Errorf("plugin %q load() failed: %w", short, err)
}
}
@@ -329,15 +328,11 @@ func (m *Manager) load(short string) error {
// unload behavior. The caller must not hold any Manager lock.
func (m *Manager) dropInstance(short string, inst *Instance) {
m.mu.Lock()
defer m.mu.Unlock()
if m.instances[short] != inst {
m.mu.Unlock()
return // replaced by a newer load or already unloaded
}
delete(m.instances, short)
m.mu.Unlock()
_ = m.unregisterNotificationChannels(inst)
m.mu.Lock()
defer m.mu.Unlock()
inst.mu.Lock()
for method := range inst.rpcMethods {
rpc.Unregister(method)
@@ -390,9 +385,6 @@ func (m *Manager) unload(short string) error {
}
}
}
if err := m.unregisterNotificationChannels(inst); err != nil && unloadErr == nil {
unloadErr = err
}
m.mu.Lock()
inst.mu.Lock()
for method := range inst.rpcMethods {
@@ -684,7 +676,7 @@ func (m *Manager) hooksOf(kind hookKind) []*hookEntry {
// during script evaluation; it takes the manager lock itself. matcher is
// nil for unfiltered hooks; the plugin-declared body limit is captured at
// registration so unloaded plugins never contribute a stale limit.
func (m *Manager) registerHook(short string, kind hookKind, fn moejs.Value, matcher *hookMatcher) {
func (m *Manager) registerHook(short string, kind hookKind, fn goja.Callable, matcher *hookMatcher) {
m.mu.Lock()
defer m.mu.Unlock()
inst := m.instances[short]
-507
View File
@@ -1,507 +0,0 @@
package plugin
import (
"errors"
"fmt"
"math"
"net"
"sync"
"time"
"unicode/utf8"
"github.com/Calcium-Ion/moejs"
"github.com/Calcium-Ion/moejs/engine"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/pkg/jsruntime"
"github.com/komari-monitor/komari/pkg/jsruntime/native"
"github.com/komari-monitor/komari/web/connection"
)
const (
routeWSOpen = 1
routeWSClosing = 2
routeWSClosed = 3
routeWSMaxQueuedFrames = 64
routeWSMaxQueuedBytes = 16 << 20
routeWSWriteTimeout = 5 * time.Second
routeWSCloseTimeout = 5 * time.Second
)
type routeWSFrame struct {
kind int
data []byte
}
type routeWSCloseEvent struct {
code int
reason string
clean bool
message string
}
// routeWebSocket is registered before hijacking: runtime shutdown must be able
// to close the network connection even while UpgradeSafeConn waits for a
// synchronous wsConnect hook. No SafeConn operation or hook runs on the VM loop.
// mu protects transport state only; jsObject belongs exclusively to the loop.
type routeWebSocket struct {
host *jsruntime.Host
mu sync.Mutex
resourceID uint64
netConn net.Conn
conn *connection.SafeConn
raw *websocket.Conn
state int
started bool
finished bool
notified bool
result routeWSCloseEvent
closeTimer *time.Timer
queue []routeWSFrame
queuedCount int // includes the writer's in-flight frame
queuedBytes int
wake chan struct{}
done chan struct{}
closeRequested bool
closeCode int
closeReason string
closeWritten bool
peerClosed bool
peerCode int
peerReason string
pong string
pongPending bool
jsObject *engine.Object
}
func newRouteWebSocket(host *jsruntime.Host) *routeWebSocket {
ws := &routeWebSocket{
host: host, state: routeWSClosed,
wake: make(chan struct{}, 1), done: make(chan struct{}),
}
// AddResource may call abort synchronously, or shutdown may race with its
// return. Never publish the ID without checking whether teardown already ran.
id := host.AddResource(ws.abort)
ws.mu.Lock()
ws.resourceID = id
finished := ws.finished
ws.mu.Unlock()
if finished {
host.RemoveResource(id)
}
return ws
}
// captureConn is called by the HTTP writer's Hijack method, before the
// upgrader can enter any hooks. It also handles shutdown winning that race.
func (ws *routeWebSocket) captureConn(conn net.Conn) {
ws.mu.Lock()
finished := ws.finished
if !finished {
ws.netConn = conn
}
ws.mu.Unlock()
if finished && conn != nil {
_ = conn.Close()
}
}
// attach runs on the HTTP pump, never the VM loop: GetConn takes SafeConn's
// write mutex. The underlying Gorilla connection is private to these pumps.
func (ws *routeWebSocket) attach(conn *connection.SafeConn) bool {
if conn == nil {
return false
}
raw := conn.GetConn()
raw.SetReadLimit(maxWSFrameBytes)
raw.SetCloseHandler(func(code int, reason string) error {
ws.mu.Lock()
if !ws.finished {
ws.peerClosed, ws.peerCode, ws.peerReason = true, code, reason
// The peer cannot consume further data. Drop queued frames and
// prioritize the close reply (the in-flight writer stays counted).
for _, frame := range ws.queue {
ws.queuedCount--
ws.queuedBytes -= len(frame.data)
}
ws.queue = nil
ws.pong, ws.pongPending = "", false
if !ws.closeRequested {
ws.requestCloseLocked(code, reason)
}
}
ws.mu.Unlock()
ws.signal()
return nil // the writer, not the reader, sends the close reply
})
raw.SetPingHandler(func(data string) error {
ws.mu.Lock()
if !ws.finished {
// A ping flood must not allocate an unbounded control queue. RFC 6455
// allows replying only to the most recently processed ping.
ws.pong, ws.pongPending = data, true
}
ws.mu.Unlock()
ws.signal()
return nil
})
ws.mu.Lock()
if ws.finished || ws.conn != nil {
ws.mu.Unlock()
_ = raw.Close()
go func() { _ = conn.Close() }()
return false
}
ws.conn, ws.raw, ws.state = conn, raw, routeWSOpen
if ws.netConn == nil {
ws.netConn = raw.UnderlyingConn()
}
ws.mu.Unlock()
return true
}
// abort is a non-waiting resource callback. Close the captured network socket
// first to unblock I/O; SafeConn.Close may wait for same-plugin hooks, so it
// must happen separately, after the socket is already closed.
func (ws *routeWebSocket) abort() {
ws.finish(routeWSCloseEvent{code: websocket.CloseAbnormalClosure})
}
func (ws *routeWebSocket) finish(result routeWSCloseEvent) {
ws.mu.Lock()
if ws.finished {
ws.mu.Unlock()
return
}
ws.finished, ws.state, ws.result = true, routeWSClosed, result
ws.queue = nil
ws.queuedCount, ws.queuedBytes = 0, 0
if ws.closeTimer != nil {
ws.closeTimer.Stop()
}
netConn, conn, id := ws.netConn, ws.conn, ws.resourceID
notify := ws.started && !ws.notified
if notify {
ws.notified = true
}
ws.mu.Unlock()
if netConn != nil {
_ = netConn.Close()
}
ws.host.RemoveResource(id)
close(ws.done)
if conn != nil {
go func() { _ = conn.Close() }()
}
if notify {
ws.notifyClose(result)
}
}
// start is called on a later loop turn than promise resolution. This barrier
// lets .then/await reactions install handlers before any data or close event.
func (ws *routeWebSocket) start() {
ws.mu.Lock()
if ws.started {
ws.mu.Unlock()
return
}
ws.started = true
if ws.finished {
notify, result := !ws.notified, ws.result
ws.notified = true
ws.mu.Unlock()
if notify {
ws.notifyClose(result)
}
return
}
attached := ws.conn != nil
ws.mu.Unlock()
if !attached || !ws.host.ResourcesOpen() {
ws.abort()
return
}
go ws.writePump()
go ws.readPump()
}
func (ws *routeWebSocket) signal() {
select {
case ws.wake <- struct{}{}:
default:
}
}
func (ws *routeWebSocket) requestCloseLocked(code int, reason string) {
ws.state, ws.closeRequested = routeWSClosing, true
ws.closeCode, ws.closeReason = code, reason
ws.closeTimer = time.AfterFunc(routeWSCloseTimeout, func() {
ws.fail(errors.New("WebSocket close handshake timed out"))
})
}
func (ws *routeWebSocket) fail(err error) {
ws.finish(routeWSCloseEvent{code: websocket.CloseAbnormalClosure, message: err.Error()})
}
func (ws *routeWebSocket) writePump() {
for {
ws.mu.Lock()
if ws.finished {
ws.mu.Unlock()
return
}
if ws.closeWritten && ws.peerClosed {
result := routeWSCloseEvent{code: ws.peerCode, reason: ws.peerReason, clean: true}
ws.mu.Unlock()
ws.finish(result)
return
}
var frame routeWSFrame
control, haveFrame := false, false
switch {
case ws.pongPending && !ws.closeWritten:
frame = routeWSFrame{kind: websocket.PongMessage, data: []byte(ws.pong)}
ws.pong, ws.pongPending, control, haveFrame = "", false, true, true
case len(ws.queue) != 0:
frame, haveFrame = ws.queue[0], true
ws.queue[0] = routeWSFrame{}
ws.queue = ws.queue[1:]
case ws.closeRequested && !ws.closeWritten:
frame = routeWSFrame{kind: websocket.CloseMessage, data: websocket.FormatCloseMessage(ws.closeCode, ws.closeReason)}
control, haveFrame = true, true
}
conn, raw := ws.conn, ws.raw
ws.mu.Unlock()
if !haveFrame {
select {
case <-ws.wake:
case <-ws.done:
return
}
continue
}
deadline := time.Now().Add(routeWSWriteTimeout)
var err error
if control {
err = conn.WriteControl(frame.kind, frame.data, deadline)
} else {
err = raw.SetWriteDeadline(deadline)
if err == nil {
err = conn.WriteMessage(frame.kind, frame.data)
}
}
if err != nil {
ws.fail(err)
return
}
ws.mu.Lock()
if !ws.finished {
if frame.kind == websocket.CloseMessage {
ws.closeWritten = true
} else if !control {
ws.queuedCount--
ws.queuedBytes -= len(frame.data)
}
}
ws.mu.Unlock()
}
}
func (ws *routeWebSocket) readPump() {
for {
kind, data, err := ws.conn.ReadMessage()
if err != nil {
ws.mu.Lock()
peerClosed := ws.peerClosed
ws.mu.Unlock()
if peerClosed {
ws.signal() // the writer completes the close handshake
} else {
ws.fail(err)
}
return
}
if len(data) > maxWSFrameBytes {
ws.fail(fmt.Errorf("WebSocket message exceeds %d bytes", maxWSFrameBytes))
return
}
if kind != websocket.TextMessage && kind != websocket.BinaryMessage {
continue
}
if kind == websocket.TextMessage && !utf8.Valid(data) {
ws.fail(errors.New("WebSocket text message is not valid UTF-8"))
return
}
ack := make(chan struct{})
if !ws.host.RunOnLoop(func(vm *moejs.Runtime) {
defer close(ack)
jobErr := ws.host.RunJob(vm, "plugin WebSocket.onmessage", func() error {
ws.mu.Lock()
open := ws.state == routeWSOpen
ws.mu.Unlock()
if !open || ws.jsObject == nil {
return nil
}
var value any = string(data)
if kind == websocket.BinaryMessage {
buffer, err := vm.Realm().NewArrayBuffer(data)
if err != nil {
return err
}
value = engine.ObjectValue(buffer)
}
event := vm.Realm().NewObject()
if err := native.Set(vm, event, "data", value); err != nil {
return err
}
return ws.callHandler(vm, "onmessage", event)
})
if jobErr != nil {
ws.fail(jobErr)
}
}) {
ws.abort()
return
}
// Only one inbound payload may wait for JavaScript. Cancellation must
// also release this reader when queued jobs are discarded on shutdown.
select {
case <-ack:
case <-ws.done:
return
}
}
}
// All methods below that touch jsObject or JS values run only on the host loop.
func (ws *routeWebSocket) object(vm *moejs.Runtime) *engine.Object {
if ws.jsObject != nil {
return ws.jsObject
}
obj := vm.Realm().NewObject()
ws.jsObject = obj
for _, name := range []string{"onmessage", "onerror", "onclose"} {
_ = native.Set(vm, obj, name, moejs.Null())
}
getter := native.Bind(vm, "get readyState", 0, func(_ *moejs.Realm, _ moejs.Value, _ []moejs.Value) (moejs.Value, error) {
ws.mu.Lock()
state := ws.state
ws.mu.Unlock()
return native.From(vm, state), nil
})
desc := engine.AccessorDescriptor(getter.AsObject(), nil, 0)
desc.SetEnumerable(true)
if err := obj.DefinePropertyOrThrow(vm.Realm(), vm.Realm().KeyFromGoString("readyState"), desc); err != nil {
panic(err)
}
_ = native.Function(vm, obj, "send", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
value := moejs.Arg(args, 0)
kind := websocket.BinaryMessage
var data []byte
if value.IsString() {
kind = websocket.TextMessage
text := native.String(vm, value)
if len(text) > maxWSFrameBytes {
return moejs.Undefined(), native.TypeError(vm, "WebSocket message exceeds %d bytes", maxWSFrameBytes)
}
data = []byte(text)
} else if value.IsObject() {
var ok bool
data, ok = value.AsObject().BufferData()
if !ok {
return moejs.Undefined(), native.TypeError(vm, "WebSocket.send requires a string, ArrayBuffer or ArrayBuffer view")
}
} else {
return moejs.Undefined(), native.TypeError(vm, "WebSocket.send requires a string, ArrayBuffer or ArrayBuffer view")
}
if len(data) > maxWSFrameBytes {
return moejs.Undefined(), native.TypeError(vm, "WebSocket message exceeds %d bytes", maxWSFrameBytes)
}
ws.mu.Lock()
if ws.state != routeWSOpen {
ws.mu.Unlock()
return moejs.Undefined(), native.TypeError(vm, "WebSocket is not open")
}
if ws.queuedCount >= routeWSMaxQueuedFrames || len(data) > routeWSMaxQueuedBytes-ws.queuedBytes {
ws.mu.Unlock()
return moejs.Undefined(), native.TypeError(vm, "WebSocket send queue is full")
}
ws.queue = append(ws.queue, routeWSFrame{kind: kind, data: append([]byte(nil), data...)})
ws.queuedCount++
ws.queuedBytes += len(data)
ws.mu.Unlock()
ws.signal()
return moejs.Undefined(), nil
})
_ = native.Function(vm, obj, "close", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
code := websocket.CloseNormalClosure
if v := moejs.Arg(args, 0); !v.IsUndefined() {
number := native.Float(vm, v)
if math.IsNaN(number) || math.IsInf(number, 0) || math.Trunc(number) != number || (number != 1000 && (number < 3000 || number > 4999)) {
return moejs.Undefined(), native.TypeError(vm, "WebSocket close code must be 1000 or between 3000 and 4999")
}
code = int(number)
}
reason := ""
if v := moejs.Arg(args, 1); !v.IsUndefined() {
reason = native.String(vm, v)
}
if len(reason) > 123 || !utf8.ValidString(reason) {
return moejs.Undefined(), native.TypeError(vm, "WebSocket close reason must be valid UTF-8 and at most 123 bytes")
}
ws.mu.Lock()
if ws.state == routeWSOpen {
ws.requestCloseLocked(code, reason)
}
ws.mu.Unlock()
ws.signal()
return moejs.Undefined(), nil
})
return obj
}
func (ws *routeWebSocket) callHandler(vm *moejs.Runtime, name string, event *engine.Object) error {
if ws.jsObject == nil {
return nil
}
callback, err := vm.Get(engine.ObjectValue(ws.jsObject), name)
if err != nil || !engine.IsCallable(callback) {
return err
}
_, err = vm.CallFunction(callback, engine.ObjectValue(ws.jsObject), engine.ObjectValue(event))
return err
}
func (ws *routeWebSocket) notifyClose(result routeWSCloseEvent) {
ws.host.RunOnLoop(func(vm *moejs.Runtime) {
if ws.jsObject == nil {
return
}
defer func() { ws.jsObject = nil }()
if result.message != "" {
_ = ws.host.RunJob(vm, "plugin WebSocket.onerror", func() error {
event := vm.Realm().NewObject()
if err := native.Set(vm, event, "message", result.message); err != nil {
return err
}
return ws.callHandler(vm, "onerror", event)
})
}
// A throwing onerror must not suppress the final onclose callback.
_ = ws.host.RunJob(vm, "plugin WebSocket.onclose", func() error {
event := vm.Realm().NewObject()
_ = native.Set(vm, event, "code", result.code)
_ = native.Set(vm, event, "reason", result.reason)
_ = native.Set(vm, event, "wasClean", result.clean)
return ws.callHandler(vm, "onclose", event)
})
})
}
+22 -24
View File
@@ -7,8 +7,7 @@ import (
"strings"
"time"
"github.com/Calcium-Ion/moejs"
"github.com/komari-monitor/komari/pkg/jsruntime/native"
"github.com/dop251/goja"
"github.com/komari-monitor/komari/pkg/rpc"
)
@@ -16,7 +15,7 @@ import (
// JavaScript function receiving the JSON-RPC params and returning the result
// (or throwing an Error with optional code/data). Called from the plugin's
// own event loop during script evaluation; it takes the manager lock itself.
func (m *Manager) registerRPC(short, method string, handler moejs.Value) error {
func (m *Manager) registerRPC(short, method string, handler goja.Callable) error {
method = strings.TrimSpace(method)
if method == "" || strings.HasPrefix(method, "rpc.") {
return fmt.Errorf("invalid RPC method name %q", method)
@@ -29,7 +28,7 @@ func (m *Manager) registerRPC(short, method string, handler moejs.Value) error {
}
inst.mu.Lock()
if inst.rpcMethods == nil {
inst.rpcMethods = make(map[string]moejs.Value)
inst.rpcMethods = make(map[string]goja.Callable)
}
if _, exists := inst.rpcMethods[method]; exists {
inst.mu.Unlock()
@@ -58,24 +57,24 @@ func (m *Manager) rpcHandler(short, method string) rpc.Handler {
host := inst.host
alive := inst.runtime != nil
inst.mu.RUnlock()
if !alive || handler.IsUndefined() || host == nil {
if !alive || handler == nil || host == nil {
return nil, &rpc.JsonRpcError{Code: rpc.MethodNotFound, Message: "method not found", Data: method}
}
resultCh := make(chan any, 1)
errCh := make(chan *rpc.JsonRpcError, 1)
queued := host.RunOnLoop(func(vm *moejs.Runtime) {
queued := host.RunOnLoop(func(vm *goja.Runtime) {
_ = host.RunJob(vm, "plugin rpc "+method, func() error {
params := native.From(vm, req.Params)
if params.IsUndefined() {
params = moejs.Null()
params := vm.ToValue(req.Params)
if params == nil {
params = goja.Null()
}
value, callErr := vm.CallFunction(handler, moejs.Undefined(), params)
value, callErr := handler(goja.Undefined(), params)
if callErr != nil {
errCh <- jsErrorToRPCError(vm, callErr)
return nil // error is captured, do not double-report
}
exported, exportErr := exportJSValue(vm, value)
exported, exportErr := exportJSValue(value)
if exportErr != nil {
errCh <- &rpc.JsonRpcError{Code: rpc.InternalError, Message: exportErr.Error()}
return nil
@@ -100,39 +99,38 @@ func (m *Manager) rpcHandler(short, method string) rpc.Handler {
}
}
// exportJSValue exports a native JavaScript value into a JSON-RPC result. Circular
// exportJSValue exports a goja value into a JSON-RPC result. Circular
// structures and other export failures become an error instead of panicking
// inside the event loop.
func exportJSValue(vm *moejs.Runtime, value moejs.Value) (result any, err error) {
func exportJSValue(value goja.Value) (result any, err error) {
defer func() {
if recovered := recover(); recovered != nil {
result = nil
err = fmt.Errorf("export JavaScript value: %v", recovered)
}
}()
return vm.ToGo(value)
return value.Export(), nil
}
// jsErrorToRPCError converts a thrown JavaScript error into a JSON-RPC error,
// honoring optional code/data properties.
func jsErrorToRPCError(vm *moejs.Runtime, err error) *rpc.JsonRpcError {
func jsErrorToRPCError(vm *goja.Runtime, err error) *rpc.JsonRpcError {
code := rpc.InternalError
message := err.Error()
var data any
var exception *moejs.Exception
var exception *goja.Exception
if errors.As(err, &exception) {
if exception.Value.IsObject() {
obj := exception.Value.AsObject()
if value := native.Get(vm, obj, "code"); !value.IsUndefined() {
if n := native.Int(vm, value); n != 0 {
if obj, ok := exception.Value().(*goja.Object); ok {
if value := obj.Get("code"); value != nil && !goja.IsUndefined(value) {
if n := value.ToInteger(); n != 0 {
code = int(n)
}
}
if value := native.Get(vm, obj, "message"); !value.IsUndefined() && native.String(vm, value) != "" {
message = native.String(vm, value)
if value := obj.Get("message"); value != nil && !goja.IsUndefined(value) && value.String() != "" {
message = value.String()
}
if value := native.Get(vm, obj, "data"); !value.IsUndefined() {
if exported, exportErr := exportJSValue(vm, value); exportErr == nil {
if value := obj.Get("data"); value != nil && !goja.IsUndefined(value) {
if exported, exportErr := exportJSValue(value); exportErr == nil {
data = exported
}
}
+134 -341
View File
@@ -1,13 +1,11 @@
package plugin
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
@@ -16,17 +14,13 @@ import (
"sync/atomic"
"time"
"github.com/Calcium-Ion/moejs"
"github.com/Calcium-Ion/moejs/engine"
"github.com/dop251/goja"
"github.com/dop251/goja_nodejs/buffer"
"github.com/dop251/goja_nodejs/require"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/jsruntime"
"github.com/komari-monitor/komari/pkg/jsruntime/httpbody"
"github.com/komari-monitor/komari/pkg/jsruntime/native"
"github.com/komari-monitor/komari/pkg/jsruntime/require"
"github.com/komari-monitor/komari/pkg/rpc"
"github.com/komari-monitor/komari/web/api"
)
// registerServerModule registers the "server" native module for one plugin
@@ -34,8 +28,7 @@ import (
//
// server.route(method, path, handler) register an HTTP route on the host
// engine; handler receives (req, res)
// and finishes via res.end() or an
// awaited req.upgradeWebSocket()
// and must call res.end() to finish
// server.static(path, dir, opts) serve a folder from the plugin
// directory at a mount path; opts.spa
// makes unmatched paths fall back to
@@ -60,8 +53,6 @@ import (
// result or rejects with an Error
// carrying code/message/data
// server.registerRPC(method, handler) register a plugin-owned RPC method
// server.registerNotificationChannel(id, configuration, handler)
// register a notification channel
// server.getConfig() resolve the saved plugin configuration
// server.cron(expr, fn) run fn on the plugin event loop each
// time the cron expression fires
@@ -77,50 +68,49 @@ import (
// The host engine keeps a registered route slot after unload; requests then
// receive 404 until the plugin is loaded again.
func (m *Manager) registerServerModule(host *jsruntime.Host, registry *require.Registry, inst *Instance) {
registry.RegisterNativeModule("server", func(vm *moejs.Runtime, module *engine.Object) {
exports := vm.Realm().NewObject()
_ = native.Function(vm, exports, "route", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
method := strings.ToUpper(strings.TrimSpace(native.String(vm, moejs.Arg(args, 0))))
path := native.String(vm, moejs.Arg(args, 1))
handler := moejs.Arg(args, 2)
ok := engine.IsCallable(handler)
registry.RegisterNativeModule("server", func(vm *goja.Runtime, module *goja.Object) {
exports := vm.NewObject()
_ = exports.Set("route", func(call goja.FunctionCall) goja.Value {
method := strings.ToUpper(strings.TrimSpace(call.Argument(0).String()))
path := call.Argument(1).String()
handler, ok := goja.AssertFunction(call.Argument(2))
if method == "" {
return moejs.Undefined(), native.TypeError(vm, "server.route requires an HTTP method")
panic(vm.NewTypeError("server.route requires an HTTP method"))
}
if path == "" || !strings.HasPrefix(path, "/") {
return moejs.Undefined(), native.TypeError(vm, "server.route requires a path starting with /")
panic(vm.NewTypeError("server.route requires a path starting with /"))
}
if !ok {
return moejs.Undefined(), native.TypeError(vm, "server.route requires a function handler")
panic(vm.NewTypeError("server.route requires a function handler"))
}
if !inst.info.Permissions.AllowRoutes {
return moejs.Undefined(), native.TypeError(vm, "server.route requires the \"route\" permission (allowRoutes)")
panic(vm.NewTypeError("server.route requires the \"route\" permission (allowRoutes)"))
}
if err := m.registerRoute(inst.info.Short, method, path, handler); err != nil {
return moejs.Undefined(), err
panic(vm.NewGoError(err))
}
return moejs.Undefined(), nil
return goja.Undefined()
})
_ = native.Function(vm, exports, "static", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
mount := strings.TrimSpace(native.String(vm, moejs.Arg(args, 0)))
_ = exports.Set("static", func(call goja.FunctionCall) goja.Value {
mount := strings.TrimSpace(call.Argument(0).String())
mount = strings.TrimRight(mount, "/")
dir := strings.TrimSpace(native.String(vm, moejs.Arg(args, 1)))
dir := strings.TrimSpace(call.Argument(1).String())
if mount == "" || mount == "/" || !strings.HasPrefix(mount, "/") {
return moejs.Undefined(), native.TypeError(vm, "server.static requires a mount path starting with / (and not \"/\")")
panic(vm.NewTypeError("server.static requires a mount path starting with / (and not \"/\")"))
}
if dir == "" {
return moejs.Undefined(), native.TypeError(vm, "server.static requires a folder name")
panic(vm.NewTypeError("server.static requires a folder name"))
}
if !filepath.IsLocal(dir) {
return moejs.Undefined(), native.TypeError(vm, "server.static folder must be a relative path inside the plugin directory")
panic(vm.NewTypeError("server.static folder must be a relative path inside the plugin directory"))
}
if !inst.info.Permissions.AllowRoutes {
return moejs.Undefined(), native.TypeError(vm, "server.static requires the \"route\" permission (allowRoutes)")
panic(vm.NewTypeError("server.static requires the \"route\" permission (allowRoutes)"))
}
var opts map[string]any
if !moejs.Arg(args, 2).IsUndefined() && !moejs.Arg(args, 2).IsNull() {
if err := native.ExportTo(vm, moejs.Arg(args, 2), &opts); err != nil {
return moejs.Undefined(), native.TypeError(vm, "server.static options must be an object like { spa: true }")
if !goja.IsUndefined(call.Argument(2)) && !goja.IsNull(call.Argument(2)) {
if err := vm.ExportTo(call.Argument(2), &opts); err != nil {
panic(vm.NewTypeError("server.static options must be an object like { spa: true }"))
}
}
spa := false
@@ -128,172 +118,142 @@ func (m *Manager) registerServerModule(host *jsruntime.Host, registry *require.R
spa, _ = v.(bool)
}
if err := m.registerStatic(inst.info.Short, mount, dir, spa); err != nil {
return moejs.Undefined(), err
panic(vm.NewGoError(err))
}
return moejs.Undefined(), nil
return goja.Undefined()
})
_ = native.Function(vm, exports, "call", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
method := strings.TrimSpace(native.String(vm, moejs.Arg(args, 0)))
_ = exports.Set("call", func(call goja.FunctionCall) goja.Value {
method := strings.TrimSpace(call.Argument(0).String())
if method == "" {
return moejs.Undefined(), native.TypeError(vm, "server.call requires an RPC method name")
panic(vm.NewTypeError("server.call requires an RPC method name"))
}
params := callParams(vm, args)
params := callParams(call)
promise, resolve, reject := vm.NewPromise()
go func() {
if !inst.info.Permissions.AllowSystemRPC {
host.RunOnLoop(func(vm *moejs.Runtime) {
_ = reject(native.GoError(vm, errors.New("server.call requires the \"system RPC\" permission (allowSystemRPC)")))
host.RunOnLoop(func(vm *goja.Runtime) {
_ = reject(vm.NewGoError(errors.New("server.call requires the \"system RPC\" permission (allowSystemRPC)")))
})
return
}
meta := &rpc.ContextMeta{Permission: rpc.RoleAdmin, Principal: rpc.PrincipalFromRole(rpc.RoleAdmin)}
ctx := rpc.NewContextWithMeta(context.Background(), meta)
resp := rpc.CallWithContext(ctx, nil, method, params)
host.RunOnLoop(func(vm *moejs.Runtime) {
host.RunOnLoop(func(vm *goja.Runtime) {
if resp.Error != nil {
_ = reject(engine.ObjectValue(jsRPCError(vm, resp.Error)))
_ = reject(jsRPCError(vm, resp.Error))
return
}
result, err := normalizeRPCValue(resp.Result)
if err != nil {
_ = reject(native.GoError(vm, err))
_ = reject(vm.NewGoError(err))
return
}
_ = resolve(native.From(vm, result))
_ = resolve(result)
})
}()
return promise, nil
return vm.ToValue(promise)
})
_ = native.Function(vm, exports, "hook", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
kind := strings.ToLower(strings.TrimSpace(native.String(vm, moejs.Arg(args, 0))))
_ = exports.Set("hook", func(call goja.FunctionCall) goja.Value {
kind := strings.ToLower(strings.TrimSpace(call.Argument(0).String()))
switch kind {
case "request", "response", string(hookWSConnect), string(hookWSMessage), string(hookWSSend), string(hookWSClose):
if !inst.info.Permissions.AllowHooks {
return moejs.Undefined(), native.TypeError(vm, "server.hook requires the \"hook\" permission (allowHooks)")
panic(vm.NewTypeError("server.hook requires the \"hook\" permission (allowHooks)"))
}
default:
return moejs.Undefined(), native.TypeError(vm, "server.hook kind must be \"request\", \"response\", \"wsConnect\", \"wsMessage\", \"wsSend\" or \"wsClose\"")
panic(vm.NewTypeError("server.hook kind must be \"request\", \"response\", \"wsConnect\", \"wsMessage\", \"wsSend\" or \"wsClose\""))
}
// server.hook(kind, fn) or server.hook(kind, matcher, fn) where
// matcher is "METHOD /path", "/path" or "/path/*". The ws kinds
// only accept a path matcher: every upgrade is a GET request.
fnValue := moejs.Arg(args, 1)
fnValue := call.Argument(1)
var matcher *hookMatcher
if fnValue.IsString() {
parsed, err := parseHookMatcher(native.String(vm, fnValue))
if goja.IsString(fnValue) {
parsed, err := parseHookMatcher(fnValue.String())
if err != nil {
return moejs.Undefined(), native.TypeError(vm, "%s", err.Error())
panic(vm.NewTypeError(err.Error()))
}
if isWSHookKind(kind) && parsed.method != "" {
return moejs.Undefined(), native.TypeError(vm, "server.hook %q only accepts a path matcher like /api/*", kind)
panic(vm.NewTypeError("server.hook \"" + kind + "\" only accepts a path matcher like \"/api/*\""))
}
matcher = parsed
fnValue = moejs.Arg(args, 2)
fnValue = call.Argument(2)
}
fn := fnValue
ok := engine.IsCallable(fn)
fn, ok := goja.AssertFunction(fnValue)
if !ok {
return moejs.Undefined(), native.TypeError(vm, "server.hook requires a function handler")
panic(vm.NewTypeError("server.hook requires a function handler"))
}
m.registerHook(inst.info.Short, hookKind(kind), fn, matcher)
return moejs.Undefined(), nil
return goja.Undefined()
})
_ = native.Function(vm, exports, "injectHTML", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
_ = exports.Set("injectHTML", func(call goja.FunctionCall) goja.Value {
if !inst.info.Permissions.AllowHTMLInject {
return moejs.Undefined(), native.TypeError(vm, "server.injectHTML requires the \"HTML inject\" permission (allowHTMLInject)")
panic(vm.NewTypeError("server.injectHTML requires the \"HTML inject\" permission (allowHTMLInject)"))
}
m.registerInject(inst.info.Short, native.String(vm, moejs.Arg(args, 0)), native.String(vm, moejs.Arg(args, 1)))
return moejs.Undefined(), nil
m.registerInject(inst.info.Short, call.Argument(0).String(), call.Argument(1).String())
return goja.Undefined()
})
_ = native.Function(vm, exports, "cron", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
spec := strings.TrimSpace(native.String(vm, moejs.Arg(args, 0)))
fn := moejs.Arg(args, 1)
ok := engine.IsCallable(fn)
_ = exports.Set("cron", func(call goja.FunctionCall) goja.Value {
spec := strings.TrimSpace(call.Argument(0).String())
fn, ok := goja.AssertFunction(call.Argument(1))
if spec == "" {
return moejs.Undefined(), native.TypeError(vm, "server.cron requires a cron expression")
panic(vm.NewTypeError("server.cron requires a cron expression"))
}
if !ok {
return moejs.Undefined(), native.TypeError(vm, "server.cron requires a function handler")
panic(vm.NewTypeError("server.cron requires a function handler"))
}
if err := m.registerCron(inst.info.Short, spec, fn); err != nil {
return moejs.Undefined(), err
panic(vm.NewGoError(err))
}
return moejs.Undefined(), nil
return goja.Undefined()
})
_ = native.Function(vm, exports, "registerRPC", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
method := strings.TrimSpace(native.String(vm, moejs.Arg(args, 0)))
fn := moejs.Arg(args, 1)
ok := engine.IsCallable(fn)
_ = exports.Set("registerRPC", func(call goja.FunctionCall) goja.Value {
method := strings.TrimSpace(call.Argument(0).String())
fn, ok := goja.AssertFunction(call.Argument(1))
if method == "" {
return moejs.Undefined(), native.TypeError(vm, "server.registerRPC requires an RPC method name")
panic(vm.NewTypeError("server.registerRPC requires an RPC method name"))
}
if !ok {
return moejs.Undefined(), native.TypeError(vm, "server.registerRPC requires a function handler")
panic(vm.NewTypeError("server.registerRPC requires a function handler"))
}
if err := m.registerRPC(inst.info.Short, method, fn); err != nil {
return moejs.Undefined(), err
panic(vm.NewGoError(err))
}
return moejs.Undefined(), nil
return goja.Undefined()
})
_ = native.Function(vm, exports, "registerNotificationChannel", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
id := strings.TrimSpace(native.String(vm, moejs.Arg(args, 0)))
handler := moejs.Arg(args, 2)
ok := engine.IsCallable(handler)
if id == "" {
return moejs.Undefined(), native.TypeError(vm, "server.registerNotificationChannel requires a channel id")
}
if !ok {
return moejs.Undefined(), native.TypeError(vm, "server.registerNotificationChannel requires a function handler")
}
rawConfiguration, err := exportJSValue(vm, moejs.Arg(args, 1))
if err != nil {
return moejs.Undefined(), native.TypeError(vm, "server.registerNotificationChannel requires a managed configuration object")
}
configurationJSON, err := json.Marshal(rawConfiguration)
if err != nil {
return moejs.Undefined(), native.TypeError(vm, "server.registerNotificationChannel requires a managed configuration object")
}
var configuration models.Configuration
if err := json.Unmarshal(configurationJSON, &configuration); err != nil {
return moejs.Undefined(), native.TypeError(vm, "server.registerNotificationChannel requires a managed configuration object")
}
if err := m.registerNotificationChannel(inst.info.Short, id, configuration, handler, host); err != nil {
return moejs.Undefined(), err
}
return moejs.Undefined(), nil
})
_ = native.Function(vm, exports, "getConfig", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
_ = exports.Set("getConfig", func(call goja.FunctionCall) goja.Value {
promise, resolve, reject := vm.NewPromise()
go func() {
data, err := GetConfiguration(inst.info.Short)
host.RunOnLoop(func(vm *moejs.Runtime) {
host.RunOnLoop(func(vm *goja.Runtime) {
if err != nil {
_ = reject(native.GoError(vm, err))
_ = reject(vm.NewGoError(err))
return
}
_ = resolve(native.From(vm, data))
_ = resolve(data)
})
}()
return promise, nil
return vm.ToValue(promise)
})
_ = native.Set(vm, module, "exports", exports)
_ = module.Set("exports", exports)
})
}
// callParams converts JS arguments into JSON-RPC params: no params, a single
// value, or a positional array. Values that cannot be exported (for example
// circular objects) are skipped so the call rejects instead of panicking.
func callParams(vm *moejs.Runtime, args []moejs.Value) any {
switch n := len(args); {
func callParams(call goja.FunctionCall) any {
switch n := len(call.Arguments); {
case n <= 1:
return nil
case n == 2:
exported, _ := exportJSValue(vm, moejs.Arg(args, 1))
exported, _ := exportJSValue(call.Argument(1))
return exported
default:
params := make([]any, 0, n-1)
for i := 1; i < n; i++ {
exported, err := exportJSValue(vm, moejs.Arg(args, i))
exported, err := exportJSValue(call.Argument(i))
if err != nil {
return nil
}
@@ -303,11 +263,11 @@ func callParams(vm *moejs.Runtime, args []moejs.Value) any {
}
}
func jsRPCError(vm *moejs.Runtime, e *rpc.JsonRpcError) *engine.Object {
obj := native.Object(vm, native.GoError(vm, fmt.Errorf("%s", e.Message)))
_ = native.Set(vm, obj, "code", e.Code)
func jsRPCError(vm *goja.Runtime, e *rpc.JsonRpcError) *goja.Object {
obj := vm.NewGoError(fmt.Errorf("%s", e.Message))
_ = obj.Set("code", e.Code)
if e.Data != nil {
_ = native.Set(vm, obj, "data", e.Data)
_ = obj.Set("data", e.Data)
}
return obj
}
@@ -329,7 +289,7 @@ func normalizeRPCValue(value any) (any, error) {
// called from the plugin's own event loop during script evaluation and takes
// the manager lock itself. The critical section is short and never waits on
// the event loop, so no lock cycle is possible.
func (m *Manager) registerRoute(short, method, path string, handler moejs.Value) (err error) {
func (m *Manager) registerRoute(short, method, path string, handler goja.Callable) (err error) {
key := method + " " + path
m.mu.Lock()
defer m.mu.Unlock()
@@ -345,8 +305,7 @@ func (m *Manager) registerRoute(short, method, path string, handler moejs.Value)
}
slotExists := m.routes[short][key]
inst.mu.Lock()
// A missing map entry yields a zero moejs.Value, not JavaScript undefined.
if _, registered := inst.handlers[key]; !slotExists && registered {
if !slotExists && inst.handlers[key] != nil {
inst.mu.Unlock()
return nil // already registered in this load
}
@@ -385,7 +344,7 @@ func (m *Manager) routeHandler(short string) gin.HandlerFunc {
alive := inst.runtime != nil
limit := inst.info.Permissions.MaxHTTPBodyBytes
inst.mu.RUnlock()
if !alive || handler.IsUndefined() || host == nil {
if !alive || handler == nil || host == nil {
c.Status(http.StatusNotFound)
return
}
@@ -406,23 +365,11 @@ func (m *Manager) routeHandler(short string) gin.HandlerFunc {
chunks: make(chan []byte, 4),
abortCh: make(chan struct{}),
}
state.upgrades = make(chan *routeUpgrade, 1)
defer state.seal()
// Gin recycles c when the request returns, including on a timeout while
// this turn is still queued. Only the HTTP pump uses the original.
snapshot := c.Copy()
snapshot.Request = c.Request.Clone(c.Request.Context())
if principal := rpcPrincipalFromGin(c); principal != nil {
copy := *principal
copy.Roles = append([]string(nil), principal.Roles...)
snapshot.Set(principalContextKey, &copy)
}
queued := host.RunOnLoop(func(vm *moejs.Runtime) {
req := routeRequest(vm, snapshot, body)
queued := host.RunOnLoop(func(vm *goja.Runtime) {
req := routeRequest(vm, c, body)
res := routeResponseObject(vm, state)
bindRouteUpgrade(vm, req, res, state, host, snapshot.Request)
runErr := host.RunJob(vm, "plugin route "+short, func() error {
_, callErr := vm.CallFunction(handler, moejs.Undefined(), engine.ObjectValue(req), engine.ObjectValue(res))
_, callErr := handler(goja.Undefined(), req, res)
return callErr
})
if runErr != nil {
@@ -463,36 +410,6 @@ func (m *Manager) routeHandler(short string) gin.HandlerFunc {
committed := false
for {
select {
case upgrade := <-state.upgrades:
state.mu.Lock()
state.sealed = true
state.upgradeClaimed = true
state.mu.Unlock()
idle.Stop()
if c.Request.Context().Err() != nil || !host.ResourcesOpen() {
upgrade.ws.abort()
upgrade.complete(errors.New("WebSocket upgrade canceled"))
return
}
stopCancel := context.AfterFunc(c.Request.Context(), upgrade.ws.abort)
original := c.Writer
c.Writer = &routeUpgradeWriter{ResponseWriter: original, ws: upgrade.ws}
socket, upgradeErr := api.UpgradeSafeConn(c, func(upgrader *websocket.Upgrader) {
upgrader.HandshakeTimeout = host.Timeout()
})
c.Writer = original
stopCancel()
if upgradeErr != nil {
upgrade.ws.abort()
upgrade.complete(upgradeErr)
return
}
if !upgrade.ws.attach(socket) {
upgrade.complete(errors.New("WebSocket upgrade canceled"))
return
}
upgrade.complete(nil)
return
case chunk := <-state.chunks:
if !committed {
state.mu.Lock()
@@ -518,18 +435,19 @@ func (m *Manager) routeHandler(short string) gin.HandlerFunc {
}
idle.Reset(host.Timeout())
case <-state.done:
state.seal()
if !committed {
writeFinal(0, nil)
}
return
case <-c.Request.Context().Done():
state.seal()
state.aborted.Store(true)
close(state.abortCh)
return
case <-idle.C:
state.seal()
if state.isStreaming() {
// Streaming idle timeout: end the stream and notify JS
state.aborted.Store(true)
close(state.abortCh)
return
}
writeFinal(http.StatusGatewayTimeout, []byte("plugin route handler timed out"))
@@ -672,100 +590,12 @@ type routeResponse struct {
chunks chan []byte
aborted atomic.Bool
abortCh chan struct{}
wrote bool
sealed bool
upgradeClaimed bool
upgrade *routeUpgrade
upgrades chan *routeUpgrade
}
// routeUpgrade keeps the Promise on its original loop. No network goroutine
// touches the resolver or socket object directly.
type routeUpgrade struct {
ws *routeWebSocket
complete func(error)
}
type routeUpgradeWriter struct {
gin.ResponseWriter
ws *routeWebSocket
}
func (w *routeUpgradeWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
conn, rw, err := w.ResponseWriter.Hijack()
if err == nil {
w.ws.captureConn(conn)
}
return conn, rw, err
}
func bindRouteUpgrade(vm *moejs.Runtime, req, res *engine.Object, state *routeResponse, host *jsruntime.Host, request *http.Request) {
_ = native.Function(vm, req, "upgradeWebSocket", func(_ *moejs.Realm, _ moejs.Value, _ []moejs.Value) (moejs.Value, error) {
promise, resolve, reject := vm.NewPromise()
fail := func(message string) (moejs.Value, error) {
_ = reject(native.GoError(vm, errors.New(message)))
return native.From(vm, promise), nil
}
streaming := native.Bool(native.Get(vm, res, "streaming"))
state.mu.Lock()
if state.sealed || state.ended || state.upgrade != nil || state.wrote || state.streaming || streaming {
state.mu.Unlock()
return fail("HTTP response is no longer available for WebSocket upgrade")
}
if request.Method != http.MethodGet || !websocket.IsWebSocketUpgrade(request) {
state.mu.Unlock()
return fail("require a GET WebSocket upgrade request")
}
ws := newRouteWebSocket(host)
u := &routeUpgrade{ws: ws}
u.complete = func(upgradeErr error) {
if !host.RunOnLoop(func(vm *moejs.Runtime) {
if !host.ResourcesOpen() {
ws.abort()
return
}
err := host.RunJob(vm, "WebSocket upgrade", func() error {
if upgradeErr != nil {
return reject(native.GoError(vm, upgradeErr))
}
return resolve(engine.ObjectValue(ws.object(vm)))
})
if err != nil || upgradeErr != nil {
ws.abort()
return
}
// Promise continuations install handlers before a later loop turn
// starts delivering messages (including an immediate peer send).
if !host.RunOnLoop(func(_ *moejs.Runtime) { ws.start() }) {
ws.abort()
}
}) {
ws.abort()
}
}
state.upgrade = u
state.mu.Unlock()
state.upgrades <- u
return native.From(vm, promise), nil
})
}
// seal prevents queued JavaScript from writing or upgrading a returned request.
func (state *routeResponse) seal() {
func (state *routeResponse) markStreaming() {
state.mu.Lock()
wasSealed := state.sealed
state.sealed = true
pending := state.upgrade
claimed := state.upgradeClaimed
state.streaming = true
state.mu.Unlock()
if state.aborted.CompareAndSwap(false, true) {
close(state.abortCh)
}
if pending != nil && !claimed && !wasSealed {
pending.ws.abort()
pending.complete(errors.New("WebSocket upgrade canceled"))
}
}
func (state *routeResponse) isStreaming() bool {
@@ -788,7 +618,7 @@ func (state *routeResponse) pushChunk(p []byte) bool {
func (state *routeResponse) finish(status int, body []byte) {
state.mu.Lock()
defer state.mu.Unlock()
if state.ended || state.sealed || state.upgrade != nil {
if state.ended {
return
}
if status != 0 {
@@ -801,107 +631,70 @@ func (state *routeResponse) finish(status int, body []byte) {
close(state.done)
}
func routeRequest(vm *moejs.Runtime, c *gin.Context, body []byte) *engine.Object {
func routeRequest(vm *goja.Runtime, c *gin.Context, body []byte) *goja.Object {
req := hookRequestObject(vm, c.Request, body)
_ = native.Set(vm, req, "context", routeRequestContext(vm, c))
_ = req.Set("context", routeRequestContext(vm, c))
return req
}
func routeResponseObject(vm *moejs.Runtime, state *routeResponse) *engine.Object {
res := vm.Realm().NewObject()
_ = native.Set(vm, res, "statusCode", http.StatusOK)
_ = native.Set(vm, res, "statusMessage", "")
_ = native.Set(vm, res, "streaming", false)
_ = native.Function(vm, res, "isAborted", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
return native.From(vm, state.aborted.Load()), nil
})
_ = native.Function(vm, res, "setHeader", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
name, value := native.String(vm, moejs.Arg(args, 0)), moejs.Arg(args, 1)
func routeResponseObject(vm *goja.Runtime, state *routeResponse) *goja.Object {
res := vm.NewObject()
_ = res.Set("statusCode", http.StatusOK)
_ = res.Set("statusMessage", "")
_ = res.Set("streaming", false)
_ = res.Set("isAborted", func() bool { return state.aborted.Load() })
_ = res.Set("setHeader", func(name string, value goja.Value) *goja.Object {
state.mu.Lock()
defer state.mu.Unlock()
var values []string
if err := native.ExportTo(vm, value, &values); err != nil {
values = []string{native.String(vm, value)}
if err := vm.ExportTo(value, &values); err == nil {
state.headers[name] = values
} else {
state.headers.Set(name, value.String())
}
state.mu.Lock()
defer state.mu.Unlock()
if state.upgrade != nil {
return moejs.Undefined(), native.TypeError(vm, "HTTP response is unavailable after WebSocket upgrade")
}
if !state.sealed {
state.headers[http.CanonicalHeaderKey(name)] = values
}
return engine.ObjectValue(res), nil
return res
})
_ = native.Function(vm, res, "getHeader", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
_ = res.Set("getHeader", func(name string) goja.Value {
state.mu.Lock()
defer state.mu.Unlock()
values := state.headers.Values(native.String(vm, moejs.Arg(args, 0)))
values := state.headers.Values(name)
if len(values) == 0 {
return moejs.Undefined(), nil
return goja.Undefined()
}
if len(values) == 1 {
return native.From(vm, values[0]), nil
return vm.ToValue(values[0])
}
return native.From(vm, values), nil
return vm.ToValue(values)
})
_ = native.Function(vm, res, "removeHeader", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
name := native.String(vm, moejs.Arg(args, 0))
_ = res.Set("removeHeader", func(name string) {
state.mu.Lock()
defer state.mu.Unlock()
if state.upgrade != nil {
return moejs.Undefined(), native.TypeError(vm, "HTTP response is unavailable after WebSocket upgrade")
}
if !state.sealed {
state.headers.Del(name)
}
return moejs.Undefined(), nil
state.headers.Del(name)
state.mu.Unlock()
})
_ = native.Function(vm, res, "write", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
value := moejs.Arg(args, 0)
var data []byte
if err := native.ExportTo(vm, value, &data); err != nil {
data = []byte(native.String(vm, value))
_ = res.Set("write", func(call goja.FunctionCall) goja.Value {
data := buffer.Bytes(vm, call.Argument(0))
if value := res.Get("streaming"); value != nil && value.ToBoolean() {
state.markStreaming()
_ = state.pushChunk(data)
return vm.ToValue(true)
}
streaming := native.Bool(native.Get(vm, res, "streaming"))
state.mu.Lock()
if state.upgrade != nil {
state.mu.Unlock()
return moejs.Undefined(), native.TypeError(vm, "HTTP response is unavailable after WebSocket upgrade")
}
if state.sealed || state.ended {
state.mu.Unlock()
return native.From(vm, false), nil
}
state.wrote = true
if streaming {
state.streaming = true
state.mu.Unlock()
return native.From(vm, state.pushChunk(data)), nil
}
_, _ = state.body.Write(data)
state.mu.Unlock()
return native.From(vm, true), nil
return vm.ToValue(true)
})
_ = native.Function(vm, res, "end", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
var tail string
if len(args) > 0 && !native.IsNil(args[0]) {
tail = native.String(vm, args[0])
}
status := int(native.Int(vm, native.Get(vm, res, "statusCode")))
_ = res.Set("end", func(call goja.FunctionCall) goja.Value {
state.mu.Lock()
defer state.mu.Unlock()
if state.upgrade != nil {
return moejs.Undefined(), native.TypeError(vm, "HTTP response is unavailable after WebSocket upgrade")
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Argument(0)) && !goja.IsNull(call.Argument(0)) {
_, _ = state.body.WriteString(call.Argument(0).String())
}
if state.sealed || state.ended {
return engine.ObjectValue(res), nil
}
_, _ = state.body.WriteString(tail)
state.statusCode = status
state.statusCode = int(res.Get("statusCode").ToInteger())
if !state.ended {
state.ended = true
close(state.done)
}
return engine.ObjectValue(res), nil
state.mu.Unlock()
return res
})
return res
}
+4 -1
View File
@@ -75,6 +75,9 @@ func (s *State) saveLocked() {
file := struct {
Plugins map[string]PluginState `json:"plugins"`
}{Plugins: s.current}
data, _ := json.MarshalIndent(file, "", " ")
data, err := json.MarshalIndent(file, "", " ")
if err != nil {
return
}
_ = os.WriteFile(s.path, data, 0644)
}
+40 -50
View File
@@ -3,10 +3,8 @@ package plugin
import (
"time"
"github.com/Calcium-Ion/moejs"
"github.com/Calcium-Ion/moejs/engine"
"github.com/dop251/goja"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/pkg/jsruntime/native"
"github.com/komari-monitor/komari/web/connection"
)
@@ -17,10 +15,10 @@ import (
// GET. Like the HTTP kinds, the input is lowercased, so any casing works
// from JS.
const (
hookWSConnect hookKind = "wsconnect"
hookWSMessage hookKind = "wsmessage"
hookWSSend hookKind = "wssend"
hookWSClose hookKind = "wsclose"
hookWSConnect hookKind = "wsconnect"
hookWSMessage hookKind = "wsmessage"
hookWSSend hookKind = "wssend"
hookWSClose hookKind = "wsclose"
)
// wsFrameHookTimeout bounds one frame-level callback. The agent read pumps
@@ -49,21 +47,21 @@ func (m *Manager) OnConnect(info *connection.ConnInfo) (deny bool, reason string
for _, h := range m.wsHooksFor(hookWSConnect, info.Path) {
var denied bool
var why string
queued, timedOut := runHookTurn(h.host, "plugin wsConnect hook "+h.short, func(vm *moejs.Runtime) {
queued, timedOut := runHookTurn(h.host, "plugin wsConnect hook "+h.short, func(vm *goja.Runtime) {
_ = h.host.RunJob(vm, "plugin wsConnect hook "+h.short, func() error {
res, err := vm.CallFunction(h.fn, moejs.Undefined(), engine.ObjectValue(wsHookContext(vm, info)))
res, err := h.fn(goja.Undefined(), wsHookContext(vm, info))
if err != nil {
return err
}
if res.IsUndefined() || res.IsNull() {
if goja.IsUndefined(res) || goja.IsNull(res) {
return nil
}
obj := native.Object(vm, res)
if v := native.Get(vm, obj, "deny"); native.Bool(v) {
obj := res.ToObject(vm)
if v := obj.Get("deny"); v != nil && v.ToBoolean() {
denied = true
}
if v := native.Get(vm, obj, "reason"); !v.IsUndefined() {
why = native.String(vm, v)
if v := obj.Get("reason"); v != nil && !goja.IsUndefined(v) {
why = v.String()
}
return nil
})
@@ -105,25 +103,25 @@ func (m *Manager) runFrameHooks(kind hookKind, info *connection.ConnInfo, frameT
for _, h := range hooks {
var drop bool
var hookErr error
queued, timedOut := runHookTurnTimeout(h.host, "plugin "+string(kind)+" hook "+h.short, wsFrameHookTimeout, func(vm *moejs.Runtime) {
queued, timedOut := runHookTurnTimeout(h.host, "plugin "+string(kind)+" hook "+h.short, wsFrameHookTimeout, func(vm *goja.Runtime) {
msg := wsFrameObject(vm, info, frameType, next)
hookErr = h.host.RunJob(vm, "plugin "+string(kind)+" hook "+h.short, func() error {
res, err := vm.CallFunction(h.fn, moejs.Undefined(), engine.ObjectValue(wsHookContext(vm, info)), engine.ObjectValue(msg))
res, err := h.fn(goja.Undefined(), wsHookContext(vm, info), msg)
if err != nil {
return err
}
if res.IsUndefined() || res.IsNull() {
if goja.IsUndefined(res) || goja.IsNull(res) {
return nil
}
obj := native.Object(vm, res)
if v := native.Get(vm, obj, "drop"); native.Bool(v) {
obj := res.ToObject(vm)
if v := obj.Get("drop"); v != nil && v.ToBoolean() {
drop = true
return nil
}
if v := native.Get(vm, obj, "type"); !v.IsUndefined() {
frameType = int(native.Int(vm, v))
if v := obj.Get("type"); v != nil && !goja.IsUndefined(v) {
frameType = int(v.ToInteger())
}
if v := native.Get(vm, obj, "data"); !v.IsUndefined() && !v.IsNull() {
if v := obj.Get("data"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
if data := wsFrameBytes(vm, v); data != nil {
next = data
}
@@ -153,9 +151,9 @@ func (m *Manager) runFrameHooks(kind hookKind, info *connection.ConnInfo, frameT
func (m *Manager) OnClose(info *connection.ConnInfo) {
for _, h := range m.wsHooksFor(hookWSClose, info.Path) {
var hookErr error
queued, timedOut := runHookTurn(h.host, "plugin wsClose hook "+h.short, func(vm *moejs.Runtime) {
queued, timedOut := runHookTurn(h.host, "plugin wsClose hook "+h.short, func(vm *goja.Runtime) {
hookErr = h.host.RunJob(vm, "plugin wsClose hook "+h.short, func() error {
_, err := vm.CallFunction(h.fn, moejs.Undefined(), engine.ObjectValue(wsHookContext(vm, info)))
_, err := h.fn(goja.Undefined(), wsHookContext(vm, info))
return err
})
})
@@ -186,41 +184,41 @@ func (m *Manager) wsHooksFor(kind hookKind, path string) []*hookEntry {
}
// wsHookContext builds the immutable per-connection context object.
func wsHookContext(vm *moejs.Runtime, info *connection.ConnInfo) *engine.Object {
ctx := vm.Realm().NewObject()
_ = native.Set(vm, ctx, "path", info.Path)
_ = native.Set(vm, ctx, "connId", info.ID)
_ = native.Set(vm, ctx, "remoteIp", info.RemoteIP)
_ = native.Set(vm, ctx, "userAgent", info.UserAgent)
func wsHookContext(vm *goja.Runtime, info *connection.ConnInfo) *goja.Object {
ctx := vm.NewObject()
_ = ctx.Set("path", info.Path)
_ = ctx.Set("connId", info.ID)
_ = ctx.Set("remoteIp", info.RemoteIP)
_ = ctx.Set("userAgent", info.UserAgent)
if info.ClientUUID != "" {
_ = native.Set(vm, ctx, "clientUuid", info.ClientUUID)
_ = ctx.Set("clientUuid", info.ClientUUID)
}
return ctx
}
// wsFrameObject builds the frame object handed to wsMessage/wsSend hooks.
// Text frames arrive as strings, binary frames as ArrayBuffers.
func wsFrameObject(vm *moejs.Runtime, info *connection.ConnInfo, frameType int, data []byte) *engine.Object {
msg := vm.Realm().NewObject()
_ = native.Set(vm, msg, "type", frameType)
func wsFrameObject(vm *goja.Runtime, info *connection.ConnInfo, frameType int, data []byte) *goja.Object {
msg := vm.NewObject()
_ = msg.Set("type", frameType)
if frameType == websocket.BinaryMessage {
_ = native.Set(vm, msg, "data", newFrameBuffer(vm, data))
_ = msg.Set("data", vm.NewArrayBuffer(append([]byte(nil), data...)))
} else {
_ = native.Set(vm, msg, "data", string(data))
_ = msg.Set("data", string(data))
}
_ = native.Set(vm, msg, "connId", info.ID)
_ = native.Set(vm, msg, "path", info.Path)
_ = msg.Set("connId", info.ID)
_ = msg.Set("path", info.Path)
return msg
}
// wsFrameBytes converts a hook-returned payload (string or ArrayBuffer) back
// to bytes; anything else reports nil and leaves the frame untouched.
func wsFrameBytes(vm *moejs.Runtime, v moejs.Value) []byte {
if v.IsString() {
return []byte(native.String(vm, v))
func wsFrameBytes(vm *goja.Runtime, v goja.Value) []byte {
if goja.IsString(v) {
return []byte(v.String())
}
var data []byte
if err := native.ExportTo(vm, v, &data); err == nil {
if err := vm.ExportTo(v, &data); err == nil {
return data
}
return nil
@@ -233,11 +231,3 @@ func (m *Manager) logWSHookTimeout(h *hookEntry) {
// The Manager is the process-wide WebSocket frame interceptor.
var _ connection.FrameInterceptor = (*Manager)(nil)
func newFrameBuffer(vm *moejs.Runtime, data []byte) moejs.Value {
obj, err := vm.Realm().NewArrayBuffer(append([]byte(nil), data...))
if err != nil {
panic(err)
}
return engine.ObjectValue(obj)
}
-147
View File
@@ -1,147 +0,0 @@
// Package sampling provides deterministic proportional downsampling used by
// history responses. It is independent of transport and storage.
package sampling
import (
"math"
"sort"
)
// Group is one named set of candidate points.
type Group[K comparable] struct {
Key K
Length int
}
type remainder struct {
idx int
frac float64
}
// AllocateTargets splits maxTotal across groups proportionally.
func AllocateTargets[K comparable](groups []Group[K], maxTotal int) map[K]int {
result := make(map[K]int, len(groups))
if maxTotal < 0 {
for _, g := range groups {
result[g.Key] = g.Length
}
return result
}
total := 0
for _, g := range groups {
total += g.Length
}
if total <= maxTotal {
for _, g := range groups {
result[g.Key] = g.Length
}
return result
}
remainders := make([]remainder, 0, len(groups))
sumTargets := 0
for i, g := range groups {
if g.Length <= 0 {
result[g.Key] = 0
continue
}
raw := float64(g.Length) * float64(maxTotal) / float64(total)
t := int(math.Floor(raw))
if t > g.Length {
t = g.Length
}
result[groups[i].Key] = t
sumTargets += t
remainders = append(remainders, remainder{i, raw - float64(t)})
}
if sumTargets < maxTotal {
need := maxTotal - sumTargets
sortRemainders(remainders, false)
for _, r := range remainders {
if need == 0 {
break
}
g := groups[r.idx]
if result[g.Key] < g.Length {
result[g.Key]++
need--
}
}
for need > 0 {
for _, g := range groups {
if need == 0 {
break
}
if result[g.Key] < g.Length {
result[g.Key]++
need--
}
}
if need > 0 {
break
}
}
} else if sumTargets > maxTotal {
over := sumTargets - maxTotal
sortRemainders(remainders, true)
for _, r := range remainders {
if over == 0 {
break
}
g := groups[r.idx]
if result[g.Key] > 0 {
result[g.Key]--
over--
}
}
for over > 0 {
for _, g := range groups {
if over == 0 {
break
}
if result[g.Key] > 0 {
result[g.Key]--
over--
}
}
if over > 0 {
break
}
}
}
return result
}
func sortRemainders(r []remainder, asc bool) {
sort.Slice(r, func(i, j int) bool {
if asc {
return r[i].frac < r[j].frac
}
return r[i].frac > r[j].frac
})
}
// SampleEvenly selects k points including both endpoints.
func SampleEvenly[T any](in []T, k int) []T {
n := len(in)
if k <= 0 || n == 0 {
return []T{}
}
if k >= n {
return in
}
out := make([]T, 0, k)
if k == 1 {
out = append(out, in[n-1])
return out
}
for i := 0; i < k; i++ {
idx := int(math.Round(float64(i) * float64(n-1) / float64(k-1)))
if idx < 0 {
idx = 0
} else if idx >= n {
idx = n - 1
}
out = append(out, in[idx])
}
return out
}
+3 -7
View File
@@ -5,9 +5,9 @@ import (
"fmt"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/internal/notifications"
"github.com/komari-monitor/komari/utils/geoip"
logger "github.com/komari-monitor/komari/utils/log"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/web/oauth"
)
@@ -18,12 +18,8 @@ func (a *App) InitProviders() error {
go geoip.InitGeoIp()
a.addCleanup("geoip", func(context.Context) error { return geoip.Shutdown() })
if err := notifications.Initialize(); err != nil {
return fmt.Errorf("initialize notification channels: %w", err)
}
a.addCleanup("notification-channels", func(context.Context) error {
return notifications.Shutdown()
})
messageSender.Initialize()
a.addCleanup("message-sender", func(context.Context) error { return messageSender.Shutdown() })
return nil
}
+11 -1
View File
@@ -14,6 +14,7 @@ import (
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/auditlog"
d_notification "github.com/komari-monitor/komari/database/notification"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/internal/lifecycle"
@@ -22,6 +23,7 @@ import (
"github.com/komari-monitor/komari/internal/scheduler"
"github.com/komari-monitor/komari/utils/geoip"
logger "github.com/komari-monitor/komari/utils/log"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/notifier"
"github.com/komari-monitor/komari/web/api"
"github.com/komari-monitor/komari/web/oauth"
@@ -74,6 +76,11 @@ func (a *App) registerReloadHandlers(cors *security.CorsController) {
go geoip.InitGeoIp()
}
})
a.reload.Register("message-sender", func(event config.ConfigEvent) {
if event.IsChanged(config.NotificationMethodKey) {
go messageSender.Initialize()
}
})
a.reload.Register("cors", func(event config.ConfigEvent) { cors.Update(event) })
}
@@ -181,6 +188,9 @@ func registerScheduledWork() {
if err := tasks.ReloadPingSchedule(); err != nil {
logger.ErrorArgs("server", "Failed to reload ping schedule:", err)
}
if err := d_notification.ReloadLoadNotificationSchedule(); err != nil {
logger.ErrorArgs("server", "Failed to reload load notification schedule:", err)
}
if err := scheduler.AddFunc("records:cleanup", "@every 30m", cleanupScheduledData); err != nil {
logger.ErrorArgs("server", "Failed to add cleanup scheduled task:", err)
}
@@ -193,7 +203,7 @@ func registerScheduledWork() {
if err := scheduler.AddFunc("notifier:traffic", "@every 1m", notifier.CheckTraffic); err != nil {
logger.ErrorArgs("server", "Failed to add traffic notification task:", err)
}
if err := scheduler.AddFunc("notifier:expire", "0 0 9 * * *", notifier.CheckExpire); err != nil {
if err := scheduler.AddFunc("notifier:expire", "0 0 9 * * *", notifier.CheckExpireScheduledWork); err != nil {
logger.ErrorArgs("server", "Failed to add expire notification task:", err)
}
}
+21
View File
@@ -0,0 +1,21 @@
package sqlitetune
import (
"testing"
"time"
)
func TestNormalizeAllowsUnlimitedJournalSize(t *testing.T) {
options, err := normalize(Options{
BusyTimeout: 5 * time.Second,
CacheSizeKB: 8,
WALAutoCheckpoint: 1,
JournalSizeLimitBytes: -1,
})
if err != nil {
t.Fatalf("normalize unlimited journal size: %v", err)
}
if options.JournalSizeLimitBytes != -1 {
t.Fatalf("journal size limit = %d, want -1", options.JournalSizeLimitBytes)
}
}
+1 -1
View File
@@ -365,7 +365,7 @@ const value = Buffer.from("komari").toString("hex");
| `domainToASCII/domainToUnicode` | `可用` |
| 旧式 `parse/format/resolve/resolveObject/urlToHttpOptions` | `未实现` |
`URL` 和 `URLSearchParams` 会作为全局变量注入,也可以从模块取得:
`URL` 和 `URLSearchParams` 不会自动成为全局变量,应从模块取得:
```js
const { URL, URLSearchParams } = require("node:url");
-13
View File
@@ -1,13 +0,0 @@
Copyright (c) 2016 Dmitry Panov
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-196
View File
@@ -1,196 +0,0 @@
// Package buffer implements Node buffers using native JavaScript typed arrays.
package buffer
import (
_ "embed"
"encoding/base64"
"encoding/hex"
moe "github.com/Calcium-Ion/moejs"
"github.com/Calcium-Ion/moejs/engine"
"github.com/komari-monitor/komari/pkg/jsruntime/native"
"github.com/komari-monitor/komari/pkg/jsruntime/require"
"strings"
"unicode/utf16"
"unicode/utf8"
)
const ModuleName = "buffer"
//go:embed buffer.js
var source string
func codecName(s string) string {
switch strings.ToLower(s) {
case "", "utf8", "utf-8":
return "utf8"
case "hex", "base64", "base64url", "ascii":
return strings.ToLower(s)
case "latin1", "binary":
return "latin1"
case "ucs2", "ucs-2", "utf16le", "utf-16le":
return "utf16le"
}
return ""
}
func decode(s, enc string) []byte {
switch codecName(enc) {
case "hex":
b, _ := hex.DecodeString(s)
return b
case "base64", "base64url":
s = strings.Map(func(r rune) rune {
if r == '-' {
return '+'
}
if r == '_' {
return '/'
}
if r > 127 {
return -1
}
if strings.ContainsRune("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=", r) {
return r
}
return -1
}, s)
s = strings.TrimRight(s, "=")
b, _ := base64.RawStdEncoding.DecodeString(s)
return b
case "latin1", "ascii":
u := utf16.Encode([]rune(s))
b := make([]byte, len(u))
for i, v := range u {
b[i] = byte(v)
}
return b
case "utf16le":
u := utf16.Encode([]rune(s))
b := make([]byte, len(u)*2)
for i, v := range u {
b[2*i] = byte(v)
b[2*i+1] = byte(v >> 8)
}
return b
default:
return []byte(s)
}
}
func encode(b []byte, enc string) string {
switch codecName(enc) {
case "hex":
return hex.EncodeToString(b)
case "base64":
return base64.StdEncoding.EncodeToString(b)
case "base64url":
return base64.RawURLEncoding.EncodeToString(b)
case "latin1", "ascii":
r := make([]rune, len(b))
for i, v := range b {
if codecName(enc) == "ascii" {
v &= 127
}
r[i] = rune(v)
}
return string(r)
case "utf16le":
u := make([]uint16, len(b)/2)
for i := range u {
u[i] = uint16(b[2*i]) | uint16(b[2*i+1])<<8
}
return string(utf16.Decode(u))
default:
if utf8.Valid(b) {
return string(b)
}
var out strings.Builder
for len(b) > 0 {
r, n := utf8.DecodeRune(b)
out.WriteRune(r)
b = b[n:]
}
return out.String()
}
}
// Bytes returns a live view of the bytes of ArrayBuffer, TypedArray and DataView.
func Bytes(vm *moe.Runtime, v moe.Value) []byte {
if v.IsObject() {
if b, ok := v.AsObject().BufferData(); ok {
return b
}
}
if v.IsString() {
return []byte(native.String(vm, v))
}
var b []byte
if err := native.ExportTo(vm, v, &b); err != nil {
panic(vm.Realm().TypeError("Expected a buffer or byte array"))
}
return b
}
func DecodeBytes(vm *moe.Runtime, arg, enc moe.Value) []byte {
if arg.IsString() {
s := "utf8"
if !native.IsNil(enc) {
s = native.String(vm, enc)
}
return decode(native.String(vm, arg), s)
}
return Bytes(vm, arg)
}
// WrapBytes shares data with a native ArrayBuffer; callers must synchronize access.
func WrapBytes(vm *moe.Runtime, data []byte) *engine.Object {
ab, err := vm.Realm().NewArrayBuffer(data)
if err != nil {
panic(err)
}
m := require.Require(vm, ModuleName)
ctor := native.Get(vm, m.AsObject(), "Buffer")
from := native.Get(vm, ctor.AsObject(), "from")
v, err := vm.CallFunction(from, ctor, engine.ObjectValue(ab))
if err != nil {
panic(err)
}
return v.AsObject()
}
func EncodeBytes(vm *moe.Runtime, data []byte, enc moe.Value) moe.Value {
if !native.IsNil(enc) {
s := native.String(vm, enc)
if codecName(s) != "" {
return native.From(vm, encode(data, s))
}
}
return engine.ObjectValue(WrapBytes(vm, data))
}
func Enable(vm *moe.Runtime) {
m := require.Require(vm, ModuleName)
if err := vm.SetGlobal("Buffer", native.Get(vm, m.AsObject(), "Buffer")); err != nil {
panic(err)
}
}
func Require(vm *moe.Runtime, module *engine.Object) {
factory, err := native.RunScript(vm, "buffer.js", source)
if err != nil {
panic(err)
}
enc := native.Bind(vm, "encode", 2, moe.NativeFunc(func(_ *moe.Realm, _ moe.Value, a []moe.Value) (moe.Value, error) {
return native.From(vm, encode(Bytes(vm, moe.Arg(a, 0)), native.String(vm, moe.Arg(a, 1)))), nil
}))
dec := native.Bind(vm, "decode", 2, moe.NativeFunc(func(_ *moe.Realm, _ moe.Value, a []moe.Value) (moe.Value, error) {
b := decode(native.String(vm, moe.Arg(a, 0)), native.String(vm, moe.Arg(a, 1)))
ab, e := vm.Realm().NewArrayBuffer(b)
if e != nil {
return moe.Undefined(), e
}
return engine.ObjectValue(ab), nil
}))
exports, err := vm.CallFunction(factory, moe.Undefined(), enc, dec)
if err != nil {
panic(err)
}
if err = native.Set(vm, module, "exports", exports); err != nil {
panic(err)
}
}
func init() { require.RegisterCoreModule(ModuleName, Require) }
-45
View File
@@ -1,45 +0,0 @@
(function(encode, decode) {
'use strict';
const encodings = ['utf8','utf-8','hex','base64','base64url','ascii','latin1','binary','ucs2','ucs-2','utf16le','utf-16le'];
function encoding(e) { e = e === undefined ? 'utf8' : String(e).toLowerCase(); if (!encodings.includes(e)) throw new TypeError('Unknown encoding: '+e); return e; }
function size(n) { if (typeof n !== 'number') throw new TypeError('size must be a number'); if (!Number.isInteger(n)||n<0||n>0x7fffffff) throw new RangeError('Invalid buffer size'); return n; }
function bounds(b,o,n) { if (!Number.isInteger(o)||!Number.isInteger(n)||n<0||o<0||o+n>b.length) throw new RangeError('Offset is outside the bounds of the Buffer'); }
class Buffer extends Uint8Array {
constructor(a,b,c) { if(typeof a==='string') super(decode(a,encoding(b))); else super(a,b,c); }
static from(a,b,c) { if(typeof a==='number'||a==null) throw new TypeError('Invalid Buffer input'); if(typeof a==='string')return new Buffer(decode(a,encoding(b))); if(a instanceof ArrayBuffer) return new Buffer(a,b,c); if(a.type==='Buffer'&&Array.isArray(a.data)) a=a.data; if(a.valueOf){const v=a.valueOf();if(v!==a)return Buffer.from(v,b,c);}if(typeof a.length!=='number'&&!ArrayBuffer.isView(a)&&!a[Symbol.iterator])throw new TypeError('Invalid Buffer input'); return new Buffer(a); }
static alloc(n,fill,enc) {const b=new Buffer(size(n));if(fill!==undefined)b.fill(fill,0,b.length,enc);return b;}
static allocUnsafe(n) {return new Buffer(size(n));}
static allocUnsafeSlow(n) {return Buffer.allocUnsafe(n);}
static isBuffer(b) {return b instanceof Buffer;}
static isEncoding(e) {return typeof e==='string'&&encodings.includes(e.toLowerCase());}
static byteLength(s,e) {if(typeof s!=='string'){if(s instanceof ArrayBuffer||ArrayBuffer.isView(s))return s.byteLength;throw new TypeError('Expected string or buffer');}e=encoding(e);return e==='hex'?Math.floor(s.length/2):decode(s,e).byteLength;}
static concat(list,total) {if(!Array.isArray(list))throw new TypeError('Expected array');if(total===undefined)total=list.reduce((n,b)=>n+b.length,0);const b=Buffer.alloc(total);let o=0;for(const p of list){const n=Math.min(p.length,total-o);if(n<=0)break;b.set(p.subarray(0,n),o);o+=n;}return b;}
static compare(a,b) {for(let i=0;i<Math.min(a.length,b.length);i++)if(a[i]!==b[i])return a[i]<b[i]?-1:1;return a.length===b.length?0:(a.length<b.length?-1:1);}
toString(e,start,end) {e=encoding(e);start=Math.max(0,start===undefined?0:Math.trunc(start));end=Math.min(this.length,end===undefined?this.length:Math.trunc(end));return encode(this.subarray(start,Math.max(start,end)),e);}
toLocaleString() {return this.toString();}
toJSON() {return {type:'Buffer',data:Array.from(this)};}
slice(s,e) {return this.subarray(s,e);}
equals(b) {return Buffer.compare(this,b)===0;}
compare(b,ts,te,ss,se) {return Buffer.compare(this.subarray(ss,se),b.subarray(ts,te));}
copy(target,ts,ss,se) {ts=ts===undefined?0:ts;ss=ss===undefined?0:ss;se=se===undefined?this.length:se;if(ts<0||ss<0||se<0)throw new RangeError('Invalid offset');const n=Math.max(0,Math.min(se-ss,this.length-ss,target.length-ts));if(n>0)target.set(this.subarray(ss,ss+n),ts);return n;}
fill(v,start,end,e) {if(typeof start==='string'){e=start;start=0;}if(typeof end==='string'){e=end;end=this.length;}start=start===undefined?0:start;end=end===undefined?this.length:end;bounds(this,start,Math.max(0,end-start));if(typeof v==='number') {super.fill(v,start,end);return this;}const p=typeof v==='string'?Buffer.from(v,e):v;if(!p||!p.length){if(v===''){super.fill(0,start,end);return this;}throw new TypeError('Invalid fill value');}for(let i=start;i<end;i++)this[i]=p[(i-start)%p.length];return this;}
write(s,o,n,e) {if(typeof o==='string'){e=o;o=0;n=this.length;}o=o===undefined?0:o;if(typeof n==='string'){e=n;n=undefined;}n=n===undefined?this.length-o:n;bounds(this,o,n);let b=Buffer.from(s,e);let count=Math.min(n,b.length);if((e===undefined||/^utf-?8$/i.test(e))&&count<b.length){while(count>0&&(b[count]&192)===128)count--;}this.set(b.subarray(0,count),o);return count;}
indexOf(v,o,e) {return this._find(v,o,e,false);}
lastIndexOf(v,o,e) {return this._find(v,o,e,true);}
includes(v,o,e) {return this.indexOf(v,o,e)!==-1;}
_find(v,o,e,reverse) {if(typeof o==='string'){e=o;o=undefined;}if(typeof v==='number')v=Buffer.from([v]);else if(typeof v==='string')v=Buffer.from(v,e);o=o===undefined?(reverse?this.length:0):Math.trunc(o);if(o<0)o=Math.max(0,this.length+o);if(!v.length)return Math.min(o,this.length);if(reverse){for(let i=Math.min(o,this.length-v.length);i>=0;i--){let j=0;while(j<v.length&&this[i+j]===v[j])j++;if(j===v.length)return i;}}else{for(let i=o;i<=this.length-v.length;i++){let j=0;while(j<v.length&&this[i+j]===v[j])j++;if(j===v.length)return i;}}return -1;}
}
for(const width of [8,16,32,64]) for(const little of [false,true]) for(const signed of [false,true]) {
if(width===8&&little)continue;
const big=width===64, bytes=width/8, suffix=width+(width===8?'':little?'LE':'BE');
const kind=big?(signed?'BigInt':'BigUInt'):(signed?'Int':'UInt');
const dvkind=big?(signed?'BigInt64':'BigUint64'):(signed?'Int':'Uint')+width;
Buffer.prototype['read'+kind+suffix]=function(o){o=o===undefined?0:o;bounds(this,o,bytes);return new DataView(this.buffer,this.byteOffset,this.byteLength)['get'+dvkind](o,little);};
Buffer.prototype['write'+kind+suffix]=function(v,o){o=o===undefined?0:o;bounds(this,o,bytes);if(big&&(typeof v!=='bigint'||v<(signed?-(1n<<63n):0n)||v>(signed?(1n<<63n)-1n:(1n<<64n)-1n)))throw new RangeError('Value out of range');if(!big&&(!Number.isFinite(v)||v<(signed?-(2**(width-1)):0)||v>(signed?2**(width-1)-1:2**width-1)))throw new RangeError('Value out of range');new DataView(this.buffer,this.byteOffset,this.byteLength)['set'+dvkind](o,v,little);return o+bytes;};
if(!signed){Buffer.prototype['read'+(big?'BigUint':'Uint')+suffix]=Buffer.prototype['read'+kind+suffix];Buffer.prototype['write'+(big?'BigUint':'Uint')+suffix]=Buffer.prototype['write'+kind+suffix];}
}
for(const pair of [['Float',32],['Double',64]])for(const little of [false,true]){const name=pair[0]+(little?'LE':'BE'),bytes=pair[1]/8;Buffer.prototype['read'+name]=function(o){o=o===undefined?0:o;bounds(this,o,bytes);return new DataView(this.buffer,this.byteOffset,this.byteLength)['getFloat'+pair[1]](o,little);};Buffer.prototype['write'+name]=function(v,o){o=o===undefined?0:o;bounds(this,o,bytes);new DataView(this.buffer,this.byteOffset,this.byteLength)['setFloat'+pair[1]](o,v,little);return o+bytes;};}
for(const little of [false,true])for(const signed of [false,true]){const name=(signed?'Int':'UInt')+(little?'LE':'BE');Buffer.prototype['read'+name]=function(o,n){if(!Number.isInteger(n)||n<1||n>6)throw new RangeError('Invalid byteLength');bounds(this,o,n);let v=0;for(let i=0;i<n;i++)v=v*256+this[o+(little?n-i-1:i)];if(signed&&v>=2**(n*8-1))v-=2**(n*8);return v;};Buffer.prototype['write'+name]=function(v,o,n){if(!Number.isInteger(n)||n<1||n>6)throw new RangeError('Invalid byteLength');bounds(this,o,n);const min=signed?-(2**(n*8-1)):0,max=signed?2**(n*8-1)-1:2**(n*8)-1;if(!Number.isFinite(v)||v<min||v>max)throw new RangeError('Value out of range');if(v<0)v+=2**(n*8);for(let i=0;i<n;i++){this[o+(little?i:n-i-1)]=v%256;v=Math.floor(v/256);}return o+n;};if(!signed){Buffer.prototype['readUint'+(little?'LE':'BE')]=Buffer.prototype['read'+name];Buffer.prototype['writeUint'+(little?'LE':'BE')]=Buffer.prototype['write'+name];}}
for(const n of [2,4,8])Buffer.prototype['swap'+n*8]=function(){if(this.length%n)throw new RangeError('Buffer size must be a multiple of '+n);for(let i=0;i<this.length;i+=n)for(let j=0;j<n/2;j++){const x=this[i+j];this[i+j]=this[i+n-j-1];this[i+n-j-1]=x;}return this;};
return {Buffer,SlowBuffer:Buffer.alloc,INSPECT_MAX_BYTES:50,kMaxLength:0x7fffffff,constants:{MAX_LENGTH:0x7fffffff,MAX_STRING_LENGTH:0x1fffffe8}};
})
-83
View File
@@ -1,83 +0,0 @@
package buffer
import (
moe "github.com/Calcium-Ion/moejs"
"github.com/Calcium-Ion/moejs/engine"
"github.com/komari-monitor/komari/pkg/jsruntime/native"
"github.com/komari-monitor/komari/pkg/jsruntime/require"
"testing"
)
func TestNativeBuffer(t *testing.T) {
vm := moe.NewRuntime(moe.Options{})
require.NewRegistry().Enable(vm)
Enable(vm)
for _, s := range []string{
`Object.getPrototypeOf(Buffer.prototype) === Uint8Array.prototype`,
`Object.isFrozen(Object.prototype)&&Object.isFrozen(Uint8Array.prototype)`,
`(function(){const b=Buffer.alloc(1);return b.copy(Buffer.alloc(0),999)===0&&Buffer.byteLength("zz","hex")===1})()`,
`(function(){for(const f of [()=>Buffer.from({}),()=>Buffer.alloc(4).readUIntLE(0,1.5),()=>Buffer.alloc(8).writeBigUInt64LE(-1n),()=>Buffer.alloc(8).writeUIntLE(NaN,0,2)]){try{f();return false;}catch(e){}}return true;})()`,
`(function(){const a=new Uint8Array([1,2,3]);const b=Buffer.from(a);b[0]=9;const ab=a.buffer;const v=Buffer.from(ab,1,1);v[0]=7;return a[0]===1&&a[1]===7&&v.length===1&&b instanceof Uint8Array})()`,
`Buffer.from("hello").toString() === "hello"`,
`Buffer.from("hello").toString("hex") === "68656c6c6f"`,
`Buffer.from("68656c6c6f", "hex").toString() === "hello"`,
`Buffer.from("通知").toString() === "通知"`,
`Buffer.from("aGVsbG8=", "base64").toString() === "hello"`,
`Buffer.from("hello") instanceof Buffer`,
`Buffer.alloc(4, "ab").toString() === "abab"`,
`Buffer.concat([Buffer.from("a"),Buffer.from("b")]).toString() === "ab"`,
`(function(){const b=Buffer.alloc(16);b.writeUInt32LE(0x12345678,1);b.writeInt16BE(-123,5);b.writeDoubleBE(1.25,8);return b.readUInt32LE(1)===0x12345678&&b.readInt16BE(5)===-123&&b.readDoubleBE(8)===1.25})()`,
`(function(){const b=Buffer.from("abcd");const v=b.slice(1,3);v[0]=120;return b.toString()==="axcd"&&Buffer.isBuffer(v)})()`,
`(function(){const b=Buffer.alloc(8);b.writeBigUInt64LE(123n);return b.readBigUInt64LE()===123n})()`,
} {
v, err := native.Run(vm, s)
if err != nil {
t.Fatalf("%s: %v", s, err)
}
if !native.Bool(v) {
t.Errorf("false: %s", s)
}
}
data := []byte("wrapped")
b := WrapBytes(vm, data)
if err := vm.SetGlobal("wrapped", engine.ObjectValue(b)); err != nil {
t.Fatal(err)
}
if _, err := native.Run(vm, "wrapped[0]=65"); err != nil {
t.Fatal(err)
}
if data[0] != 65 {
t.Fatal("WrapBytes did not share backing storage")
}
view, err := native.Run(vm, "wrapped.subarray(1,3)")
if err != nil {
t.Fatal(err)
}
raw := Bytes(vm, view)
raw[0] = 66
if data[1] != 66 || len(raw) != 2 {
t.Fatal("Bytes did not return a live bounded subarray")
}
for _, source := range []string{"new DataView(wrapped.buffer,1,2)", "new Uint16Array(wrapped.buffer,0,2)", "wrapped.buffer"} {
v, err := native.Run(vm, source)
if err != nil {
t.Fatal(err)
}
b := Bytes(vm, v)
if len(b) == 0 {
t.Fatalf("empty bytes: %s", source)
}
b[0] = 77
offset := 0
if source == "new DataView(wrapped.buffer,1,2)" {
offset = 1
if len(b) != 2 {
t.Fatal("unbounded DataView")
}
}
if data[offset] != 77 {
t.Fatalf("bytes not live: %s", source)
}
}
}
+218 -229
View File
@@ -6,15 +6,6 @@ import (
_ "embed"
"errors"
"fmt"
"github.com/Calcium-Ion/moejs"
"github.com/Calcium-Ion/moejs/engine"
"github.com/komari-monitor/komari/pkg/jsruntime/buffer"
"github.com/komari-monitor/komari/pkg/jsruntime/events"
"github.com/komari-monitor/komari/pkg/jsruntime/fs"
"github.com/komari-monitor/komari/pkg/jsruntime/internal/bridge"
"github.com/komari-monitor/komari/pkg/jsruntime/internal/writequeue"
"github.com/komari-monitor/komari/pkg/jsruntime/native"
"github.com/komari-monitor/komari/pkg/jsruntime/require"
"io"
"os"
"os/exec"
@@ -22,6 +13,14 @@ import (
"strings"
"sync"
"time"
"github.com/dop251/goja"
"github.com/dop251/goja_nodejs/buffer"
"github.com/dop251/goja_nodejs/require"
"github.com/komari-monitor/komari/pkg/jsruntime/events"
"github.com/komari-monitor/komari/pkg/jsruntime/fs"
"github.com/komari-monitor/komari/pkg/jsruntime/internal/bridge"
"github.com/komari-monitor/komari/pkg/jsruntime/internal/writequeue"
)
type childCommandOptions struct {
@@ -32,6 +31,7 @@ type childCommandOptions struct {
encoding string
maxBuffer int
}
type childOutputLimitError struct {
stream string
}
@@ -65,12 +65,9 @@ func (b *childOutputBuffer) Write(data []byte) (int, error) {
b.once.Do(b.cancel)
return len(data), nil
}
func (b *childOutputBuffer) Len() int {
return b.buffer.Len()
}
func (b *childOutputBuffer) Bytes() []byte {
return b.buffer.Bytes()
}
func (b *childOutputBuffer) Len() int { return b.buffer.Len() }
func (b *childOutputBuffer) Bytes() []byte { return b.buffer.Bytes() }
type Module struct {
runtime *bridge.Runtime
@@ -82,131 +79,134 @@ type Module struct {
func New(runtime *bridge.Runtime, filesystem *fs.Module, allowExec bool, maxOutput int) *Module {
return &Module{runtime: runtime, filesystem: filesystem, allowExec: allowExec, maxOutput: maxOutput}
}
func (m *Module) Load(vm *moejs.Runtime, module *engine.Object) {
func (m *Module) Load(vm *goja.Runtime, module *goja.Object) {
if !m.allowExec {
panic(native.GoError(vm, fmt.Errorf("child_process requires AllowExec")))
panic(vm.NewGoError(fmt.Errorf("child_process requires AllowExec")))
}
exports := vm.Realm().NewObject()
_ = native.Set(vm, exports, "spawn", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
command, arguments, options := m.childCommandArguments(vm, args, false)
return native.From(vm, m.spawnChild(vm, command, arguments, options)), nil
}))
_ = native.Set(vm, exports, "exec", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
command := native.String(vm, moejs.Arg(args, 0))
optionsValue, callbackValue := moejs.Arg(args, 1), moejs.Arg(args, 2)
if _, ok := checkedFunction(optionsValue); ok {
callbackValue, optionsValue = optionsValue, moejs.Undefined()
exports := vm.NewObject()
_ = exports.Set("spawn", func(call goja.FunctionCall) goja.Value {
command, arguments, options := m.childCommandArguments(vm, call, false)
return m.spawnChild(vm, command, arguments, options)
})
_ = exports.Set("exec", func(call goja.FunctionCall) goja.Value {
command := call.Argument(0).String()
optionsValue, callbackValue := call.Argument(1), call.Argument(2)
if _, ok := goja.AssertFunction(optionsValue); ok {
callbackValue, optionsValue = optionsValue, goja.Undefined()
}
callback, ok := checkedFunction(callbackValue)
callback, ok := goja.AssertFunction(callbackValue)
if !ok {
panic(native.TypeError(vm, "callback must be a function"))
panic(vm.NewTypeError("callback must be a function"))
}
options := m.childOptions(vm, optionsValue)
options.shell = true
return native.From(vm, m.execChild(vm, command, nil, options, callback)), nil
}))
_ = native.Set(vm, exports, "execFile", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
command, arguments, options := m.childCommandArguments(vm, args, true)
return m.execChild(vm, command, nil, options, callback)
})
_ = exports.Set("execFile", func(call goja.FunctionCall) goja.Value {
command, arguments, options := m.childCommandArguments(vm, call, true)
callbackIndex := 1
if _, ok := native.Export(vm, moejs.Arg(args, 1)).([]any); ok {
if _, ok := call.Argument(1).Export().([]any); ok {
callbackIndex = 2
}
callbackValue := moejs.Arg(args, callbackIndex)
if _, ok := checkedFunction(callbackValue); !ok {
callbackValue = moejs.Arg(args, callbackIndex+1)
callbackValue := call.Argument(callbackIndex)
if _, ok := goja.AssertFunction(callbackValue); !ok {
callbackValue = call.Argument(callbackIndex + 1)
}
callback, ok := checkedFunction(callbackValue)
callback, ok := goja.AssertFunction(callbackValue)
if !ok {
panic(native.TypeError(vm, "callback must be a function"))
panic(vm.NewTypeError("callback must be a function"))
}
return native.From(vm, m.execChild(vm, command, arguments, options, callback)), nil
}))
_ = native.Set(vm, exports, "spawnSync", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
command, arguments, options := m.childCommandArguments(vm, args, false)
return native.From(vm, m.spawnChildSync(vm, command, arguments, options)), nil
}))
_ = native.Set(vm, exports, "execFileSync", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
command, arguments, options := m.childCommandArguments(vm, args, false)
return m.execChild(vm, command, arguments, options, callback)
})
_ = exports.Set("spawnSync", func(call goja.FunctionCall) goja.Value {
command, arguments, options := m.childCommandArguments(vm, call, false)
return m.spawnChildSync(vm, command, arguments, options)
})
_ = exports.Set("execFileSync", func(call goja.FunctionCall) goja.Value {
command, arguments, options := m.childCommandArguments(vm, call, false)
result := m.runChildSync(command, arguments, options)
if result.err != nil {
panic(engine.ObjectValue(childProcessError(vm, result.err, result.exitCode, result.stdout, result.stderr)))
panic(childProcessError(vm, result.err, result.exitCode, result.stdout, result.stderr))
}
return native.From(vm, encodeChildOutput(vm, result.stdout, options.encoding)), nil
}))
_ = native.Set(vm, exports, "execSync", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
options := m.childOptions(vm, moejs.Arg(args, 1))
return encodeChildOutput(vm, result.stdout, options.encoding)
})
_ = exports.Set("execSync", func(call goja.FunctionCall) goja.Value {
options := m.childOptions(vm, call.Argument(1))
options.shell = true
result := m.runChildSync(native.String(vm, moejs.Arg(args, 0)), nil, options)
result := m.runChildSync(call.Argument(0).String(), nil, options)
if result.err != nil {
panic(engine.ObjectValue(childProcessError(vm, result.err, result.exitCode, result.stdout, result.stderr)))
panic(childProcessError(vm, result.err, result.exitCode, result.stdout, result.stderr))
}
return native.From(vm, encodeChildOutput(vm, result.stdout, options.encoding)), nil
}))
_ = native.Set(vm, exports, "fork", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
panic(native.GoError(vm, fmt.Errorf("child_process.fork is unavailable because this runtime is not a Node executable")))
}))
_ = native.Set(vm, module, "exports", exports)
return encodeChildOutput(vm, result.stdout, options.encoding)
})
_ = exports.Set("fork", func() {
panic(vm.NewGoError(fmt.Errorf("child_process.fork is unavailable because this runtime is not a Node executable")))
})
_ = module.Set("exports", exports)
}
func (m *Module) childCommandArguments(vm *moejs.Runtime, args []moejs.Value, callbackExpected bool) (string, []string, childCommandOptions) {
command := native.String(vm, moejs.Arg(args, 0))
func (m *Module) childCommandArguments(vm *goja.Runtime, call goja.FunctionCall, callbackExpected bool) (string, []string, childCommandOptions) {
command := call.Argument(0).String()
arguments := []string{}
optionsIndex := 1
if value := moejs.Arg(args, 1); !value.IsUndefined() && !value.IsNull() {
if value := call.Argument(1); !goja.IsUndefined(value) && !goja.IsNull(value) {
var exported []string
if err := native.ExportTo(vm, value, &exported); err == nil {
if err := vm.ExportTo(value, &exported); err == nil {
arguments = exported
optionsIndex = 2
}
}
options := m.childOptions(vm, moejs.Arg(args, optionsIndex))
options := m.childOptions(vm, call.Argument(optionsIndex))
_ = callbackExpected
return command, arguments, options
}
func (m *Module) childOptions(vm *moejs.Runtime, value moejs.Value) childCommandOptions {
func (m *Module) childOptions(vm *goja.Runtime, value goja.Value) childCommandOptions {
options := childCommandOptions{cwd: m.filesystem.Cwd(), env: os.Environ(), maxBuffer: m.maxOutput}
if native.IsNil(value) || value.IsUndefined() || value.IsNull() {
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
return options
}
object := native.Object(vm, value)
if cwd := native.Get(vm, object, "cwd"); !native.IsNil(cwd) && !cwd.IsUndefined() && !cwd.IsNull() {
resolved, err := m.filesystem.Resolve(native.String(vm, cwd), false)
object := value.ToObject(vm)
if cwd := object.Get("cwd"); cwd != nil && !goja.IsUndefined(cwd) && !goja.IsNull(cwd) {
resolved, err := m.filesystem.Resolve(cwd.String(), false)
if err != nil {
panic(native.GoError(vm, err))
panic(vm.NewGoError(err))
}
options.cwd = resolved
}
if environment := native.Get(vm, object, "env"); !native.IsNil(environment) && !environment.IsUndefined() && !environment.IsNull() {
if environment := object.Get("env"); environment != nil && !goja.IsUndefined(environment) && !goja.IsNull(environment) {
var values map[string]string
if err := native.ExportTo(vm, environment, &values); err != nil {
panic(native.TypeError(vm, "env must be an object"))
if err := vm.ExportTo(environment, &values); err != nil {
panic(vm.NewTypeError("env must be an object"))
}
options.env = make([]string, 0, len(values))
for name, value := range values {
options.env = append(options.env, name+"="+value)
}
}
if shell := native.Get(vm, object, "shell"); !native.IsNil(shell) {
options.shell = native.Bool(shell)
if shell := object.Get("shell"); shell != nil {
options.shell = shell.ToBoolean()
}
if timeoutValue := native.Get(vm, object, "timeout"); !native.IsNil(timeoutValue) && native.Int(vm, timeoutValue) > 0 {
timeout := native.Int(vm, timeoutValue)
if timeoutValue := object.Get("timeout"); timeoutValue != nil && timeoutValue.ToInteger() > 0 {
timeout := timeoutValue.ToInteger()
options.timeout = time.Duration(timeout) * time.Millisecond
}
if encoding := native.Get(vm, object, "encoding"); !native.IsNil(encoding) && !encoding.IsUndefined() && !encoding.IsNull() {
options.encoding = native.String(vm, encoding)
if encoding := object.Get("encoding"); encoding != nil && !goja.IsUndefined(encoding) && !goja.IsNull(encoding) {
options.encoding = encoding.String()
}
if maxBuffer := native.Get(vm, object, "maxBuffer"); !native.IsNil(maxBuffer) {
requested := native.Int(vm, maxBuffer)
if maxBuffer := object.Get("maxBuffer"); maxBuffer != nil {
requested := maxBuffer.ToInteger()
if requested > 0 && requested < int64(options.maxBuffer) {
options.maxBuffer = int(requested)
}
}
return options
}
func childExecCommand(command string, arguments []string, options childCommandOptions) (*exec.Cmd, context.CancelFunc) {
ctx := context.Background()
cancel := func() {
}
cancel := func() {}
if options.timeout > 0 {
ctx, cancel = context.WithTimeout(ctx, options.timeout)
}
@@ -222,13 +222,15 @@ func childExecCommand(command string, arguments []string, options childCommandOp
}
return exec.CommandContext(ctx, command, arguments...), cancel
}
func (m *Module) childOutputLimit(options childCommandOptions) int {
if options.maxBuffer > 0 && options.maxBuffer < m.maxOutput {
return options.maxBuffer
}
return m.maxOutput
}
func (m *Module) spawnChild(vm *moejs.Runtime, command string, arguments []string, options childCommandOptions) *engine.Object {
func (m *Module) spawnChild(vm *goja.Runtime, command string, arguments []string, options childCommandOptions) *goja.Object {
child := events.NewEmitter(vm)
cmd, cancel := childExecCommand(command, arguments, options)
cmd.Dir = options.cwd
@@ -237,45 +239,40 @@ func (m *Module) spawnChild(vm *moejs.Runtime, command string, arguments []strin
stdoutReader, stdoutErr := cmd.StdoutPipe()
stderrReader, stderrErr := cmd.StderrPipe()
if err := errors.Join(stdinErr, stdoutErr, stderrErr); err != nil {
panic(native.GoError(vm, err))
panic(vm.NewGoError(err))
}
stdinStream, stdout, stderr := m.childStreams(vm, stdin)
_ = native.Set(vm, child, "stdout", stdout)
_ = native.Set(vm, child, "stderr", stderr)
_ = native.Set(vm, child, "stdin", stdinStream)
_ = native.Set(vm, child, "stdio", []any{stdinStream, stdout, stderr})
_ = native.Set(vm, child, "pid", moejs.Undefined())
_ = native.Set(vm, child, "exitCode", moejs.Null())
_ = native.Set(vm, child, "signalCode", moejs.Null())
_ = native.Set(vm, child, "killed", false)
_ = native.Set(vm, child, "connected", false)
_ = child.Set("stdout", stdout)
_ = child.Set("stderr", stderr)
_ = child.Set("stdin", stdinStream)
_ = child.Set("stdio", []any{stdinStream, stdout, stderr})
_ = child.Set("pid", goja.Undefined())
_ = child.Set("exitCode", goja.Null())
_ = child.Set("signalCode", goja.Null())
_ = child.Set("killed", false)
_ = child.Set("connected", false)
if err := cmd.Start(); err != nil {
cancel()
m.runtime.Loop().SetTimeout(func(vm *moejs.Runtime) {
m.runtime.Loop().SetTimeout(func(vm *goja.Runtime) {
_ = m.runtime.RunJob(vm, "child_process.spawn", func() error {
return events.Emit(vm, child, "error", native.GoError(vm, err))
return events.Emit(vm, child, "error", vm.NewGoError(err))
})
}, 0)
return child
}
_ = native.Set(vm, child, "pid", cmd.Process.Pid)
resourceID := m.runtime.AddResource(func() {
_ = cmd.Process.Kill()
cancel()
})
_ = child.Set("pid", cmd.Process.Pid)
resourceID := m.runtime.AddResource(func() { _ = cmd.Process.Kill(); cancel() })
if resourceID == 0 {
_ = stdin.Close()
_ = stdoutReader.Close()
_ = stderrReader.Close()
go func() {
_ = cmd.Wait()
}()
go func() { _ = cmd.Wait() }()
return child
}
_ = native.Set(vm, child, "kill", native.Bind(vm, "", 1, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
signal := native.String(vm, moejs.Arg(args, 0))
_ = child.Set("kill", func(signal string) bool {
if cmd.Process == nil {
return native.From(vm, false), nil
return false
}
var err error
if signal == "SIGKILL" {
@@ -284,26 +281,24 @@ func (m *Module) spawnChild(vm *moejs.Runtime, command string, arguments []strin
err = cmd.Process.Signal(os.Interrupt)
}
if err == nil {
_ = native.Set(vm, child, "killed", true)
_ = child.Set("killed", true)
}
return native.From(vm, err == nil), nil
}))
_ = native.Set(vm, child, "ref", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
panic(native.GoError(vm, fmt.Errorf("child_process.ChildProcess.ref is not supported by jsruntime; the event loop is host-driven")))
}))
_ = native.Set(vm, child, "unref", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
panic(native.GoError(vm, fmt.Errorf("child_process.ChildProcess.unref is not supported by jsruntime; the event loop is host-driven")))
}))
_ = native.Set(vm, child, "disconnect", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
_ = events.Emit(vm, child, "disconnect")
return moejs.Undefined(), nil
}))
_ = native.Set(vm, child, "send", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
if callback, ok := checkedFunction(moejs.Arg(args, len(args)-1)); ok {
_, _ = native.Call(vm, callback, moejs.Undefined(), native.GoError(vm, fmt.Errorf("IPC is not enabled")))
return err == nil
})
_ = child.Set("ref", func() *goja.Object {
panic(vm.NewGoError(fmt.Errorf("child_process.ChildProcess.ref is not supported by jsruntime; the event loop is host-driven")))
})
_ = child.Set("unref", func() *goja.Object {
panic(vm.NewGoError(fmt.Errorf("child_process.ChildProcess.unref is not supported by jsruntime; the event loop is host-driven")))
})
_ = child.Set("disconnect", func() { _ = events.Emit(vm, child, "disconnect") })
_ = child.Set("send", func(call goja.FunctionCall) goja.Value {
if callback, ok := goja.AssertFunction(call.Argument(len(call.Arguments) - 1)); ok {
_, _ = callback(goja.Undefined(), vm.NewGoError(fmt.Errorf("IPC is not enabled")))
}
return native.From(vm, false), nil
}))
return vm.ToValue(false)
})
m.pipeChildOutput(vm, stdoutReader, stdout, options.encoding)
m.pipeChildOutput(vm, stderrReader, stderr, options.encoding)
go func() {
@@ -318,13 +313,13 @@ func (m *Module) spawnChild(vm *moejs.Runtime, command string, arguments []strin
exitCode = -1
}
}
m.runtime.RunOnLoop(func(vm *moejs.Runtime) {
m.runtime.RunOnLoop(func(vm *goja.Runtime) {
_ = m.runtime.RunJob(vm, "child_process", func() error {
_ = native.Set(vm, child, "exitCode", exitCode)
if emitErr := events.Emit(vm, child, "exit", exitCode, moejs.Null()); emitErr != nil {
_ = child.Set("exitCode", exitCode)
if emitErr := events.Emit(vm, child, "exit", exitCode, goja.Null()); emitErr != nil {
return emitErr
}
return events.Emit(vm, child, "close", exitCode, moejs.Null())
return events.Emit(vm, child, "close", exitCode, goja.Null())
})
})
}()
@@ -334,99 +329,96 @@ func (m *Module) spawnChild(vm *moejs.Runtime, command string, arguments []strin
//go:embed stdio.js
var childStdioSource string
func (m *Module) childStreams(vm *moejs.Runtime, stdin io.WriteCloser) (*engine.Object, *engine.Object, *engine.Object) {
factoryValue, err := native.Run(vm, childStdioSource)
// childStreams creates the Writable stdin and Readable stdout/stderr streams
// for one spawned child process. The Go side owns the underlying pipes and
// serializes writes through a per-child queue.
func (m *Module) childStreams(vm *goja.Runtime, stdin io.WriteCloser) (*goja.Object, *goja.Object, *goja.Object) {
factoryValue, err := vm.RunString(childStdioSource)
if err != nil {
panic(native.GoError(vm, fmt.Errorf("load child_process stdio: %w", err)))
panic(vm.NewGoError(fmt.Errorf("load child_process stdio: %w", err)))
}
factory, _ := checkedFunction(factoryValue)
hooks := vm.Realm().NewObject()
factory, _ := goja.AssertFunction(factoryValue)
hooks := vm.NewObject()
var writes writequeue.Queue
_ = native.Set(vm, hooks, "stdinWrite", native.Bind(vm, "", 2, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
chunk := moejs.Arg(args, 0)
callback := moejs.Arg(args, 1)
_ = hooks.Set("stdinWrite", func(chunk goja.Value, callback goja.Callable) {
data := append([]byte(nil), buffer.Bytes(vm, chunk)...)
writes.Submit(func() {
_, err := stdin.Write(data)
if native.IsNil(callback) {
if callback == nil {
return
}
m.runtime.RunOnLoop(func(vm *moejs.Runtime) {
m.runtime.RunOnLoop(func(vm *goja.Runtime) {
_ = m.runtime.RunJob(vm, "child_process.stdin.write", func() error {
if err != nil {
_, callbackErr := native.Call(vm, callback, moejs.Undefined(), native.GoError(vm, err))
_, callbackErr := callback(goja.Undefined(), vm.NewGoError(err))
return callbackErr
}
_, callbackErr := native.Call(vm, callback, moejs.Undefined())
_, callbackErr := callback(goja.Undefined())
return callbackErr
})
})
})
return moejs.Undefined(), nil
}))
_ = native.Set(vm, hooks, "stdinEnd", native.Bind(vm, "", 1, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
callback := moejs.Arg(args, 0)
})
_ = hooks.Set("stdinEnd", func(callback goja.Callable) {
writes.Submit(func() {
err := stdin.Close()
m.runtime.RunOnLoop(func(vm *moejs.Runtime) {
m.runtime.RunOnLoop(func(vm *goja.Runtime) {
_ = m.runtime.RunJob(vm, "child_process.stdin.end", func() error {
if !native.IsNil(callback) {
if callback != nil {
if err != nil {
_, callbackErr := native.Call(vm, callback, moejs.Undefined(), native.GoError(vm, err))
_, callbackErr := callback(goja.Undefined(), vm.NewGoError(err))
return callbackErr
}
_, callbackErr := native.Call(vm, callback, moejs.Undefined())
_, callbackErr := callback(goja.Undefined())
return callbackErr
}
return nil
})
})
})
return moejs.Undefined(), nil
}))
_ = native.Set(vm, hooks, "stdinDestroy", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
go func() {
_ = stdin.Close()
}()
return moejs.Undefined(), nil
}))
streams, err := native.Call(vm, factory, moejs.Undefined(), require.Require(vm, "stream"), engine.ObjectValue(hooks))
})
_ = hooks.Set("stdinDestroy", func() {
go func() { _ = stdin.Close() }()
})
streams, err := factory(goja.Undefined(), require.Require(vm, "stream"), hooks)
if err != nil {
panic(err)
}
streamsObject := native.Object(vm, streams)
createStdin, _ := checkedFunction(native.Get(vm, streamsObject, "createStdin"))
createOutput, _ := checkedFunction(native.Get(vm, streamsObject, "createOutput"))
if native.IsNil(createStdin) || native.IsNil(createOutput) {
panic(native.GoError(vm, fmt.Errorf("child_process stdio factory is incomplete")))
streamsObject := streams.ToObject(vm)
createStdin, _ := goja.AssertFunction(streamsObject.Get("createStdin"))
createOutput, _ := goja.AssertFunction(streamsObject.Get("createOutput"))
if createStdin == nil || createOutput == nil {
panic(vm.NewGoError(fmt.Errorf("child_process stdio factory is incomplete")))
}
stdinValue, err := native.Call(vm, createStdin, moejs.Undefined())
stdinValue, err := createStdin(goja.Undefined())
if err != nil {
panic(err)
}
stdoutValue, err := native.Call(vm, createOutput, moejs.Undefined())
stdoutValue, err := createOutput(goja.Undefined())
if err != nil {
panic(err)
}
stderrValue, err := native.Call(vm, createOutput, moejs.Undefined())
stderrValue, err := createOutput(goja.Undefined())
if err != nil {
panic(err)
}
return native.Object(vm, stdinValue), native.Object(vm, stdoutValue), native.Object(vm, stderrValue)
return stdinValue.ToObject(vm), stdoutValue.ToObject(vm), stderrValue.ToObject(vm)
}
func childCallback(args []moejs.Value) moejs.Value {
for index := len(args) - 1; index >= 0; index-- {
if callback, ok := checkedFunction(args[index]); ok {
func childCallback(call goja.FunctionCall) goja.Callable {
for index := len(call.Arguments) - 1; index >= 0; index-- {
if callback, ok := goja.AssertFunction(call.Arguments[index]); ok {
return callback
}
}
return moejs.Undefined()
return nil
}
func (m *Module) pipeChildOutput(vm *moejs.Runtime, reader io.Reader, stream *engine.Object, encoding string) {
push, _ := checkedFunction(native.Get(vm, stream, "push"))
setEncoding, _ := checkedFunction(native.Get(vm, stream, "setEncoding"))
if encoding != "" && !native.IsNil(setEncoding) {
_, _ = native.Call(vm, setEncoding, engine.ObjectValue(stream), native.From(vm, encoding))
func (m *Module) pipeChildOutput(vm *goja.Runtime, reader io.Reader, stream *goja.Object, encoding string) {
push, _ := goja.AssertFunction(stream.Get("push"))
setEncoding, _ := goja.AssertFunction(stream.Get("setEncoding"))
if encoding != "" && setEncoding != nil {
_, _ = setEncoding(stream, vm.ToValue(encoding))
}
go func() {
data := make([]byte, 32*1024)
@@ -434,12 +426,11 @@ func (m *Module) pipeChildOutput(vm *moejs.Runtime, reader io.Reader, stream *en
count, err := reader.Read(data)
if count > 0 {
chunk := append([]byte(nil), data[:count]...)
delivered := make(chan struct {
})
if !m.runtime.RunOnLoop(func(vm *moejs.Runtime) {
delivered := make(chan struct{})
if !m.runtime.RunOnLoop(func(vm *goja.Runtime) {
defer close(delivered)
_ = m.runtime.RunJob(vm, "child_process stream", func() error {
_, pushErr := native.Call(vm, push, engine.ObjectValue(stream), engine.ObjectValue(buffer.WrapBytes(vm, chunk)))
_, pushErr := push(stream, buffer.WrapBytes(vm, chunk))
return pushErr
})
}) {
@@ -448,9 +439,9 @@ func (m *Module) pipeChildOutput(vm *moejs.Runtime, reader io.Reader, stream *en
<-delivered
}
if err != nil {
m.runtime.RunOnLoop(func(vm *moejs.Runtime) {
m.runtime.RunOnLoop(func(vm *goja.Runtime) {
_ = m.runtime.RunJob(vm, "child_process stream close", func() error {
_, pushErr := native.Call(vm, push, engine.ObjectValue(stream), moejs.Null())
_, pushErr := push(stream, goja.Null())
return pushErr
})
})
@@ -459,10 +450,11 @@ func (m *Module) pipeChildOutput(vm *moejs.Runtime, reader io.Reader, stream *en
}
}()
}
func (m *Module) execChild(vm *moejs.Runtime, command string, arguments []string, options childCommandOptions, callback moejs.Value) *engine.Object {
func (m *Module) execChild(vm *goja.Runtime, command string, arguments []string, options childCommandOptions, callback goja.Callable) *goja.Object {
child := events.NewEmitter(vm)
_ = native.Set(vm, child, "pid", moejs.Undefined())
_ = native.Set(vm, child, "killed", false)
_ = child.Set("pid", goja.Undefined())
_ = child.Set("killed", false)
cmd, cancel := childExecCommand(command, arguments, options)
cmd.Dir, cmd.Env = options.cwd, options.env
outputLimit := m.childOutputLimit(options)
@@ -471,34 +463,29 @@ func (m *Module) execChild(vm *moejs.Runtime, command string, arguments []string
cmd.Stdout, cmd.Stderr = stdout, stderr
if err := cmd.Start(); err != nil {
cancel()
m.runtime.Loop().SetTimeout(func(vm *moejs.Runtime) {
m.runtime.Loop().SetTimeout(func(vm *goja.Runtime) {
_ = m.runtime.RunJob(vm, "child_process.exec", func() error {
if _, callbackErr := native.Call(vm, callback, moejs.Undefined(), engine.ObjectValue(childProcessError(vm, err, -1, nil, nil)), moejs.Undefined(), moejs.Undefined()); callbackErr != nil {
if _, callbackErr := callback(goja.Undefined(), childProcessError(vm, err, -1, nil, nil), goja.Undefined(), goja.Undefined()); callbackErr != nil {
return callbackErr
}
return events.Emit(vm, child, "error", native.GoError(vm, err))
return events.Emit(vm, child, "error", vm.NewGoError(err))
})
}, 0)
return child
}
_ = native.Set(vm, child, "pid", cmd.Process.Pid)
resourceID := m.runtime.AddResource(func() {
_ = cmd.Process.Kill()
cancel()
})
_ = child.Set("pid", cmd.Process.Pid)
resourceID := m.runtime.AddResource(func() { _ = cmd.Process.Kill(); cancel() })
if resourceID == 0 {
go func() {
_ = cmd.Wait()
}()
go func() { _ = cmd.Wait() }()
return child
}
_ = native.Set(vm, child, "kill", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
_ = child.Set("kill", func() bool {
err := cmd.Process.Kill()
if err == nil {
_ = native.Set(vm, child, "killed", true)
_ = child.Set("killed", true)
}
return native.From(vm, err == nil), nil
}))
return err == nil
})
go func() {
err := cmd.Wait()
err = errors.Join(err, stdout.err, stderr.err)
@@ -511,20 +498,20 @@ func (m *Module) execChild(vm *moejs.Runtime, command string, arguments []string
} else if err != nil {
exitCode = -1
}
m.runtime.RunOnLoop(func(vm *moejs.Runtime) {
m.runtime.RunOnLoop(func(vm *goja.Runtime) {
_ = m.runtime.RunJob(vm, "child_process.exec", func() error {
stdoutValue := encodeChildOutput(vm, stdout.Bytes(), options.encoding)
stderrValue := encodeChildOutput(vm, stderr.Bytes(), options.encoding)
var errorValue moejs.Value = moejs.Null()
var errorValue goja.Value = goja.Null()
if err != nil {
errorValue = engine.ObjectValue(childProcessError(vm, err, exitCode, stdout.Bytes(), stderr.Bytes()))
errorValue = childProcessError(vm, err, exitCode, stdout.Bytes(), stderr.Bytes())
}
if _, callbackErr := native.Call(vm, callback, moejs.Undefined(), errorValue, stdoutValue, stderrValue); callbackErr != nil {
if _, callbackErr := callback(goja.Undefined(), errorValue, stdoutValue, stderrValue); callbackErr != nil {
return callbackErr
}
_ = native.Set(vm, child, "exitCode", exitCode)
_ = events.Emit(vm, child, "exit", exitCode, moejs.Null())
return events.Emit(vm, child, "close", exitCode, moejs.Null())
_ = child.Set("exitCode", exitCode)
_ = events.Emit(vm, child, "exit", exitCode, goja.Null())
return events.Emit(vm, child, "close", exitCode, goja.Null())
})
})
}()
@@ -561,38 +548,40 @@ func (m *Module) runChildSync(command string, arguments []string, options childC
}
return result
}
func (m *Module) spawnChildSync(vm *moejs.Runtime, command string, arguments []string, options childCommandOptions) *engine.Object {
func (m *Module) spawnChildSync(vm *goja.Runtime, command string, arguments []string, options childCommandOptions) *goja.Object {
result := m.runChildSync(command, arguments, options)
object := vm.Realm().NewObject()
_ = native.Set(vm, object, "pid", result.pid)
_ = native.Set(vm, object, "status", result.exitCode)
_ = native.Set(vm, object, "signal", moejs.Null())
_ = native.Set(vm, object, "stdout", encodeChildOutput(vm, result.stdout, options.encoding))
_ = native.Set(vm, object, "stderr", encodeChildOutput(vm, result.stderr, options.encoding))
_ = native.Set(vm, object, "output", []any{moejs.Null(), encodeChildOutput(vm, result.stdout, options.encoding), encodeChildOutput(vm, result.stderr, options.encoding)})
object := vm.NewObject()
_ = object.Set("pid", result.pid)
_ = object.Set("status", result.exitCode)
_ = object.Set("signal", goja.Null())
_ = object.Set("stdout", encodeChildOutput(vm, result.stdout, options.encoding))
_ = object.Set("stderr", encodeChildOutput(vm, result.stderr, options.encoding))
_ = object.Set("output", []any{goja.Null(), encodeChildOutput(vm, result.stdout, options.encoding), encodeChildOutput(vm, result.stderr, options.encoding)})
if result.err != nil {
_ = native.Set(vm, object, "error", childProcessError(vm, result.err, result.exitCode, result.stdout, result.stderr))
_ = object.Set("error", childProcessError(vm, result.err, result.exitCode, result.stdout, result.stderr))
}
return object
}
func encodeChildOutput(vm *moejs.Runtime, data []byte, encoding string) moejs.Value {
func encodeChildOutput(vm *goja.Runtime, data []byte, encoding string) goja.Value {
if encoding == "" || encoding == "buffer" {
return engine.ObjectValue(buffer.WrapBytes(vm, append([]byte(nil), data...)))
return buffer.WrapBytes(vm, append([]byte(nil), data...))
}
return buffer.EncodeBytes(vm, data, native.From(vm, encoding))
return buffer.EncodeBytes(vm, data, vm.ToValue(encoding))
}
func childProcessError(vm *moejs.Runtime, err error, exitCode int, stdout, stderr []byte) *engine.Object {
object := native.Object(vm, native.GoError(vm, err))
func childProcessError(vm *goja.Runtime, err error, exitCode int, stdout, stderr []byte) *goja.Object {
object := vm.NewGoError(err)
var limitError *childOutputLimitError
if errors.As(err, &limitError) {
_ = native.Set(vm, object, "code", "ERR_CHILD_PROCESS_STDIO_MAXBUFFER")
_ = object.Set("code", "ERR_CHILD_PROCESS_STDIO_MAXBUFFER")
} else {
_ = native.Set(vm, object, "code", exitCode)
_ = object.Set("code", exitCode)
}
_ = native.Set(vm, object, "killed", false)
_ = native.Set(vm, object, "signal", moejs.Null())
_ = native.Set(vm, object, "stdout", encodeChildOutput(vm, stdout, ""))
_ = native.Set(vm, object, "stderr", encodeChildOutput(vm, stderr, ""))
_ = object.Set("killed", false)
_ = object.Set("signal", goja.Null())
_ = object.Set("stdout", encodeChildOutput(vm, stdout, ""))
_ = object.Set("stderr", encodeChildOutput(vm, stderr, ""))
return object
}
func checkedFunction(v moejs.Value) (moejs.Value, bool) { return v, engine.IsCallable(v) }
+97 -61
View File
@@ -1,14 +1,17 @@
package console
// JavaScript compatibility: console.assert(), debug(), error(), exception(),
// info(), log(), trace(), and warn(). Supports basic %s/%d/%i/%f/%o/%O/%c/%%
// substitutions. assert(false, ...) and trace() include a stack trace.
import (
"encoding/json"
"fmt"
moe "github.com/Calcium-Ion/moejs"
e "github.com/Calcium-Ion/moejs/engine"
"github.com/komari-monitor/komari/pkg/jsruntime/native"
logger "github.com/komari-monitor/komari/utils/log"
"io"
"strings"
"github.com/dop251/goja"
logger "github.com/komari-monitor/komari/utils/log"
)
type level uint8
@@ -20,78 +23,81 @@ const (
errorLevel
)
type Module struct{ output io.Writer }
type Module struct {
output io.Writer
}
func New(w io.Writer) *Module { return &Module{output: w} }
func (m *Module) Inject(vm *moe.Runtime) error {
o := vm.Realm().NewObject()
for _, entry := range []struct {
name string
level level
stack bool
}{{"debug", debug, false}, {"info", info, false}, {"log", info, false}, {"warn", warn, false}, {"error", errorLevel, false}, {"exception", errorLevel, false}, {"trace", debug, true}} {
entry := entry
if err := native.Function(vm, o, entry.name, func(_ *moe.Realm, _ moe.Value, args []moe.Value) (moe.Value, error) {
m.write(vm, entry.level, args, entry.stack)
return moe.Undefined(), nil
}); err != nil {
return err
func New(output io.Writer) *Module {
return &Module{output: output}
}
func (m *Module) Inject(vm *goja.Runtime) error {
console := vm.NewObject()
console.Set("assert", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 && call.Argument(0).ToBoolean() {
return goja.Undefined()
}
}
if err := native.Function(vm, o, "assert", func(_ *moe.Realm, _ moe.Value, args []moe.Value) (moe.Value, error) {
if len(args) > 0 && native.Bool(args[0]) {
return moe.Undefined(), nil
}
values := []moe.Value{moe.String("Assertion failed:")}
if len(args) > 1 {
values = append(values, args[1:]...)
values := call.Arguments
if len(values) <= 1 {
values = []goja.Value{vm.ToValue("Assertion failed")}
} else {
values = append([]goja.Value{vm.ToValue("Assertion failed:")}, values[1:]...)
}
m.write(vm, errorLevel, values, true)
return moe.Undefined(), nil
}); err != nil {
return err
return goja.Undefined()
})
console.Set("debug", func(call goja.FunctionCall) goja.Value {
m.write(vm, debug, call.Arguments, false)
return goja.Undefined()
})
error := func(call goja.FunctionCall) goja.Value {
m.write(vm, errorLevel, call.Arguments, false)
return goja.Undefined()
}
return vm.SetGlobal("console", e.ObjectValue(o))
console.Set("error", error)
console.Set("exception", error)
console.Set("info", func(call goja.FunctionCall) goja.Value {
m.write(vm, info, call.Arguments, false)
return goja.Undefined()
})
console.Set("log", func(call goja.FunctionCall) goja.Value {
m.write(vm, info, call.Arguments, false)
return goja.Undefined()
})
console.Set("trace", func(call goja.FunctionCall) goja.Value {
m.write(vm, debug, call.Arguments, true)
return goja.Undefined()
})
console.Set("warn", func(call goja.FunctionCall) goja.Value {
m.write(vm, warn, call.Arguments, false)
return goja.Undefined()
})
return vm.Set("console", console)
}
func (m *Module) WriteError(vm *moe.Runtime, values []moe.Value, stack bool) {
m.write(vm, errorLevel, values, stack)
func (m *Module) WriteError(vm *goja.Runtime, values []goja.Value, withStack bool) {
m.write(vm, errorLevel, values, withStack)
}
func (m *Module) Report(vm *moe.Runtime, name string, err error) {
m.WriteError(vm, []moe.Value{moe.String(fmt.Sprintf("%s callback failed: %v", name, err))}, false)
func (m *Module) Report(vm *goja.Runtime, name string, err error) {
m.WriteError(vm, []goja.Value{vm.ToValue(fmt.Sprintf("%s callback failed: %v", name, err))}, false)
}
func (m *Module) write(vm *moe.Runtime, l level, values []moe.Value, withStack bool) {
args := make([]any, len(values))
for i, v := range values {
x, err := vm.ToGo(v)
if err != nil {
x = native.String(vm, v)
}
args[i] = x
}
message := ""
if len(args) > 0 {
if format, ok := args[0].(string); ok && strings.Contains(format, "%") {
message = formatConsoleString(format, args[1:])
} else {
parts := make([]string, len(args))
for i, x := range args {
parts[i] = formatConsoleValue(x)
}
message = strings.Join(parts, " ")
}
}
func (m *Module) write(vm *goja.Runtime, messageLevel level, values []goja.Value, withStack bool) {
message := formatConsoleValues(values)
if withStack {
errObj := vm.Realm().NewError(e.KindError, "")
stack := vm.Realm().StackTrace(e.ObjectValue(errObj))
if stack != "" {
message += "\n" + stack
if stack := stackTrace(vm); stack != "" {
if message != "" {
message += "\n"
}
message += stack
}
}
if m.output != nil {
_, _ = fmt.Fprintln(m.output, message)
return
}
switch l {
switch messageLevel {
case debug:
logger.Debug("JavaScript", message)
case warn:
@@ -102,6 +108,25 @@ func (m *Module) write(vm *moe.Runtime, l level, values []moe.Value, withStack b
logger.Info("JavaScript", message)
}
}
func formatConsoleValues(values []goja.Value) string {
if len(values) == 0 {
return ""
}
args := make([]any, 0, len(values))
for _, value := range values {
args = append(args, value.Export())
}
if format, ok := args[0].(string); ok && strings.Contains(format, "%") {
return formatConsoleString(format, args[1:])
}
parts := make([]string, 0, len(args))
for _, arg := range args {
parts = append(parts, formatConsoleValue(arg))
}
return strings.Join(parts, " ")
}
func formatConsoleValue(value any) string {
switch value := value.(type) {
case nil:
@@ -197,3 +222,14 @@ func toConsoleFloat(value any) float64 {
return 0
}
}
func stackTrace(vm *goja.Runtime) string {
if vm == nil {
return ""
}
value, err := vm.RunString("new Error().stack")
if err != nil {
return ""
}
return value.String()
}
File diff suppressed because it is too large. Load diff
-140
View File
@@ -1,140 +0,0 @@
package crypto
import (
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"strconv"
"testing"
"time"
moe "github.com/Calcium-Ion/moejs"
"github.com/komari-monitor/komari/pkg/jsruntime/buffer"
"github.com/komari-monitor/komari/pkg/jsruntime/internal/bridge"
"github.com/komari-monitor/komari/pkg/jsruntime/loop"
"github.com/komari-monitor/komari/pkg/jsruntime/native"
)
func runNativeCryptoRegression(t *testing.T, script string) {
t.Helper()
results := make(chan string, 8)
l := loop.NewEventLoop(loop.WithOnError(func(err error) { results <- err.Error() }))
t.Cleanup(l.Terminate)
services := bridge.New(l, 10*time.Second)
services.SetErrorReporter(func(_ *moe.Runtime, _ string, err error) { results <- err.Error() })
if err := l.Start(); err != nil {
t.Fatal(err)
}
l.RunOnLoop(func(vm *moe.Runtime) {
buffer.Enable(vm)
module := vm.Realm().NewObject()
New(services).Load(vm, module)
_ = vm.SetGlobal("crypto", native.Get(vm, module, "exports"))
_ = vm.SetGlobal("done", native.Bind(vm, "done", 1, func(_ *moe.Realm, _ moe.Value, args []moe.Value) (moe.Value, error) {
results <- native.String(vm, moe.Arg(args, 0))
return moe.Undefined(), nil
}))
if _, err := native.RunScript(vm, "crypto-native-test.js", script); err != nil {
results <- err.Error()
}
})
select {
case result := <-results:
if result != "ok" {
t.Fatal(result)
}
case <-time.After(12 * time.Second):
t.Fatal("native crypto callback was not delivered")
}
}
func TestNativeCryptoRandomIntSyncAndAsync(t *testing.T) {
runNativeCryptoRegression(t, `
const a = crypto.randomInt(1);
const b = crypto.randomInt(-3, -2);
if (typeof a !== "number" || a !== 0 || b !== -3) throw new Error("randomInt must return synchronously without callback");
let rejected = false;
try { crypto.randomFill(new Uint8Array(1)); } catch (e) { rejected = e instanceof TypeError; }
if (!rejected) throw new Error("missing randomFill callback accepted");
crypto.randomInt(4, 5, (err, value) => done(!err && value === 4 ? "ok" : "randomInt callback"));
`)
}
func TestNativeCryptoRandomFillLoopOwnership(t *testing.T) {
runNativeCryptoRegression(t, `
const backing = new ArrayBuffer(128);
const view = new Uint16Array(backing, 8, 32);
const raw = new Uint8Array(backing);
crypto.randomFill(view, 2, 16, (err, result) => {
if (err || result !== view) return done("randomFill callback identity");
if (raw.slice(0, 12).some(x => x !== 123) || raw.slice(44).some(x => x !== 123)) return done("randomFill wrote outside requested view range");
if (raw.slice(12, 44).every(x => x === 123)) return done("randomFill did not copy on loop");
done("ok");
});
// No loop callback can run yet. This must not overlap a worker writing raw.
for (let i = 0; i < 1000; i++) raw.fill(123);
`)
}
func TestNativeCryptoRandomFillDetachAndResize(t *testing.T) {
for _, mutation := range []string{"ab.transfer()", "ab.resize(4)"} {
t.Run(mutation, func(t *testing.T) {
runNativeCryptoRegression(t, `
const ab = new ArrayBuffer(64, {maxByteLength: 128});
const view = new Uint8Array(ab, 8, 32);
let calls = 0;
crypto.randomFill(view, 2, 16, (err, value) => {
if (++calls !== 1 || !(err instanceof Error) || value !== undefined) return done("detach/resize must callback with error");
done("ok");
});
`+mutation+`;`)
})
}
}
func TestNativeCryptoRandomFillReacquiresResizedStorage(t *testing.T) {
runNativeCryptoRegression(t, `
const ab = new ArrayBuffer(64, {maxByteLength: 1024});
const view = new DataView(ab, 4, 32);
crypto.randomFill(view, 3, 24, (err, value) => {
const raw = new Uint8Array(ab);
if (err || value !== view || raw.slice(7, 31).every(x => x === 0)) return done("stale randomFill storage after resize");
if (raw.slice(0, 7).some(x => x !== 0) || raw.slice(31).some(x => x !== 0)) return done("randomFill resize range");
done("ok");
});
ab.resize(1024);
`)
}
func TestNativeCryptoAsyncInputSnapshots(t *testing.T) {
public, private, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
privateDER, err := x509.MarshalPKCS8PrivateKey(private)
if err != nil {
t.Fatal(err)
}
publicDER, err := x509.MarshalPKIXPublicKey(public)
if err != nil {
t.Fatal(err)
}
privatePEM := string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privateDER}))
publicPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: publicDER}))
runNativeCryptoRegression(t, "const privatePEM = "+strconv.Quote(privatePEM)+"; const publicPEM = "+strconv.Quote(publicPEM)+";"+`
const password = Buffer.from("password"), salt = Buffer.from("salt");
const expectedPBKDF = crypto.pbkdf2Sync(password, salt, 1000, 32, "sha256").toString("hex");
const expectedScrypt = crypto.scryptSync(password, salt, 32, {N: 16, r: 1, p: 1}).toString("hex");
const data = Buffer.from("message"), privateKey = Buffer.from(privatePEM), publicKey = Buffer.from(publicPEM);
const signature = crypto.sign(null, data, privateKey);
let remaining = 4;
function checked(ok) { if (!ok) throw new Error("async crypto input was not snapshotted"); if (--remaining === 0) done("ok"); }
crypto.pbkdf2(password, salt, 1000, 32, "sha256", (err, value) => checked(!err && value.toString("hex") === expectedPBKDF));
crypto.scrypt(password, salt, 32, {N: 16, r: 1, p: 1}, (err, value) => checked(!err && value.toString("hex") === expectedScrypt));
crypto.sign(null, data, privateKey, (err, value) => checked(!err && value.toString("hex") === expectedSignature));
const expectedSignature = signature.toString("hex");
crypto.verify(null, data, publicKey, signature, (err, value) => checked(!err && value === true));
password.fill(0); salt.fill(0); data.fill(0); privateKey.fill(0); publicKey.fill(0); signature.fill(0);
`)
}
+29 -27
View File
@@ -2,44 +2,46 @@ package events
import (
_ "embed"
moe "github.com/Calcium-Ion/moejs"
e "github.com/Calcium-Ion/moejs/engine"
"github.com/komari-monitor/komari/pkg/jsruntime/native"
"github.com/komari-monitor/komari/pkg/jsruntime/require"
"fmt"
"github.com/dop251/goja"
"github.com/dop251/goja_nodejs/require"
)
func Load(vm *moe.Runtime, module *e.Object) {
constructor, err := native.Run(vm, eventEmitterSource)
func Load(vm *goja.Runtime, module *goja.Object) {
constructor, err := vm.RunString(eventEmitterSource)
if err != nil {
panic(err)
}
object := native.Object(vm, constructor)
if err = native.Set(vm, object, "EventEmitter", constructor); err != nil {
panic(err)
}
_ = native.Set(vm, object, "defaultMaxListeners", 10)
if err = native.Set(vm, module, "exports", constructor); err != nil {
panic(err)
panic(vm.NewGoError(fmt.Errorf("load events module: %w", err)))
}
_ = constructor.ToObject(vm).Set("EventEmitter", constructor)
_ = constructor.ToObject(vm).Set("defaultMaxListeners", 10)
_ = module.Set("exports", constructor)
}
func NewEmitter(vm *moe.Runtime) *e.Object {
object, err := native.New(vm, require.Require(vm, "events"))
func NewEmitter(vm *goja.Runtime) *goja.Object {
constructor := require.Require(vm, "events")
object, err := vm.New(constructor)
if err != nil {
panic(err)
}
return object
}
func Emit(vm *moe.Runtime, emitter *e.Object, name string, values ...any) error {
args := make([]moe.Value, 1, len(values)+1)
args[0] = moe.String(name)
for _, x := range values {
v, err := vm.FromGo(x)
if err != nil {
return err
}
args = append(args, v)
func Emit(vm *goja.Runtime, emitter *goja.Object, name string, values ...any) error {
emit, ok := goja.AssertFunction(emitter.Get("emit"))
if !ok {
return fmt.Errorf("EventEmitter.emit is not callable")
}
_, err := vm.CallFunction(native.Get(vm, emitter, "emit"), e.ObjectValue(emitter), args...)
arguments := make([]goja.Value, 1, len(values)+1)
arguments[0] = vm.ToValue(name)
for _, value := range values {
if jsValue, ok := value.(goja.Value); ok {
arguments = append(arguments, jsValue)
} else {
arguments = append(arguments, vm.ToValue(value))
}
}
_, err := emit(emitter, arguments...)
return err
}
+138 -120
View File
@@ -1,17 +1,10 @@
package fetch
import (
"github.com/Calcium-Ion/moejs/engine"
"github.com/komari-monitor/komari/pkg/jsruntime/native"
)
import (
"bytes"
"context"
"errors"
"fmt"
"github.com/Calcium-Ion/moejs"
"github.com/komari-monitor/komari/pkg/jsruntime/httpbody"
"github.com/komari-monitor/komari/pkg/jsruntime/internal/bridge"
"io"
"mime"
"mime/multipart"
@@ -21,12 +14,17 @@ import (
"strconv"
"strings"
"sync"
"github.com/dop251/goja"
"github.com/komari-monitor/komari/pkg/jsruntime/httpbody"
"github.com/komari-monitor/komari/pkg/jsruntime/internal/bridge"
)
type abortSignalState struct {
aborted bool
requests map[int64]context.CancelFunc
}
type nativeRequest struct {
method string
url string
@@ -35,6 +33,7 @@ type nativeRequest struct {
signalID int64
redirect string
}
type httpResult struct {
statusCode int
statusText string
@@ -43,6 +42,7 @@ type httpResult struct {
url string
redirected bool
}
type formEntry struct {
Name string
Value string
@@ -51,9 +51,10 @@ type formEntry struct {
Type string
File bool
}
type Module struct {
runtime *bridge.Runtime
vm *moejs.Runtime
vm *goja.Runtime
client *http.Client
maxBodyBytes int64
abortMu sync.Mutex
@@ -62,57 +63,51 @@ type Module struct {
}
func New(runtime *bridge.Runtime, client *http.Client, maxBodyBytes int64) *Module {
return &Module{runtime: runtime, client: client, maxBodyBytes: maxBodyBytes, abortState: make(map[int64]*abortSignalState)}
return &Module{
runtime: runtime,
client: client,
maxBodyBytes: maxBodyBytes,
abortState: make(map[int64]*abortSignalState),
}
}
func (m *Module) Inject(vm *moejs.Runtime) error {
func (m *Module) Inject(vm *goja.Runtime) error {
m.vm = vm
if err := vm.SetGlobal("__komariBodyBuffer", native.Bind(vm, "__komariBodyBuffer", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return m.bodyBuffer(args), nil
})); err != nil {
if err := vm.Set("__komariBodyBuffer", m.bodyBuffer); err != nil {
return err
}
if err := vm.SetGlobal("__komariBodyText", native.Bind(vm, "__komariBodyText", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return m.bodyText(args), nil
})); err != nil {
if err := vm.Set("__komariBodyText", m.bodyText); err != nil {
return err
}
if err := vm.SetGlobal("__komariEncodeFormData", native.Bind(vm, "__komariEncodeFormData", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return m.encodeFormData(args), nil
})); err != nil {
if err := vm.Set("__komariEncodeFormData", m.encodeFormData); err != nil {
return err
}
if err := vm.SetGlobal("__komariParseFormData", native.Bind(vm, "__komariParseFormData", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return m.parseFormData(args), nil
})); err != nil {
if err := vm.Set("__komariParseFormData", m.parseFormData); err != nil {
return err
}
if err := vm.SetGlobal("__komariNewAbortSignal", native.Bind(vm, "__komariNewAbortSignal", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return m.newAbortSignal(args), nil
})); err != nil {
if err := vm.Set("__komariNewAbortSignal", m.newAbortSignal); err != nil {
return err
}
if err := vm.SetGlobal("__komariAbortSignal", native.Bind(vm, "__komariAbortSignal", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return m.abortSignal(args), nil
})); err != nil {
if err := vm.Set("__komariAbortSignal", m.abortSignal); err != nil {
return err
}
if err := vm.SetGlobal("__komariFetch", native.Bind(vm, "__komariFetch", 1, m.createFetchFunction(false))); err != nil {
if err := vm.Set("__komariFetch", m.createFetchFunction(false)); err != nil {
return err
}
if err := vm.SetGlobal("__komariFetchSync", native.Bind(vm, "__komariFetchSync", 1, m.createFetchFunction(true))); err != nil {
if err := vm.Set("__komariFetchSync", m.createFetchFunction(true)); err != nil {
return err
}
if _, err := native.Run(vm, fetchAPISource); err != nil {
if _, err := vm.RunString(fetchAPISource); err != nil {
return fmt.Errorf("inject Fetch API: %w", err)
}
return nil
}
func (m *Module) createFetchFunction(syncRequest bool) moejs.NativeFunc {
vm := m.vm
return func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
request, err := m.exportNativeRequest(moejs.Arg(args, 0))
func (m *Module) createFetchFunction(syncRequest bool) func(goja.FunctionCall) goja.Value {
return func(call goja.FunctionCall) goja.Value {
request, err := m.exportNativeRequest(call.Argument(0))
if err != nil {
panic(native.TypeError(m.vm, "%s", err))
panic(m.vm.NewTypeError("%s", err))
}
ctx, releaseSignal := m.requestContext(request.signalID)
ctx, cancelTimeout := context.WithTimeout(ctx, m.runtime.Timeout())
@@ -126,12 +121,13 @@ func (m *Module) createFetchFunction(syncRequest bool) moejs.NativeFunc {
if requestErr != nil {
panic(webAPIError(m.vm, "TypeError", fmt.Sprintf("fetch failed: %v", requestErr)))
}
return native.From(vm, m.httpResultValue(m.vm, result)), nil
return m.httpResultValue(m.vm, result)
}
promise, resolve, reject := m.vm.NewPromise()
go func() {
result, requestErr := m.doHTTPRequest(ctx, request)
if !m.runtime.RunOnLoop(func(vm *moejs.Runtime) {
if !m.runtime.RunOnLoop(func(vm *goja.Runtime) {
defer release()
m.runtime.RunJob(vm, "fetch", func() error {
if ctx.Err() != nil {
@@ -150,25 +146,30 @@ func (m *Module) createFetchFunction(syncRequest bool) moejs.NativeFunc {
release()
}
}()
return native.From(vm, native.From(m.vm, promise)), nil
return m.vm.ToValue(promise)
}
}
func (m *Module) exportNativeRequest(value moejs.Value) (nativeRequest, error) {
vm := m.vm
if native.IsNil(value) || value.IsUndefined() || value.IsNull() {
func (m *Module) exportNativeRequest(value goja.Value) (nativeRequest, error) {
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
return nativeRequest{}, errors.New("missing request data")
}
object := native.Object(m.vm, value)
request := nativeRequest{method: native.String(vm, native.Get(vm, object, "method")), url: native.String(vm, native.Get(vm, object, "url")), signalID: native.Int(vm, native.Get(vm, object, "signalId")), redirect: native.String(vm, native.Get(vm, object, "redirect"))}
object := value.ToObject(m.vm)
request := nativeRequest{
method: object.Get("method").String(),
url: object.Get("url").String(),
signalID: object.Get("signalId").ToInteger(),
redirect: object.Get("redirect").String(),
}
if request.method == "" {
request.method = http.MethodGet
}
if request.redirect == "" {
request.redirect = "follow"
}
if value := native.Get(vm, object, "headers"); !native.IsNil(value) && !value.IsUndefined() {
if value := object.Get("headers"); value != nil && !goja.IsUndefined(value) {
var headers [][]string
if err := native.ExportTo(m.vm, value, &headers); err != nil {
if err := m.vm.ExportTo(value, &headers); err != nil {
return nativeRequest{}, fmt.Errorf("invalid request headers: %w", err)
}
for _, header := range headers {
@@ -178,7 +179,7 @@ func (m *Module) exportNativeRequest(value moejs.Value) (nativeRequest, error) {
request.headers = append(request.headers, [2]string{header[0], header[1]})
}
}
if value := native.Get(vm, object, "body"); !native.IsNil(value) && !value.IsUndefined() && !value.IsNull() {
if value := object.Get("body"); value != nil && !goja.IsUndefined(value) && !goja.IsNull(value) {
body, err := exportBytes(m.vm, value)
if err != nil {
return nativeRequest{}, fmt.Errorf("invalid request body: %w", err)
@@ -187,6 +188,7 @@ func (m *Module) exportNativeRequest(value moejs.Value) (nativeRequest, error) {
}
return request, nil
}
func (m *Module) doHTTPRequest(ctx context.Context, request nativeRequest) (httpResult, error) {
httpRequest, err := http.NewRequestWithContext(ctx, request.method, request.url, bytes.NewReader(request.body))
if err != nil {
@@ -199,6 +201,7 @@ func (m *Module) doHTTPRequest(ctx context.Context, request nativeRequest) (http
}
httpRequest.Header.Add(header[0], header[1])
}
client := *m.client
switch request.redirect {
case "manual":
@@ -213,6 +216,7 @@ func (m *Module) doHTTPRequest(ctx context.Context, request nativeRequest) (http
default:
return httpResult{}, fmt.Errorf("unsupported redirect mode %q", request.redirect)
}
response, err := client.Do(httpRequest)
if err != nil {
return httpResult{}, err
@@ -227,60 +231,75 @@ func (m *Module) doHTTPRequest(ctx context.Context, request nativeRequest) (http
responseURL = response.Request.URL.String()
}
statusText := strings.TrimSpace(strings.TrimPrefix(response.Status, strconv.Itoa(response.StatusCode)))
return httpResult{statusCode: response.StatusCode, statusText: statusText, headers: response.Header.Clone(), body: body, url: responseURL, redirected: responseURL != request.url}, nil
return httpResult{
statusCode: response.StatusCode,
statusText: statusText,
headers: response.Header.Clone(),
body: body,
url: responseURL,
redirected: responseURL != request.url,
}, nil
}
func (m *Module) httpResultValue(vm *moejs.Runtime, result httpResult) moejs.Value {
object := vm.Realm().NewObject()
_ = native.Set(vm, object, "status", result.statusCode)
_ = native.Set(vm, object, "statusText", result.statusText)
_ = native.Set(vm, object, "url", result.url)
_ = native.Set(vm, object, "redirected", result.redirected)
_ = native.Set(vm, object, "body", native.From(vm, append([]byte(nil), result.body...)))
func (m *Module) httpResultValue(vm *goja.Runtime, result httpResult) goja.Value {
object := vm.NewObject()
_ = object.Set("status", result.statusCode)
_ = object.Set("statusText", result.statusText)
_ = object.Set("url", result.url)
_ = object.Set("redirected", result.redirected)
_ = object.Set("body", vm.NewArrayBuffer(append([]byte(nil), result.body...)))
headers := make([][2]string, 0, len(result.headers))
for name, values := range result.headers {
for _, value := range values {
headers = append(headers, [2]string{name, value})
}
}
_ = native.Set(vm, object, "headers", headers)
return engine.ObjectValue(object)
_ = object.Set("headers", headers)
return object
}
func (m *Module) bodyBuffer(args []moejs.Value) moejs.Value {
data, err := exportBytes(m.vm, moejs.Arg(args, 0))
func (m *Module) bodyBuffer(call goja.FunctionCall) goja.Value {
data, err := exportBytes(m.vm, call.Argument(0))
if err != nil {
panic(native.TypeError(m.vm, "unsupported body value: %v", err))
panic(m.vm.NewTypeError("unsupported body value: %v", err))
}
return native.From(m.vm, native.From(m.vm, data))
return m.vm.ToValue(m.vm.NewArrayBuffer(data))
}
func (m *Module) bodyText(args []moejs.Value) moejs.Value {
data, err := exportBytes(m.vm, moejs.Arg(args, 0))
func (m *Module) bodyText(call goja.FunctionCall) goja.Value {
data, err := exportBytes(m.vm, call.Argument(0))
if err != nil {
panic(native.TypeError(m.vm, "invalid body buffer: %v", err))
panic(m.vm.NewTypeError("invalid body buffer: %v", err))
}
return native.From(m.vm, strings.ToValidUTF8(string(data), "\uFFFD"))
return m.vm.ToValue(strings.ToValidUTF8(string(data), "\uFFFD"))
}
func exportBytes(vm *moejs.Runtime, value moejs.Value) ([]byte, error) {
if native.IsNil(value) {
func exportBytes(vm *goja.Runtime, value goja.Value) ([]byte, error) {
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
return nil, nil
}
if value.IsString() {
return []byte(native.String(vm, value)), nil
if stringValue, ok := value.Export().(string); ok {
return []byte(stringValue), nil
}
var data []byte
if err := native.ExportTo(vm, value, &data); err != nil {
if buffer, ok := value.Export().(goja.ArrayBuffer); ok {
return append([]byte(nil), buffer.Bytes()...), nil
}
var bytesValue []byte
if err := vm.ExportTo(value, &bytesValue); err != nil {
return nil, err
}
return append([]byte(nil), data...), nil
return append([]byte(nil), bytesValue...), nil
}
func (m *Module) newAbortSignal(args []moejs.Value) moejs.Value {
func (m *Module) newAbortSignal(goja.FunctionCall) goja.Value {
m.abortMu.Lock()
defer m.abortMu.Unlock()
m.abortIDs++
return native.From(m.vm, m.abortIDs)
return m.vm.ToValue(m.abortIDs)
}
func (m *Module) abortSignal(args []moejs.Value) moejs.Value {
vm := m.vm
id := native.Int(vm, moejs.Arg(args, 0))
func (m *Module) abortSignal(call goja.FunctionCall) goja.Value {
id := call.Argument(0).ToInteger()
m.abortMu.Lock()
state := m.abortState[id]
if state == nil {
@@ -292,13 +311,15 @@ func (m *Module) abortSignal(args []moejs.Value) moejs.Value {
cancel()
}
m.abortMu.Unlock()
return moejs.Undefined()
return goja.Undefined()
}
func (m *Module) requestContext(signalID int64) (context.Context, context.CancelFunc) {
ctx, cancel := context.WithCancel(context.Background())
if signalID <= 0 {
return ctx, cancel
}
m.abortMu.Lock()
state := m.abortState[signalID]
if state != nil && state.aborted {
@@ -314,6 +335,7 @@ func (m *Module) requestContext(signalID int64) (context.Context, context.Cancel
requestID := m.abortIDs
state.requests[requestID] = cancel
m.abortMu.Unlock()
return ctx, func() {
m.abortMu.Lock()
if current := m.abortState[signalID]; current != nil {
@@ -326,6 +348,7 @@ func (m *Module) requestContext(signalID int64) (context.Context, context.Cancel
cancel()
}
}
func (m *Module) Close() {
m.abortMu.Lock()
defer m.abortMu.Unlock()
@@ -336,34 +359,36 @@ func (m *Module) Close() {
}
}
}
func webAPIError(vm *moejs.Runtime, name, message string) moejs.Value {
object := native.GoError(vm, errors.New(message))
_ = native.Set(vm, native.Object(vm, object), "name", name)
func webAPIError(vm *goja.Runtime, name, message string) *goja.Object {
object := vm.NewGoError(errors.New(message))
_ = object.Set("name", name)
return object
}
func (m *Module) encodeFormData(args []moejs.Value) moejs.Value {
vm := m.vm
func (m *Module) encodeFormData(call goja.FunctionCall) goja.Value {
var entries []formEntry
array := native.Object(m.vm, moejs.Arg(args, 0))
length := int(native.Int(vm, native.Get(vm, array, "length")))
array := call.Argument(0).ToObject(m.vm)
length := int(array.Get("length").ToInteger())
for index := 0; index < length; index++ {
entryObject := native.Object(m.vm, native.Get(vm, array, strconv.Itoa(index)))
entry := formEntry{Name: native.String(vm, native.Get(vm, entryObject, "name"))}
value := native.Get(vm, entryObject, "value")
if native.Bool(native.Get(vm, entryObject, "file")) {
entryObject := array.Get(strconv.Itoa(index)).ToObject(m.vm)
entry := formEntry{Name: entryObject.Get("name").String()}
value := entryObject.Get("value")
if entryObject.Get("file").ToBoolean() {
entry.File = true
entry.Filename = native.String(vm, native.Get(vm, entryObject, "filename"))
entry.Type = native.String(vm, native.Get(vm, entryObject, "type"))
entry.Filename = entryObject.Get("filename").String()
entry.Type = entryObject.Get("type").String()
data, err := exportBytes(m.vm, value)
if err != nil {
panic(native.TypeError(m.vm, "invalid FormData file: %v", err))
panic(m.vm.NewTypeError("invalid FormData file: %v", err))
}
entry.Bytes = data
} else {
entry.Value = native.String(vm, value)
entry.Value = value.String()
}
entries = append(entries, entry)
}
var output bytes.Buffer
writer := multipart.NewWriter(&output)
for _, entry := range entries {
@@ -378,27 +403,27 @@ func (m *Module) encodeFormData(args []moejs.Value) moejs.Value {
}
part, err := writer.CreatePart(header)
if err != nil {
panic(native.GoError(m.vm, err))
panic(m.vm.NewGoError(err))
}
_, _ = part.Write(entry.Bytes)
}
if err := writer.Close(); err != nil {
panic(native.GoError(m.vm, err))
panic(m.vm.NewGoError(err))
}
result := m.vm.Realm().NewObject()
_ = native.Set(vm, result, "body", native.From(m.vm, output.Bytes()))
_ = native.Set(vm, result, "contentType", writer.FormDataContentType())
return native.From(vm, result)
result := m.vm.NewObject()
_ = result.Set("body", m.vm.NewArrayBuffer(output.Bytes()))
_ = result.Set("contentType", writer.FormDataContentType())
return result
}
func (m *Module) parseFormData(args []moejs.Value) moejs.Value {
vm := m.vm
data, err := exportBytes(m.vm, moejs.Arg(args, 0))
func (m *Module) parseFormData(call goja.FunctionCall) goja.Value {
data, err := exportBytes(m.vm, call.Argument(0))
if err != nil {
panic(native.TypeError(m.vm, "invalid FormData body: %v", err))
panic(m.vm.NewTypeError("invalid FormData body: %v", err))
}
mediaType, parameters, err := mime.ParseMediaType(native.String(vm, moejs.Arg(args, 1)))
mediaType, parameters, err := mime.ParseMediaType(call.Argument(1).String())
if err != nil {
panic(native.TypeError(m.vm, "invalid form content type: %v", err))
panic(m.vm.NewTypeError("invalid form content type: %v", err))
}
entries := make([]map[string]any, 0)
switch mediaType {
@@ -410,13 +435,13 @@ func (m *Module) parseFormData(args []moejs.Value) moejs.Value {
nameValue := strings.SplitN(field, "=", 2)
name, parseErr := url.QueryUnescape(nameValue[0])
if parseErr != nil {
panic(native.TypeError(m.vm, "invalid form field name: %v", parseErr))
panic(m.vm.NewTypeError("invalid form field name: %v", parseErr))
}
value := ""
if len(nameValue) == 2 {
value, parseErr = url.QueryUnescape(nameValue[1])
if parseErr != nil {
panic(native.TypeError(m.vm, "invalid form field value: %v", parseErr))
panic(m.vm.NewTypeError("invalid form field value: %v", parseErr))
}
}
entries = append(entries, map[string]any{"name": name, "value": value})
@@ -424,7 +449,7 @@ func (m *Module) parseFormData(args []moejs.Value) moejs.Value {
case "multipart/form-data":
boundary := parameters["boundary"]
if boundary == "" {
panic(native.TypeError(m.vm, "multipart form data has no boundary"))
panic(m.vm.NewTypeError("multipart form data has no boundary"))
}
reader := multipart.NewReader(bytes.NewReader(data), boundary)
for {
@@ -433,11 +458,11 @@ func (m *Module) parseFormData(args []moejs.Value) moejs.Value {
break
}
if partErr != nil {
panic(native.TypeError(m.vm, "invalid multipart body: %v", partErr))
panic(m.vm.NewTypeError("invalid multipart body: %v", partErr))
}
partBytes, readErr := io.ReadAll(part)
if readErr != nil {
panic(native.TypeError(m.vm, "read multipart body: %v", readErr))
panic(m.vm.NewTypeError("read multipart body: %v", readErr))
}
entry := map[string]any{"name": part.FormName()}
if part.FileName() == "" {
@@ -446,19 +471,12 @@ func (m *Module) parseFormData(args []moejs.Value) moejs.Value {
entry["file"] = true
entry["filename"] = part.FileName()
entry["type"] = part.Header.Get("Content-Type")
entry["value"] = native.From(m.vm, partBytes)
entry["value"] = m.vm.NewArrayBuffer(partBytes)
}
entries = append(entries, entry)
}
default:
panic(native.TypeError(m.vm, "unsupported form content type %q", mediaType))
panic(m.vm.NewTypeError("unsupported form content type %q", mediaType))
}
return native.From(m.vm, entries)
}
func callable(v moejs.Value) (moejs.Value, bool) { return v, engine.IsCallable(v) }
func asObject(v moejs.Value) (*engine.Object, bool) {
if !v.IsObject() {
return nil, false
}
return v.AsObject(), true
return m.vm.ToValue(entries)
}
-45
View File
@@ -1,45 +0,0 @@
package fetch
import (
"github.com/Calcium-Ion/moejs"
"github.com/komari-monitor/komari/pkg/jsruntime/native"
"net/http"
"testing"
)
func TestNativeFormDataAndBody(t *testing.T) {
vm := moejs.NewRuntime(moejs.Options{})
m := New(nil, http.DefaultClient, 1<<20)
if err := m.Inject(vm); err != nil {
t.Fatal(err)
}
value, err := native.Run(vm, `(async function () {
const blob = new Blob(["ab", new Uint8Array([99])], {type:"TEXT/PLAIN"});
if (blob.size !== 3 || await blob.text() !== "abc") throw new Error("blob bytes changed");
const form = new FormData();
form.append("field", "one"); form.append("field", "two");
form.append("file", new File([blob], "test.txt", {type:blob.type}));
const request = new Request("https://example.test/", {method:"POST", body:form});
const response = new Response(await request.arrayBuffer(), {headers:request.headers});
const parsed = await response.formData();
if (parsed.getAll("field").join(",") !== "one,two") throw new Error("form fields changed");
const file = parsed.get("file");
if (file.name !== "test.txt" || file.type !== "text/plain" || await file.text() !== "abc") throw new Error("multipart file changed");
const urlencoded = await new Response("hello=world&hello=again", {headers:{"content-type":"application/x-www-form-urlencoded"}}).formData();
if (urlencoded.getAll("hello").join(",") !== "world,again") throw new Error("urlencoded changed");
return true;
})()`)
if err != nil {
t.Fatal(err)
}
state, result, ok := moejs.PromiseResult(value)
if !ok {
t.Fatal("expected promise")
}
if state != moejs.PromiseFulfilled {
t.Fatalf("promise state %v result %v", state, native.String(vm, result))
}
if !native.Bool(result) {
t.Fatal("body/form verification failed")
}
}
+89 -81
View File
@@ -3,50 +3,55 @@ package fs
import (
"errors"
"fmt"
"github.com/Calcium-Ion/moejs"
"github.com/komari-monitor/komari/pkg/jsruntime/buffer"
"github.com/komari-monitor/komari/pkg/jsruntime/native"
"io"
"io/fs"
"os"
"github.com/dop251/goja"
"github.com/dop251/goja_nodejs/buffer"
)
type fsAsyncOperation struct {
run func() (any, error)
values func(*moejs.Runtime, any) []moejs.Value
values func(*goja.Runtime, any) []goja.Value
}
type fsReadFileResult struct {
data []byte
encoding string
}
type fsReadDirResult struct {
entries []fs.DirEntry
withTypes bool
}
type fsReadResult struct {
count int
data []byte
target moejs.Value
target goja.Value
offset int
}
type fsWriteResult struct {
count int
target moejs.Value
target goja.Value
}
func fsArgument(arguments []moejs.Value, index int) moejs.Value {
func fsArgument(arguments []goja.Value, index int) goja.Value {
if index < 0 || index >= len(arguments) {
return moejs.Undefined()
return goja.Undefined()
}
return arguments[index]
}
func fsNoValues(*moejs.Runtime, any) []moejs.Value {
return nil
func fsNoValues(*goja.Runtime, any) []goja.Value { return nil }
func fsSingleValue(vm *goja.Runtime, result any) []goja.Value {
return []goja.Value{vm.ToValue(result)}
}
func fsSingleValue(vm *moejs.Runtime, result any) []moejs.Value {
return []moejs.Value{native.From(vm, result)}
}
func (m *Module) prepareFSAsync(vm *moejs.Runtime, name string, arguments []moejs.Value) fsAsyncOperation {
func (m *Module) prepareFSAsync(vm *goja.Runtime, name string, arguments []goja.Value) fsAsyncOperation {
cwd := m.Cwd()
follow := func(path string, allowMissing bool) (string, error) {
return m.resolveNodePathAt(path, cwd, allowMissing, nodePathFollowFinal)
@@ -54,25 +59,29 @@ func (m *Module) prepareFSAsync(vm *moejs.Runtime, name string, arguments []moej
noFollow := func(path string, allowMissing bool) (string, error) {
return m.resolveNodePathAt(path, cwd, allowMissing, nodePathNoFollowFinal)
}
switch name {
case "readFile":
pathName := native.String(vm, fsArgument(arguments, 0))
encoding := fsEncoding(vm, fsArgument(arguments, 1))
return fsAsyncOperation{run: func() (any, error) {
path, err := follow(pathName, false)
if err != nil {
return nil, err
}
data, err := m.nodeReadFile(path)
return fsReadFileResult{data: data, encoding: encoding}, err
}, values: func(vm *moejs.Runtime, result any) []moejs.Value {
value := result.(fsReadFileResult)
return []moejs.Value{encodeFSData(vm, value.data, value.encoding)}
}}
pathName := fsArgument(arguments, 0).String()
encoding := fsEncoding(fsArgument(arguments, 1))
return fsAsyncOperation{
run: func() (any, error) {
path, err := follow(pathName, false)
if err != nil {
return nil, err
}
data, err := m.nodeReadFile(path)
return fsReadFileResult{data: data, encoding: encoding}, err
},
values: func(vm *goja.Runtime, result any) []goja.Value {
value := result.(fsReadFileResult)
return []goja.Value{encodeFSData(vm, value.data, value.encoding)}
},
}
case "writeFile", "appendFile":
pathName := native.String(vm, fsArgument(arguments, 0))
pathName := fsArgument(arguments, 0).String()
data := append([]byte(nil), buffer.Bytes(vm, fsArgument(arguments, 1))...)
mode := fsMode(vm, fsArgument(arguments, 2), 0o666)
mode := fsMode(fsArgument(arguments, 2), 0o666)
appendMode := name == "appendFile"
return fsAsyncOperation{run: func() (any, error) {
path, err := follow(pathName, true)
@@ -91,8 +100,8 @@ func (m *Module) prepareFSAsync(vm *moejs.Runtime, name string, arguments []moej
return nil, errors.Join(writeErr, file.Close())
}, values: fsNoValues}
case "access":
pathName := native.String(vm, fsArgument(arguments, 0))
mode := fsAccessMode(vm, fsArgument(arguments, 1))
pathName := fsArgument(arguments, 0).String()
mode := fsAccessMode(fsArgument(arguments, 1))
return fsAsyncOperation{run: func() (any, error) {
path, err := follow(pathName, false)
if err == nil {
@@ -101,7 +110,7 @@ func (m *Module) prepareFSAsync(vm *moejs.Runtime, name string, arguments []moej
return nil, err
}, values: fsNoValues}
case "stat", "lstat":
pathName := native.String(vm, fsArgument(arguments, 0))
pathName := fsArgument(arguments, 0).String()
isLstat := name == "lstat"
return fsAsyncOperation{run: func() (any, error) {
var path string
@@ -118,12 +127,12 @@ func (m *Module) prepareFSAsync(vm *moejs.Runtime, name string, arguments []moej
return m.nodeStat(path, false)
}
return m.nodeStat(path, true)
}, values: func(vm *moejs.Runtime, result any) []moejs.Value {
return []moejs.Value{native.From(vm, fsStatObject(vm, result.(os.FileInfo)))}
}, values: func(vm *goja.Runtime, result any) []goja.Value {
return []goja.Value{fsStatObject(vm, result.(os.FileInfo))}
}}
case "readdir":
pathName := native.String(vm, fsArgument(arguments, 0))
withTypes := fsWithFileTypes(vm, fsArgument(arguments, 1))
pathName := fsArgument(arguments, 0).String()
withTypes := fsWithFileTypes(fsArgument(arguments, 1))
return fsAsyncOperation{run: func() (any, error) {
path, err := follow(pathName, false)
if err != nil {
@@ -131,7 +140,7 @@ func (m *Module) prepareFSAsync(vm *moejs.Runtime, name string, arguments []moej
}
entries, err := m.nodeReadDir(path)
return fsReadDirResult{entries: entries, withTypes: withTypes}, err
}, values: func(vm *moejs.Runtime, result any) []moejs.Value {
}, values: func(vm *goja.Runtime, result any) []goja.Value {
value := result.(fsReadDirResult)
entries := make([]any, 0, len(value.entries))
for _, entry := range value.entries {
@@ -141,12 +150,12 @@ func (m *Module) prepareFSAsync(vm *moejs.Runtime, name string, arguments []moej
entries = append(entries, entry.Name())
}
}
return []moejs.Value{native.From(vm, entries)}
return []goja.Value{vm.ToValue(entries)}
}}
case "mkdir":
pathName := native.String(vm, fsArgument(arguments, 0))
pathName := fsArgument(arguments, 0).String()
options := fsArgument(arguments, 1)
recursive, mode := fsBooleanOption(vm, options, "recursive"), fsMode(vm, options, 0o777)
recursive, mode := fsBooleanOption(options, "recursive"), fsMode(options, 0o777)
return fsAsyncOperation{run: func() (any, error) {
path, err := noFollow(pathName, true)
if err != nil {
@@ -160,9 +169,9 @@ func (m *Module) prepareFSAsync(vm *moejs.Runtime, name string, arguments []moej
return nil, err
}, values: fsNoValues}
case "rm":
pathName := native.String(vm, fsArgument(arguments, 0))
pathName := fsArgument(arguments, 0).String()
options := fsArgument(arguments, 1)
recursive, force := fsBooleanOption(vm, options, "recursive"), fsBooleanOption(vm, options, "force")
recursive, force := fsBooleanOption(options, "recursive"), fsBooleanOption(options, "force")
return fsAsyncOperation{run: func() (any, error) {
path, err := noFollow(pathName, true)
if err != nil {
@@ -179,7 +188,7 @@ func (m *Module) prepareFSAsync(vm *moejs.Runtime, name string, arguments []moej
return nil, err
}, values: fsNoValues}
case "unlink", "rmdir":
pathName := native.String(vm, fsArgument(arguments, 0))
pathName := fsArgument(arguments, 0).String()
return fsAsyncOperation{run: func() (any, error) {
path, err := noFollow(pathName, false)
if err == nil {
@@ -188,7 +197,7 @@ func (m *Module) prepareFSAsync(vm *moejs.Runtime, name string, arguments []moej
return nil, err
}, values: fsNoValues}
case "rename":
oldName, newName := native.String(vm, fsArgument(arguments, 0)), native.String(vm, fsArgument(arguments, 1))
oldName, newName := fsArgument(arguments, 0).String(), fsArgument(arguments, 1).String()
return fsAsyncOperation{run: func() (any, error) {
oldPath, err := noFollow(oldName, false)
if err != nil {
@@ -201,7 +210,7 @@ func (m *Module) prepareFSAsync(vm *moejs.Runtime, name string, arguments []moej
return nil, err
}, values: fsNoValues}
case "copyFile":
sourceName, targetName := native.String(vm, fsArgument(arguments, 0)), native.String(vm, fsArgument(arguments, 1))
sourceName, targetName := fsArgument(arguments, 0).String(), fsArgument(arguments, 1).String()
return fsAsyncOperation{run: func() (any, error) {
source, err := follow(sourceName, false)
if err != nil {
@@ -224,7 +233,7 @@ func (m *Module) prepareFSAsync(vm *moejs.Runtime, name string, arguments []moej
return nil, errors.Join(copyErr, output.Close())
}, values: fsNoValues}
case "realpath", "readlink":
pathName := native.String(vm, fsArgument(arguments, 0))
pathName := fsArgument(arguments, 0).String()
readLink := name == "readlink"
return fsAsyncOperation{run: func() (any, error) {
if readLink {
@@ -237,7 +246,7 @@ func (m *Module) prepareFSAsync(vm *moejs.Runtime, name string, arguments []moej
return follow(pathName, false)
}, values: fsSingleValue}
case "symlink":
targetName, linkName := native.String(vm, fsArgument(arguments, 0)), native.String(vm, fsArgument(arguments, 1))
targetName, linkName := fsArgument(arguments, 0).String(), fsArgument(arguments, 1).String()
return fsAsyncOperation{run: func() (any, error) {
target, err := follow(targetName, true)
if err != nil {
@@ -250,7 +259,7 @@ func (m *Module) prepareFSAsync(vm *moejs.Runtime, name string, arguments []moej
return nil, err
}, values: fsNoValues}
case "truncate":
pathName, size := native.String(vm, fsArgument(arguments, 0)), native.Int(vm, fsArgument(arguments, 1))
pathName, size := fsArgument(arguments, 0).String(), fsArgument(arguments, 1).ToInteger()
return fsAsyncOperation{run: func() (any, error) {
path, err := follow(pathName, false)
if err == nil {
@@ -259,8 +268,8 @@ func (m *Module) prepareFSAsync(vm *moejs.Runtime, name string, arguments []moej
return nil, err
}, values: fsNoValues}
case "chmod":
pathName := native.String(vm, fsArgument(arguments, 0))
mode := os.FileMode(native.Int(vm, fsArgument(arguments, 1)))
pathName := fsArgument(arguments, 0).String()
mode := os.FileMode(fsArgument(arguments, 1).ToInteger())
return fsAsyncOperation{run: func() (any, error) {
path, err := follow(pathName, false)
if err == nil {
@@ -269,8 +278,8 @@ func (m *Module) prepareFSAsync(vm *moejs.Runtime, name string, arguments []moej
return nil, err
}, values: fsNoValues}
case "utimes":
pathName := native.String(vm, fsArgument(arguments, 0))
accessTime, modifyTime := fsTime(native.Export(vm, fsArgument(arguments, 1))), fsTime(native.Export(vm, fsArgument(arguments, 2)))
pathName := fsArgument(arguments, 0).String()
accessTime, modifyTime := fsTime(fsArgument(arguments, 1).Export()), fsTime(fsArgument(arguments, 2).Export())
return fsAsyncOperation{run: func() (any, error) {
path, err := follow(pathName, false)
if err == nil {
@@ -279,7 +288,7 @@ func (m *Module) prepareFSAsync(vm *moejs.Runtime, name string, arguments []moej
return nil, err
}, values: fsNoValues}
case "mkdtemp":
prefix := native.String(vm, fsArgument(arguments, 0))
prefix := fsArgument(arguments, 0).String()
return fsAsyncOperation{run: func() (any, error) {
path, err := noFollow(prefix, true)
if err != nil {
@@ -288,9 +297,9 @@ func (m *Module) prepareFSAsync(vm *moejs.Runtime, name string, arguments []moej
return m.nodeMkdirTemp(path)
}, values: fsSingleValue}
case "open":
pathName := native.String(vm, fsArgument(arguments, 0))
flags := native.String(vm, fsArgument(arguments, 1))
mode := fsMode(vm, fsArgument(arguments, 2), 0o666)
pathName := fsArgument(arguments, 0).String()
flags := fsArgument(arguments, 1).String()
mode := fsMode(fsArgument(arguments, 2), 0o666)
return fsAsyncOperation{run: func() (any, error) {
path, err := follow(pathName, true)
if err != nil {
@@ -299,23 +308,21 @@ func (m *Module) prepareFSAsync(vm *moejs.Runtime, name string, arguments []moej
return m.fsOpenPath(path, flags, mode)
}, values: fsSingleValue}
case "close":
fd := int(native.Int(vm, fsArgument(arguments, 0)))
return fsAsyncOperation{run: func() (any, error) {
return nil, m.fsClose(fd)
}, values: fsNoValues}
fd := int(fsArgument(arguments, 0).ToInteger())
return fsAsyncOperation{run: func() (any, error) { return nil, m.fsClose(fd) }, values: fsNoValues}
case "fstat":
fd := int(native.Int(vm, fsArgument(arguments, 0)))
fd := int(fsArgument(arguments, 0).ToInteger())
return fsAsyncOperation{run: func() (any, error) {
file, err := m.fsFile(fd)
if err != nil {
return nil, err
}
return file.Stat()
}, values: func(vm *moejs.Runtime, result any) []moejs.Value {
return []moejs.Value{native.From(vm, fsStatObject(vm, result.(os.FileInfo)))}
}, values: func(vm *goja.Runtime, result any) []goja.Value {
return []goja.Value{fsStatObject(vm, result.(os.FileInfo))}
}}
case "fsync":
fd := int(native.Int(vm, fsArgument(arguments, 0)))
fd := int(fsArgument(arguments, 0).ToInteger())
return fsAsyncOperation{run: func() (any, error) {
file, err := m.fsFile(fd)
if err == nil {
@@ -324,16 +331,16 @@ func (m *Module) prepareFSAsync(vm *moejs.Runtime, name string, arguments []moej
return nil, err
}, values: fsNoValues}
case "read":
fd := int(native.Int(vm, fsArgument(arguments, 0)))
fd := int(fsArgument(arguments, 0).ToInteger())
target := fsArgument(arguments, 1)
offset := int(native.Int(vm, fsArgument(arguments, 2)))
length := int(native.Int(vm, fsArgument(arguments, 3)))
offset := int(fsArgument(arguments, 2).ToInteger())
length := int(fsArgument(arguments, 3).ToInteger())
positionValue := fsArgument(arguments, 4)
hasPosition := !positionValue.IsNull() && !positionValue.IsUndefined()
position := native.Int(vm, positionValue)
hasPosition := !goja.IsNull(positionValue) && !goja.IsUndefined(positionValue)
position := positionValue.ToInteger()
bufferLength := len(buffer.Bytes(vm, target))
if offset < 0 || length < 0 || offset+length > bufferLength {
panic(native.TypeError(vm, "buffer range is out of bounds"))
panic(vm.NewTypeError("buffer range is out of bounds"))
}
return fsAsyncOperation{run: func() (any, error) {
file, err := m.fsFile(fd)
@@ -351,28 +358,28 @@ func (m *Module) prepareFSAsync(vm *moejs.Runtime, name string, arguments []moej
err = nil
}
return fsReadResult{count: count, data: data[:count], target: target, offset: offset}, err
}, values: func(vm *moejs.Runtime, result any) []moejs.Value {
}, values: func(vm *goja.Runtime, result any) []goja.Value {
value := result.(fsReadResult)
copy(buffer.Bytes(vm, value.target)[value.offset:], value.data)
return []moejs.Value{native.From(vm, value.count), value.target}
return []goja.Value{vm.ToValue(value.count), value.target}
}}
case "write":
fd := int(native.Int(vm, fsArgument(arguments, 0)))
fd := int(fsArgument(arguments, 0).ToInteger())
target := fsArgument(arguments, 1)
data := append([]byte(nil), buffer.Bytes(vm, target)...)
positionValue := fsArgument(arguments, 4)
if _, isString := native.Export(vm, target).(string); !isString {
offset := int(native.Int(vm, fsArgument(arguments, 2)))
length := int(native.Int(vm, fsArgument(arguments, 3)))
if _, isString := target.Export().(string); !isString {
offset := int(fsArgument(arguments, 2).ToInteger())
length := int(fsArgument(arguments, 3).ToInteger())
if offset < 0 || length < 0 || offset+length > len(data) {
panic(native.TypeError(vm, "buffer range is out of bounds"))
panic(vm.NewTypeError("buffer range is out of bounds"))
}
data = data[offset : offset+length]
} else {
positionValue = fsArgument(arguments, 2)
}
hasPosition := !positionValue.IsNull() && !positionValue.IsUndefined()
position := native.Int(vm, positionValue)
hasPosition := !goja.IsNull(positionValue) && !goja.IsUndefined(positionValue)
position := positionValue.ToInteger()
return fsAsyncOperation{run: func() (any, error) {
file, err := m.fsFile(fd)
if err != nil {
@@ -385,9 +392,9 @@ func (m *Module) prepareFSAsync(vm *moejs.Runtime, name string, arguments []moej
count, err = file.Write(data)
}
return fsWriteResult{count: count, target: target}, err
}, values: func(vm *moejs.Runtime, result any) []moejs.Value {
}, values: func(vm *goja.Runtime, result any) []goja.Value {
value := result.(fsWriteResult)
return []moejs.Value{native.From(vm, value.count), value.target}
return []goja.Value{vm.ToValue(value.count), value.target}
}}
default:
return fsAsyncOperation{run: func() (any, error) {
@@ -395,6 +402,7 @@ func (m *Module) prepareFSAsync(vm *moejs.Runtime, name string, arguments []moej
}, values: fsNoValues}
}
}
func (m *Module) fsOpenPath(path, flags string, mode os.FileMode) (int, error) {
file, err := m.nodeOpenFile(path, fsOpenFlags(flags), mode)
if err != nil {
+10 -11
View File
@@ -2,15 +2,14 @@ package fs
import (
"errors"
"github.com/Calcium-Ion/moejs"
"github.com/Calcium-Ion/moejs/engine"
"github.com/komari-monitor/komari/pkg/jsruntime/native"
"io/fs"
"os"
"strings"
"github.com/dop251/goja"
)
func nodeErrorObject(vm *moejs.Runtime, err error, fallbackSyscall string) *engine.Object {
func nodeErrorObject(vm *goja.Runtime, err error, fallbackSyscall string) *goja.Object {
operation, path, destination := fallbackSyscall, "", ""
underlying := err
var linkError *os.LinkError
@@ -42,16 +41,16 @@ func nodeErrorObject(vm *moejs.Runtime, err error, fallbackSyscall string) *engi
if errno == 0 {
errno = nodeErrnoForCode(code)
}
object := native.Object(vm, native.GoError(vm, err))
_ = native.Set(vm, object, "name", "Error")
_ = native.Set(vm, object, "code", code)
_ = native.Set(vm, object, "errno", errno)
_ = native.Set(vm, object, "syscall", operation)
object := vm.NewGoError(err)
_ = object.Set("name", "Error")
_ = object.Set("code", code)
_ = object.Set("errno", errno)
_ = object.Set("syscall", operation)
if path != "" {
_ = native.Set(vm, object, "path", path)
_ = object.Set("path", path)
}
if destination != "" {
_ = native.Set(vm, object, "dest", destination)
_ = object.Set("dest", destination)
}
return object
}
+344 -347
View File
File diff suppressed because it is too large. Load diff
+3 -6
View File
@@ -3,19 +3,16 @@ package jsruntime
import (
_ "embed"
"fmt"
"github.com/komari-monitor/komari/pkg/jsruntime/native"
nodeurl "github.com/komari-monitor/komari/pkg/jsruntime/url"
"github.com/komari-monitor/komari/pkg/jsruntime/xhr"
)
// JavaScript compatibility: injects bounded timer APIs, console, URL APIs,
// Fetch API, XMLHttpRequest, and queueMicrotask.
// JavaScript compatibility: injects bounded timer APIs, console, Fetch API,
// XMLHttpRequest, and queueMicrotask.
func (r *Runtime) injectGlobals() error {
if err := r.consoleMod.Inject(r.vm); err != nil {
return fmt.Errorf("inject console: %w", err)
}
nodeurl.Enable(r.vm)
if err := r.timersMod.Inject(r.vm); err != nil {
return fmt.Errorf("inject timers: %w", err)
}
@@ -30,7 +27,7 @@ func (r *Runtime) injectGlobals() error {
if err := xhr.Inject(r.vm); err != nil {
return fmt.Errorf("inject XMLHttpRequest: %w", err)
}
if _, err := native.Run(r.vm, queueMicrotaskSource); err != nil {
if _, err := r.vm.RunString(queueMicrotaskSource); err != nil {
return fmt.Errorf("inject queueMicrotask: %w", err)
}
return nil
+3 -24
View File
@@ -9,7 +9,7 @@ package jsruntime
import (
"time"
moe "github.com/Calcium-Ion/moejs"
"github.com/dop251/goja"
"github.com/komari-monitor/komari/pkg/jsruntime/internal/bridge"
)
@@ -22,14 +22,14 @@ type Host struct {
// RunOnLoop queues job on the runtime's event loop. It returns false when
// the loop is no longer running, for example after Runtime.Close.
func (h *Host) RunOnLoop(job func(*moe.Runtime)) bool {
func (h *Host) RunOnLoop(job func(*goja.Runtime)) bool {
return h.runtime.RunOnLoop(job)
}
// RunJob runs one asynchronous host callback as a bounded JavaScript turn,
// reporting errors through the runtime's error reporter (the console by
// default). Exit code 0 is treated as a normal termination.
func (h *Host) RunJob(vm *moe.Runtime, name string, job func() error) error {
func (h *Host) RunJob(vm *goja.Runtime, name string, job func() error) error {
return h.runtime.RunJob(vm, name, job)
}
@@ -37,24 +37,3 @@ func (h *Host) RunJob(vm *moe.Runtime, name string, job func() error) error {
func (h *Host) Timeout() time.Duration {
return h.runtime.Timeout()
}
// AddResource registers a closer to run when the runtime shuts down. If shutdown
// has already started, it runs the closer immediately and returns 0. Closers
// must be nonblocking and must not access the JavaScript VM or wait for its event
// loop; they may run outside the loop during shutdown.
func (h *Host) AddResource(close func()) uint64 {
return h.runtime.AddResource(close)
}
// RemoveResource unregisters a resource without calling its closer. Unknown IDs
// and 0 are ignored.
func (h *Host) RemoveResource(id uint64) {
h.runtime.RemoveResource(id)
}
// ResourcesOpen reports whether the runtime still accepts resources, not whether
// any resources are registered. The result is a snapshot; AddResource handles
// registration racing with shutdown by immediately closing the new resource.
func (h *Host) ResourcesOpen() bool {
return h.runtime.ResourcesOpen()
}
+249 -292
View File
@@ -1,25 +1,22 @@
package http
import (
"github.com/Calcium-Ion/moejs/engine"
"github.com/komari-monitor/komari/pkg/jsruntime/native"
)
import (
"bytes"
_ "embed"
"fmt"
"github.com/Calcium-Ion/moejs"
"github.com/komari-monitor/komari/pkg/jsruntime/buffer"
"github.com/komari-monitor/komari/pkg/jsruntime/events"
"github.com/komari-monitor/komari/pkg/jsruntime/httpbody"
"github.com/komari-monitor/komari/pkg/jsruntime/internal/bridge"
"github.com/komari-monitor/komari/pkg/jsruntime/require"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/dop251/goja"
"github.com/dop251/goja_nodejs/buffer"
"github.com/dop251/goja_nodejs/require"
"github.com/komari-monitor/komari/pkg/jsruntime/events"
"github.com/komari-monitor/komari/pkg/jsruntime/httpbody"
"github.com/komari-monitor/komari/pkg/jsruntime/internal/bridge"
)
type nodeHTTPResponse struct {
@@ -29,63 +26,61 @@ type nodeHTTPResponse struct {
statusCode int
statusText string
ended bool
done chan struct {
}
done chan struct{}
}
type Module struct {
runtime *bridge.Runtime
allowListen bool
maxBodyBytes int64
runtime *bridge.Runtime
allowListen bool
maxBodyBytes int64
streamMu sync.Mutex
createIncoming moejs.Value
createResponse moejs.Value
createIncoming goja.Callable
createResponse goja.Callable
}
func New(runtime *bridge.Runtime, allowListen bool, maxBodyBytes int64) *Module {
return &Module{runtime: runtime, allowListen: allowListen, maxBodyBytes: maxBodyBytes}
}
func (m *Module) Load(vm *moejs.Runtime, module *engine.Object) {
exports := vm.Realm().NewObject()
_ = native.Set(vm, exports, "createServer", native.Bind(vm, "createServer", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
func (m *Module) Load(vm *goja.Runtime, module *goja.Object) {
exports := vm.NewObject()
_ = exports.Set("createServer", func(call goja.FunctionCall) goja.Value {
server := m.newHTTPServer(vm)
listener := moejs.Arg(args, 0)
if _, ok := callable(listener); !ok {
listener = moejs.Arg(args, 1)
listener := call.Argument(0)
if _, ok := goja.AssertFunction(listener); !ok {
listener = call.Argument(1)
}
if _, ok := callable(listener); ok {
on, _ := callable(native.Get(vm, server, "on"))
_, _ = native.Call(vm, on, native.From(vm, server), native.From(vm, "request"), native.From(vm, listener))
if _, ok := goja.AssertFunction(listener); ok {
on, _ := goja.AssertFunction(server.Get("on"))
_, _ = on(server, vm.ToValue("request"), listener)
}
return native.From(vm, server), nil
}))
_ = native.Set(vm, exports, "METHODS", []string{"ACL", "BIND", "CHECKOUT", "CONNECT", "COPY", "DELETE", "GET", "HEAD", "LINK", "LOCK", "M-SEARCH", "MERGE", "MKACTIVITY", "MKCALENDAR", "MKCOL", "MOVE", "NOTIFY", "OPTIONS", "PATCH", "POST", "PROPFIND", "PROPPATCH", "PURGE", "PUT", "QUERY", "REBIND", "REPORT", "SEARCH", "SOURCE", "SUBSCRIBE", "TRACE", "UNBIND", "UNLINK", "UNLOCK", "UNSUBSCRIBE"})
return server
})
_ = exports.Set("METHODS", []string{"ACL", "BIND", "CHECKOUT", "CONNECT", "COPY", "DELETE", "GET", "HEAD", "LINK", "LOCK", "M-SEARCH", "MERGE", "MKACTIVITY", "MKCALENDAR", "MKCOL", "MOVE", "NOTIFY", "OPTIONS", "PATCH", "POST", "PROPFIND", "PROPPATCH", "PURGE", "PUT", "QUERY", "REBIND", "REPORT", "SEARCH", "SOURCE", "SUBSCRIBE", "TRACE", "UNBIND", "UNLINK", "UNLOCK", "UNSUBSCRIBE"})
statusCodes := make(map[int]string)
for code := 100; code <= 599; code++ {
if text := http.StatusText(code); text != "" {
statusCodes[code] = text
}
}
_ = native.Set(vm, exports, "STATUS_CODES", statusCodes)
_ = native.Set(vm, exports, "maxHeaderSize", 1<<20)
_ = native.Set(vm, exports, "validateHeaderName", native.Bind(vm, "validateHeaderName", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
name := native.String(vm, moejs.Arg(args, 0))
_ = exports.Set("STATUS_CODES", statusCodes)
_ = exports.Set("maxHeaderSize", 1<<20)
_ = exports.Set("validateHeaderName", func(name string) {
if !validHTTPToken(name) {
panic(native.TypeError(vm, "Invalid HTTP header name"))
panic(vm.NewTypeError("Invalid HTTP header name"))
}
return moejs.Undefined(), nil
}))
_ = native.Set(vm, exports, "validateHeaderValue", native.Bind(vm, "validateHeaderValue", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
name := native.String(vm, moejs.Arg(args, 0))
value := native.String(vm, moejs.Arg(args, 1))
})
_ = exports.Set("validateHeaderValue", func(name, value string) {
if strings.ContainsAny(value, "\x00\r\n") {
panic(native.TypeError(vm, "Invalid value for header %s", name))
panic(vm.NewTypeError("Invalid value for header %s", name))
}
return moejs.Undefined(), nil
}))
})
m.attachHTTPClient(vm, exports)
_ = native.Set(vm, module, "exports", exports)
_ = module.Set("exports", exports)
}
func (m *Module) newHTTPServer(vm *moejs.Runtime) *engine.Object {
func (m *Module) newHTTPServer(vm *goja.Runtime) *goja.Object {
serverObject := events.NewEmitter(vm)
var server *http.Server
var listener net.Listener
@@ -94,38 +89,35 @@ func (m *Module) newHTTPServer(vm *moejs.Runtime) *engine.Object {
var listenGeneration uint64
var listenPending bool
var readTimeout time.Duration
_ = native.Set(vm, serverObject, "listening", false)
_ = native.Set(vm, serverObject, "timeout", 0)
_ = native.Set(vm, serverObject, "keepAliveTimeout", 5000)
_ = native.Set(vm, serverObject, "headersTimeout", 60000)
_ = native.Set(vm, serverObject, "requestTimeout", 300000)
_ = serverObject.Set("listening", false)
_ = serverObject.Set("timeout", 0)
_ = serverObject.Set("keepAliveTimeout", 5000)
_ = serverObject.Set("headersTimeout", 60000)
_ = serverObject.Set("requestTimeout", 300000)
handler := http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
body, err := httpbody.ReadAll(request.Body, m.maxBodyBytes)
if err != nil {
http.Error(response, err.Error(), http.StatusRequestEntityTooLarge)
return
}
state := &nodeHTTPResponse{headers: make(http.Header), statusCode: http.StatusOK, done: make(chan struct {
})}
queued := m.runtime.RunOnLoop(func(vm *moejs.Runtime) {
state := &nodeHTTPResponse{headers: make(http.Header), statusCode: http.StatusOK, done: make(chan struct{})}
queued := m.runtime.RunOnLoop(func(vm *goja.Runtime) {
incoming := m.httpIncomingMessage(vm, request, body)
outgoing := m.httpServerResponse(vm, state)
err := m.runtime.RunJob(vm, "http.Server request", func() error {
return events.Emit(vm, serverObject, "request", incoming, outgoing)
})
err := m.runtime.RunJob(vm, "http.Server request", func() error { return events.Emit(vm, serverObject, "request", incoming, outgoing) })
if err != nil {
state.finish(http.StatusInternalServerError, nil)
return
}
m.runtime.Loop().SetTimeout(func(vm *moejs.Runtime) {
m.runtime.Loop().SetTimeout(func(vm *goja.Runtime) {
_ = m.runtime.RunJob(vm, "http.IncomingMessage body", func() error {
push, _ := callable(native.Get(vm, incoming, "push"))
push, _ := goja.AssertFunction(incoming.Get("push"))
if len(body) > 0 {
if _, pushErr := native.Call(vm, push, native.From(vm, incoming), native.From(vm, buffer.WrapBytes(vm, append([]byte(nil), body...)))); pushErr != nil {
if _, pushErr := push(incoming, buffer.WrapBytes(vm, append([]byte(nil), body...))); pushErr != nil {
return pushErr
}
}
_, pushErr := native.Call(vm, push, native.From(vm, incoming), native.From(vm, moejs.Null()))
_, pushErr := push(incoming, goja.Null())
return pushErr
})
}, 0)
@@ -155,15 +147,15 @@ func (m *Module) newHTTPServer(vm *moejs.Runtime) *engine.Object {
response.WriteHeader(statusCode)
_, _ = response.Write(responseBody)
})
_ = native.Set(vm, serverObject, "listen", native.Bind(vm, "listen", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
_ = serverObject.Set("listen", func(call goja.FunctionCall) goja.Value {
if !m.allowListen {
panic(native.GoError(vm, fmt.Errorf("http.Server.listen requires AllowListen")))
panic(vm.NewGoError(fmt.Errorf("http.Server.listen requires AllowListen")))
}
address, callback := listenArguments(vm, args)
address, callback := listenArguments(vm, call)
serverMu.Lock()
if listenPending || listener != nil {
serverMu.Unlock()
panic(native.GoError(vm, fmt.Errorf("server is already listening")))
panic(vm.NewGoError(fmt.Errorf("server is already listening")))
}
listenGeneration++
generation := listenGeneration
@@ -180,15 +172,19 @@ func (m *Module) newHTTPServer(vm *moejs.Runtime) *engine.Object {
}
serverMu.Unlock()
if active {
m.runtime.RunOnLoop(func(vm *moejs.Runtime) {
m.runtime.RunOnLoop(func(vm *goja.Runtime) {
_ = m.runtime.RunJob(vm, "http.Server error", func() error {
return events.Emit(vm, serverObject, "error", native.GoError(vm, err))
return events.Emit(vm, serverObject, "error", vm.NewGoError(err))
})
})
}
return
}
createdServer := &http.Server{Handler: handler, ReadHeaderTimeout: m.runtime.Timeout(), ReadTimeout: configuredReadTimeout}
createdServer := &http.Server{
Handler: handler,
ReadHeaderTimeout: m.runtime.Timeout(),
ReadTimeout: configuredReadTimeout,
}
serverMu.Lock()
if !listenPending || listenGeneration != generation {
serverMu.Unlock()
@@ -209,19 +205,19 @@ func (m *Module) newHTTPServer(vm *moejs.Runtime) *engine.Object {
resourceID = id
listenPending = false
serverMu.Unlock()
m.runtime.RunOnLoop(func(vm *moejs.Runtime) {
m.runtime.RunOnLoop(func(vm *goja.Runtime) {
serverMu.RLock()
active := listenGeneration == generation && server == createdServer && listener == created
serverMu.RUnlock()
if !active {
return
}
if !native.IsNil(callback) {
once, _ := callable(native.Get(vm, serverObject, "once"))
_, _ = native.Call(vm, once, native.From(vm, serverObject), native.From(vm, "listening"), native.From(vm, callback))
if callback != nil {
once, _ := goja.AssertFunction(serverObject.Get("once"))
_, _ = once(serverObject, vm.ToValue("listening"), vm.ToValue(callback))
}
_ = m.runtime.RunJob(vm, "http.Server listening", func() error {
_ = native.Set(vm, serverObject, "listening", true)
_ = serverObject.Set("listening", true)
return events.Emit(vm, serverObject, "listening")
})
})
@@ -236,19 +232,19 @@ func (m *Module) newHTTPServer(vm *moejs.Runtime) *engine.Object {
m.runtime.RemoveResource(id)
}
if active && err != nil && err != http.ErrServerClosed {
m.runtime.RunOnLoop(func(vm *moejs.Runtime) {
m.runtime.RunOnLoop(func(vm *goja.Runtime) {
_ = m.runtime.RunJob(vm, "http.Server error", func() error {
return events.Emit(vm, serverObject, "error", native.GoError(vm, err))
return events.Emit(vm, serverObject, "error", vm.NewGoError(err))
})
})
}
}()
return native.From(vm, serverObject), nil
}))
_ = native.Set(vm, serverObject, "close", native.Bind(vm, "close", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
if callback, ok := callable(moejs.Arg(args, 0)); ok {
once, _ := callable(native.Get(vm, serverObject, "once"))
_, _ = native.Call(vm, once, native.From(vm, serverObject), native.From(vm, "close"), native.From(vm, callback))
return serverObject
})
_ = serverObject.Set("close", func(call goja.FunctionCall) goja.Value {
if callback, ok := goja.AssertFunction(call.Argument(0)); ok {
once, _ := goja.AssertFunction(serverObject.Get("once"))
_, _ = once(serverObject, vm.ToValue("close"), vm.ToValue(callback))
}
serverMu.Lock()
wasActive := listenPending || listener != nil
@@ -266,59 +262,55 @@ func (m *Module) newHTTPServer(vm *moejs.Runtime) *engine.Object {
if currentResourceID != 0 {
m.runtime.RemoveResource(currentResourceID)
}
_ = native.Set(vm, serverObject, "listening", false)
_ = serverObject.Set("listening", false)
if wasActive {
m.runtime.Loop().SetTimeout(func(vm *moejs.Runtime) {
_ = m.runtime.RunJob(vm, "http.Server close", func() error {
return events.Emit(vm, serverObject, "close")
})
m.runtime.Loop().SetTimeout(func(vm *goja.Runtime) {
_ = m.runtime.RunJob(vm, "http.Server close", func() error { return events.Emit(vm, serverObject, "close") })
}, 0)
}
return native.From(vm, serverObject), nil
}))
_ = native.Set(vm, serverObject, "closeAllConnections", native.Bind(vm, "closeAllConnections", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return serverObject
})
_ = serverObject.Set("closeAllConnections", func() {
serverMu.RLock()
current := server
serverMu.RUnlock()
if current != nil {
_ = current.Close()
}
return moejs.Undefined(), nil
}))
_ = native.Set(vm, serverObject, "closeIdleConnections", native.Bind(vm, "closeIdleConnections", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
panic(native.GoError(vm, fmt.Errorf("http.Server.closeIdleConnections is not supported by jsruntime; net/http does not expose individual idle server connections")))
}))
_ = native.Set(vm, serverObject, "address", native.Bind(vm, "address", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
})
_ = serverObject.Set("closeIdleConnections", func() {
panic(vm.NewGoError(fmt.Errorf("http.Server.closeIdleConnections is not supported by jsruntime; net/http does not expose individual idle server connections")))
})
_ = serverObject.Set("address", func() goja.Value {
serverMu.RLock()
current := listener
serverMu.RUnlock()
if current == nil {
return native.From(vm, moejs.Null()), nil
return goja.Null()
}
host, port, _ := net.SplitHostPort(current.Addr().String())
portNumber, _ := strconv.Atoi(port)
return native.From(vm, map[string]any{"address": host, "family": addressFamily(host), "port": portNumber}), nil
}))
_ = native.Set(vm, serverObject, "setTimeout", native.Bind(vm, "setTimeout", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
milliseconds := native.Int(vm, moejs.Arg(args, 0))
callback := moejs.Arg(args, 1)
return vm.ToValue(map[string]any{"address": host, "family": addressFamily(host), "port": portNumber})
})
_ = serverObject.Set("setTimeout", func(milliseconds int64, callback goja.Value) *goja.Object {
serverMu.Lock()
readTimeout = time.Duration(milliseconds) * time.Millisecond
serverMu.Unlock()
if function, ok := callable(callback); ok {
on, _ := callable(native.Get(vm, serverObject, "on"))
_, _ = native.Call(vm, on, native.From(vm, serverObject), native.From(vm, "timeout"), native.From(vm, function))
if function, ok := goja.AssertFunction(callback); ok {
on, _ := goja.AssertFunction(serverObject.Get("on"))
_, _ = on(serverObject, vm.ToValue("timeout"), vm.ToValue(function))
}
return native.From(vm, serverObject), nil
}))
_ = native.Set(vm, serverObject, "ref", native.Bind(vm, "ref", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
panic(native.GoError(vm, fmt.Errorf("http.Server.ref is not supported by jsruntime; the event loop is host-driven")))
}))
_ = native.Set(vm, serverObject, "unref", native.Bind(vm, "unref", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
panic(native.GoError(vm, fmt.Errorf("http.Server.unref is not supported by jsruntime; the event loop is host-driven")))
}))
return serverObject
})
_ = serverObject.Set("ref", func() *goja.Object {
panic(vm.NewGoError(fmt.Errorf("http.Server.ref is not supported by jsruntime; the event loop is host-driven")))
})
_ = serverObject.Set("unref", func() *goja.Object {
panic(vm.NewGoError(fmt.Errorf("http.Server.unref is not supported by jsruntime; the event loop is host-driven")))
})
return serverObject
}
func (state *nodeHTTPResponse) finish(status int, body []byte) {
state.mu.Lock()
if state.ended {
@@ -335,13 +327,14 @@ func (state *nodeHTTPResponse) finish(status int, body []byte) {
close(state.done)
state.mu.Unlock()
}
func (m *Module) httpIncomingMessage(vm *moejs.Runtime, request *http.Request, body []byte) *engine.Object {
func (m *Module) httpIncomingMessage(vm *goja.Runtime, request *http.Request, body []byte) *goja.Object {
m.initHTTPStreams(vm)
value, err := native.Call(vm, m.createIncoming, native.From(vm, moejs.Undefined()))
value, err := m.createIncoming(goja.Undefined())
if err != nil {
panic(err)
}
incoming := native.Object(vm, value)
incoming := value.ToObject(vm)
headers := make(map[string]string, len(request.Header))
rawHeaders := make([]string, 0, len(request.Header)*2)
for name, values := range request.Header {
@@ -350,28 +343,26 @@ func (m *Module) httpIncomingMessage(vm *moejs.Runtime, request *http.Request, b
rawHeaders = append(rawHeaders, name, value)
}
}
_ = native.Set(vm, incoming, "method", request.Method)
_ = native.Set(vm, incoming, "url", request.URL.RequestURI())
_ = native.Set(vm, incoming, "headers", headers)
_ = native.Set(vm, incoming, "headersDistinct", request.Header.Clone())
_ = native.Set(vm, incoming, "rawHeaders", rawHeaders)
_ = native.Set(vm, incoming, "httpVersion", fmt.Sprintf("%d.%d", request.ProtoMajor, request.ProtoMinor))
_ = native.Set(vm, incoming, "httpVersionMajor", request.ProtoMajor)
_ = native.Set(vm, incoming, "httpVersionMinor", request.ProtoMinor)
_ = native.Set(vm, incoming, "complete", true)
_ = native.Set(vm, incoming, "aborted", false)
_ = native.Set(vm, incoming, "socket", httpSocketInfo(vm, request))
_ = native.Set(vm, incoming, "connection", native.Get(vm, incoming, "socket"))
_ = native.Set(vm, incoming, "_destroy", native.Bind(vm, "_destroy", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
err := moejs.Arg(args, 0)
callback := moejs.Arg(args, 1)
_ = incoming.Set("method", request.Method)
_ = incoming.Set("url", request.URL.RequestURI())
_ = incoming.Set("headers", headers)
_ = incoming.Set("headersDistinct", request.Header.Clone())
_ = incoming.Set("rawHeaders", rawHeaders)
_ = incoming.Set("httpVersion", fmt.Sprintf("%d.%d", request.ProtoMajor, request.ProtoMinor))
_ = incoming.Set("httpVersionMajor", request.ProtoMajor)
_ = incoming.Set("httpVersionMinor", request.ProtoMinor)
_ = incoming.Set("complete", true)
_ = incoming.Set("aborted", false)
_ = incoming.Set("socket", httpSocketInfo(vm, request))
_ = incoming.Set("connection", incoming.Get("socket"))
_ = incoming.Set("_destroy", func(err goja.Value, callback goja.Callable) {
_ = request.Body.Close()
_, _ = native.Call(vm, callback, native.From(vm, moejs.Undefined()), native.From(vm, err))
return moejs.Undefined(), nil
}))
_, _ = callback(goja.Undefined(), err)
})
return incoming
}
func httpSocketInfo(vm *moejs.Runtime, request *http.Request) *engine.Object {
func httpSocketInfo(vm *goja.Runtime, request *http.Request) *goja.Object {
socket := events.NewEmitter(vm)
remoteHost, remotePort, _ := net.SplitHostPort(request.RemoteAddr)
localHost, localPort := "", 0
@@ -379,185 +370,161 @@ func httpSocketInfo(vm *moejs.Runtime, request *http.Request) *engine.Object {
localHost, localPort = splitAddress(local)
}
remotePortNumber, _ := strconv.Atoi(remotePort)
_ = native.Set(vm, socket, "remoteAddress", remoteHost)
_ = native.Set(vm, socket, "remotePort", remotePortNumber)
_ = native.Set(vm, socket, "remoteFamily", addressFamily(remoteHost))
_ = native.Set(vm, socket, "localAddress", localHost)
_ = native.Set(vm, socket, "localPort", localPort)
_ = native.Set(vm, socket, "localFamily", addressFamily(localHost))
_ = native.Set(vm, socket, "encrypted", request.TLS != nil)
_ = socket.Set("remoteAddress", remoteHost)
_ = socket.Set("remotePort", remotePortNumber)
_ = socket.Set("remoteFamily", addressFamily(remoteHost))
_ = socket.Set("localAddress", localHost)
_ = socket.Set("localPort", localPort)
_ = socket.Set("localFamily", addressFamily(localHost))
_ = socket.Set("encrypted", request.TLS != nil)
return socket
}
func (m *Module) httpServerResponse(vm *moejs.Runtime, state *nodeHTTPResponse) *engine.Object {
func (m *Module) httpServerResponse(vm *goja.Runtime, state *nodeHTTPResponse) *goja.Object {
m.initHTTPStreams(vm)
hooks := vm.Realm().NewObject()
_ = native.Set(vm, hooks, "write", native.Bind(vm, "write", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
response := native.Object(vm, moejs.Arg(args, 0))
chunk := moejs.Arg(args, 1)
callback := moejs.Arg(args, 2)
hooks := vm.NewObject()
_ = hooks.Set("write", func(response *goja.Object, chunk goja.Value, callback goja.Callable) {
state.mu.Lock()
_, _ = state.body.Write(buffer.Bytes(vm, chunk))
state.mu.Unlock()
_ = native.Set(vm, response, "headersSent", true)
if !native.IsNil(callback) {
_, _ = native.Call(vm, callback, native.From(vm, moejs.Undefined()))
_ = response.Set("headersSent", true)
if callback != nil {
_, _ = callback(goja.Undefined())
}
return moejs.Undefined(), nil
}))
_ = native.Set(vm, hooks, "finish", native.Bind(vm, "finish", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
response := native.Object(vm, moejs.Arg(args, 0))
callback := moejs.Arg(args, 1)
})
_ = hooks.Set("finish", func(response *goja.Object, callback goja.Callable) {
state.mu.Lock()
state.statusCode = int(native.Int(vm, native.Get(vm, response, "statusCode")))
state.statusText = native.String(vm, native.Get(vm, response, "statusMessage"))
state.statusCode = int(response.Get("statusCode").ToInteger())
state.statusText = response.Get("statusMessage").String()
if !state.ended {
state.ended = true
close(state.done)
}
state.mu.Unlock()
_ = native.Set(vm, response, "headersSent", true)
if !native.IsNil(callback) {
_, _ = native.Call(vm, callback, native.From(vm, moejs.Undefined()))
_ = response.Set("headersSent", true)
if callback != nil {
_, _ = callback(goja.Undefined())
}
return moejs.Undefined(), nil
}))
_ = native.Set(vm, hooks, "destroy", native.Bind(vm, "destroy", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
})
_ = hooks.Set("destroy", func(*goja.Object) {
state.finish(0, nil)
return moejs.Undefined(), nil
}))
value, err := native.Call(vm, m.createResponse, native.From(vm, moejs.Undefined()), native.From(vm, hooks))
})
value, err := m.createResponse(goja.Undefined(), hooks)
if err != nil {
panic(err)
}
response := native.Object(vm, value)
_ = native.Set(vm, response, "statusCode", http.StatusOK)
_ = native.Set(vm, response, "statusMessage", "")
_ = native.Set(vm, response, "headersSent", false)
_ = native.Set(vm, response, "sendDate", true)
_ = native.Set(vm, response, "setHeader", native.Bind(vm, "setHeader", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
name := native.String(vm, moejs.Arg(args, 0))
value := moejs.Arg(args, 1)
response := value.ToObject(vm)
_ = response.Set("statusCode", http.StatusOK)
_ = response.Set("statusMessage", "")
_ = response.Set("headersSent", false)
_ = response.Set("sendDate", true)
_ = response.Set("setHeader", func(name string, value goja.Value) *goja.Object {
state.mu.Lock()
defer state.mu.Unlock()
var values []string
if err := native.ExportTo(vm, value, &values); err == nil {
if err := vm.ExportTo(value, &values); err == nil {
state.headers[name] = values
} else {
state.headers.Set(name, native.String(vm, value))
state.headers.Set(name, value.String())
}
return native.From(vm, response), nil
}))
_ = native.Set(vm, response, "appendHeader", native.Bind(vm, "appendHeader", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
name := native.String(vm, moejs.Arg(args, 0))
value := native.String(vm, moejs.Arg(args, 1))
return response
})
_ = response.Set("appendHeader", func(name, value string) *goja.Object {
state.mu.Lock()
state.headers.Add(name, value)
state.mu.Unlock()
return native.From(vm, response), nil
}))
_ = native.Set(vm, response, "getHeader", native.Bind(vm, "getHeader", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
name := native.String(vm, moejs.Arg(args, 0))
return response
})
_ = response.Set("getHeader", func(name string) goja.Value {
state.mu.Lock()
defer state.mu.Unlock()
values := state.headers.Values(name)
if len(values) == 0 {
return native.From(vm, moejs.Undefined()), nil
return goja.Undefined()
}
if len(values) == 1 {
return native.From(vm, values[0]), nil
return vm.ToValue(values[0])
}
return native.From(vm, values), nil
}))
_ = native.Set(vm, response, "getHeaders", native.Bind(vm, "getHeaders", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
state.mu.Lock()
defer state.mu.Unlock()
return native.From(vm, state.headers.Clone()), nil
}))
_ = native.Set(vm, response, "getHeaderNames", native.Bind(vm, "getHeaderNames", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return vm.ToValue(values)
})
_ = response.Set("getHeaders", func() http.Header { state.mu.Lock(); defer state.mu.Unlock(); return state.headers.Clone() })
_ = response.Set("getHeaderNames", func() []string {
state.mu.Lock()
defer state.mu.Unlock()
names := make([]string, 0, len(state.headers))
for name := range state.headers {
names = append(names, strings.ToLower(name))
}
return native.From(vm, names), nil
}))
_ = native.Set(vm, response, "hasHeader", native.Bind(vm, "hasHeader", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
name := native.String(vm, moejs.Arg(args, 0))
return names
})
_ = response.Set("hasHeader", func(name string) bool {
state.mu.Lock()
defer state.mu.Unlock()
return native.From(vm, len(state.headers.Values(name)) > 0), nil
}))
_ = native.Set(vm, response, "removeHeader", native.Bind(vm, "removeHeader", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
name := native.String(vm, moejs.Arg(args, 0))
state.mu.Lock()
state.headers.Del(name)
state.mu.Unlock()
return moejs.Undefined(), nil
}))
_ = native.Set(vm, response, "writeHead", native.Bind(vm, "writeHead", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
_ = native.Set(vm, response, "statusCode", native.Int(vm, moejs.Arg(args, 0)))
if text := moejs.Arg(args, 1); !text.IsUndefined() {
if _, ok := asObject(text); ok {
return len(state.headers.Values(name)) > 0
})
_ = response.Set("removeHeader", func(name string) { state.mu.Lock(); state.headers.Del(name); state.mu.Unlock() })
_ = response.Set("writeHead", func(call goja.FunctionCall) goja.Value {
_ = response.Set("statusCode", call.Argument(0).ToInteger())
if text := call.Argument(1); !goja.IsUndefined(text) {
if _, ok := text.(*goja.Object); ok {
applyHTTPHeaders(vm, response, text)
} else {
_ = native.Set(vm, response, "statusMessage", native.String(vm, text))
applyHTTPHeaders(vm, response, moejs.Arg(args, 2))
_ = response.Set("statusMessage", text.String())
applyHTTPHeaders(vm, response, call.Argument(2))
}
}
_ = native.Set(vm, response, "headersSent", true)
return native.From(vm, response), nil
}))
_ = native.Set(vm, response, "flushHeaders", native.Bind(vm, "flushHeaders", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
_ = native.Set(vm, response, "headersSent", true)
return moejs.Undefined(), nil
}))
_ = native.Set(vm, response, "writeContinue", native.Bind(vm, "writeContinue", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
panic(native.GoError(vm, fmt.Errorf("http.ServerResponse.writeContinue is not supported by jsruntime; the Go server never sends 100 Continue")))
}))
_ = native.Set(vm, response, "setTimeout", native.Bind(vm, "setTimeout", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
milliseconds := native.Int(vm, moejs.Arg(args, 0))
callback := moejs.Arg(args, 1)
if function, ok := callable(callback); ok {
m.runtime.Loop().SetTimeout(func(vm *moejs.Runtime) {
_ = response.Set("headersSent", true)
return response
})
_ = response.Set("flushHeaders", func() { _ = response.Set("headersSent", true) })
_ = response.Set("writeContinue", func() {
panic(vm.NewGoError(fmt.Errorf("http.ServerResponse.writeContinue is not supported by jsruntime; the Go server never sends 100 Continue")))
})
_ = response.Set("setTimeout", func(milliseconds int64, callback goja.Value) *goja.Object {
if function, ok := goja.AssertFunction(callback); ok {
m.runtime.Loop().SetTimeout(func(vm *goja.Runtime) {
_ = m.runtime.RunJob(vm, "http.ServerResponse timeout", func() error {
_, err := native.Call(vm, function, native.From(vm, response))
_, err := function(response)
return err
})
}, time.Duration(milliseconds)*time.Millisecond)
}
return native.From(vm, response), nil
}))
return response
})
return response
}
func applyHTTPHeaders(vm *moejs.Runtime, response *engine.Object, value moejs.Value) {
if native.IsNil(value) || value.IsUndefined() || value.IsNull() {
func applyHTTPHeaders(vm *goja.Runtime, response *goja.Object, value goja.Value) {
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
return
}
setHeader, _ := callable(native.Get(vm, response, "setHeader"))
object := native.Object(vm, value)
for _, name := range objectKeys(object) {
_, _ = native.Call(vm, setHeader, native.From(vm, response), native.From(vm, name), native.From(vm, native.Get(vm, object, name)))
setHeader, _ := goja.AssertFunction(response.Get("setHeader"))
object := value.ToObject(vm)
for _, name := range object.Keys() {
_, _ = setHeader(response, vm.ToValue(name), object.Get(name))
}
}
func listenArguments(vm *moejs.Runtime, args []moejs.Value) (string, moejs.Value) {
func listenArguments(vm *goja.Runtime, call goja.FunctionCall) (string, goja.Callable) {
host := "127.0.0.1"
port := int(native.Int(vm, moejs.Arg(args, 0)))
port := int(call.Argument(0).ToInteger())
callbackIndex := 1
if object, ok := asObject(moejs.Arg(args, 0)); ok {
port = int(native.Int(vm, native.Get(vm, object, "port")))
if value := native.Get(vm, object, "host"); !value.IsUndefined() {
host = native.String(vm, value)
if object, ok := call.Argument(0).(*goja.Object); ok {
port = int(object.Get("port").ToInteger())
if value := object.Get("host"); !goja.IsUndefined(value) {
host = value.String()
}
} else if value := moejs.Arg(args, 1); !value.IsUndefined() {
if _, ok := callable(value); !ok {
host = native.String(vm, value)
} else if value := call.Argument(1); !goja.IsUndefined(value) {
if _, ok := goja.AssertFunction(value); !ok {
host = value.String()
callbackIndex = 2
}
}
callback, _ := callable(moejs.Arg(args, callbackIndex))
callback, _ := goja.AssertFunction(call.Argument(callbackIndex))
return net.JoinHostPort(host, strconv.Itoa(port)), callback
}
func splitAddress(address net.Addr) (string, int) {
if address == nil {
return "", 0
@@ -566,27 +533,30 @@ func splitAddress(address net.Addr) (string, int) {
portNumber, _ := strconv.Atoi(port)
return host, portNumber
}
func addressFamily(host string) string {
if ip := net.ParseIP(host); ip != nil && ip.To4() == nil {
return "IPv6"
}
return "IPv4"
}
func (m *Module) attachHTTPClient(vm *moejs.Runtime, exports *engine.Object) {
factoryValue, err := native.Run(vm, httpClientSource)
func (m *Module) attachHTTPClient(vm *goja.Runtime, exports *goja.Object) {
factoryValue, err := vm.RunString(httpClientSource)
if err != nil {
panic(native.GoError(vm, err))
panic(vm.NewGoError(err))
}
factory, _ := callable(factoryValue)
client, err := native.Call(vm, factory, native.From(vm, moejs.Undefined()), native.From(vm, require.Require(vm, "events")), native.From(vm, native.Global(vm, "fetch")), native.From(vm, native.Global(vm, "AbortController")), native.From(vm, native.Global(vm, "Blob")))
factory, _ := goja.AssertFunction(factoryValue)
client, err := factory(goja.Undefined(), require.Require(vm, "events"), vm.Get("fetch"), vm.Get("AbortController"), vm.Get("Blob"))
if err != nil {
panic(err)
}
clientObject := native.Object(vm, client)
for _, name := range objectKeys(clientObject) {
_ = native.Set(vm, exports, name, native.Get(vm, clientObject, name))
clientObject := client.ToObject(vm)
for _, name := range clientObject.Keys() {
_ = exports.Set(name, clientObject.Get(name))
}
}
func validHTTPToken(value string) bool {
if value == "" {
return false
@@ -599,46 +569,33 @@ func validHTTPToken(value string) bool {
return true
}
func (m *Module) initHTTPStreams(vm *moejs.Runtime) {
m.streamMu.Lock()
defer m.streamMu.Unlock()
if engine.IsCallable(m.createIncoming) {
return
}
factoryValue, err := native.Run(vm, httpStreamsSource)
if err != nil {
panic(native.GoError(vm, fmt.Errorf("load http streams: %w", err)))
}
factory, _ := callable(factoryValue)
value, err := native.Call(vm, factory, native.From(vm, moejs.Undefined()), native.From(vm, require.Require(vm, "stream")))
if err != nil {
panic(err)
}
object := native.Object(vm, value)
m.createIncoming, _ = callable(native.Get(vm, object, "createIncomingMessage"))
m.createResponse, _ = callable(native.Get(vm, object, "createServerResponse"))
if !engine.IsCallable(m.createIncoming) || !engine.IsCallable(m.createResponse) {
panic(native.GoError(vm, fmt.Errorf("http streams factory is incomplete")))
}
}
func callable(v moejs.Value) (moejs.Value, bool) { return v, engine.IsCallable(v) }
func asObject(v moejs.Value) (*engine.Object, bool) {
if !v.IsObject() {
return nil, false
}
return v.AsObject(), true
}
func objectKeys(o *engine.Object) []string {
keys := o.OwnEnumerableStringKeys()
out := make([]string, len(keys))
for i, k := range keys {
out[i] = k.GoString()
}
return out
}
//go:embed client.js
var httpClientSource string
//go:embed streams.js
var httpStreamsSource string
// initHTTPStreams loads the embedded factory that builds IncomingMessage
// (Readable) and ServerResponse (Writable) instances from the stream module.
func (m *Module) initHTTPStreams(vm *goja.Runtime) {
m.streamMu.Lock()
defer m.streamMu.Unlock()
if m.createIncoming != nil {
return
}
factoryValue, err := vm.RunString(httpStreamsSource)
if err != nil {
panic(vm.NewGoError(fmt.Errorf("load http streams: %w", err)))
}
factory, _ := goja.AssertFunction(factoryValue)
value, err := factory(goja.Undefined(), require.Require(vm, "stream"))
if err != nil {
panic(err)
}
object := value.ToObject(vm)
m.createIncoming, _ = goja.AssertFunction(object.Get("createIncomingMessage"))
m.createResponse, _ = goja.AssertFunction(object.Get("createServerResponse"))
if m.createIncoming == nil || m.createResponse == nil {
panic(vm.NewGoError(fmt.Errorf("http streams factory is incomplete")))
}
}
+33 -8
View File
@@ -3,19 +3,44 @@ package bridge
import (
"errors"
"fmt"
moe "github.com/Calcium-Ion/moejs"
"github.com/dop251/goja"
)
type ExitRequestedError struct{ Code int64 }
// ExitRequestedError stops a script turn with the requested process exit code.
// Code 0 is treated as a normal termination; nonzero codes are surfaced as
// failures.
type ExitRequestedError struct {
Code int64
}
func (e *ExitRequestedError) Error() string { return fmt.Sprintf("process.exit(%d) requested", e.Code) }
func (e *ExitRequestedError) Error() string {
return fmt.Sprintf("process.exit(%d) requested", e.Code)
}
// ExitCodeFromError returns the exit code when err was thrown by process.exit.
func ExitCodeFromError(err error) (int64, bool) {
var exit *ExitRequestedError
if !errors.As(err, &exit) {
var exitErr *ExitRequestedError
if !errors.As(err, &exitErr) {
return 0, false
}
return exit.Code, true
return exitErr.Code, true
}
func ExitCodeFromValue(value moe.Value) (int64, bool) {
return ExitCodeFromError(&moe.Exception{Value: value})
// ExitCodeFromValue returns the exit code when a rejected Promise carries the
// process.exit error object.
func ExitCodeFromValue(value goja.Value) (int64, bool) {
object, ok := value.(*goja.Object)
if !ok {
return 0, false
}
raw := object.Get("value")
if raw == nil || goja.IsUndefined(raw) || goja.IsNull(raw) {
return 0, false
}
err, ok := raw.Export().(error)
if !ok {
return 0, false
}
return ExitCodeFromError(err)
}
+12 -17
View File
@@ -8,19 +8,19 @@ import (
"sync/atomic"
"time"
moe "github.com/Calcium-Ion/moejs"
"github.com/komari-monitor/komari/pkg/jsruntime/loop"
"github.com/dop251/goja"
"github.com/dop251/goja_nodejs/eventloop"
)
var ErrExecutionTimeout = errors.New("JavaScript execution timeout")
type TurnRunner func(*moe.Runtime, func() error) error
type TurnRunner func(*goja.Runtime, func() error) error
type ErrorReporter func(*moe.Runtime, string, error)
type ErrorReporter func(*goja.Runtime, string, error)
// Runtime is the host-facing service boundary used by JavaScript modules.
type Runtime struct {
loop *loop.EventLoop
loop *eventloop.EventLoop
timeout time.Duration
callbackMu sync.RWMutex
@@ -33,7 +33,7 @@ type Runtime struct {
resourcesClosed bool
}
func New(loop *loop.EventLoop, timeout time.Duration) *Runtime {
func New(loop *eventloop.EventLoop, timeout time.Duration) *Runtime {
return &Runtime{
loop: loop,
timeout: timeout,
@@ -41,7 +41,7 @@ func New(loop *loop.EventLoop, timeout time.Duration) *Runtime {
}
}
func (r *Runtime) Loop() *loop.EventLoop { return r.loop }
func (r *Runtime) Loop() *eventloop.EventLoop { return r.loop }
func (r *Runtime) Timeout() time.Duration { return r.timeout }
@@ -57,11 +57,11 @@ func (r *Runtime) SetErrorReporter(report ErrorReporter) {
r.callbackMu.Unlock()
}
func (r *Runtime) RunOnLoop(job func(*moe.Runtime)) bool {
func (r *Runtime) RunOnLoop(job func(*goja.Runtime)) bool {
return r.loop.RunOnLoop(job)
}
func (r *Runtime) RunTurn(vm *moe.Runtime, job func() error) error {
func (r *Runtime) RunTurn(vm *goja.Runtime, job func() error) error {
r.callbackMu.RLock()
run := r.runTurn
r.callbackMu.RUnlock()
@@ -72,7 +72,7 @@ func (r *Runtime) RunTurn(vm *moe.Runtime, job func() error) error {
}
// RunJob executes one asynchronous host callback as a bounded JavaScript turn.
func (r *Runtime) RunJob(vm *moe.Runtime, name string, job func() error) error {
func (r *Runtime) RunJob(vm *goja.Runtime, name string, job func() error) error {
if !r.ResourcesOpen() {
return nil
}
@@ -146,7 +146,7 @@ func (r *Runtime) CloseResources() {
}
// RunWithDeadline interrupts synchronous JavaScript that exceeds deadline.
func RunWithDeadline(vm *moe.Runtime, deadline time.Time, fn func() error) error {
func RunWithDeadline(vm *goja.Runtime, deadline time.Time, fn func() error) error {
remaining := time.Until(deadline)
if remaining <= 0 {
return ErrExecutionTimeout
@@ -164,9 +164,7 @@ func RunWithDeadline(vm *moe.Runtime, deadline time.Time, fn func() error) error
}
})
var err error
var recovered any
func() { defer func() { recovered = recover() }(); err = fn() }()
err := fn()
if running.CompareAndSwap(true, false) {
if !timer.Stop() {
<-timerDone
@@ -178,8 +176,5 @@ func RunWithDeadline(vm *moe.Runtime, deadline time.Time, fn func() error) error
if timedOut.Load() {
return ErrExecutionTimeout
}
if recovered != nil {
panic(recovered)
}
return err
}
-46
View File
@@ -1,46 +0,0 @@
package jsruntime
import (
"io"
"testing"
"time"
)
func TestMutableIntrinsicsAreIsolated(t *testing.T) {
for _, nodeJS := range []bool{false, true} {
name := "Web"
if nodeJS {
name = "Node"
}
t.Run(name, func(t *testing.T) {
opts := Options{NodeJS: nodeJS, BaseDir: t.TempDir(), Console: io.Discard, Timeout: time.Second}
first, err := New(`
Object.defineProperty(Reflect,"decorate",{value:function(){return "decorated"},configurable:true,writable:true});
Object.defineProperty(Array.prototype,"__komariPluginProbe",{value:"private",configurable:true});
function verify(){return Reflect.decorate()==="decorated"&&[].__komariPluginProbe==="private";}
`, opts)
if err != nil {
t.Fatal(err)
}
defer first.Close()
if err := first.Call("verify"); err != nil {
t.Fatal(err)
}
second, err := New(`
if(Object.prototype.hasOwnProperty.call(Reflect,"decorate")||Object.prototype.hasOwnProperty.call(Array.prototype,"__komariPluginProbe")){throw new Error("intrinsics leaked between plugins");}
Reflect.decorate=function(){return "second"};
function verify(){return Reflect.decorate()==="second"&&typeof [].__komariPluginProbe==="undefined";}
`, opts)
if err != nil {
t.Fatal(err)
}
defer second.Close()
if err := second.Call("verify"); err != nil {
t.Fatal(err)
}
if err := first.Call("verify"); err != nil {
t.Fatalf("second runtime changed first: %v", err)
}
})
}
}
-13
View File
@@ -1,13 +0,0 @@
Copyright (c) 2016 Dmitry Panov
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-140
View File
@@ -1,140 +0,0 @@
// Package loop serializes native MoeJS host jobs.
package loop
import (
"github.com/Calcium-Ion/moejs"
upstream "github.com/Calcium-Ion/moejs/eventloop"
"github.com/komari-monitor/komari/pkg/jsruntime/native"
"github.com/komari-monitor/komari/pkg/jsruntime/require"
"sync/atomic"
"time"
)
type EventLoop struct {
core *upstream.Loop
registry *require.Registry
onError func(error)
setTimeout, setInterval, clearTimeout, clearInterval moejs.Value
}
type Option func(*EventLoop)
func WithRegistry(r *require.Registry) Option { return func(l *EventLoop) { l.registry = r } }
func WithOnError(f func(error)) Option { return func(l *EventLoop) { l.onError = f } }
func NewEventLoop(opts ...Option) *EventLoop {
l := &EventLoop{}
for _, opt := range opts {
opt(l)
}
// Plugins may extend builtins (for example reflect-metadata). Keep those
// changes private to this runtime instead of using shared frozen intrinsics.
vm := moejs.NewRuntime(moejs.Options{MutableIntrinsics: true})
var err error
l.core, err = upstream.New(vm, upstream.Options{OnError: func(err error) {
if l.onError != nil {
l.onError(err)
}
}})
if err != nil {
panic(err)
}
if l.registry == nil {
l.registry = require.NewRegistry()
}
l.registry.Enable(vm)
l.setTimeout = native.Global(vm, "setTimeout")
l.setInterval = native.Global(vm, "setInterval")
l.clearTimeout = native.Global(vm, "clearTimeout")
l.clearInterval = native.Global(vm, "clearInterval")
return l
}
func (l *EventLoop) Runtime() *moejs.Runtime { return l.core.Runtime() }
func (l *EventLoop) Start() error { return l.core.Start() }
func (l *EventLoop) Run(fn func(*moejs.Runtime)) error {
return l.core.Run(func(vm *moejs.Runtime) error {
if fn != nil {
fn(vm)
}
return nil
})
}
func (l *EventLoop) RunOnLoop(fn func(*moejs.Runtime)) bool { return l.core.RunOnLoop(fn) }
func (l *EventLoop) Stop() error { return l.core.Stop() }
func (l *EventLoop) StopNoWait() { l.core.StopNoWait() }
// Terminate is an external-goroutine close: interrupt, join, drop pending work.
func (l *EventLoop) Terminate() { l.core.Terminate() }
func (l *EventLoop) Interrupt(v any) { l.core.Interrupt(v) }
type timerState struct {
cancelled atomic.Bool
value moejs.Value
}
type Timer struct{ state timerState }
type Interval struct{ state timerState }
func (l *EventLoop) schedule(s *timerState, fn func(*moejs.Runtime), delay time.Duration, repeating bool) bool {
return l.RunOnLoop(func(vm *moejs.Runtime) {
if s.cancelled.Load() {
return
}
cb := native.Bind(vm, "hostTimer", 0, func(_ *moejs.Realm, _ moejs.Value, _ []moejs.Value) (moejs.Value, error) {
if !s.cancelled.Load() {
fn(vm)
}
return moejs.Undefined(), nil
})
setter := l.setTimeout
if repeating {
setter = l.setInterval
}
v, err := vm.CallFunction(setter, moejs.Undefined(), cb, native.From(vm, float64(delay)/float64(time.Millisecond)))
if err != nil {
if l.onError != nil {
l.onError(err)
}
return
}
s.value = v
})
}
func (l *EventLoop) SetTimeout(fn func(*moejs.Runtime), delay time.Duration) *Timer {
t := &Timer{}
if !l.schedule(&t.state, fn, delay, false) {
return nil
}
return t
}
func (l *EventLoop) SetInterval(fn func(*moejs.Runtime), delay time.Duration) *Interval {
t := &Interval{}
if !l.schedule(&t.state, fn, delay, true) {
return nil
}
return t
}
func (l *EventLoop) cancel(s *timerState, repeating bool) {
s.cancelled.Store(true)
l.RunOnLoop(func(vm *moejs.Runtime) {
if s.value.IsUndefined() {
return
}
clear := l.clearTimeout
if repeating {
clear = l.clearInterval
}
_, err := vm.CallFunction(clear, moejs.Undefined(), s.value)
if err != nil && l.onError != nil {
l.onError(err)
}
})
}
func (l *EventLoop) ClearTimeout(t *Timer) {
if t != nil {
l.cancel(&t.state, false)
}
}
func (l *EventLoop) ClearInterval(t *Interval) {
if t != nil {
l.cancel(&t.state, true)
}
}
-65
View File
@@ -1,65 +0,0 @@
package loop
import (
"github.com/Calcium-Ion/moejs"
"github.com/komari-monitor/komari/pkg/jsruntime/native"
"sync/atomic"
"testing"
"time"
)
func TestQueueAndTerminate(t *testing.T) {
l := NewEventLoop()
if err := l.Start(); err != nil {
t.Fatal(err)
}
done := make(chan struct{})
if !l.RunOnLoop(func(vm *moejs.Runtime) { close(done) }) {
t.Fatal("rejected")
}
<-done
l.Terminate()
if l.RunOnLoop(func(*moejs.Runtime) {}) {
t.Fatal("accepted after close")
}
if l.SetTimeout(func(*moejs.Runtime) {}, time.Millisecond) != nil {
t.Fatal("timer after close")
}
}
func TestHostAndJSTimers(t *testing.T) {
l := NewEventLoop()
defer l.Terminate()
var count atomic.Int32
timer := l.SetTimeout(func(*moejs.Runtime) { count.Add(100) }, time.Millisecond)
l.ClearTimeout(timer)
l.SetTimeout(func(*moejs.Runtime) { count.Add(1) }, time.Millisecond)
if err := l.Run(func(vm *moejs.Runtime) {
if _, err := native.Run(vm, "var n=0;var t=setInterval(function(){n++;if(n===2)clearInterval(t)},1)"); err != nil {
t.Error(err)
}
}); err != nil {
t.Fatal(err)
}
if count.Load() != 1 {
t.Fatal(count.Load())
}
if n := native.Int(l.Runtime(), native.Global(l.Runtime(), "n")); n != 2 {
t.Fatal(n)
}
}
func TestTerminateInterrupts(t *testing.T) {
l := NewEventLoop()
if err := l.Start(); err != nil {
t.Fatal(err)
}
started := make(chan struct{})
l.RunOnLoop(func(vm *moejs.Runtime) { close(started); _, _ = native.Run(vm, "for(;;){}") })
<-started
done := make(chan struct{})
go func() { l.Terminate(); close(done) }()
select {
case <-done:
case <-time.After(time.Second * 3):
t.Fatal("terminate did not interrupt")
}
}
@@ -1,33 +0,0 @@
package jsruntime
import (
"io"
"testing"
"time"
)
// Migration regressions exercise engine/host boundaries rather than language-only
// microbenchmarks. Keep this suite identical in the goja comparison worktree.
func TestRuntimeMigrationBoundaries(t *testing.T) {
tests := []struct{ name, script string }{
{"hostThrowCatch", `function run() { try { require("fs").readFileSync("missing-file"); } catch(e) { return e instanceof Error && typeof e.message === "string"; } return false; }`},
{"bufferLiveWrite", `function run() { const b=Buffer.alloc(8); const view=b.subarray(2,6); require("crypto").randomFillSync(view); return b[0]===0 && b[1]===0 && b[6]===0 && b[7]===0 && view.some(x=>x!==0); }`},
{"floatRandomRejected", `function run() { try { crypto.getRandomValues(new Float32Array(4)); } catch(e) { return true; } return false; }`},
{"constructorAndURL", `function run() { const u=new URL("/a?x=1", "https://example.com"); return u.hostname==="example.com" && u.searchParams.get("x")==="1"; }`},
{"promiseRejectCatch", `async function run() { try { await Promise.reject(new Error("expected")); } catch(e) { return e.message==="expected"; } return false; }`},
{"commonJSIdentity", `function run() { return require("buffer")===require("node:buffer") && Buffer===require("buffer").Buffer; }`},
}
for _, tc := range tests {
b := tc
t.Run(b.name, func(t *testing.T) {
rt, err := New(b.script, Options{BaseDir: t.TempDir(), NodeJS: true, Console: io.Discard, Timeout: time.Second})
if err != nil {
t.Fatal(err)
}
defer rt.Close()
if err := rt.Call("run"); err != nil {
t.Fatal(err)
}
})
}
}
-171
View File
@@ -1,171 +0,0 @@
// Package native contains small operations on MoeJS values for Komari host modules.
// It defines no runtime/value/object wrappers and performs no reflected binding.
package native
import (
"fmt"
moe "github.com/Calcium-Ion/moejs"
e "github.com/Calcium-Ion/moejs/engine"
"math"
)
func Callable(v moe.Value) (moe.Value, bool) { return v, e.IsCallable(v) }
func AsObject(v moe.Value) (*e.Object, bool) {
if !v.IsObject() {
return nil, false
}
return v.AsObject(), true
}
func IsNil(v moe.Value) bool { return v.IsUndefined() || v.IsNull() }
func From(vm *moe.Runtime, x any) moe.Value {
v, err := vm.FromGo(x)
if err != nil {
panic(err)
}
return v
}
func Get(vm *moe.Runtime, o *e.Object, key string) moe.Value {
if o == nil {
return moe.Undefined()
}
v, err := vm.Get(e.ObjectValue(o), key)
if err != nil {
panic(err)
}
return v
}
func Set(vm *moe.Runtime, o *e.Object, key string, x any) error {
v, err := vm.FromGo(x)
if err != nil {
return err
}
return o.SetProp(vm.Realm(), vm.Realm().KeyFromGoString(key), v)
}
func Global(vm *moe.Runtime, name string) moe.Value { return Get(vm, vm.Realm().Global, name) }
func Run(vm *moe.Runtime, src string) (moe.Value, error) { return RunScript(vm, "", src) }
func RunScript(vm *moe.Runtime, name, src string) (moe.Value, error) {
s, err := moe.CompileScript(name, src)
if err != nil {
return moe.Undefined(), err
}
return vm.RunScript(s)
}
func Object(vm *moe.Runtime, v moe.Value) *e.Object {
o, err := vm.Realm().ToObject(v)
if err != nil {
panic(err)
}
return o
}
func String(vm *moe.Runtime, v moe.Value) string {
s, err := vm.Realm().ToString(v)
if err != nil {
panic(err)
}
return s.GoString()
}
func Float(vm *moe.Runtime, v moe.Value) float64 {
n, err := vm.Realm().ToNumber(v)
if err != nil {
panic(err)
}
return n
}
func Int(vm *moe.Runtime, v moe.Value) int64 {
n := Float(vm, v)
if math.IsNaN(n) || n == 0 {
return 0
}
if n >= math.MaxInt64 {
return math.MaxInt64
}
if n <= math.MinInt64 {
return math.MinInt64
}
return int64(n)
}
func Bool(v moe.Value) bool {
switch v.Type() {
case e.TypeNull, e.TypeUndefined:
return false
case e.TypeBoolean:
return v.AsBool()
case e.TypeNumber:
n := v.AsNumber()
return n != 0 && !math.IsNaN(n)
case e.TypeString:
return v.AsString().Len() != 0
case e.TypeBigInt:
return !v.AsBigInt().IsZero()
default:
return true
}
}
func Export(vm *moe.Runtime, v moe.Value) any {
x, err := vm.ToGo(v)
if err != nil {
panic(err)
}
return x
}
func ExportTo(vm *moe.Runtime, v moe.Value, target any) error {
if p, ok := target.(*[]byte); ok && v.IsObject() {
if b, ok := v.AsObject().BufferData(); ok {
*p = b
return nil
}
}
return vm.ToGoInto(v, target)
}
func Call(vm *moe.Runtime, fn, this moe.Value, args ...moe.Value) (moe.Value, error) {
return vm.CallFunction(fn, this, args...)
}
func New(vm *moe.Runtime, ctor moe.Value, args ...moe.Value) (*e.Object, error) {
r := vm.Realm()
r.HoldJobs()
v, err := r.Construct(ctor, args, nil)
err = r.ReleaseJobs(err)
if err != nil {
return nil, err
}
if !v.IsObject() {
return nil, fmt.Errorf("constructor returned non-object")
}
return v.AsObject(), nil
}
func TypeError(vm *moe.Runtime, format string, args ...any) error {
return vm.Realm().TypeError(format, args...)
}
func GoError(vm *moe.Runtime, err error) moe.Value {
v, ok := vm.ThrownValue(err)
if !ok {
return moe.Undefined()
}
return v
}
func Throw(vm *moe.Runtime, err error) moe.Value { panic(err) }
// Bind installs an explicit native signature, recovering only errors thrown by
// host operations. This is not an automatic Go function/argument adapter.
func Bind(vm *moe.Runtime, name string, arity int, fn moe.NativeFunc) moe.Value {
wrapped := func(r *moe.Realm, this moe.Value, args []moe.Value) (out moe.Value, err error) {
defer func() {
if p := recover(); p != nil {
switch x := p.(type) {
case error:
err = x
case moe.Value:
err = &e.Exception{Value: x}
default:
panic(p)
}
}
}()
return fn(r, this, args)
}
return e.ObjectValue(vm.Realm().NewNativeFunction(e.FromGoString(name), arity, wrapped))
}
func Function(vm *moe.Runtime, o *e.Object, name string, fn moe.NativeFunc) error {
return Set(vm, o, name, Bind(vm, name, 0, fn))
}
-18
View File
@@ -1,18 +0,0 @@
package native
import (
"errors"
moe "github.com/Calcium-Ion/moejs"
"testing"
)
func TestGoErrorNative(t *testing.T) {
rt := moe.NewRuntime(moe.Options{})
v := GoError(rt, errors.New("sentinel"))
if !v.IsObject() {
t.Fatalf("error value %v", v)
}
if String(rt, Get(rt, v.AsObject(), "message")) != "sentinel" {
t.Fatal("message lost")
}
}
-23
View File
@@ -1,23 +0,0 @@
package jsruntime
import (
"io"
"testing"
"time"
)
func TestNativeTypedArrayBoundaries(t *testing.T) {
rt, err := New(`function run(){
const c=require("crypto");
for(const C of [Float32Array,Float64Array]){let rejected=false;try{c.getRandomValues(new C(4))}catch(e){rejected=e instanceof TypeError}if(!rejected)throw new Error("float accepted")}
const v=new Uint16Array(4);if(c.getRandomValues(v)!==v)throw new Error("identity");
const b=new Uint8Array([1,2,3,4]);c.randomFillSync(new DataView(b.buffer,1,2));if(b[0]!==1||b[3]!==4)throw new Error("view bounds");
return true;}`, Options{NodeJS: true, BaseDir: t.TempDir(), Console: io.Discard, Timeout: time.Second})
if err != nil {
t.Fatal(err)
}
defer rt.Close()
if err := rt.Call("run"); err != nil {
t.Fatal(err)
}
}
-50
View File
@@ -1,50 +0,0 @@
package jsruntime
import (
"io"
"strings"
"testing"
"time"
)
func TestNativeLookupAndPromiseGetterDeadline(t *testing.T) {
for _, tc := range []struct {
name, source string
check bool
}{
{"hasFunction", `Object.defineProperty(globalThis,"bad",{get(){while(true){}}}); function healthy(){return true}`, true},
{"callLookup", `Object.defineProperty(globalThis,"bad",{get(){while(true){}}}); function healthy(){return true}`, false},
{"promiseThen", `function bad(){const p=Promise.resolve(true);Object.defineProperty(p,"then",{get(){while(true){}}});return p} function healthy(){return true}`, false},
} {
t.Run(tc.name, func(t *testing.T) {
rt, err := New(tc.source, Options{BaseDir: t.TempDir(), Console: io.Discard, Timeout: 50 * time.Millisecond})
if err != nil {
t.Fatal(err)
}
defer rt.Close()
done := make(chan error, 1)
go func() {
if tc.check {
if rt.HasFunction("bad") {
done <- io.ErrUnexpectedEOF
return
}
done <- nil
} else {
done <- rt.Call("bad")
}
}()
select {
case err := <-done:
if !tc.check && (err == nil || !strings.Contains(err.Error(), "timeout")) {
t.Fatalf("expected timeout: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("lookup escaped deadline")
}
if err := rt.Call("healthy"); err != nil {
t.Fatalf("runtime not reusable: %v", err)
}
})
}
}
+218 -309
View File
@@ -1,18 +1,9 @@
package net
import (
"github.com/Calcium-Ion/moejs/engine"
"github.com/komari-monitor/komari/pkg/jsruntime/native"
)
import (
"context"
"errors"
"fmt"
"github.com/Calcium-Ion/moejs"
"github.com/komari-monitor/komari/pkg/jsruntime/buffer"
"github.com/komari-monitor/komari/pkg/jsruntime/events"
"github.com/komari-monitor/komari/pkg/jsruntime/internal/bridge"
"github.com/komari-monitor/komari/pkg/jsruntime/internal/writequeue"
"io"
"net"
"strconv"
@@ -20,66 +11,51 @@ import (
"sync"
"sync/atomic"
"time"
"github.com/dop251/goja"
"github.com/dop251/goja_nodejs/buffer"
"github.com/komari-monitor/komari/pkg/jsruntime/events"
"github.com/komari-monitor/komari/pkg/jsruntime/internal/bridge"
"github.com/komari-monitor/komari/pkg/jsruntime/internal/writequeue"
)
type Module struct {
runtime *bridge.Runtime
allowListen bool
sockets sync.Map
}
type socketState struct {
connection net.Conn
resourceID uint64
readerDone chan struct {
}
detaching atomic.Bool
closed atomic.Bool
}
func New(runtime *bridge.Runtime, allowListen bool) *Module {
return &Module{runtime: runtime, allowListen: allowListen}
}
func (m *Module) Load(vm *moejs.Runtime, module *engine.Object) {
exports := vm.Realm().NewObject()
_ = native.Set(vm, exports, "createServer", native.Bind(vm, "createServer", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
func (m *Module) Load(vm *goja.Runtime, module *goja.Object) {
exports := vm.NewObject()
_ = exports.Set("createServer", func(call goja.FunctionCall) goja.Value {
server := m.newNetServer(vm)
listener := moejs.Arg(args, 0)
if _, ok := callable(listener); !ok {
listener = moejs.Arg(args, 1)
listener := call.Argument(0)
if _, ok := goja.AssertFunction(listener); !ok {
listener = call.Argument(1)
}
if _, ok := callable(listener); ok {
on, _ := callable(native.Get(vm, server, "on"))
_, _ = native.Call(vm, on, native.From(vm, server), native.From(vm, "connection"), native.From(vm, listener))
if _, ok := goja.AssertFunction(listener); ok {
on, _ := goja.AssertFunction(server.Get("on"))
_, _ = on(server, vm.ToValue("connection"), listener)
}
return native.From(vm, server), nil
}))
connect := func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return native.From(vm, m.netConnect(vm, args)), nil
}
_ = native.Set(vm, exports, "connect", native.Bind(vm, "connect", 0, connect))
_ = native.Set(vm, exports, "createConnection", native.Bind(vm, "connect", 0, connect))
_ = native.Set(vm, exports, "isIP", native.Bind(vm, "isIP", 1, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return native.From(vm, parseIPVersion(native.String(vm, moejs.Arg(args, 0)))), nil
}))
_ = native.Set(vm, exports, "isIPv4", native.Bind(vm, "isIPv4", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
value := native.String(vm, moejs.Arg(args, 0))
ip := net.ParseIP(value)
return native.From(vm, ip != nil && ip.To4() != nil), nil
}))
_ = native.Set(vm, exports, "isIPv6", native.Bind(vm, "isIPv6", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
value := native.String(vm, moejs.Arg(args, 0))
ip := net.ParseIP(value)
return native.From(vm, ip != nil && ip.To4() == nil), nil
}))
_ = native.Set(vm, exports, "getDefaultAutoSelectFamily", native.Bind(vm, "getDefaultAutoSelectFamily", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return native.From(vm, true), nil
}))
_ = native.Set(vm, exports, "setDefaultAutoSelectFamily", native.Bind(vm, "setDefaultAutoSelectFamily", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
panic(native.GoError(vm, fmt.Errorf("net.setDefaultAutoSelectFamily is not supported by jsruntime; autoSelectFamily is always enabled")))
}))
_ = native.Set(vm, module, "exports", exports)
return server
})
connect := func(call goja.FunctionCall) goja.Value { return m.netConnect(vm, call) }
_ = exports.Set("connect", connect)
_ = exports.Set("createConnection", connect)
_ = exports.Set("isIP", parseIPVersion)
_ = exports.Set("isIPv4", func(value string) bool { ip := net.ParseIP(value); return ip != nil && ip.To4() != nil })
_ = exports.Set("isIPv6", func(value string) bool { ip := net.ParseIP(value); return ip != nil && ip.To4() == nil })
_ = exports.Set("getDefaultAutoSelectFamily", func() bool { return true })
_ = exports.Set("setDefaultAutoSelectFamily", func(bool) {
panic(vm.NewGoError(fmt.Errorf("net.setDefaultAutoSelectFamily is not supported by jsruntime; autoSelectFamily is always enabled")))
})
_ = module.Set("exports", exports)
}
func (m *Module) newNetServer(vm *moejs.Runtime) *engine.Object {
func (m *Module) newNetServer(vm *goja.Runtime) *goja.Object {
server := events.NewEmitter(vm)
var listener net.Listener
var resourceID uint64
@@ -87,17 +63,17 @@ func (m *Module) newNetServer(vm *moejs.Runtime) *engine.Object {
var listenGeneration uint64
var listenPending bool
var connections atomic.Int64
_ = native.Set(vm, server, "listening", false)
_ = native.Set(vm, server, "maxConnections", 0)
_ = native.Set(vm, server, "listen", native.Bind(vm, "listen", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
_ = server.Set("listening", false)
_ = server.Set("maxConnections", 0)
_ = server.Set("listen", func(call goja.FunctionCall) goja.Value {
if !m.allowListen {
panic(native.GoError(vm, fmt.Errorf("server.listen requires AllowListen")))
panic(vm.NewGoError(fmt.Errorf("server.listen requires AllowListen")))
}
address, callback := netListenArguments(vm, args)
address, callback := netListenArguments(vm, call)
serverMu.Lock()
if listenPending || listener != nil {
serverMu.Unlock()
panic(native.GoError(vm, fmt.Errorf("server is already listening")))
panic(vm.NewGoError(fmt.Errorf("server is already listening")))
}
listenGeneration++
generation := listenGeneration
@@ -113,9 +89,9 @@ func (m *Module) newNetServer(vm *moejs.Runtime) *engine.Object {
}
serverMu.Unlock()
if active {
m.runtime.RunOnLoop(func(vm *moejs.Runtime) {
m.runtime.RunOnLoop(func(vm *goja.Runtime) {
_ = m.runtime.RunJob(vm, "net.Server error", func() error {
return events.Emit(vm, server, "error", native.GoError(vm, err))
return events.Emit(vm, server, "error", vm.NewGoError(err))
})
})
}
@@ -127,9 +103,7 @@ func (m *Module) newNetServer(vm *moejs.Runtime) *engine.Object {
_ = created.Close()
return
}
id := m.runtime.AddResource(func() {
_ = created.Close()
})
id := m.runtime.AddResource(func() { _ = created.Close() })
if id == 0 {
listenPending = false
serverMu.Unlock()
@@ -139,19 +113,19 @@ func (m *Module) newNetServer(vm *moejs.Runtime) *engine.Object {
resourceID = id
listenPending = false
serverMu.Unlock()
m.runtime.RunOnLoop(func(vm *moejs.Runtime) {
m.runtime.RunOnLoop(func(vm *goja.Runtime) {
serverMu.RLock()
active := listenGeneration == generation && listener == created
serverMu.RUnlock()
if !active {
return
}
if !native.IsNil(callback) {
once, _ := callable(native.Get(vm, server, "once"))
_, _ = native.Call(vm, once, native.From(vm, server), native.From(vm, "listening"), native.From(vm, callback))
if callback != nil {
once, _ := goja.AssertFunction(server.Get("once"))
_, _ = once(server, vm.ToValue("listening"), vm.ToValue(callback))
}
_ = m.runtime.RunJob(vm, "net.Server listening", func() error {
_ = native.Set(vm, server, "listening", true)
_ = server.Set("listening", true)
return events.Emit(vm, server, "listening")
})
})
@@ -162,25 +136,21 @@ func (m *Module) newNetServer(vm *moejs.Runtime) *engine.Object {
active := listenGeneration == generation && listener == created
serverMu.RUnlock()
if active {
m.runtime.RunOnLoop(func(vm *moejs.Runtime) {
m.runtime.RunOnLoop(func(vm *goja.Runtime) {
_ = m.runtime.RunJob(vm, "net.Server error", func() error {
return events.Emit(vm, server, "error", native.GoError(vm, acceptErr))
return events.Emit(vm, server, "error", vm.NewGoError(acceptErr))
})
})
}
return
}
connections.Add(1)
if !m.runtime.RunOnLoop(func(vm *moejs.Runtime) {
socket := m.newNetSocket(vm, connection, func() {
connections.Add(-1)
})
if native.Bool(native.Get(vm, socket, "destroyed")) {
if !m.runtime.RunOnLoop(func(vm *goja.Runtime) {
socket := m.newNetSocket(vm, connection, func() { connections.Add(-1) })
if socket.Get("destroyed").ToBoolean() {
return
}
_ = m.runtime.RunJob(vm, "net.Server connection", func() error {
return events.Emit(vm, server, "connection", socket)
})
_ = m.runtime.RunJob(vm, "net.Server connection", func() error { return events.Emit(vm, server, "connection", socket) })
}) {
connections.Add(-1)
_ = connection.Close()
@@ -188,12 +158,12 @@ func (m *Module) newNetServer(vm *moejs.Runtime) *engine.Object {
}
}
}()
return native.From(vm, server), nil
}))
_ = native.Set(vm, server, "close", native.Bind(vm, "close", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
if callback, ok := callable(moejs.Arg(args, 0)); ok {
once, _ := callable(native.Get(vm, server, "once"))
_, _ = native.Call(vm, once, native.From(vm, server), native.From(vm, "close"), native.From(vm, callback))
return server
})
_ = server.Set("close", func(call goja.FunctionCall) goja.Value {
if callback, ok := goja.AssertFunction(call.Argument(0)); ok {
once, _ := goja.AssertFunction(server.Get("once"))
_, _ = once(server, vm.ToValue("close"), vm.ToValue(callback))
}
serverMu.Lock()
wasActive := listenPending || listener != nil
@@ -206,135 +176,125 @@ func (m *Module) newNetServer(vm *moejs.Runtime) *engine.Object {
_ = currentListener.Close()
m.runtime.RemoveResource(currentResourceID)
}
_ = native.Set(vm, server, "listening", false)
_ = server.Set("listening", false)
if wasActive {
m.runtime.Loop().SetTimeout(func(vm *moejs.Runtime) {
_ = m.runtime.RunJob(vm, "net.Server close", func() error {
return events.Emit(vm, server, "close")
})
m.runtime.Loop().SetTimeout(func(vm *goja.Runtime) {
_ = m.runtime.RunJob(vm, "net.Server close", func() error { return events.Emit(vm, server, "close") })
}, 0)
}
return native.From(vm, server), nil
}))
_ = native.Set(vm, server, "closeAllConnections", native.Bind(vm, "closeAllConnections", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
panic(native.GoError(vm, fmt.Errorf("net.Server.closeAllConnections is not supported by jsruntime; connections are not tracked individually")))
}))
_ = native.Set(vm, server, "closeIdleConnections", native.Bind(vm, "closeIdleConnections", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
panic(native.GoError(vm, fmt.Errorf("net.Server.closeIdleConnections is not supported by jsruntime; connections are not tracked individually")))
}))
_ = native.Set(vm, server, "address", native.Bind(vm, "address", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return server
})
_ = server.Set("closeAllConnections", func() {
panic(vm.NewGoError(fmt.Errorf("net.Server.closeAllConnections is not supported by jsruntime; connections are not tracked individually")))
})
_ = server.Set("closeIdleConnections", func() {
panic(vm.NewGoError(fmt.Errorf("net.Server.closeIdleConnections is not supported by jsruntime; connections are not tracked individually")))
})
_ = server.Set("address", func() goja.Value {
serverMu.RLock()
currentListener := listener
serverMu.RUnlock()
if currentListener == nil {
return native.From(vm, moejs.Null()), nil
return goja.Null()
}
host, port, _ := net.SplitHostPort(currentListener.Addr().String())
portNumber, _ := strconv.Atoi(port)
return native.From(vm, map[string]any{"address": host, "family": netAddressFamily(host), "port": portNumber}), nil
}))
_ = native.Set(vm, server, "getConnections", native.Bind(vm, "getConnections", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
callback := moejs.Arg(args, 0)
m.runtime.Loop().SetTimeout(func(vm *moejs.Runtime) {
return vm.ToValue(map[string]any{"address": host, "family": netAddressFamily(host), "port": portNumber})
})
_ = server.Set("getConnections", func(callback goja.Callable) {
m.runtime.Loop().SetTimeout(func(vm *goja.Runtime) {
_ = m.runtime.RunJob(vm, "net.Server.getConnections", func() error {
_, err := native.Call(vm, callback, native.From(vm, moejs.Undefined()), native.From(vm, moejs.Null()), native.From(vm, connections.Load()))
_, err := callback(goja.Undefined(), goja.Null(), vm.ToValue(connections.Load()))
return err
})
}, 0)
return moejs.Undefined(), nil
}))
_ = native.Set(vm, server, "ref", native.Bind(vm, "ref", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
panic(native.GoError(vm, fmt.Errorf("net.Server.ref is not supported by jsruntime; the event loop is host-driven")))
}))
_ = native.Set(vm, server, "unref", native.Bind(vm, "unref", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
panic(native.GoError(vm, fmt.Errorf("net.Server.unref is not supported by jsruntime; the event loop is host-driven")))
}))
})
_ = server.Set("ref", func() *goja.Object {
panic(vm.NewGoError(fmt.Errorf("net.Server.ref is not supported by jsruntime; the event loop is host-driven")))
})
_ = server.Set("unref", func() *goja.Object {
panic(vm.NewGoError(fmt.Errorf("net.Server.unref is not supported by jsruntime; the event loop is host-driven")))
})
return server
}
func netListenArguments(vm *moejs.Runtime, args []moejs.Value) (string, moejs.Value) {
func netListenArguments(vm *goja.Runtime, call goja.FunctionCall) (string, goja.Callable) {
host := "127.0.0.1"
port := int(native.Int(vm, moejs.Arg(args, 0)))
port := int(call.Argument(0).ToInteger())
callbackIndex := 1
if object, ok := asObject(moejs.Arg(args, 0)); ok {
port = int(native.Int(vm, native.Get(vm, object, "port")))
if value := native.Get(vm, object, "host"); !value.IsUndefined() {
host = native.String(vm, value)
if object, ok := call.Argument(0).(*goja.Object); ok {
port = int(object.Get("port").ToInteger())
if value := object.Get("host"); !goja.IsUndefined(value) {
host = value.String()
}
callbackIndex = 1
} else if value := moejs.Arg(args, 1); !value.IsUndefined() {
if _, ok := callable(value); !ok {
host = native.String(vm, value)
} else if value := call.Argument(1); !goja.IsUndefined(value) {
if _, ok := goja.AssertFunction(value); !ok {
host = value.String()
callbackIndex = 2
}
}
callback, _ := callable(moejs.Arg(args, callbackIndex))
callback, _ := goja.AssertFunction(call.Argument(callbackIndex))
return net.JoinHostPort(host, strconv.Itoa(port)), callback
}
func (m *Module) netConnect(vm *moejs.Runtime, args []moejs.Value) *engine.Object {
func (m *Module) netConnect(vm *goja.Runtime, call goja.FunctionCall) *goja.Object {
host := "127.0.0.1"
port := int(native.Int(vm, moejs.Arg(args, 0)))
port := int(call.Argument(0).ToInteger())
callbackIndex := 1
if object, ok := asObject(moejs.Arg(args, 0)); ok {
port = int(native.Int(vm, native.Get(vm, object, "port")))
if value := native.Get(vm, object, "host"); !value.IsUndefined() {
host = native.String(vm, value)
if object, ok := call.Argument(0).(*goja.Object); ok {
port = int(object.Get("port").ToInteger())
if value := object.Get("host"); !goja.IsUndefined(value) {
host = value.String()
}
} else if _, ok := callable(moejs.Arg(args, 1)); !ok && !moejs.Arg(args, 1).IsUndefined() {
host = native.String(vm, moejs.Arg(args, 1))
} else if _, ok := goja.AssertFunction(call.Argument(1)); !ok && !goja.IsUndefined(call.Argument(1)) {
host = call.Argument(1).String()
callbackIndex = 2
}
placeholder := events.NewEmitter(vm)
_ = native.Set(vm, placeholder, "connecting", true)
_ = placeholder.Set("connecting", true)
var pendingEncoding string
_ = native.Set(vm, placeholder, "setEncoding", native.Bind(vm, "setEncoding", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
value := native.String(vm, moejs.Arg(args, 0))
pendingEncoding = value
return native.From(vm, placeholder), nil
}))
_ = native.Set(vm, placeholder, "setTimeout", native.Bind(vm, "setTimeout", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
milliseconds := native.Int(vm, moejs.Arg(args, 0))
callback := moejs.Arg(args, 1)
if function, ok := callable(callback); ok {
once, _ := callable(native.Get(vm, placeholder, "once"))
_, _ = native.Call(vm, once, native.From(vm, placeholder), native.From(vm, "timeout"), native.From(vm, function))
_ = placeholder.Set("setEncoding", func(value string) *goja.Object { pendingEncoding = value; return placeholder })
_ = placeholder.Set("setTimeout", func(milliseconds int64, callback goja.Value) *goja.Object {
if function, ok := goja.AssertFunction(callback); ok {
once, _ := goja.AssertFunction(placeholder.Get("once"))
_, _ = once(placeholder, vm.ToValue("timeout"), vm.ToValue(function))
}
if milliseconds > 0 {
m.runtime.Loop().SetTimeout(func(vm *moejs.Runtime) {
_ = m.runtime.RunJob(vm, "net.Socket timeout", func() error {
return events.Emit(vm, placeholder, "timeout")
})
m.runtime.Loop().SetTimeout(func(vm *goja.Runtime) {
_ = m.runtime.RunJob(vm, "net.Socket timeout", func() error { return events.Emit(vm, placeholder, "timeout") })
}, time.Duration(milliseconds)*time.Millisecond)
}
return native.From(vm, placeholder), nil
}))
_ = native.Set(vm, placeholder, "setNoDelay", native.Bind(vm, "setNoDelay", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
panic(native.GoError(vm, fmt.Errorf("net.Socket.setNoDelay is not supported by jsruntime before the connection is established")))
}))
_ = native.Set(vm, placeholder, "setKeepAlive", native.Bind(vm, "setKeepAlive", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
panic(native.GoError(vm, fmt.Errorf("net.Socket.setKeepAlive is not supported by jsruntime before the connection is established")))
}))
_ = native.Set(vm, placeholder, "ref", native.Bind(vm, "ref", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
panic(native.GoError(vm, fmt.Errorf("net.Socket.ref is not supported by jsruntime; the event loop is host-driven")))
}))
_ = native.Set(vm, placeholder, "unref", native.Bind(vm, "unref", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
panic(native.GoError(vm, fmt.Errorf("net.Socket.unref is not supported by jsruntime; the event loop is host-driven")))
}))
if callback, ok := callable(moejs.Arg(args, callbackIndex)); ok {
once, _ := callable(native.Get(vm, placeholder, "once"))
_, _ = native.Call(vm, once, native.From(vm, placeholder), native.From(vm, "connect"), native.From(vm, callback))
return placeholder
})
_ = placeholder.Set("setNoDelay", func() *goja.Object {
panic(vm.NewGoError(fmt.Errorf("net.Socket.setNoDelay is not supported by jsruntime before the connection is established")))
})
_ = placeholder.Set("setKeepAlive", func() *goja.Object {
panic(vm.NewGoError(fmt.Errorf("net.Socket.setKeepAlive is not supported by jsruntime before the connection is established")))
})
_ = placeholder.Set("ref", func() *goja.Object {
panic(vm.NewGoError(fmt.Errorf("net.Socket.ref is not supported by jsruntime; the event loop is host-driven")))
})
_ = placeholder.Set("unref", func() *goja.Object {
panic(vm.NewGoError(fmt.Errorf("net.Socket.unref is not supported by jsruntime; the event loop is host-driven")))
})
if callback, ok := goja.AssertFunction(call.Argument(callbackIndex)); ok {
once, _ := goja.AssertFunction(placeholder.Get("once"))
_, _ = once(placeholder, vm.ToValue("connect"), vm.ToValue(callback))
}
dialContext, cancelDial := context.WithCancel(context.Background())
pendingResourceID := m.runtime.AddResource(cancelDial)
if pendingResourceID == 0 {
_ = native.Set(vm, placeholder, "connecting", false)
_ = native.Set(vm, placeholder, "destroyed", true)
_ = placeholder.Set("connecting", false)
_ = placeholder.Set("destroyed", true)
return placeholder
}
go func() {
connection, err := (&net.Dialer{Timeout: m.runtime.Timeout()}).DialContext(dialContext, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
cancelDial()
m.runtime.RemoveResource(pendingResourceID)
queued := m.runtime.RunOnLoop(func(vm *moejs.Runtime) {
queued := m.runtime.RunOnLoop(func(vm *goja.Runtime) {
attached := false
defer func() {
if !attached && connection != nil {
@@ -343,19 +303,19 @@ func (m *Module) netConnect(vm *moejs.Runtime, args []moejs.Value) *engine.Objec
}()
_ = m.runtime.RunJob(vm, "net.Socket connect", func() error {
if err != nil {
_ = native.Set(vm, placeholder, "connecting", false)
return events.Emit(vm, placeholder, "error", native.GoError(vm, err))
_ = placeholder.Set("connecting", false)
return events.Emit(vm, placeholder, "error", vm.NewGoError(err))
}
if !m.configureNetSocket(vm, placeholder, connection, nil) {
return nil
}
attached = true
if pendingEncoding != "" {
if setEncoding, ok := callable(native.Get(vm, placeholder, "setEncoding")); ok {
_, _ = native.Call(vm, setEncoding, native.From(vm, placeholder), native.From(vm, pendingEncoding))
if setEncoding, ok := goja.AssertFunction(placeholder.Get("setEncoding")); ok {
_, _ = setEncoding(placeholder, vm.ToValue(pendingEncoding))
}
}
_ = native.Set(vm, placeholder, "connecting", false)
_ = placeholder.Set("connecting", false)
return events.Emit(vm, placeholder, "connect")
})
})
@@ -365,60 +325,30 @@ func (m *Module) netConnect(vm *moejs.Runtime, args []moejs.Value) *engine.Objec
}()
return placeholder
}
func (m *Module) newNetSocket(vm *moejs.Runtime, connection net.Conn, onClose func()) *engine.Object {
func (m *Module) newNetSocket(vm *goja.Runtime, connection net.Conn, onClose func()) *goja.Object {
socket := events.NewEmitter(vm)
if !m.configureNetSocket(vm, socket, connection, onClose) {
_ = native.Set(vm, socket, "destroyed", true)
_ = native.Set(vm, socket, "readyState", "closed")
_ = socket.Set("destroyed", true)
_ = socket.Set("readyState", "closed")
}
return socket
}
func (m *Module) AttachSocket(vm *moejs.Runtime, socket *engine.Object, connection net.Conn, onClose func()) bool {
return m.configureNetSocket(vm, socket, connection, onClose)
}
func (m *Module) DetachSocket(socket *engine.Object) (net.Conn, error) {
value, ok := m.sockets.Load(socket)
if !ok {
return nil, errors.New("socket is not connected")
}
state := value.(*socketState)
if !state.detaching.CompareAndSwap(false, true) {
return nil, errors.New("socket is already being upgraded")
}
if err := state.connection.SetReadDeadline(time.Now()); err != nil {
state.detaching.Store(false)
return nil, err
}
<-state.readerDone
if state.closed.Load() {
return nil, errors.New("socket closed during upgrade")
}
m.sockets.Delete(socket)
m.runtime.RemoveResource(state.resourceID)
if err := state.connection.SetDeadline(time.Time{}); err != nil {
_ = state.connection.Close()
return nil, err
}
return state.connection, nil
}
func (m *Module) configureNetSocket(vm *moejs.Runtime, socket *engine.Object, connection net.Conn, onClose func()) bool {
func (m *Module) configureNetSocket(vm *goja.Runtime, socket *goja.Object, connection net.Conn, onClose func()) bool {
localHost, localPort := splitNetAddress(connection.LocalAddr())
remoteHost, remotePort := splitNetAddress(connection.RemoteAddr())
_ = native.Set(vm, socket, "connecting", false)
_ = native.Set(vm, socket, "pending", false)
_ = native.Set(vm, socket, "destroyed", false)
_ = native.Set(vm, socket, "readyState", "open")
_ = native.Set(vm, socket, "remoteAddress", remoteHost)
_ = native.Set(vm, socket, "remotePort", remotePort)
_ = native.Set(vm, socket, "remoteFamily", netAddressFamily(remoteHost))
_ = native.Set(vm, socket, "localAddress", localHost)
_ = native.Set(vm, socket, "localPort", localPort)
_ = native.Set(vm, socket, "localFamily", netAddressFamily(localHost))
resourceID := m.runtime.AddResource(func() {
_ = connection.Close()
})
_ = socket.Set("connecting", false)
_ = socket.Set("pending", false)
_ = socket.Set("destroyed", false)
_ = socket.Set("readyState", "open")
_ = socket.Set("remoteAddress", remoteHost)
_ = socket.Set("remotePort", remotePort)
_ = socket.Set("remoteFamily", netAddressFamily(remoteHost))
_ = socket.Set("localAddress", localHost)
_ = socket.Set("localPort", localPort)
_ = socket.Set("localFamily", netAddressFamily(localHost))
resourceID := m.runtime.AddResource(func() { _ = connection.Close() })
if resourceID == 0 {
_ = connection.Close()
if onClose != nil {
@@ -426,15 +356,12 @@ func (m *Module) configureNetSocket(vm *moejs.Runtime, socket *engine.Object, co
}
return false
}
state := &socketState{connection: connection, resourceID: resourceID, readerDone: make(chan struct {
})}
m.sockets.Store(socket, state)
var encoding atomic.Value
encoding.Store("")
var closed atomic.Bool
var writes writequeue.Queue
closeSocket := func() {
if state.closed.CompareAndSwap(false, true) {
m.sockets.Delete(socket)
if closed.CompareAndSwap(false, true) {
m.runtime.RemoveResource(resourceID)
_ = connection.Close()
if onClose != nil {
@@ -442,33 +369,33 @@ func (m *Module) configureNetSocket(vm *moejs.Runtime, socket *engine.Object, co
}
}
}
_ = native.Set(vm, socket, "write", native.Bind(vm, "write", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
data := append([]byte(nil), buffer.Bytes(vm, moejs.Arg(args, 0))...)
callback := netCallback(args)
_ = socket.Set("write", func(call goja.FunctionCall) goja.Value {
data := append([]byte(nil), buffer.Bytes(vm, call.Argument(0))...)
callback := netCallback(call)
writes.Submit(func() {
_, err := connection.Write(data)
if !native.IsNil(callback) {
m.runtime.RunOnLoop(func(vm *moejs.Runtime) {
if callback != nil {
m.runtime.RunOnLoop(func(vm *goja.Runtime) {
_ = m.runtime.RunJob(vm, "net.Socket.write", func() error {
if err != nil {
_, callbackErr := native.Call(vm, callback, native.From(vm, moejs.Undefined()), native.From(vm, native.GoError(vm, err)))
_, callbackErr := callback(goja.Undefined(), vm.NewGoError(err))
return callbackErr
}
_, callbackErr := native.Call(vm, callback, native.From(vm, moejs.Undefined()))
_, callbackErr := callback(goja.Undefined())
return callbackErr
})
})
}
})
return native.From(vm, true), nil
}))
_ = native.Set(vm, socket, "end", native.Bind(vm, "end", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return vm.ToValue(true)
})
_ = socket.Set("end", func(call goja.FunctionCall) goja.Value {
var data []byte
_, firstArgumentIsCallback := callable(moejs.Arg(args, 0))
if len(args) > 0 && !firstArgumentIsCallback && !moejs.Arg(args, 0).IsUndefined() && !moejs.Arg(args, 0).IsNull() {
data = append([]byte(nil), buffer.Bytes(vm, moejs.Arg(args, 0))...)
_, firstArgumentIsCallback := goja.AssertFunction(call.Argument(0))
if len(call.Arguments) > 0 && !firstArgumentIsCallback && !goja.IsUndefined(call.Argument(0)) && !goja.IsNull(call.Argument(0)) {
data = append([]byte(nil), buffer.Bytes(vm, call.Argument(0))...)
}
callback := netCallback(args)
callback := netCallback(call)
writes.Submit(func() {
var err error
if len(data) > 0 {
@@ -484,88 +411,75 @@ func (m *Module) configureNetSocket(vm *moejs.Runtime, socket *engine.Object, co
if err != nil {
closeSocket()
}
if !native.IsNil(callback) {
m.runtime.RunOnLoop(func(vm *moejs.Runtime) {
if callback != nil {
m.runtime.RunOnLoop(func(vm *goja.Runtime) {
_ = m.runtime.RunJob(vm, "net.Socket.end", func() error {
if err != nil {
_, callbackErr := native.Call(vm, callback, native.From(vm, moejs.Undefined()), native.From(vm, native.GoError(vm, err)))
_, callbackErr := callback(goja.Undefined(), vm.NewGoError(err))
return callbackErr
}
_, callbackErr := native.Call(vm, callback, native.From(vm, moejs.Undefined()))
_, callbackErr := callback(goja.Undefined())
return callbackErr
})
})
}
})
return native.From(vm, socket), nil
}))
_ = native.Set(vm, socket, "destroy", native.Bind(vm, "destroy", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
closeSocket()
return native.From(vm, socket), nil
}))
_ = native.Set(vm, socket, "setEncoding", native.Bind(vm, "setEncoding", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
value := native.String(vm, moejs.Arg(args, 0))
encoding.Store(value)
return native.From(vm, socket), nil
}))
_ = native.Set(vm, socket, "setTimeout", native.Bind(vm, "setTimeout", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
milliseconds := native.Int(vm, moejs.Arg(args, 0))
callback := moejs.Arg(args, 1)
return socket
})
_ = socket.Set("destroy", func() *goja.Object { closeSocket(); return socket })
_ = socket.Set("setEncoding", func(value string) *goja.Object { encoding.Store(value); return socket })
_ = socket.Set("setTimeout", func(milliseconds int64, callback goja.Value) *goja.Object {
if milliseconds <= 0 {
_ = connection.SetDeadline(time.Time{})
} else {
_ = connection.SetDeadline(time.Now().Add(time.Duration(milliseconds) * time.Millisecond))
}
if function, ok := callable(callback); ok {
once, _ := callable(native.Get(vm, socket, "once"))
_, _ = native.Call(vm, once, native.From(vm, socket), native.From(vm, "timeout"), native.From(vm, function))
if function, ok := goja.AssertFunction(callback); ok {
once, _ := goja.AssertFunction(socket.Get("once"))
_, _ = once(socket, vm.ToValue("timeout"), vm.ToValue(function))
}
return native.From(vm, socket), nil
}))
_ = native.Set(vm, socket, "setNoDelay", native.Bind(vm, "setNoDelay", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
enabled := native.Bool(moejs.Arg(args, 0))
return socket
})
_ = socket.Set("setNoDelay", func(enabled bool) *goja.Object {
if tcp, ok := connection.(*net.TCPConn); ok {
_ = tcp.SetNoDelay(enabled)
}
return native.From(vm, socket), nil
}))
_ = native.Set(vm, socket, "setKeepAlive", native.Bind(vm, "setKeepAlive", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
enabled := native.Bool(moejs.Arg(args, 0))
return socket
})
_ = socket.Set("setKeepAlive", func(enabled bool) *goja.Object {
if tcp, ok := connection.(*net.TCPConn); ok {
_ = tcp.SetKeepAlive(enabled)
}
return native.From(vm, socket), nil
}))
_ = native.Set(vm, socket, "address", native.Bind(vm, "address", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return native.From(vm, map[string]any{"address": localHost, "family": netAddressFamily(localHost), "port": localPort}), nil
}))
_ = native.Set(vm, socket, "pause", native.Bind(vm, "pause", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
panic(native.GoError(vm, fmt.Errorf("net.Socket.pause is not supported by jsruntime; no backpressure")))
}))
_ = native.Set(vm, socket, "resume", native.Bind(vm, "resume", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
panic(native.GoError(vm, fmt.Errorf("net.Socket.resume is not supported by jsruntime; no backpressure")))
}))
_ = native.Set(vm, socket, "ref", native.Bind(vm, "ref", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
panic(native.GoError(vm, fmt.Errorf("net.Socket.ref is not supported by jsruntime; the event loop is host-driven")))
}))
_ = native.Set(vm, socket, "unref", native.Bind(vm, "unref", 0, func(r *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
panic(native.GoError(vm, fmt.Errorf("net.Socket.unref is not supported by jsruntime; the event loop is host-driven")))
}))
return socket
})
_ = socket.Set("address", func() map[string]any {
return map[string]any{"address": localHost, "family": netAddressFamily(localHost), "port": localPort}
})
_ = socket.Set("pause", func() *goja.Object {
panic(vm.NewGoError(fmt.Errorf("net.Socket.pause is not supported by jsruntime; no backpressure")))
})
_ = socket.Set("resume", func() *goja.Object {
panic(vm.NewGoError(fmt.Errorf("net.Socket.resume is not supported by jsruntime; no backpressure")))
})
_ = socket.Set("ref", func() *goja.Object {
panic(vm.NewGoError(fmt.Errorf("net.Socket.ref is not supported by jsruntime; the event loop is host-driven")))
})
_ = socket.Set("unref", func() *goja.Object {
panic(vm.NewGoError(fmt.Errorf("net.Socket.unref is not supported by jsruntime; the event loop is host-driven")))
})
go func() {
defer close(state.readerDone)
data := make([]byte, 32*1024)
for {
count, err := connection.Read(data)
if count > 0 {
chunk := append([]byte(nil), data[:count]...)
delivered := make(chan struct {
})
if !m.runtime.RunOnLoop(func(vm *moejs.Runtime) {
delivered := make(chan struct{})
if !m.runtime.RunOnLoop(func(vm *goja.Runtime) {
defer close(delivered)
_ = m.runtime.RunJob(vm, "net.Socket data", func() error {
var value any = buffer.WrapBytes(vm, chunk)
if currentEncoding := encoding.Load().(string); currentEncoding != "" {
value = buffer.EncodeBytes(vm, chunk, native.From(vm, currentEncoding))
value = buffer.EncodeBytes(vm, chunk, vm.ToValue(currentEncoding))
}
return events.Emit(vm, socket, "data", value)
})
@@ -576,16 +490,13 @@ func (m *Module) configureNetSocket(vm *moejs.Runtime, socket *engine.Object, co
<-delivered
}
if err != nil {
if state.detaching.Load() {
return
}
closeSocket()
m.runtime.RunOnLoop(func(vm *moejs.Runtime) {
m.runtime.RunOnLoop(func(vm *goja.Runtime) {
_ = m.runtime.RunJob(vm, "net.Socket close", func() error {
_ = native.Set(vm, socket, "destroyed", true)
_ = native.Set(vm, socket, "readyState", "closed")
_ = socket.Set("destroyed", true)
_ = socket.Set("readyState", "closed")
if !errors.Is(err, io.EOF) && !isNetClosed(err) {
if emitErr := events.Emit(vm, socket, "error", native.GoError(vm, err)); emitErr != nil {
if emitErr := events.Emit(vm, socket, "error", vm.NewGoError(err)); emitErr != nil {
return emitErr
}
}
@@ -601,6 +512,7 @@ func (m *Module) configureNetSocket(vm *moejs.Runtime, socket *engine.Object, co
}()
return true
}
func splitNetAddress(address net.Addr) (string, int) {
if address == nil {
return "", 0
@@ -609,23 +521,27 @@ func splitNetAddress(address net.Addr) (string, int) {
portNumber, _ := strconv.Atoi(port)
return host, portNumber
}
func netAddressFamily(host string) string {
if ip := net.ParseIP(host); ip != nil && ip.To4() == nil {
return "IPv6"
}
return "IPv4"
}
func isNetClosed(err error) bool {
return errors.Is(err, net.ErrClosed) || strings.Contains(err.Error(), "closed network connection")
}
func netCallback(args []moejs.Value) moejs.Value {
for index := len(args) - 1; index >= 0; index-- {
if callback, ok := callable(args[index]); ok {
func netCallback(call goja.FunctionCall) goja.Callable {
for index := len(call.Arguments) - 1; index >= 0; index-- {
if callback, ok := goja.AssertFunction(call.Arguments[index]); ok {
return callback
}
}
return moejs.Undefined()
return nil
}
func parseIPVersion(value string) int {
ip := net.ParseIP(value)
if ip == nil {
@@ -636,10 +552,3 @@ func parseIPVersion(value string) int {
}
return 6
}
func callable(v moejs.Value) (moejs.Value, bool) { return v, engine.IsCallable(v) }
func asObject(v moejs.Value) (*engine.Object, bool) {
if !v.IsObject() {
return nil, false
}
return v.AsObject(), true
}
+7 -8
View File
@@ -5,10 +5,10 @@ import (
stdos "os"
"path/filepath"
"github.com/komari-monitor/komari/pkg/jsruntime/buffer"
"github.com/dop251/goja_nodejs/buffer"
"github.com/dop251/goja_nodejs/require"
"github.com/komari-monitor/komari/pkg/jsruntime/events"
osmodule "github.com/komari-monitor/komari/pkg/jsruntime/os"
"github.com/komari-monitor/komari/pkg/jsruntime/require"
streammodule "github.com/komari-monitor/komari/pkg/jsruntime/stream"
)
@@ -25,7 +25,6 @@ func (r *Runtime) registerNodeModules(registry *require.Registry) {
r.registerNodeModule(registry, "fs", r.fsModule.Load)
r.registerNodeModule(registry, "child_process", r.childProcessModule.Load)
r.registerNodeModule(registry, "net", r.netModule.Load)
r.registerNodeModule(registry, "tls", r.tlsModule.Load)
r.registerNodeModule(registry, "http", r.httpModule.Load)
r.registerNodeModule(registry, "stream", streammodule.Load)
r.registerNodeModule(registry, "stream/promises", streammodule.LoadPromises)
@@ -40,21 +39,21 @@ func (r *Runtime) registerNodeModule(registry *require.Registry, name string, lo
func (r *Runtime) injectNodeGlobals() error {
buffer.Enable(r.vm)
processValue := require.Require(r.vm, "process")
if err := r.vm.SetGlobal("process", processValue); err != nil {
if err := r.vm.Set("process", processValue); err != nil {
return fmt.Errorf("inject process: %w", err)
}
if err := r.vm.SetGlobal("global", r.vm.Realm().Global); err != nil {
if err := r.vm.Set("global", r.vm.GlobalObject()); err != nil {
return fmt.Errorf("inject global: %w", err)
}
cwd := r.fsModule.Cwd()
if err := r.vm.SetGlobal("__dirname", cwd); err != nil {
if err := r.vm.Set("__dirname", cwd); err != nil {
return fmt.Errorf("inject __dirname: %w", err)
}
if err := r.vm.SetGlobal("__filename", filepath.Join(cwd, "script.js")); err != nil {
if err := r.vm.Set("__filename", filepath.Join(cwd, "script.js")); err != nil {
return fmt.Errorf("inject __filename: %w", err)
}
if r.storageDir != "" {
if err := r.vm.SetGlobal("__storageDir__", r.storageDir); err != nil {
if err := r.vm.Set("__storageDir__", r.storageDir); err != nil {
return fmt.Errorf("inject __storageDir__: %w", err)
}
}
+1 -6
View File
@@ -661,12 +661,7 @@ func TestChildProcessPermissionAndExecution(t *testing.T) {
child.on("error", () => resolve(false));
});
}
`, Options{
// Spawning a shell is slow under -race and -coverpkg instrumentation,
// so this budget is deliberately generous to avoid flakes.
NodeJS: true, AllowExec: true, BaseDir: t.TempDir(), Console: io.Discard,
Timeout: 30 * time.Second,
})
`, Options{NodeJS: true, AllowExec: true, BaseDir: t.TempDir(), Console: io.Discard, Timeout: 5 * time.Second})
if err != nil {
t.Fatal(err)
}
+47 -75
View File
@@ -1,111 +1,80 @@
package os
import (
"github.com/Calcium-Ion/moejs"
"github.com/Calcium-Ion/moejs/engine"
"github.com/komari-monitor/komari/pkg/jsruntime/internal/metrics"
"github.com/komari-monitor/komari/pkg/jsruntime/native"
"net"
"os"
"os/user"
"runtime"
"unsafe"
"github.com/dop251/goja"
"github.com/komari-monitor/komari/pkg/jsruntime/internal/metrics"
)
func Load(vm *moejs.Runtime, module *engine.Object) {
exports := vm.Realm().NewObject()
func Load(vm *goja.Runtime, module *goja.Object) {
exports := vm.NewObject()
hostname, _ := os.Hostname()
home, _ := os.UserHomeDir()
currentUser, _ := user.Current()
systemMetrics := func() metrics.System {
result, err := metrics.ReadSystem()
if err != nil {
panic(native.GoError(vm, err))
panic(vm.NewGoError(err))
}
return result
}
_ = native.Set(vm, exports, "arch", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return native.From(vm, metrics.Arch()), nil
}))
_ = native.Set(vm, exports, "platform", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return native.From(vm, metrics.Platform()), nil
}))
_ = native.Set(vm, exports, "type", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
_ = exports.Set("arch", func() string { return metrics.Arch() })
_ = exports.Set("platform", func() string { return metrics.Platform() })
_ = exports.Set("type", func() string {
switch runtime.GOOS {
case "windows":
return native.From(vm, "Windows_NT"), nil
return "Windows_NT"
case "darwin":
return native.From(vm, "Darwin"), nil
return "Darwin"
case "freebsd":
return native.From(vm, "FreeBSD"), nil
return "FreeBSD"
case "openbsd":
return native.From(vm, "OpenBSD"), nil
return "OpenBSD"
case "netbsd":
return native.From(vm, "NetBSD"), nil
return "NetBSD"
case "aix":
return native.From(vm, "AIX"), nil
return "AIX"
default:
return native.From(vm, runtime.GOOS), nil
return runtime.GOOS
}
}))
_ = native.Set(vm, exports, "release", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return native.From(vm, systemMetrics().Release), nil
}))
_ = native.Set(vm, exports, "version", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return native.From(vm, systemMetrics().Version), nil
}))
_ = native.Set(vm, exports, "machine", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return native.From(vm, runtime.GOARCH), nil
}))
_ = native.Set(vm, exports, "hostname", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return native.From(vm, hostname), nil
}))
_ = native.Set(vm, exports, "homedir", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return native.From(vm, home), nil
}))
_ = native.Set(vm, exports, "tmpdir", native.Bind(vm, "tmpdir", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return native.From(vm, os.TempDir()), nil
}))
_ = native.Set(vm, exports, "endianness", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
})
_ = exports.Set("release", func() string { return systemMetrics().Release })
_ = exports.Set("version", func() string { return systemMetrics().Version })
_ = exports.Set("machine", func() string { return runtime.GOARCH })
_ = exports.Set("hostname", func() string { return hostname })
_ = exports.Set("homedir", func() string { return home })
_ = exports.Set("tmpdir", os.TempDir)
_ = exports.Set("endianness", func() string {
var value uint16 = 1
if *(*byte)(unsafe.Pointer(&value)) == 1 {
return native.From(vm, "LE"), nil
return "LE"
}
return native.From(vm, "BE"), nil
}))
_ = native.Set(vm, exports, "EOL", map[bool]string{true: "\r\n", false: "\n"}[runtime.GOOS == "windows"])
_ = native.Set(vm, exports, "devNull", map[bool]string{true: `\\.\nul`, false: "/dev/null"}[runtime.GOOS == "windows"])
_ = native.Set(vm, exports, "uptime", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return native.From(vm, systemMetrics().Uptime), nil
}))
_ = native.Set(vm, exports, "loadavg", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
result := systemMetrics()
return native.From(vm, result.LoadAvg[:]), nil
}))
_ = native.Set(vm, exports, "totalmem", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return native.From(vm, systemMetrics().TotalMem), nil
}))
_ = native.Set(vm, exports, "freemem", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return native.From(vm, systemMetrics().FreeMem), nil
}))
_ = native.Set(vm, exports, "availableParallelism", native.Bind(vm, "availableParallelism", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return native.From(vm, runtime.NumCPU()), nil
}))
_ = native.Set(vm, exports, "cpus", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return native.From(vm, metrics.CPUInfoValues(systemMetrics().CPUs)), nil
}))
_ = native.Set(vm, exports, "userInfo", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return "BE"
})
_ = exports.Set("EOL", map[bool]string{true: "\r\n", false: "\n"}[runtime.GOOS == "windows"])
_ = exports.Set("devNull", map[bool]string{true: `\\.\nul`, false: "/dev/null"}[runtime.GOOS == "windows"])
_ = exports.Set("uptime", func() float64 { return systemMetrics().Uptime })
_ = exports.Set("loadavg", func() []float64 { result := systemMetrics(); return result.LoadAvg[:] })
_ = exports.Set("totalmem", func() uint64 { return systemMetrics().TotalMem })
_ = exports.Set("freemem", func() uint64 { return systemMetrics().FreeMem })
_ = exports.Set("availableParallelism", runtime.NumCPU)
_ = exports.Set("cpus", func() []map[string]any { return metrics.CPUInfoValues(systemMetrics().CPUs) })
_ = exports.Set("userInfo", func() map[string]any {
if currentUser == nil {
return native.From(vm, map[string]any{"uid": -1, "gid": -1, "username": "", "homedir": home, "shell": ""}), nil
return map[string]any{"uid": -1, "gid": -1, "username": "", "homedir": home, "shell": ""}
}
return native.From(vm, map[string]any{"uid": currentUser.Uid, "gid": currentUser.Gid, "username": currentUser.Username, "homedir": currentUser.HomeDir, "shell": ""}), nil
}))
_ = native.Set(vm, exports, "networkInterfaces", native.Bind(vm, "networkInterfaces", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return native.From(vm, nodeNetworkInterfaces()), nil
}))
_ = native.Set(vm, exports, "constants", map[string]any{"signals": map[string]int{"SIGINT": 2, "SIGTERM": 15, "SIGKILL": 9}, "errno": map[string]int{}})
_ = native.Set(vm, module, "exports", exports)
return map[string]any{"uid": currentUser.Uid, "gid": currentUser.Gid, "username": currentUser.Username, "homedir": currentUser.HomeDir, "shell": ""}
})
_ = exports.Set("networkInterfaces", nodeNetworkInterfaces)
_ = exports.Set("constants", map[string]any{"signals": map[string]int{"SIGINT": 2, "SIGTERM": 15, "SIGKILL": 9}, "errno": map[string]int{}})
_ = module.Set("exports", exports)
}
func nodeNetworkInterfaces() map[string][]map[string]any {
result := make(map[string][]map[string]any)
interfaces, _ := net.Interfaces()
@@ -120,7 +89,10 @@ func nodeNetworkInterfaces() map[string][]map[string]any {
if ip.To4() != nil {
family = "IPv4"
}
result[networkInterface.Name] = append(result[networkInterface.Name], map[string]any{"address": ip.String(), "netmask": net.IP(network.Mask).String(), "family": family, "mac": networkInterface.HardwareAddr.String(), "internal": networkInterface.Flags&net.FlagLoopback != 0, "cidr": address.String()})
result[networkInterface.Name] = append(result[networkInterface.Name], map[string]any{
"address": ip.String(), "netmask": net.IP(network.Mask).String(), "family": family,
"mac": networkInterface.HardwareAddr.String(), "internal": networkInterface.Flags&net.FlagLoopback != 0, "cidr": address.String(),
})
}
}
return result
+91 -95
View File
@@ -1,12 +1,11 @@
package path
import (
"github.com/Calcium-Ion/moejs"
"github.com/Calcium-Ion/moejs/engine"
"github.com/komari-monitor/komari/pkg/jsruntime/native"
"path"
"runtime"
"strings"
"github.com/dop251/goja"
)
type Module struct {
@@ -16,14 +15,16 @@ type Module struct {
func New(cwd func() string) *Module {
return &Module{cwd: cwd}
}
func (m *Module) Load(vm *moejs.Runtime, module *engine.Object) {
func (m *Module) Load(vm *goja.Runtime, module *goja.Object) {
defaultPath := m.pathExports(vm, runtime.GOOS == "windows")
_ = native.Set(vm, defaultPath, "posix", m.pathExports(vm, false))
_ = native.Set(vm, defaultPath, "win32", m.pathExports(vm, true))
_ = native.Set(vm, module, "exports", defaultPath)
_ = defaultPath.Set("posix", m.pathExports(vm, false))
_ = defaultPath.Set("win32", m.pathExports(vm, true))
_ = module.Set("exports", defaultPath)
}
func (m *Module) pathExports(vm *moejs.Runtime, windows bool) *engine.Object {
exports := vm.Realm().NewObject()
func (m *Module) pathExports(vm *goja.Runtime, windows bool) *goja.Object {
exports := vm.NewObject()
separator, delimiter := "/", ":"
if windows {
separator, delimiter = `\`, ";"
@@ -65,104 +66,93 @@ func (m *Module) pathExports(vm *moejs.Runtime, windows bool) *engine.Object {
}
return path.IsAbs(value)
}
extname := func(value string) string {
return nodeExtname(value, windows)
}
_ = native.Set(vm, exports, "sep", separator)
_ = native.Set(vm, exports, "delimiter", delimiter)
_ = native.Set(vm, exports, "normalize", native.Bind(vm, "normalize", 1, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return native.From(vm, normalize(native.String(vm, moejs.Arg(args, 0)))), nil
}))
_ = native.Set(vm, exports, "isAbsolute", native.Bind(vm, "isAbsolute", 1, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return native.From(vm, isAbs(native.String(vm, moejs.Arg(args, 0)))), nil
}))
_ = native.Set(vm, exports, "join", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
parts := make([]string, len(args))
for index, value := range args {
parts[index] = native.String(vm, value)
extname := func(value string) string { return nodeExtname(value, windows) }
_ = exports.Set("sep", separator)
_ = exports.Set("delimiter", delimiter)
_ = exports.Set("normalize", normalize)
_ = exports.Set("isAbsolute", isAbs)
_ = exports.Set("join", func(call goja.FunctionCall) goja.Value {
parts := make([]string, len(call.Arguments))
for index, value := range call.Arguments {
parts[index] = value.String()
}
return native.From(vm, join(parts)), nil
}))
_ = native.Set(vm, exports, "resolve", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
parts := make([]string, len(args))
for index, value := range args {
parts[index] = native.String(vm, value)
return vm.ToValue(join(parts))
})
_ = exports.Set("resolve", func(call goja.FunctionCall) goja.Value {
parts := make([]string, len(call.Arguments))
for index, value := range call.Arguments {
parts[index] = value.String()
}
return native.From(vm, resolve(parts)), nil
}))
_ = native.Set(vm, exports, "relative", native.Bind(vm, "", 2, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
from := native.String(vm, moejs.Arg(args, 0))
to := native.String(vm, moejs.Arg(args, 1))
return vm.ToValue(resolve(parts))
})
_ = exports.Set("relative", func(from, to string) string {
if windows {
return native.From(vm, winPathRelative(m.cwd(), from, to)), nil
return winPathRelative(m.cwd(), from, to)
}
return native.From(vm, posixPathRelative(m.cwd(), from, to)), nil
}))
_ = native.Set(vm, exports, "dirname", native.Bind(vm, "dirname", 1, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return native.From(vm, dirname(native.String(vm, moejs.Arg(args, 0)))), nil
}))
_ = native.Set(vm, exports, "basename", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
result := basename(native.String(vm, moejs.Arg(args, 0)))
if suffix := moejs.Arg(args, 1); !suffix.IsUndefined() && strings.HasSuffix(result, native.String(vm, suffix)) {
result = strings.TrimSuffix(result, native.String(vm, suffix))
return posixPathRelative(m.cwd(), from, to)
})
_ = exports.Set("dirname", dirname)
_ = exports.Set("basename", func(call goja.FunctionCall) goja.Value {
result := basename(call.Argument(0).String())
if suffix := call.Argument(1); !goja.IsUndefined(suffix) && strings.HasSuffix(result, suffix.String()) {
result = strings.TrimSuffix(result, suffix.String())
}
return native.From(vm, result), nil
}))
_ = native.Set(vm, exports, "extname", native.Bind(vm, "extname", 1, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return native.From(vm, extname(native.String(vm, moejs.Arg(args, 0)))), nil
}))
_ = native.Set(vm, exports, "parse", native.Bind(vm, "", 1, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
value := native.String(vm, moejs.Arg(args, 0))
return vm.ToValue(result)
})
_ = exports.Set("extname", extname)
_ = exports.Set("parse", func(value string) map[string]string {
if windows {
return native.From(vm, winPathParse(value)), nil
return winPathParse(value)
}
return native.From(vm, posixPathParse(value)), nil
}))
_ = native.Set(vm, exports, "format", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
object := native.Object(vm, moejs.Arg(args, 0))
resultDir := jsStringProperty(vm, object, "dir")
return posixPathParse(value)
})
_ = exports.Set("format", func(call goja.FunctionCall) goja.Value {
object := call.Argument(0).ToObject(vm)
resultDir := jsStringProperty(object, "dir")
if resultDir == "" {
resultDir = jsStringProperty(vm, object, "root")
resultDir = jsStringProperty(object, "root")
}
resultBase := jsStringProperty(vm, object, "base")
resultBase := jsStringProperty(object, "base")
if resultBase == "" {
extension := jsStringProperty(vm, object, "ext")
extension := jsStringProperty(object, "ext")
if extension != "" && !strings.HasPrefix(extension, ".") {
extension = "." + extension
}
resultBase = jsStringProperty(vm, object, "name") + extension
resultBase = jsStringProperty(object, "name") + extension
}
if resultDir == "" {
return native.From(vm, resultBase), nil
return vm.ToValue(resultBase)
}
return native.From(vm, join([]string{resultDir, resultBase})), nil
}))
_ = native.Set(vm, exports, "toNamespacedPath", native.Bind(vm, "", 1, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
value := native.String(vm, moejs.Arg(args, 0))
return vm.ToValue(join([]string{resultDir, resultBase}))
})
_ = exports.Set("toNamespacedPath", func(value string) string {
if !windows || value == "" {
return native.From(vm, value), nil
return value
}
resolved := winPathResolve(m.cwd(), []string{value})
if strings.HasPrefix(resolved, `\\?\`) || strings.HasPrefix(resolved, `\\.\`) {
return native.From(vm, resolved), nil
return resolved
}
if strings.HasPrefix(resolved, `\\`) {
return native.From(vm, `\\?\UNC\`+strings.TrimLeft(resolved, `\`)), nil
return `\\?\UNC\` + strings.TrimLeft(resolved, `\`)
}
if winDriveVolume(resolved) != "" {
return native.From(vm, `\\?\`+resolved), nil
return `\\?\` + resolved
}
return native.From(vm, value), nil
}))
return value
})
return exports
}
func jsStringProperty(vm *moejs.Runtime, object *engine.Object, name string) string {
value := native.Get(vm, object, name)
if native.IsNil(value) || value.IsUndefined() || value.IsNull() {
func jsStringProperty(object *goja.Object, name string) string {
value := object.Get(name)
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
return ""
}
return native.String(vm, value)
return value.String()
}
func posixPathResolve(cwd string, parts []string) string {
cwd = strings.ReplaceAll(cwd, `\`, "/")
if volume := winDriveVolume(cwd); volume != "" {
@@ -190,6 +180,7 @@ func posixPathResolve(cwd string, parts []string) string {
}
return result
}
func posixPathNormalize(value string) string {
if value == "" {
return "."
@@ -201,6 +192,7 @@ func posixPathNormalize(value string) string {
}
return result
}
func posixPathJoin(parts []string) string {
nonEmpty := make([]string, 0, len(parts))
for _, part := range parts {
@@ -213,6 +205,7 @@ func posixPathJoin(parts []string) string {
}
return posixPathNormalize(strings.Join(nonEmpty, "/"))
}
func posixPathParts(value string) []string {
value = strings.Trim(value, "/")
if value == "" {
@@ -220,6 +213,7 @@ func posixPathParts(value string) []string {
}
return strings.Split(value, "/")
}
func posixPathRelative(cwd, from, to string) string {
from = posixPathResolve(cwd, []string{from})
to = posixPathResolve(cwd, []string{to})
@@ -239,6 +233,7 @@ func posixPathRelative(cwd, from, to string) string {
result = append(result, toParts[common:]...)
return strings.Join(result, "/")
}
func posixPathBasename(value string) string {
end := len(value)
for end > 0 && value[end-1] == '/' {
@@ -253,6 +248,7 @@ func posixPathBasename(value string) string {
}
return value[start:end]
}
func posixPathParse(value string) map[string]string {
root := ""
rootEnd := 0
@@ -280,15 +276,16 @@ func posixPathParse(value string) map[string]string {
extension := nodeExtname(base, false)
return map[string]string{"root": root, "dir": dir, "base": base, "ext": extension, "name": strings.TrimSuffix(base, extension)}
}
func isWinSeparator(character byte) bool {
return character == '\\' || character == '/'
}
func isWinSeparator(character byte) bool { return character == '\\' || character == '/' }
func winDriveVolume(value string) string {
if len(value) >= 2 && ((value[0] >= 'A' && value[0] <= 'Z') || (value[0] >= 'a' && value[0] <= 'z')) && value[1] == ':' {
return value[:2]
}
return ""
}
func splitWinPath(value string) (volume, root, rest string) {
value = strings.ReplaceAll(value, "/", `\`)
if drive := winDriveVolume(value); drive != "" {
@@ -299,9 +296,7 @@ func splitWinPath(value string) (volume, root, rest string) {
return
}
if strings.HasPrefix(value, `\\`) {
parts := strings.FieldsFunc(strings.TrimLeft(value, `\`), func(character rune) bool {
return character == '\\'
})
parts := strings.FieldsFunc(strings.TrimLeft(value, `\`), func(character rune) bool { return character == '\\' })
if len(parts) >= 2 {
volume = `\\` + parts[0] + `\` + parts[1]
root = `\`
@@ -318,10 +313,9 @@ func splitWinPath(value string) (volume, root, rest string) {
}
return "", "", value
}
func cleanWinParts(rest string, absolute bool) []string {
parts := strings.FieldsFunc(rest, func(character rune) bool {
return character == '\\' || character == '/'
})
parts := strings.FieldsFunc(rest, func(character rune) bool { return character == '\\' || character == '/' })
result := make([]string, 0, len(parts))
for _, part := range parts {
switch part {
@@ -338,6 +332,7 @@ func cleanWinParts(rest string, absolute bool) []string {
}
return result
}
func winPathNormalize(value string) string {
if value == "" {
return "."
@@ -356,6 +351,7 @@ func winPathNormalize(value string) string {
}
return result
}
func winPathJoin(parts []string) string {
nonEmpty := make([]string, 0, len(parts))
for _, part := range parts {
@@ -368,6 +364,7 @@ func winPathJoin(parts []string) string {
}
return winPathNormalize(strings.Join(nonEmpty, `\`))
}
func winPathResolve(cwd string, parts []string) string {
resolvedDevice, resolvedTail := "", ""
resolvedAbsolute := false
@@ -419,6 +416,7 @@ func winPathResolve(cwd string, parts []string) string {
}
return result
}
func winPathRelative(cwd, from, to string) string {
from = winPathResolve(cwd, []string{from})
to = winPathResolve(cwd, []string{to})
@@ -443,10 +441,9 @@ func winPathRelative(cwd, from, to string) string {
result = append(result, toParts[common:]...)
return strings.Join(result, `\`)
}
func winPathBasename(value string) string {
value = strings.TrimRightFunc(value, func(character rune) bool {
return character == '\\' || character == '/'
})
value = strings.TrimRightFunc(value, func(character rune) bool { return character == '\\' || character == '/' })
if value == "" {
return ""
}
@@ -457,6 +454,7 @@ func winPathBasename(value string) string {
}
return value[start:]
}
func winPathParse(value string) map[string]string {
value = strings.ReplaceAll(value, "/", `\`)
volume, rootSeparator, rest := splitWinPath(value)
@@ -489,11 +487,10 @@ func winPathParse(value string) map[string]string {
extension := nodeExtname(base, true)
return map[string]string{"root": root, "dir": dir, "base": base, "ext": extension, "name": strings.TrimSuffix(base, extension)}
}
func winPathDirname(value string) string {
volume, root, _ := splitWinPath(value)
trimmed := strings.TrimRightFunc(value, func(character rune) bool {
return character == '\\' || character == '/'
})
trimmed := strings.TrimRightFunc(value, func(character rune) bool { return character == '\\' || character == '/' })
index := strings.LastIndexAny(trimmed, `/\`)
if index < len(volume) {
if root != "" {
@@ -504,14 +501,13 @@ func winPathDirname(value string) string {
}
return "."
}
result := strings.TrimRightFunc(trimmed[:index], func(character rune) bool {
return character == '\\' || character == '/'
})
result := strings.TrimRightFunc(trimmed[:index], func(character rune) bool { return character == '\\' || character == '/' })
if result == volume {
return volume + root
}
return result
}
func nodeExtname(value string, windows bool) string {
startDot, startPart, end := -1, 0, -1
matchedSlash, preDotState := true, 0
+24 -29
View File
@@ -4,32 +4,30 @@ import (
_ "embed"
"errors"
"fmt"
"github.com/Calcium-Ion/moejs"
"github.com/komari-monitor/komari/pkg/jsruntime/native"
"github.com/dop251/goja"
)
func (m *Module) initNextTickScheduler(vm *moejs.Runtime) error {
factoryValue, err := native.Run(vm, nextTickSchedulerSource)
func (m *Module) initNextTickScheduler(vm *goja.Runtime) error {
factoryValue, err := vm.RunString(nextTickSchedulerSource)
if err != nil {
return fmt.Errorf("create process.nextTick scheduler: %w", err)
}
factory, _ := checkedFunction(factoryValue)
schedulerValue, err := native.Call(vm, factory, moejs.Undefined(), native.From(vm, native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
m.drainNextTicks(vm)
return moejs.Undefined(), nil
})))
factory, _ := goja.AssertFunction(factoryValue)
schedulerValue, err := factory(goja.Undefined(), vm.ToValue(func() { m.drainNextTicks(vm) }))
if err != nil {
return fmt.Errorf("initialize process.nextTick scheduler: %w", err)
}
scheduler := native.Object(vm, schedulerValue)
m.nextTickTurn, _ = checkedFunction(native.Get(vm, scheduler, "run"))
m.nextTickSchedule, _ = checkedFunction(native.Get(vm, scheduler, "schedule"))
if native.IsNil(m.nextTickTurn) || native.IsNil(m.nextTickSchedule) {
scheduler := schedulerValue.ToObject(vm)
m.nextTickTurn, _ = goja.AssertFunction(scheduler.Get("run"))
m.nextTickSchedule, _ = goja.AssertFunction(scheduler.Get("schedule"))
if m.nextTickTurn == nil || m.nextTickSchedule == nil {
return errors.New("process.nextTick scheduler is incomplete")
}
return nil
}
func (m *Module) enqueueNextTick(vm *moejs.Runtime, tick nodeNextTick) {
func (m *Module) enqueueNextTick(vm *goja.Runtime, tick nodeNextTick) {
m.nextTickMu.Lock()
m.nextTicks = append(m.nextTicks, tick)
shouldSchedule := !m.nextTickScheduled && !m.nextTickDraining
@@ -40,16 +38,15 @@ func (m *Module) enqueueNextTick(vm *moejs.Runtime, tick nodeNextTick) {
if !shouldSchedule {
return
}
if !native.IsNil(m.nextTickSchedule) {
if _, err := native.Call(vm, m.nextTickSchedule, moejs.Undefined()); err == nil {
if m.nextTickSchedule != nil {
if _, err := m.nextTickSchedule(goja.Undefined()); err == nil {
return
}
}
m.runtime.Loop().SetTimeout(func(vm *moejs.Runtime) {
m.drainNextTicks(vm)
}, 0)
m.runtime.Loop().SetTimeout(func(vm *goja.Runtime) { m.drainNextTicks(vm) }, 0)
}
func (m *Module) drainNextTicks(vm *moejs.Runtime) {
func (m *Module) drainNextTicks(vm *goja.Runtime) {
m.nextTickMu.Lock()
m.nextTickDraining = true
m.nextTickScheduled = false
@@ -65,26 +62,24 @@ func (m *Module) drainNextTicks(vm *moejs.Runtime) {
m.nextTicks[0] = nodeNextTick{}
m.nextTicks = m.nextTicks[1:]
m.nextTickMu.Unlock()
if _, err := native.Call(vm, tick.callback, moejs.Undefined(), tick.arguments...); err != nil {
if _, err := tick.callback(goja.Undefined(), tick.arguments...); err != nil {
if m.reportError != nil {
m.reportError(vm, []moejs.Value{native.From(vm, fmt.Sprintf("process.nextTick callback failed: %v", err))})
m.reportError(vm, []goja.Value{vm.ToValue(fmt.Sprintf("process.nextTick callback failed: %v", err))})
}
}
}
}
func (m *Module) RunTurn(vm *moejs.Runtime, job func() error) error {
if native.IsNil(m.nextTickTurn) {
func (m *Module) RunTurn(vm *goja.Runtime, job func() error) error {
if m.nextTickTurn == nil {
return job()
}
m.nextTickMu.Lock()
m.nextTickScheduled = true
m.nextTickMu.Unlock()
var jobErr error
jobValue := native.From(vm, native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
jobErr = job()
return moejs.Undefined(), nil
}))
_, turnErr := native.Call(vm, m.nextTickTurn, moejs.Undefined(), jobValue)
jobValue := vm.ToValue(func() { jobErr = job() })
_, turnErr := m.nextTickTurn(goja.Undefined(), jobValue)
return errors.Join(jobErr, turnErr)
}
+118 -126
View File
@@ -3,46 +3,48 @@ package process
import (
_ "embed"
"fmt"
"github.com/Calcium-Ion/moejs"
"github.com/Calcium-Ion/moejs/engine"
"github.com/komari-monitor/komari/pkg/jsruntime/buffer"
"github.com/komari-monitor/komari/pkg/jsruntime/events"
"github.com/komari-monitor/komari/pkg/jsruntime/fs"
"github.com/komari-monitor/komari/pkg/jsruntime/internal/bridge"
"github.com/komari-monitor/komari/pkg/jsruntime/internal/metrics"
"github.com/komari-monitor/komari/pkg/jsruntime/native"
"github.com/komari-monitor/komari/pkg/jsruntime/require"
"github.com/komari-monitor/komari/utils"
"math/big"
"os"
"runtime"
"strings"
"sync"
"time"
"github.com/dop251/goja"
"github.com/dop251/goja_nodejs/buffer"
"github.com/dop251/goja_nodejs/require"
"github.com/komari-monitor/komari/pkg/jsruntime/events"
"github.com/komari-monitor/komari/pkg/jsruntime/fs"
"github.com/komari-monitor/komari/pkg/jsruntime/internal/bridge"
"github.com/komari-monitor/komari/pkg/jsruntime/internal/metrics"
"github.com/komari-monitor/komari/utils"
)
type nodeNextTick struct {
callback moejs.Value
arguments []moejs.Value
callback goja.Callable
arguments []goja.Value
}
type Module struct {
runtime *bridge.Runtime
fs *fs.Module
allowExec bool
startedAt time.Time
reportError func(*moejs.Runtime, []moejs.Value)
runtime *bridge.Runtime
fs *fs.Module
allowExec bool
startedAt time.Time
reportError func(*goja.Runtime, []goja.Value)
nextTickMu sync.Mutex
nextTicks []nodeNextTick
nextTickScheduled bool
nextTickDraining bool
nextTickTurn moejs.Value
nextTickSchedule moejs.Value
nextTickTurn goja.Callable
nextTickSchedule goja.Callable
}
func New(runtime *bridge.Runtime, filesystem *fs.Module, allowExec bool, startedAt time.Time, reportError func(*moejs.Runtime, []moejs.Value)) *Module {
func New(runtime *bridge.Runtime, filesystem *fs.Module, allowExec bool, startedAt time.Time, reportError func(*goja.Runtime, []goja.Value)) *Module {
return &Module{runtime: runtime, fs: filesystem, allowExec: allowExec, startedAt: startedAt, reportError: reportError}
}
func (m *Module) Load(vm *moejs.Runtime, module *engine.Object) {
func (m *Module) Load(vm *goja.Runtime, module *goja.Object) {
process := events.NewEmitter(vm)
environment := make(map[string]string)
for _, item := range os.Environ() {
@@ -51,78 +53,77 @@ func (m *Module) Load(vm *moejs.Runtime, module *engine.Object) {
environment[parts[0]] = parts[1]
}
}
_ = native.Set(vm, process, "env", environment)
_ = native.Set(vm, process, "argv", append([]string{os.Args[0], "script.js"}, os.Args[1:]...))
_ = native.Set(vm, process, "execArgv", []string{})
_ = native.Set(vm, process, "execPath", os.Args[0])
_ = native.Set(vm, process, "pid", os.Getpid())
_ = native.Set(vm, process, "ppid", os.Getppid())
_ = native.Set(vm, process, "platform", metrics.Platform())
_ = native.Set(vm, process, "arch", metrics.Arch())
_ = native.Set(vm, process, "version", "v"+strings.TrimPrefix(runtime.Version(), "go"))
_ = native.Set(vm, process, "versions", map[string]string{"node": "0.0.0-moejs", "go": runtime.Version(), "komari": utils.CurrentVersion, "hash": utils.VersionHash})
_ = native.Set(vm, process, "release", map[string]string{"name": "node", "sourceUrl": "", "headersUrl": ""})
_ = native.Set(vm, process, "title", "komari-jsruntime")
_ = native.Set(vm, process, "exitCode", 0)
_ = native.Set(vm, process, "connected", false)
_ = native.Set(vm, process, "config", map[string]any{})
_ = native.Set(vm, process, "cwd", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return native.From(vm, m.fs.Cwd()), nil
}))
_ = native.Set(vm, process, "chdir", native.Bind(vm, "", 1, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
path := native.String(vm, moejs.Arg(args, 0))
_ = process.Set("env", environment)
_ = process.Set("argv", append([]string{os.Args[0], "script.js"}, os.Args[1:]...))
_ = process.Set("execArgv", []string{})
_ = process.Set("execPath", os.Args[0])
_ = process.Set("pid", os.Getpid())
_ = process.Set("ppid", os.Getppid())
_ = process.Set("platform", metrics.Platform())
_ = process.Set("arch", metrics.Arch())
_ = process.Set("version", "v"+strings.TrimPrefix(runtime.Version(), "go"))
_ = process.Set("versions", map[string]string{"node": "0.0.0-goja", "go": runtime.Version(), "komari": utils.CurrentVersion, "hash": utils.VersionHash})
_ = process.Set("release", map[string]string{"name": "node", "sourceUrl": "", "headersUrl": ""})
_ = process.Set("title", "komari-jsruntime")
_ = process.Set("exitCode", 0)
_ = process.Set("connected", false)
_ = process.Set("config", map[string]any{})
_ = process.Set("cwd", func() string { return m.fs.Cwd() })
_ = process.Set("chdir", func(path string) {
if err := m.fs.Chdir(path); err != nil {
panic(native.GoError(vm, err))
panic(vm.NewGoError(err))
}
return moejs.Undefined(), nil
}))
_ = native.Set(vm, process, "uptime", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return native.From(vm, time.Since(m.startedAt).Seconds()), nil
}))
memoryUsage := native.Object(vm, native.From(vm, native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
})
_ = process.Set("uptime", func() float64 { return time.Since(m.startedAt).Seconds() })
memoryUsage := vm.ToValue(func() map[string]uint64 {
var memory runtime.MemStats
runtime.ReadMemStats(&memory)
usage, err := metrics.ReadProcess()
if err != nil {
panic(native.GoError(vm, err))
panic(vm.NewGoError(err))
}
return native.From(vm, map[string]uint64{"rss": usage.RSS, "heapTotal": memory.HeapSys, "heapUsed": memory.HeapAlloc, "external": memory.OtherSys, "arrayBuffers": 0}), nil
})))
_ = native.Set(vm, memoryUsage, "rss", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return map[string]uint64{"rss": usage.RSS, "heapTotal": memory.HeapSys, "heapUsed": memory.HeapAlloc, "external": memory.OtherSys, "arrayBuffers": 0}
}).ToObject(vm)
_ = memoryUsage.Set("rss", func() uint64 {
usage, err := metrics.ReadProcess()
if err != nil {
panic(native.GoError(vm, err))
panic(vm.NewGoError(err))
}
return native.From(vm, usage.RSS), nil
}))
_ = native.Set(vm, process, "memoryUsage", memoryUsage)
_ = native.Set(vm, process, "cpuUsage", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return usage.RSS
})
_ = process.Set("memoryUsage", memoryUsage)
_ = process.Set("cpuUsage", func(call goja.FunctionCall) goja.Value {
usage, err := metrics.ReadProcess()
if err != nil {
panic(native.GoError(vm, err))
panic(vm.NewGoError(err))
}
userMicros, systemMicros := usage.UserCPU.Microseconds(), usage.SystemCPU.Microseconds()
if previous := moejs.Arg(args, 0); !previous.IsUndefined() && !previous.IsNull() {
object := native.Object(vm, previous)
userMicros -= native.Int(vm, native.Get(vm, object, "user"))
systemMicros -= native.Int(vm, native.Get(vm, object, "system"))
if previous := call.Argument(0); !goja.IsUndefined(previous) && !goja.IsNull(previous) {
object := previous.ToObject(vm)
userMicros -= object.Get("user").ToInteger()
systemMicros -= object.Get("system").ToInteger()
}
return native.From(vm, map[string]int64{"user": max(userMicros, 0), "system": max(systemMicros, 0)}), nil
}))
_ = native.Set(vm, process, "resourceUsage", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return vm.ToValue(map[string]int64{"user": max(userMicros, 0), "system": max(systemMicros, 0)})
})
_ = process.Set("resourceUsage", func() map[string]any {
usage, err := metrics.ReadProcess()
if err != nil {
panic(native.GoError(vm, err))
panic(vm.NewGoError(err))
}
return native.From(vm, map[string]any{"userCPUTime": usage.UserCPU.Microseconds(), "systemCPUTime": usage.SystemCPU.Microseconds(), "maxRSS": usage.MaxRSS, "minorPageFault": usage.MinorPageFault, "majorPageFault": usage.MajorPageFault, "fsRead": usage.FSRead, "fsWrite": usage.FSWrite, "voluntaryContextSwitches": usage.VoluntarySwitches, "involuntaryContextSwitches": usage.InvoluntarySwitches}), nil
}))
hrtime := native.Object(vm, native.From(vm, native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return map[string]any{
"userCPUTime": usage.UserCPU.Microseconds(), "systemCPUTime": usage.SystemCPU.Microseconds(), "maxRSS": usage.MaxRSS,
"minorPageFault": usage.MinorPageFault, "majorPageFault": usage.MajorPageFault,
"fsRead": usage.FSRead, "fsWrite": usage.FSWrite,
"voluntaryContextSwitches": usage.VoluntarySwitches, "involuntaryContextSwitches": usage.InvoluntarySwitches,
}
})
hrtime := vm.ToValue(func(call goja.FunctionCall) goja.Value {
now := time.Since(m.startedAt)
seconds := int64(now / time.Second)
nanoseconds := int64(now % time.Second)
if previous := moejs.Arg(args, 0); !previous.IsUndefined() {
if previous := call.Argument(0); !goja.IsUndefined(previous) {
var values []int64
if err := native.ExportTo(vm, previous, &values); err == nil && len(values) >= 2 {
if err := vm.ExportTo(previous, &values); err == nil && len(values) >= 2 {
seconds -= values[0]
nanoseconds -= values[1]
if nanoseconds < 0 {
@@ -131,36 +132,32 @@ func (m *Module) Load(vm *moejs.Runtime, module *engine.Object) {
}
}
}
return native.From(vm, []int64{seconds, nanoseconds}), nil
})))
_ = native.Set(vm, hrtime, "bigint", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return native.From(vm, big.NewInt(time.Since(m.startedAt).Nanoseconds())), nil
}))
_ = native.Set(vm, process, "hrtime", hrtime)
_ = native.Set(vm, process, "nextTick", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
callback, ok := checkedFunction(moejs.Arg(args, 0))
return vm.ToValue([]int64{seconds, nanoseconds})
}).ToObject(vm)
_ = hrtime.Set("bigint", func() *big.Int { return big.NewInt(time.Since(m.startedAt).Nanoseconds()) })
_ = process.Set("hrtime", hrtime)
_ = process.Set("nextTick", func(call goja.FunctionCall) goja.Value {
callback, ok := goja.AssertFunction(call.Argument(0))
if !ok {
panic(native.TypeError(vm, "callback must be a function"))
panic(vm.NewTypeError("callback must be a function"))
}
arguments := append([]moejs.Value(nil), args[1:]...)
arguments := append([]goja.Value(nil), call.Arguments[1:]...)
m.enqueueNextTick(vm, nodeNextTick{callback: callback, arguments: arguments})
return native.From(vm, moejs.Undefined()), nil
}))
_ = native.Set(vm, process, "emitWarning", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
return goja.Undefined()
})
_ = process.Set("emitWarning", func(call goja.FunctionCall) goja.Value {
if m.reportError != nil {
m.reportError(vm, []moejs.Value{native.From(vm, "Warning: "+native.String(vm, moejs.Arg(args, 0)))})
m.reportError(vm, []goja.Value{vm.ToValue("Warning: " + call.Argument(0).String())})
}
return native.From(vm, moejs.Undefined()), nil
}))
_ = native.Set(vm, process, "kill", native.Bind(vm, "", 2, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
pid := int(native.Int(vm, moejs.Arg(args, 0)))
signal := native.String(vm, moejs.Arg(args, 1))
return goja.Undefined()
})
_ = process.Set("kill", func(pid int, signal string) bool {
if !m.allowExec {
panic(native.GoError(vm, fmt.Errorf("process.kill requires AllowExec")))
panic(vm.NewGoError(fmt.Errorf("process.kill requires AllowExec")))
}
process, err := os.FindProcess(pid)
if err != nil {
panic(native.GoError(vm, err))
panic(vm.NewGoError(err))
}
if signal == "SIGKILL" {
err = process.Kill()
@@ -168,39 +165,36 @@ func (m *Module) Load(vm *moejs.Runtime, module *engine.Object) {
err = process.Signal(os.Interrupt)
}
if err != nil {
panic(native.GoError(vm, err))
panic(vm.NewGoError(err))
}
return native.From(vm, true), nil
}))
_ = native.Set(vm, process, "exit", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
code := native.Int(vm, moejs.Arg(args, 0))
_ = native.Set(vm, process, "exitCode", code)
panic(native.GoError(vm, &bridge.ExitRequestedError{Code: code}))
}))
_ = native.Set(vm, process, "abort", native.Bind(vm, "", 0, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
panic(native.GoError(vm, fmt.Errorf("process.abort() requested")))
}))
return true
})
_ = process.Set("exit", func(call goja.FunctionCall) goja.Value {
code := call.Argument(0).ToInteger()
_ = process.Set("exitCode", code)
panic(vm.NewGoError(&bridge.ExitRequestedError{Code: code}))
})
_ = process.Set("abort", func() { panic(vm.NewGoError(fmt.Errorf("process.abort() requested"))) })
m.attachProcessStreams(vm, process)
if err := m.initNextTickScheduler(vm); err != nil {
panic(native.GoError(vm, err))
panic(vm.NewGoError(err))
}
_ = native.Set(vm, module, "exports", process)
_ = module.Set("exports", process)
}
//go:embed stdio.js
var stdioSource string
func (m *Module) attachProcessStreams(vm *moejs.Runtime, process *engine.Object) {
factoryValue, err := native.Run(vm, stdioSource)
// attachProcessStreams wires process.stdout/stderr as Writable streams and
// process.stdin as a Readable stream backed by the jsruntime stream module.
func (m *Module) attachProcessStreams(vm *goja.Runtime, process *goja.Object) {
factoryValue, err := vm.RunString(stdioSource)
if err != nil {
panic(native.GoError(vm, fmt.Errorf("load process stdio: %w", err)))
panic(vm.NewGoError(fmt.Errorf("load process stdio: %w", err)))
}
factory, _ := checkedFunction(factoryValue)
hooks := vm.Realm().NewObject()
_ = native.Set(vm, hooks, "write", native.Bind(vm, "", 3, func(_ *moejs.Realm, this moejs.Value, args []moejs.Value) (moejs.Value, error) {
name := native.String(vm, moejs.Arg(args, 0))
chunk := moejs.Arg(args, 1)
callback := moejs.Arg(args, 2)
factory, _ := goja.AssertFunction(factoryValue)
hooks := vm.NewObject()
_ = hooks.Set("write", func(name string, chunk goja.Value, callback goja.Callable) {
var output *os.File
switch name {
case "stdout":
@@ -208,24 +202,22 @@ func (m *Module) attachProcessStreams(vm *moejs.Runtime, process *engine.Object)
case "stderr":
output = os.Stderr
default:
panic(native.GoError(vm, fmt.Errorf("process.%s.write is not supported by jsruntime; the stream is not connected", name)))
panic(vm.NewGoError(fmt.Errorf("process.%s.write is not supported by jsruntime; the stream is not connected", name)))
}
_, _ = output.Write(buffer.Bytes(vm, chunk))
if !native.IsNil(callback) {
_, _ = native.Call(vm, callback, moejs.Undefined())
if callback != nil {
_, _ = callback(goja.Undefined())
}
return moejs.Undefined(), nil
}))
streams, err := native.Call(vm, factory, moejs.Undefined(), require.Require(vm, "stream"), engine.ObjectValue(hooks))
})
streams, err := factory(goja.Undefined(), require.Require(vm, "stream"), hooks)
if err != nil {
panic(err)
}
streamsObject := native.Object(vm, streams)
streamsObject := streams.ToObject(vm)
for _, name := range []string{"stdout", "stderr", "stdin"} {
stream := native.Object(vm, native.Get(vm, streamsObject, name))
_ = native.Set(vm, stream, "isTTY", false)
_ = native.Set(vm, stream, "fd", -1)
_ = native.Set(vm, process, name, stream)
stream := streamsObject.Get(name).ToObject(vm)
_ = stream.Set("isTTY", false)
_ = stream.Set("fd", -1)
_ = process.Set(name, stream)
}
}
func checkedFunction(v moejs.Value) (moejs.Value, bool) { return v, engine.IsCallable(v) }
Loaded 100 of 240 files, more files were not shown because too many files have changed in this diff. Show more