fix(extensions): restore URL globals for provider link resolution

This commit is contained in:
zarzet committed 2026-09-15 21:44:11 +07:00
1 parent aa99726439
commit eb717a6e7b
6 files changed
+429 -1

No files matched your search

@@ -17,6 +17,7 @@ pub(crate) fn register<'js>(
services: &ExtensionServices,
) -> rquickjs::Result<Object<'js>> {
let host = Object::new(ctx.clone())?;
crate::url_host::register(ctx, &host)?;
crate::utility_host::register(ctx, &host, Arc::clone(&control), services)?;
crate::legacy_host::register(ctx, &host, Arc::clone(&control), services)?;
let item_control = Arc::clone(&control);
@@ -30,6 +30,7 @@ mod session_host;
pub mod signed_session;
pub mod storage;
pub mod transfer_policy;
mod url_host;
mod utility_host;
pub use runtime::{ExtensionError, ExtensionRuntime, ExtensionServices, RuntimeLimits};
@@ -67,6 +67,46 @@
}
return goString(value);
}
function queryMethods(values, mutable) {
const result = {};
for (const method of mutable ? ["append", "delete", "get", "getAll", "has", "set"] : ["get", "getAll", "has"]) {
if (method === "append" || method === "set" || method === "delete") {
result[method] = function(key, value) {
if (arguments.length >= (method === "delete" ? 1 : 2)) {
values.write(method, goString(key), method === "delete" ? "" : goString(value));
}
};
} else {
result[method] = function(key) {
if (!arguments.length) return method === "getAll" ? [] : method === "has" ? false : null;
return values.read(method, goString(key));
};
}
}
result.toString = function() { return values.encode(); };
return result;
}
globalThis.URL = function URL(input, base) {
if (!new.target) throw new TypeError("URL requires new");
if (!arguments.length) { this.href = ""; return; }
const parsed = host.parseURL(goString(input), base === undefined ? undefined : goString(base));
Object.assign(this, parsed);
if (parsed.searchParams) {
this.searchParams = queryMethods(parsed.searchParams, false);
this.toString = this.toJSON = function() { return parsed.href; };
}
};
globalThis.URLSearchParams = function URLSearchParams(init) {
if (!new.target) throw new TypeError("URLSearchParams requires new");
const values = host.parseQuery(typeof init === "string" ? goString(init).replace(/^\?/, "") : "");
const boxed = [boxedStringValue, boxedNumberValue, boxedBooleanValue].some(valueOf => {
try { stringApply(valueOf, init, []); return true; } catch (_) { return false; }
});
if (isMap(init) && !boxed) {
for (const key of Object.keys(init)) values.write("set", key.toWellFormed(), formatGo(init[key]));
}
Object.assign(this, queryMethods(values, true));
};
const byteArrays = new WeakSet();
const responseByteArrays = new WeakMap();
function emptyBytes() {
@@ -0,0 +1,245 @@
use crate::host::decode_go_utf8;
use rquickjs::{Ctx, Function, IntoJs, Object, Value};
use spotiflac_network::{
query,
url::{UrlParts, unescape_path},
};
use std::{cell::RefCell, rc::Rc};
pub(crate) fn register<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> {
host.set("parseURL", Function::new(ctx.clone(), parse_url)?)?;
host.set("parseQuery", Function::new(ctx.clone(), query_object)?)?;
Ok(())
}
fn parse_url<'js>(
ctx: Ctx<'js>,
input: String,
base: Option<String>,
) -> rquickjs::Result<Object<'js>> {
let object = Object::new(ctx.clone())?;
let parsed = base
.as_deref()
.and_then(UrlParts::parse)
.and_then(|base| base.resolve_reference(&input))
.or_else(|| UrlParts::parse(&input));
let Some(parsed) = parsed else {
object.set("href", input)?;
return Ok(object);
};
object.set("href", parsed.reference_string())?;
object.set("protocol", format!("{}:", parsed.scheme))?;
object.set("host", decode_go_utf8(&parsed.host))?;
let host_end = parsed.host.len() - parsed.port.as_ref().map_or(0, |port| port.len() + 1);
let hostname = &parsed.host[..host_end];
let hostname = hostname
.strip_prefix(b"[")
.and_then(|host| host.strip_suffix(b"]"))
.unwrap_or(hostname);
object.set("hostname", decode_go_utf8(hostname))?;
object.set("port", parsed.port.as_deref().unwrap_or_default())?;
object.set(
"pathname",
if parsed.raw_path.is_empty() {
String::new()
} else {
decode_go_utf8(&parsed.path)
},
)?;
object.set(
"search",
if parsed.raw_query.is_empty() {
String::new()
} else {
format!("?{}", parsed.raw_query)
},
)?;
let fragment = decode_go_utf8(&unescape_path(&parsed.fragment).unwrap_or_default());
object.set(
"hash",
if fragment.is_empty() {
String::new()
} else {
format!("#{fragment}")
},
)?;
object.set(
"origin",
format!("{}://{}", parsed.scheme, decode_go_utf8(&parsed.host)),
)?;
object.set("username", decode_go_utf8(&parsed.username))?;
object.set(
"password",
decode_go_utf8(parsed.password.as_deref().unwrap_or_default()),
)?;
object.set("searchParams", query_object(ctx, parsed.raw_query)?)?;
Ok(object)
}
fn query_object<'js>(ctx: Ctx<'js>, input: String) -> rquickjs::Result<Object<'js>> {
let object = Object::new(ctx.clone())?;
let values = Rc::new(RefCell::new(query::parse(&input)));
let read = Rc::clone(&values);
object.set(
"read",
Function::new(
ctx.clone(),
move |ctx: Ctx<'js>, method: String, key: String| {
let values = read.borrow();
let found = values.get(key.as_bytes());
match method.as_str() {
"has" => found.is_some().into_js(&ctx),
"getAll" => match found {
Some(values) => values
.iter()
.map(|value| decode_go_utf8(value))
.collect::<Vec<_>>()
.into_js(&ctx),
None => Ok(Value::new_null(ctx)),
},
_ => match found
.and_then(|values| values.first())
.filter(|value| !value.is_empty())
{
Some(value) => decode_go_utf8(value).into_js(&ctx),
None => Ok(Value::new_null(ctx)),
},
}
},
)?,
)?;
let write = Rc::clone(&values);
object.set(
"write",
Function::new(
ctx.clone(),
move |method: String, key: String, value: String| {
let mut values = write.borrow_mut();
match method.as_str() {
"append" => values
.entry(key.into_bytes())
.or_default()
.push(value.into_bytes()),
"set" => query::set(&mut values, &key, &value),
_ => {
values.remove(key.as_bytes());
}
}
},
)?,
)?;
object.set(
"encode",
Function::new(ctx, move || query::encode(&values.borrow()))?,
)?;
Ok(object)
}
#[cfg(test)]
mod tests {
use crate::{ExtensionRuntime, RuntimeLimits};
use serde_json::json;
#[test]
fn url_globals_resolve_track_paths_and_album_query_ids() {
let runtime = ExtensionRuntime::load(
r#"
registerExtension({probe() {
return [typeof URL, typeof URLSearchParams, ...[
"https://music.example.test/tracks/TRACK123/",
"https://music.example.test/albums/ALBUM123?trackAsin=TRACK456",
"https://music.example.test/artists/ARTIST123"
].map(link => {
try {
const url = new URL(link);
if (url.hostname !== "music.example.test") return null;
const track = url.searchParams.get("trackAsin");
if (track) return track;
const match = url.pathname.match(/^\/tracks\/([^/]+)/);
return match ? match[1] : null;
} catch (_) { return null; }
})];
}});
"#,
"{}",
RuntimeLimits::default(),
)
.unwrap();
let result: serde_json::Value =
serde_json::from_str(&runtime.call("probe", "[]", None, 1000).unwrap()).unwrap();
assert_eq!(
result,
json!(["function", "function", "TRACK123", "TRACK456", null])
);
}
#[test]
fn url_and_query_objects_preserve_legacy_constructor_contracts() {
let runtime = ExtensionRuntime::load(r#"
registerExtension({probe() {
const url = new URL("../a%2Fb?z=2&z=3&empty&bad=%Q&semi=a;b#hi%20there", "https://u:p@EXAMPLE.test:443/x/y");
const original = url.toString();
url.href = "changed";
const params = new URLSearchParams("?z=first&empty=&z=second&raw=%FF&bad=%Q&semi=a;b");
const before = [params.get("z"), params.getAll("z"), params.get("empty"), params.has("empty"), params.getAll("missing"), params.getAll(), params.toString()];
params.append("z", "last"); params.set("empty", "a b"); params.delete("raw"); params.set("ignored");
const object = new URLSearchParams({z: [1, 2], nil: null, ok: true});
return [original, url.protocol, url.host, url.hostname, url.port, url.pathname, url.search, url.hash, url.origin, url.username, url.password,
url.toString(), JSON.stringify(url), url.searchParams.toString(), typeof url.searchParams.set,
before, params.toString(), object.toString(),
Object.keys(new URL()), Object.keys(new URL("http://[bad]")),
new URL("https://example.test?").toString(), new URL("mailto:a@example.test").toString(),
new URL("/a", "broken base").toString(), new URL("https://[fe80::1%25en0]:80/a").host,
Object.keys(params), new URLSearchParams(new String("?x=y")).toString(),
new URL("https://%FF%E0%A4.test/").hostname,
new URLSearchParams("x=\ud800&\udfff=v").toString()];
}});
"#, "{}", RuntimeLimits::default()).unwrap();
let result: serde_json::Value =
serde_json::from_str(&runtime.call("probe", "[]", None, 1000).unwrap()).unwrap();
let href = "https://u:p@EXAMPLE.test:443/a%2Fb?z=2&z=3&empty&bad=%Q&semi=a;b#hi%20there";
assert_eq!(
result,
json!([
href,
"https:",
"EXAMPLE.test:443",
"EXAMPLE.test",
"443",
"/a/b",
"?z=2&z=3&empty&bad=%Q&semi=a;b",
"#hi there",
"https://EXAMPLE.test:443",
"u",
"p",
href,
serde_json::to_string(href).unwrap(),
"empty=&z=2&z=3",
"undefined",
[
"first",
["first", "second"],
null,
true,
null,
[],
"empty=&raw=%FF&z=first&z=second"
],
"empty=a+b&z=first&z=second&z=last",
"nil=%3Cnil%3E&ok=true&z=%5B1+2%5D",
["href"],
["href"],
"https://example.test?",
"mailto:a@example.test",
"/a",
"[fe80::1%en0]:80",
[
"append", "delete", "get", "getAll", "has", "set", "toString"
],
"",
"���.test",
"x=%EF%BF%BD&%EF%BF%BD=v"
])
);
}
}