/
t3
/
cli
Обзор
Документация
Войти
/
t3
/
cli
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
master
internal/engine/bridge/s3.go
292 строки
10 KB
Ivan
move s3 to the root
09 июл 2026, 13:51
09 июл 2026, 13:51
c214eff
Код
Авторство
О чём код?
package bridge import ( "context" "fmt" "log/slog" "github.com/dop251/goja" ) // NewS3 инжектирует t3.s3 в goja runtime. // Если cache == nil, все операции с S3 будут кидать исключения с сообщением // "s3 is not available" (для setup-фазы на контроллере). func NewS3(vm *goja.Runtime, cache *S3Cache, s3Config *S3Config, logger *slog.Logger, ctx context.Context) { t3Val := vm.Get("t3") var t3Obj *goja.Object if t3Val == nil || goja.IsUndefined(t3Val) { t3Obj = vm.NewObject() vm.Set("t3", t3Obj) } else { t3Obj = t3Val.ToObject(vm) } s3Obj := vm.NewObject() // download(url, config) — скачивает или достаёт из кэша файл с S3 s3Obj.Set("download", makeDownloadFunc(vm, cache, s3Config, logger, ctx)) // lines(file) — разбивает содержимое File на массив строк s3Obj.Set("lines", func(call goja.FunctionCall) goja.Value { fileObj := call.Argument(0) if fileObj == nil || goja.IsUndefined(fileObj) { panic(vm.ToValue("s3.lines(): file argument is required")) } cachedFile := getCachedFile(vm, fileObj) return vm.ToValue(cachedFile.Lines) }) // lineCount(file) — количество строк в File s3Obj.Set("lineCount", func(call goja.FunctionCall) goja.Value { fileObj := call.Argument(0) if fileObj == nil || goja.IsUndefined(fileObj) { panic(vm.ToValue("s3.lineCount(): file argument is required")) } cachedFile := getCachedFile(vm, fileObj) return vm.ToValue(len(cachedFile.Lines)) }) // readLine(file, n) — строка №n (1-based) из File s3Obj.Set("readLine", func(call goja.FunctionCall) goja.Value { fileObj := call.Argument(0) if fileObj == nil || goja.IsUndefined(fileObj) { panic(vm.ToValue("s3.readLine(): file argument is required")) } nArg := call.Argument(1) if nArg == nil || goja.IsUndefined(nArg) { panic(vm.ToValue("s3.readLine(): n argument is required")) } cachedFile := getCachedFile(vm, fileObj) n := int(nArg.ToInteger()) if n < 1 || n > len(cachedFile.Lines) { panic(vm.ToValue( fmt.Sprintf("s3.readLine(%d): line number out of range. File has %d lines (1-based)", n, len(cachedFile.Lines)), )) } return vm.ToValue(cachedFile.Lines[n-1]) }) t3Obj.Set("s3", s3Obj) } // NewGlobalS3 инжектирует глобальный объект s3 (без префикса t3.) // в goja runtime. Все методы — алиасы t3.s3.*. func NewGlobalS3(vm *goja.Runtime, cache *S3Cache, s3Config *S3Config, logger *slog.Logger, ctx context.Context) { s3Obj := vm.NewObject() // download(url, config) — скачивает или достаёт из кэша файл с S3 s3Obj.Set("download", makeDownloadFunc(vm, cache, s3Config, logger, ctx)) // lines(file) — разбивает содержимое File на массив строк s3Obj.Set("lines", func(call goja.FunctionCall) goja.Value { fileObj := call.Argument(0) if fileObj == nil || goja.IsUndefined(fileObj) { panic(vm.ToValue("s3.lines(): file argument is required")) } cachedFile := getCachedFile(vm, fileObj) return vm.ToValue(cachedFile.Lines) }) // lineCount(file) — количество строк в File s3Obj.Set("lineCount", func(call goja.FunctionCall) goja.Value { fileObj := call.Argument(0) if fileObj == nil || goja.IsUndefined(fileObj) { panic(vm.ToValue("s3.lineCount(): file argument is required")) } cachedFile := getCachedFile(vm, fileObj) return vm.ToValue(len(cachedFile.Lines)) }) // readLine(file, n) — строка №n (1-based) из File s3Obj.Set("readLine", func(call goja.FunctionCall) goja.Value { fileObj := call.Argument(0) if fileObj == nil || goja.IsUndefined(fileObj) { panic(vm.ToValue("s3.readLine(): file argument is required")) } nArg := call.Argument(1) if nArg == nil || goja.IsUndefined(nArg) { panic(vm.ToValue("s3.readLine(): n argument is required")) } cachedFile := getCachedFile(vm, fileObj) n := int(nArg.ToInteger()) if n < 1 || n > len(cachedFile.Lines) { panic(vm.ToValue( fmt.Sprintf("s3.readLine(%d): line number out of range. File has %d lines (1-based)", n, len(cachedFile.Lines)), )) } return vm.ToValue(cachedFile.Lines[n-1]) }) vm.Set("s3", s3Obj) } // makeDownloadFunc создаёт замыкание для s3.download() с поддержкой per-call overrides. // Второй аргумент может быть объектом с полями S3Config для переопределения. func makeDownloadFunc(vm *goja.Runtime, cache *S3Cache, s3Config *S3Config, logger *slog.Logger, ctx context.Context) func(goja.FunctionCall) goja.Value { return func(call goja.FunctionCall) goja.Value { urlArg := call.Argument(0) if urlArg == nil || goja.IsUndefined(urlArg) || urlArg.String() == "" { panic(vm.ToValue("s3.download(): url argument is required")) } if cache == nil { panic(vm.ToValue("s3 is not available in this context")) } url := urlArg.String() if logger != nil { logger.Debug("s3.download called", "url", url) } // Per-call overrides: второй аргумент — объект с partial S3Config effectiveCfg := s3Config optsArg := call.Argument(1) if optsArg != nil && !goja.IsUndefined(optsArg) && optsArg != goja.Null() { // Пробуем распарсить как объект конфига. // Если optsArg — строка или не объект, mergeS3Config вернёт base без изменений effectiveCfg = mergeS3Config(vm, s3Config, optsArg) } cachedFile, err := cache.Get(ctx, url, effectiveCfg) if err != nil { panic(vm.ToValue(err.Error())) } return newFileObject(vm, cachedFile) } } // mergeS3Config создаёт новый S3Config на основе пул-левел конфига с переопределением // полей из JS-объекта opts. func mergeS3Config(vm *goja.Runtime, base *S3Config, opts goja.Value) *S3Config { if base == nil { base = &S3Config{ Region: "ru-west-1", } } cfg := &S3Config{ Endpoint: base.Endpoint, AccessKey: base.AccessKey, SecretKey: base.SecretKey, Region: base.Region, UseSSL: base.UseSSL, InsecureSkipVerify: base.InsecureSkipVerify, BasePath: base.BasePath, } optsObj := opts.ToObject(vm) for _, key := range optsObj.Keys() { val := optsObj.Get(key) if val == nil || goja.IsUndefined(val) { continue } switch key { case "endpoint": cfg.Endpoint = val.String() case "accessKey": cfg.AccessKey = val.String() case "secretKey": cfg.SecretKey = val.String() case "region": cfg.Region = val.String() case "useSSL": cfg.UseSSL = val.ToBoolean() case "insecureSkipVerify": cfg.InsecureSkipVerify = val.ToBoolean() case "basePath": cfg.BasePath = val.String() } } return cfg } // newFileObject создаёт универсальный JS-объект File из CachedFile. // File имеет API по аналогии с fetch Response: // // file.name — имя файла из URL // file.size — размер в байтах // file.mimeType — MIME-тип (если известен, иначе "") // file.text() — содержимое как строка (zero-copy) // file.json() — содержимое, распарсенное как JSON (deep-copy per call) // file.arrayBuffer() — содержимое как ArrayBuffer (копия — обязательное требование goja) // // Все VUsers разделяют один CachedFile. Мутации из JS не затрагивают оригинал: // - text() возвращает иммутабельную Go-строку — zero-copy // - json() делает deep-copy перед обёрткой в goja-объект // - arrayBuffer() копирует в ArrayBuffer (требование goja) func newFileObject(vm *goja.Runtime, file *CachedFile) *goja.Object { obj := vm.NewObject() // Поля (доступны только для чтения через замыкание) obj.Set("name", file.FileName) obj.Set("size", len(file.RawBytes)) obj.Set("mimeType", "") // MIME-тип не определяется автоматически // text() — возвращает содержимое как строку (zero-copy) obj.Set("text", func(goja.FunctionCall) goja.Value { return vm.ToValue(file.Content) }) // json() — парсит содержимое как JSON. // Возвращает deep-copy, чтобы мутации в JS не испортили общий CachedFile. obj.Set("json", func(goja.FunctionCall) goja.Value { if file.ParsedJSON == nil { panic(vm.ToValue("file.json(): content is not valid JSON")) } return vm.ToValue(deepCopyJSON(file.ParsedJSON)) }) // arrayBuffer() — возвращает содержимое как ArrayBuffer. // Goja всегда копирует []byte в ArrayBuffer (требование рантайма). obj.Set("arrayBuffer", func(goja.FunctionCall) goja.Value { return vm.ToValue(vm.NewArrayBuffer(file.RawBytes)) }) // Сохраняем ссылку на CachedFile для getCachedFile() obj.Set("_cachedFile", vm.ToValue(file)) return obj } // getCachedFile извлекает *CachedFile из внутреннего хранилища JS-объекта File. // Мы храним ссылку на CachedFile в приватном символе _cachedFile. func getCachedFile(vm *goja.Runtime, fileObj goja.Value) *CachedFile { obj := fileObj.ToObject(vm) cachedFile := obj.Get("_cachedFile") if cachedFile == nil || goja.IsUndefined(cachedFile) { panic(vm.ToValue("file is not a valid File object created by t3.s3.download()")) } return cachedFile.Export().(*CachedFile) } // deepCopyJSON делает глубокую копию произвольной JSON-структуры. // Нужна для защиты разделяемого CachedFile от мутаций через file.json(). func deepCopyJSON(v interface{}) interface{} { switch val := v.(type) { case map[string]interface{}: newMap := make(map[string]interface{}, len(val)) for k, v := range val { newMap[k] = deepCopyJSON(v) } return newMap case []interface{}: newSlice := make([]interface{}, len(val)) for i, v := range val { newSlice[i] = deepCopyJSON(v) } return newSlice case string: return val case float64: return val case bool: return val case nil: return nil default: return val } }