feat(extension): Enhanced HTTP API for YouTube Music support

- Add http.put(), http.delete(), http.patch() shortcut methods
- Add persistent cookie jar per extension
- Add http.clearCookies() to clear session
- Fix User-Agent header respect (no longer overwritten)
- Return multi-value headers as arrays (Set-Cookie support)
- Auto-stringify objects in POST/PUT/PATCH body
- Add response.ok and response.status properties
- Update documentation with YouTube Music example
This commit is contained in:
zarzet
2026-01-12 06:37:18 +07:00
parent 4c09b988e4
commit 35532b0c73
5 changed files with 407 additions and 21 deletions
+45
View File
@@ -1,5 +1,50 @@
# Changelog
## [3.0.0-alpha.2] - 2026-01-12
### Added
- **Full HTTP Method Support**: New shortcut methods for all common HTTP verbs
- `http.put(url, body, headers)` - PUT requests
- `http.delete(url, headers)` - DELETE requests
- `http.patch(url, body, headers)` - PATCH requests
- `http.clearCookies()` - Clear all cookies for the extension
- **Persistent Cookie Jar**: Each extension now has its own cookie jar
- Cookies automatically stored from `Set-Cookie` headers
- Cookies automatically sent with subsequent requests to same domain
- Useful for APIs requiring session cookies (YouTube, etc.)
- **Multi-Value Header Support**: Response headers now return arrays for multi-value headers
- `Set-Cookie` and other headers with multiple values returned as arrays
- Single-value headers still returned as strings for convenience
- **Generic HTTP Request Method**: New `http.request()` for full HTTP control
- Supports all HTTP methods (GET, POST, PUT, DELETE, PATCH, etc.)
- Single options object for cleaner API: `http.request(url, { method, body, headers })`
- **Response Helper Properties**: HTTP responses now include convenience properties
- `response.ok` - true if status code is 2xx
- `response.status` - alias for `statusCode`
### Fixed
- **User-Agent Header Respect**: Custom `User-Agent` headers are now respected
- Previously, extension-provided User-Agent was overwritten
- Now only sets default User-Agent if extension doesn't provide one
- **HTTP POST Body Auto-Stringify**: `http.post()` now automatically stringifies objects to JSON
- Previously, passing an object as body resulted in `[object Object]`
- Now objects and arrays are automatically JSON.stringify'd
- String bodies still work as before (no double-encoding)
### Documentation
- Updated `docs/EXTENSION_DEVELOPMENT.md`:
- Added complete HTTP API documentation with all methods
- Added Cookie Jar documentation
- Added `http.put()`, `http.delete()`, `http.patch()`, `http.clearCookies()` docs
- Added YouTube Music / Innertube API example with custom User-Agent
- Added common domain lists for YouTube, SoundCloud, Bandcamp
- Improved HTTP API documentation with response properties
---
## [3.0.0-alpha.1] - 2026-01-11
#### Extension System
+358 -17
View File
@@ -101,24 +101,57 @@ type ExtensionRuntime struct {
manifest *ExtensionManifest
settings map[string]interface{}
httpClient *http.Client
cookieJar http.CookieJar
dataDir string
vm *goja.Runtime
}
// NewExtensionRuntime creates a new runtime for an extension
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
// Create a cookie jar for this extension
jar, _ := newSimpleCookieJar()
client := &http.Client{
Timeout: 30 * time.Second,
Jar: jar,
}
return &ExtensionRuntime{
extensionID: ext.ID,
manifest: ext.Manifest,
settings: make(map[string]interface{}),
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
dataDir: ext.DataDir,
vm: ext.VM,
httpClient: client,
cookieJar: jar,
dataDir: ext.DataDir,
vm: ext.VM,
}
}
// simpleCookieJar is a simple in-memory cookie jar
type simpleCookieJar struct {
cookies map[string][]*http.Cookie
mu sync.RWMutex
}
func newSimpleCookieJar() (*simpleCookieJar, error) {
return &simpleCookieJar{
cookies: make(map[string][]*http.Cookie),
}, nil
}
func (j *simpleCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) {
j.mu.Lock()
defer j.mu.Unlock()
key := u.Host
j.cookies[key] = append(j.cookies[key], cookies...)
}
func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
j.mu.RLock()
defer j.mu.RUnlock()
return j.cookies[u.Host]
}
// SetSettings updates the runtime settings
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
r.settings = settings
@@ -132,6 +165,11 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
httpObj := vm.NewObject()
httpObj.Set("get", r.httpGet)
httpObj.Set("post", r.httpPost)
httpObj.Set("put", r.httpPut)
httpObj.Set("delete", r.httpDelete)
httpObj.Set("patch", r.httpPatch)
httpObj.Set("request", r.httpRequest) // Generic HTTP request (GET, POST, PUT, DELETE, etc.)
httpObj.Set("clearCookies", r.httpClearCookies)
vm.Set("http", httpObj)
// Storage API
@@ -274,11 +312,14 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
})
}
// Set headers
// Set headers - user headers first
for k, v := range headers {
req.Header.Set(k, v)
}
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
// Only set default User-Agent if not provided by extension
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
}
// Execute request
resp, err := r.httpClient.Do(req)
@@ -297,16 +338,20 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
})
}
// Extract response headers
respHeaders := make(map[string]string)
// Extract response headers - return all values as arrays for multi-value headers (cookies, etc.)
respHeaders := make(map[string]interface{})
for k, v := range resp.Header {
if len(v) > 0 {
if len(v) == 1 {
respHeaders[k] = v[0]
} else {
respHeaders[k] = v // Return as array if multiple values
}
}
return r.vm.ToValue(map[string]interface{}{
"statusCode": resp.StatusCode,
"status": resp.StatusCode, // Alias for convenience
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"body": string(body),
"headers": respHeaders,
})
@@ -330,10 +375,26 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
})
}
// Get body if provided
// Get body if provided - support both string and object
var bodyStr string
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
bodyStr = call.Arguments[1].String()
bodyArg := call.Arguments[1].Export()
switch v := bodyArg.(type) {
case string:
bodyStr = v
case map[string]interface{}, []interface{}:
// Auto-stringify objects and arrays to JSON
jsonBytes, err := json.Marshal(v)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": fmt.Sprintf("failed to stringify body: %v", err),
})
}
bodyStr = string(jsonBytes)
default:
// Fallback to string conversion
bodyStr = call.Arguments[1].String()
}
}
// Get headers if provided
@@ -355,11 +416,14 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
})
}
// Set headers
// Set headers - user headers first
for k, v := range headers {
req.Header.Set(k, v)
}
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
// Only set defaults if not provided by extension
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
}
if req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
@@ -381,21 +445,298 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
})
}
// Extract response headers
respHeaders := make(map[string]string)
// Extract response headers - return all values as arrays for multi-value headers
respHeaders := make(map[string]interface{})
for k, v := range resp.Header {
if len(v) > 0 {
if len(v) == 1 {
respHeaders[k] = v[0]
} else {
respHeaders[k] = v // Return as array if multiple values
}
}
return r.vm.ToValue(map[string]interface{}{
"statusCode": resp.StatusCode,
"status": resp.StatusCode, // Alias for convenience
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"body": string(body),
"headers": respHeaders,
})
}
// httpRequest performs a generic HTTP request (GET, POST, PUT, DELETE, etc.)
// Usage: http.request(url, options) where options = { method, body, headers }
func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"error": "URL is required",
})
}
urlStr := call.Arguments[0].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
// Default options
method := "GET"
var bodyStr string
headers := make(map[string]string)
// Parse options if provided
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
optionsObj := call.Arguments[1].Export()
if opts, ok := optionsObj.(map[string]interface{}); ok {
// Get method
if m, ok := opts["method"].(string); ok {
method = strings.ToUpper(m)
}
// Get body - support both string and object
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
switch v := bodyArg.(type) {
case string:
bodyStr = v
case map[string]interface{}, []interface{}:
// Auto-stringify objects and arrays to JSON
jsonBytes, err := json.Marshal(v)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": fmt.Sprintf("failed to stringify body: %v", err),
})
}
bodyStr = string(jsonBytes)
default:
bodyStr = fmt.Sprintf("%v", v)
}
}
// Get headers
if h, ok := opts["headers"].(map[string]interface{}); ok {
for k, v := range h {
headers[k] = fmt.Sprintf("%v", v)
}
}
}
}
// Create request
var reqBody io.Reader
if bodyStr != "" {
reqBody = strings.NewReader(bodyStr)
}
req, err := http.NewRequest(method, urlStr, reqBody)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
// Set headers - user headers first
for k, v := range headers {
req.Header.Set(k, v)
}
// Only set defaults if not provided by extension
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
}
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
// Execute request
resp, err := r.httpClient.Do(req)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
defer resp.Body.Close()
// Read body
body, err := io.ReadAll(resp.Body)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
// Extract response headers - return all values as arrays for multi-value headers
respHeaders := make(map[string]interface{})
for k, v := range resp.Header {
if len(v) == 1 {
respHeaders[k] = v[0]
} else {
respHeaders[k] = v // Return as array if multiple values
}
}
// Return response with helper properties
return r.vm.ToValue(map[string]interface{}{
"statusCode": resp.StatusCode,
"status": resp.StatusCode, // Alias for convenience
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"body": string(body),
"headers": respHeaders,
})
}
// httpPut performs a PUT request (shortcut for http.request with method: "PUT")
func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("PUT", call)
}
// httpDelete performs a DELETE request (shortcut for http.request with method: "DELETE")
func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("DELETE", call)
}
// httpPatch performs a PATCH request (shortcut for http.request with method: "PATCH")
func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("PATCH", call)
}
// httpMethodShortcut is a helper for PUT/DELETE/PATCH shortcuts
// Signature: http.put(url, body, headers) / http.delete(url, headers) / http.patch(url, body, headers)
func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"error": "URL is required",
})
}
urlStr := call.Arguments[0].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
var bodyStr string
headers := make(map[string]string)
// For DELETE, second arg is headers; for PUT/PATCH, second arg is body
if method == "DELETE" {
// http.delete(url, headers)
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
headersObj := call.Arguments[1].Export()
if h, ok := headersObj.(map[string]interface{}); ok {
for k, v := range h {
headers[k] = fmt.Sprintf("%v", v)
}
}
}
} else {
// http.put(url, body, headers) / http.patch(url, body, headers)
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
bodyArg := call.Arguments[1].Export()
switch v := bodyArg.(type) {
case string:
bodyStr = v
case map[string]interface{}, []interface{}:
jsonBytes, err := json.Marshal(v)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": fmt.Sprintf("failed to stringify body: %v", err),
})
}
bodyStr = string(jsonBytes)
default:
bodyStr = call.Arguments[1].String()
}
}
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
headersObj := call.Arguments[2].Export()
if h, ok := headersObj.(map[string]interface{}); ok {
for k, v := range h {
headers[k] = fmt.Sprintf("%v", v)
}
}
}
}
// Create request
var reqBody io.Reader
if bodyStr != "" {
reqBody = strings.NewReader(bodyStr)
}
req, err := http.NewRequest(method, urlStr, reqBody)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
// Set headers - user headers first
for k, v := range headers {
req.Header.Set(k, v)
}
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
}
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
// Execute request
resp, err := r.httpClient.Do(req)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
defer resp.Body.Close()
// Read body
body, err := io.ReadAll(resp.Body)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
// Extract response headers
respHeaders := make(map[string]interface{})
for k, v := range resp.Header {
if len(v) == 1 {
respHeaders[k] = v[0]
} else {
respHeaders[k] = v
}
}
return r.vm.ToValue(map[string]interface{}{
"statusCode": resp.StatusCode,
"status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"body": string(body),
"headers": respHeaders,
})
}
// httpClearCookies clears all cookies for this extension
func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
jar.mu.Lock()
jar.cookies = make(map[string][]*http.Cookie)
jar.mu.Unlock()
GoLog("[Extension:%s] Cookies cleared\n", r.extensionID)
return r.vm.ToValue(true)
}
return r.vm.ToValue(false)
}
// ==================== File API (Sandboxed) ====================
// validatePath checks if the path is within the extension's data directory
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '3.0.0-alpha.1';
static const String buildNumber = '50';
static const String version = '3.0.0-alpha.2';
static const String buildNumber = '51';
static const String fullVersion = '$version+$buildNumber';
+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none"
version: 3.0.0-alpha.1+50
version: 3.0.0-alpha.2+51
environment:
sdk: ^3.10.0
+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none"
version: 3.0.0-alpha.1+50
version: 3.0.0-alpha.2+51
environment:
sdk: ^3.10.0