From 76fdc05e548f812bb0a4e14e77522f5a3b1cb6a4 Mon Sep 17 00:00:00 2001 From: Etienne Martin Date: Tue, 12 Sep 2023 09:47:32 -0400 Subject: [PATCH 1/2] URLSearchParams Implementation (#54) --- go.mod | 2 +- go.sum | 4 + url/module.go | 326 +----------------------- url/nodeurl.go | 115 +++++++++ url/testdata/url_search_params.js | 139 ++++++++++ url/testdata/url_test.js | 87 ++++--- url/url.go | 347 +++++++++++++++++++++++++ url/{module_test.go => url_test.go} | 0 url/urlsearchparams.go | 379 ++++++++++++++++++++++++++++ url/urlsearchparams_test.go | 53 ++++ 10 files changed, 1095 insertions(+), 357 deletions(-) create mode 100644 url/nodeurl.go create mode 100644 url/testdata/url_search_params.js create mode 100644 url/url.go rename url/{module_test.go => url_test.go} (100%) create mode 100644 url/urlsearchparams.go create mode 100644 url/urlsearchparams_test.go diff --git a/go.mod b/go.mod index 044325b..2401577 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/dop251/goja_nodejs go 1.16 require ( - github.com/dop251/goja v0.0.0-20230531210528-d7324b2d74f7 + github.com/dop251/goja v0.0.0-20230626124041-ba8a63e79201 golang.org/x/net v0.10.0 golang.org/x/text v0.9.0 ) diff --git a/go.sum b/go.sum index de2cb58..6222c91 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,10 @@ github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnm github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk= github.com/dop251/goja v0.0.0-20230531210528-d7324b2d74f7 h1:cVGkvrdHgyBkYeB6kMCaF5j2d9Bg4trgbIpcUrKrvk4= github.com/dop251/goja v0.0.0-20230531210528-d7324b2d74f7/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4= +github.com/dop251/goja v0.0.0-20230605162241-28ee0ee714f3 h1:+3HCtB74++ClLy8GgjUQYeC8R4ILzVcIe8+5edAJJnE= +github.com/dop251/goja v0.0.0-20230605162241-28ee0ee714f3/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4= +github.com/dop251/goja v0.0.0-20230626124041-ba8a63e79201 h1:+9NRIliCUhliHMCixEO0mcXmrv3HYwxs9oxM1Z+qnYM= +github.com/dop251/goja v0.0.0-20230626124041-ba8a63e79201/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4= github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y= github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM= github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= diff --git a/url/module.go b/url/module.go index aecfdf8..489c270 100644 --- a/url/module.go +++ b/url/module.go @@ -1,113 +1,22 @@ package url import ( - "math" - "net/url" - "reflect" - "strconv" - "strings" - - "golang.org/x/net/idna" - "github.com/dop251/goja" "github.com/dop251/goja_nodejs/require" ) const ModuleName = "url" -var ( - reflectTypeURL = reflect.TypeOf((*url.URL)(nil)) - reflectTypeInt = reflect.TypeOf(0) -) - -func isDefaultURLPort(protocol string, port int) bool { - switch port { - case 21: - if protocol == "ftp" { - return true - } - case 80: - if protocol == "http" || protocol == "ws" { - return true - } - case 443: - if protocol == "https" || protocol == "wss" { - return true - } - } - return false -} - -func isSpecialProtocol(protocol string) bool { - switch protocol { - case "ftp", "file", "http", "https", "ws", "wss": - return true - } - return false -} - -func clearURLPort(u *url.URL) { - u.Host = u.Hostname() -} - -func valueToURLPort(v goja.Value) (portNum int, empty bool) { - portNum = -1 - if et := v.ExportType(); et == reflectTypeInt { - if num := v.ToInteger(); num >= 0 && num <= math.MaxUint16 { - portNum = int(num) - } - } else { - s := v.String() - if s == "" { - return 0, true - } - for i := 0; i < len(s); i++ { - if c := s[i]; c >= '0' && c <= '9' { - if portNum == -1 { - portNum = 0 - } - portNum = portNum*10 + int(c-'0') - if portNum > math.MaxUint16 { - portNum = -1 - break - } - } else { - break - } - } - } - return -} - -func setURLPort(u *url.URL, v goja.Value) { - if u.Scheme == "file" { - return - } - portNum, empty := valueToURLPort(v) - if empty { - clearURLPort(u) - return - } - if portNum == -1 { - return - } - if isDefaultURLPort(u.Scheme, portNum) { - clearURLPort(u) - } else { - u.Host = u.Hostname() + ":" + strconv.Itoa(portNum) - } -} - -func toURL(r *goja.Runtime, v goja.Value) *url.URL { +func toURL(r *goja.Runtime, v goja.Value) *nodeURL { if v.ExportType() == reflectTypeURL { - if u := v.Export().(*url.URL); u != nil { + if u := v.Export().(*nodeURL); u != nil { return u } } panic(r.NewTypeError("Expected URL")) } -func defineURLAccessorProp(r *goja.Runtime, p *goja.Object, name string, getter func(*url.URL) interface{}, setter func(*url.URL, goja.Value)) { +func defineURLAccessorProp(r *goja.Runtime, p *goja.Object, name string, getter func(*nodeURL) interface{}, setter func(*nodeURL, goja.Value)) { var getterVal, setterVal goja.Value if getter != nil { getterVal = r.ToValue(func(call goja.FunctionCall) goja.Value { @@ -123,240 +32,15 @@ func defineURLAccessorProp(r *goja.Runtime, p *goja.Object, name string, getter p.DefineAccessorProperty(name, getterVal, setterVal, goja.FLAG_FALSE, goja.FLAG_TRUE) } -func createURLPrototype(r *goja.Runtime) *goja.Object { - p := r.NewObject() - - // host - defineURLAccessorProp(r, p, "host", func(u *url.URL) interface{} { - return u.Host - }, func(u *url.URL, arg goja.Value) { - host := arg.String() - if _, err := url.ParseRequestURI(u.Scheme + "://" + host); err == nil { - u.Host = host - fixURL(r, u) - } - }) - - // hash - defineURLAccessorProp(r, p, "hash", func(u *url.URL) interface{} { - if u.Fragment != "" { - return "#" + u.EscapedFragment() - } - return "" - }, func(u *url.URL, arg goja.Value) { - h := arg.String() - if len(h) > 0 && h[0] == '#' { - h = h[1:] - } - u.Fragment = h - }) - - // hostname - defineURLAccessorProp(r, p, "hostname", func(u *url.URL) interface{} { - return strings.Split(u.Host, ":")[0] - }, func(u *url.URL, arg goja.Value) { - h := arg.String() - if strings.IndexByte(h, ':') >= 0 { - return - } - if _, err := url.ParseRequestURI(u.Scheme + "://" + h); err == nil { - if port := u.Port(); port != "" { - u.Host = h + ":" + port - } else { - u.Host = h - } - fixURL(r, u) - } - }) - - // href - defineURLAccessorProp(r, p, "href", func(u *url.URL) interface{} { - return u.String() - }, func(u *url.URL, arg goja.Value) { - url := parseURL(r, arg.String(), true) - *u = *url - }) - - // pathname - defineURLAccessorProp(r, p, "pathname", func(u *url.URL) interface{} { - return u.EscapedPath() - }, func(u *url.URL, arg goja.Value) { - p := arg.String() - if _, err := url.Parse(p); err == nil { - switch u.Scheme { - case "https", "http", "ftp", "ws", "wss": - if !strings.HasPrefix(p, "/") { - p = "/" + p - } - } - u.Path = p - } - }) - - // origin - defineURLAccessorProp(r, p, "origin", func(u *url.URL) interface{} { - return u.Scheme + "://" + u.Hostname() - }, nil) - - // password - defineURLAccessorProp(r, p, "password", func(u *url.URL) interface{} { - p, _ := u.User.Password() - return p - }, func(u *url.URL, arg goja.Value) { - user := u.User - u.User = url.UserPassword(user.Username(), arg.String()) - }) - - // username - defineURLAccessorProp(r, p, "username", func(u *url.URL) interface{} { - return u.User.Username() - }, func(u *url.URL, arg goja.Value) { - p, has := u.User.Password() - if !has { - u.User = url.User(arg.String()) - } else { - u.User = url.UserPassword(arg.String(), p) - } - }) - - // port - defineURLAccessorProp(r, p, "port", func(u *url.URL) interface{} { - return u.Port() - }, func(u *url.URL, arg goja.Value) { - setURLPort(u, arg) - }) - - // protocol - defineURLAccessorProp(r, p, "protocol", func(u *url.URL) interface{} { - return u.Scheme + ":" - }, func(u *url.URL, arg goja.Value) { - s := arg.String() - pos := strings.IndexByte(s, ':') - if pos >= 0 { - s = s[:pos] - } - s = strings.ToLower(s) - if isSpecialProtocol(u.Scheme) == isSpecialProtocol(s) { - if _, err := url.ParseRequestURI(s + "://" + u.Host); err == nil { - u.Scheme = s - } - } - }) - - // Search - defineURLAccessorProp(r, p, "search", func(u *url.URL) interface{} { - if u.RawQuery != "" { - return "?" + u.RawQuery - } - return "" - }, func(u *url.URL, arg goja.Value) { - u.RawQuery = arg.String() - fixRawQuery(u) - }) - - p.Set("toString", r.ToValue(func(call goja.FunctionCall) goja.Value { - return r.ToValue(toURL(r, call.This).String()) - })) - - p.Set("toJSON", r.ToValue(func(call goja.FunctionCall) goja.Value { - return r.ToValue(toURL(r, call.This).String()) - })) - - return p -} - -const ( - URLNotAbsolute = "URL is not absolute" - InvalidURL = "Invalid URL" - InvalidBaseURL = "Invalid base URL" - InvalidHostname = "Invalid hostname" -) - -func newInvalidURLError(r *goja.Runtime, msg, input string) *goja.Object { - // when node's error module is added this should return a NodeError - o := r.NewTypeError(msg) - o.Set("input", r.ToValue(input)) - return o -} - -func fixRawQuery(u *url.URL) { - if u.RawQuery != "" { - var u1 url.URL - u1.Fragment = u.RawQuery - u.RawQuery = u1.EscapedFragment() - } -} - -func fixURL(r *goja.Runtime, u *url.URL) { - switch u.Scheme { - case "https", "http", "ftp", "wss", "ws": - if u.Path == "" { - u.Path = "/" - } - hostname := u.Hostname() - lh := strings.ToLower(hostname) - ch, err := idna.Punycode.ToASCII(lh) - if err != nil { - panic(newInvalidURLError(r, InvalidHostname, lh)) - } - if ch != hostname { - if port := u.Port(); port != "" { - u.Host = ch + ":" + port - } else { - u.Host = ch - } - } - fixRawQuery(u) - } -} - -func parseURL(r *goja.Runtime, s string, isBase bool) *url.URL { - u, err := url.Parse(s) - if err != nil { - if isBase { - panic(newInvalidURLError(r, InvalidBaseURL, s)) - } else { - panic(newInvalidURLError(r, InvalidURL, s)) - } - } - if isBase && !u.IsAbs() { - panic(newInvalidURLError(r, URLNotAbsolute, s)) - } - if portStr := u.Port(); portStr != "" { - if port, err := strconv.Atoi(portStr); err != nil || isDefaultURLPort(u.Scheme, port) { - clearURLPort(u) - } - } - fixURL(r, u) - return u -} - -func createURLConstructor(r *goja.Runtime) goja.Value { - f := r.ToValue(func(call goja.ConstructorCall) *goja.Object { - var u *url.URL - if baseArg := call.Argument(1); !goja.IsUndefined(baseArg) { - base := parseURL(r, baseArg.String(), true) - ref := parseURL(r, call.Arguments[0].String(), false) - u = base.ResolveReference(ref) - } else { - u = parseURL(r, call.Argument(0).String(), true) - } - res := r.ToValue(u).(*goja.Object) - res.SetPrototype(call.This.Prototype()) - return res - }).(*goja.Object) - - f.Set("prototype", createURLPrototype(r)) - return f -} - func Require(runtime *goja.Runtime, module *goja.Object) { exports := module.Get("exports").(*goja.Object) exports.Set("URL", createURLConstructor(runtime)) + exports.Set("URLSearchParams", createURLSearchParamsConstructor(runtime)) } func Enable(runtime *goja.Runtime) { runtime.Set("URL", require.Require(runtime, ModuleName).ToObject(runtime).Get("URL")) + runtime.Set("URLSearchParams", require.Require(runtime, ModuleName).ToObject(runtime).Get("URLSearchParams")) } func init() { diff --git a/url/nodeurl.go b/url/nodeurl.go new file mode 100644 index 0000000..c7a5b7f --- /dev/null +++ b/url/nodeurl.go @@ -0,0 +1,115 @@ +package url + +import ( + "fmt" + "net/url" + "strings" +) + +type searchParam struct { + name string + value string +} + +func (sp *searchParam) Encode() string { + return sp.string(true) +} + +func (sp *searchParam) string(encode bool) string { + if encode { + return fmt.Sprintf("%s=%s", url.QueryEscape(sp.name), url.QueryEscape(sp.value)) + } else { + return fmt.Sprintf("%s=%s", sp.name, sp.value) + } +} + +type searchParams []searchParam + +func (s searchParams) Len() int { + return len(s) +} + +func (s searchParams) Swap(i, j int) { + s[i], s[j] = s[j], s[i] +} + +func (s searchParams) Less(i, j int) bool { + return len(s[i].name) > len(s[j].name) +} + +func (s searchParams) Encode() string { + str := "" + sep := "" + for _, v := range s { + str = fmt.Sprintf("%s%s%s", str, sep, v.Encode()) + sep = "&" + } + return str +} + +func (s searchParams) String() string { + var b strings.Builder + sep := "" + for _, v := range s { + b.WriteString(sep) + b.WriteString(v.string(false)) // keep it raw + sep = "&" + } + return b.String() +} + +type nodeURL struct { + url *url.URL + searchParams searchParams +} + +// This methods ensures that the url.URL has the proper RawQuery based on the searchParam +// structs. If a change is made to the searchParams we need to keep them in sync. +func (nu *nodeURL) syncSearchParams() { + nu.url.RawQuery = nu.searchParams.Encode() +} + +func (nu *nodeURL) String() string { + return nu.url.String() +} + +func (nu *nodeURL) hasName(name string) bool { + for _, v := range nu.searchParams { + if v.name == name { + return true + } + } + return false +} + +func (nu *nodeURL) getValues(name string) []string { + var vals []string + for _, v := range nu.searchParams { + if v.name == name { + vals = append(vals, v.value) + } + } + + return vals +} + +func parseSearchQuery(query string) searchParams { + ret := searchParams{} + if query == "" { + return ret + } + + query = strings.TrimPrefix(query, "?") + + for _, v := range strings.Split(query, "&") { + pair := strings.SplitN(v, "=", 2) + l := len(pair) + if l == 1 { + ret = append(ret, searchParam{name: pair[0], value: ""}) + } else if l == 2 { + ret = append(ret, searchParam{name: pair[0], value: pair[1]}) + } + } + + return ret +} diff --git a/url/testdata/url_search_params.js b/url/testdata/url_search_params.js new file mode 100644 index 0000000..6afcd13 --- /dev/null +++ b/url/testdata/url_search_params.js @@ -0,0 +1,139 @@ +"use strict"; + +const assert = require("../../assert.js"); + +let params; + +function testCtor(value, expected) { + assert.sameValue(new URLSearchParams(value).toString(), expected); +} + +testCtor("user=abc&query=xyz", "user=abc&query=xyz"); +testCtor("?user=abc&query=xyz", "user=abc&query=xyz"); + +testCtor( + { + num: 1, + user: "abc", + query: ["first", "second"], + obj: { prop: "value" }, + b: true, + }, + "num=1&user=abc&query=first%2Csecond&obj=%5Bobject+Object%5D&b=true" +); + +const map = new Map(); +map.set("user", "abc"); +map.set("query", "xyz"); +testCtor(map, "user=abc&query=xyz"); + +testCtor( + [ + ["user", "abc"], + ["query", "first"], + ["query", "second"], + ], + "user=abc&query=first&query=second" +); + +// Each key-value pair must have exactly two elements +assert.throws(() => new URLSearchParams([["single_value"]]), TypeError); +assert.throws(() => new URLSearchParams([["too", "many", "values"]]), TypeError); + +params = new URLSearchParams("https://example.org/?a=b&c=d"); +params.forEach((value, name, searchParams) => { + if (name === "a") { + assert.sameValue(value, "b"); + } + if (name === "c") { + assert.sameValue(value, "d"); + } + assert.sameValue(searchParams, "a=b&c=d"); +}); + +params = new URLSearchParams("?user=abc"); +assert.throws(() => params.append(), TypeError); +assert.throws(() => params.append(), TypeError); +params.append("query", "first"); +assert.sameValue(params.toString(), "user=abc&query=first"); + +params = new URLSearchParams("first=one&second=two&third=three"); +assert.throws(() => params.delete(), TypeError); +params.delete("second", "fake-value"); +assert.sameValue(params.toString(), "first=one&second=two&third=three"); +params.delete("third", "three"); +assert.sameValue(params.toString(), "first=one&second=two"); +params.delete("second"); +assert.sameValue(params.toString(), "first=one"); + +params = new URLSearchParams("user=abc&query=xyz"); +assert.throws(() => params.get(), TypeError); +assert.sameValue(params.get("user"), "abc"); +assert.sameValue(params.get("non-existant"), null); + +params = new URLSearchParams("query=first&query=second"); +assert.throws(() => params.getAll(), TypeError); +const all = params.getAll("query"); +assert.sameValue(all.includes("first"), true); +assert.sameValue(all.includes("second"), true); +assert.sameValue(all.length, 2); +const getAllUndefined = params.getAll(undefined); +assert.sameValue(getAllUndefined.length, 0); +const getAllNonExistant = params.getAll("does_not_exists"); +assert.sameValue(getAllNonExistant.length, 0); + +params = new URLSearchParams("user=abc&query=xyz"); +assert.throws(() => params.has(), TypeError); +assert.sameValue(params.has(undefined), false); +assert.sameValue(params.has("user"), true); +assert.sameValue(params.has("user", "abc"), true); +assert.sameValue(params.has("user", "abc", "extra-param"), true); +assert.sameValue(params.has("user", "efg"), false); + +params = new URLSearchParams(); +params.append("foo", "bar"); +params.append("foo", "baz"); +params.append("abc", "def"); +assert.sameValue(params.toString(), "foo=bar&foo=baz&abc=def"); +params.set("foo", "def"); +params.set("xyz", "opq"); +assert.sameValue(params.toString(), "foo=def&abc=def&xyz=opq"); + +params = new URLSearchParams("query=first&query=second&user=abc&double=first,second"); +const entries = params.entries(); +assert.sameValue(entries.length, 4); +assert.sameValue(entries[0].toString(), ["query", "first"].toString()); +assert.sameValue(entries[1].toString(), ["query", "second"].toString()); +assert.sameValue(entries[2].toString(), ["user", "abc"].toString()); +assert.sameValue(entries[3].toString(), ["double", "first,second"].toString()); + +params = new URLSearchParams("query=first&query=second&user=abc"); +const keys = params.keys(); +assert.sameValue(keys.length, 3); +assert.sameValue(keys[0], "query"); +assert.sameValue(keys[1], "query"); +assert.sameValue(keys[2], "user"); + +params = new URLSearchParams("query=first&query=second&user=abc"); +const values = params.values(); +assert.sameValue(values.length, 3); +assert.sameValue(values[0], "first"); +assert.sameValue(values[1], "second"); +assert.sameValue(values[2], "abc"); + +params = new URLSearchParams("query[]=abc&type=search&query[]=123"); +params.sort(); +assert.sameValue(params.toString(), "query%5B%5D=abc&query%5B%5D=123&type=search"); + +params = new URLSearchParams("query=first&query=second&user=abc"); +assert.sameValue(params.size, 3); + +function* functionGeneratorExample() { + yield ["user", "abc"]; + yield ["query", "first"]; + yield ["query", "second"]; +} + +params = new URLSearchParams(functionGeneratorExample()); +console.log(params.toString()); +assert.sameValue(params.toString(), "user=abc&query=first&query=second"); diff --git a/url/testdata/url_test.js b/url/testdata/url_test.js index 90e4ba5..f3951db 100644 --- a/url/testdata/url_test.js +++ b/url/testdata/url_test.js @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; const assert = require("../../assert.js"); @@ -15,15 +15,16 @@ testURLCtorBase("/foo", "https://example.org/", "https://example.org/foo"); testURLCtorBase("http://Example.com/", "https://example.org/", "http://example.com/"); testURLCtorBase("https://Example.com/", "https://example.org/", "https://example.com/"); testURLCtorBase("foo://Example.com/", "https://example.org/", "foo://Example.com/"); -testURLCtorBase('foo:Example.com/', 'https://example.org/', "foo:Example.com/"); +testURLCtorBase("foo:Example.com/", "https://example.org/", "foo:Example.com/"); testURLCtorBase("#hash", "https://example.org/", "https://example.org/#hash"); testURLCtor("HTTP://test.com", "http://test.com/"); testURLCtor("HTTPS://á.com", "https://xn--1ca.com/"); testURLCtor("HTTPS://á.com:123", "https://xn--1ca.com:123/"); +testURLCtor("https://test.com#asdfá", "https://test.com/#asdf%C3%A1"); testURLCtor("HTTPS://á.com:123/á", "https://xn--1ca.com:123/%C3%A1"); testURLCtor("fish://á.com", "fish://%C3%A1.com"); -testURLCtor("https://test.com/?a=1 /2", "https://test.com/?a=1%20/2"); +testURLCtor("https://test.com/?a=1 /2", "https://test.com/?a=1+%2F2"); testURLCtor("https://test.com/á=1?á=1&ü=2#é", "https://test.com/%C3%A1=1?%C3%A1=1&%C3%BC=2#%C3%A9"); assert.throws(() => new URL("test"), TypeError); @@ -32,8 +33,8 @@ assert.throws(() => new URL("ssh://EEE:ddd"), TypeError); let myURL; // Hash -myURL = new URL('https://example.org/foo#bar'); -myURL.hash = 'baz'; +myURL = new URL("https://example.org/foo#bar"); +myURL.hash = "baz"; assert.sameValue(myURL.href, "https://example.org/foo#baz"); myURL.hash = "#baz"; @@ -50,31 +51,31 @@ assert.sameValue(myURL.search, ""); //assert.sameValue(myURL.hash, "#a/#b"); // Host -myURL = new URL('https://example.org:81/foo'); -myURL.host = 'example.com:82'; +myURL = new URL("https://example.org:81/foo"); +myURL.host = "example.com:82"; assert.sameValue(myURL.href, "https://example.com:82/foo"); // Hostname -myURL = new URL('https://example.org:81/foo'); -myURL.hostname = 'example.com:82'; +myURL = new URL("https://example.org:81/foo"); +myURL.hostname = "example.com:82"; assert.sameValue(myURL.href, "https://example.org:81/foo"); myURL.hostname = "á.com"; assert.sameValue(myURL.href, "https://xn--1ca.com:81/foo"); // href -myURL = new URL('https://example.org/foo'); -myURL.href = 'https://example.com/bar'; +myURL = new URL("https://example.org/foo"); +myURL.href = "https://example.com/bar"; assert.sameValue(myURL.href, "https://example.com/bar"); // Password -myURL = new URL('https://abc:xyz@example.com'); -myURL.password = '123'; +myURL = new URL("https://abc:xyz@example.com"); +myURL.password = "123"; assert.sameValue(myURL.href, "https://abc:123@example.com/"); // pathname -myURL = new URL('https://example.org/abc/xyz?123'); -myURL.pathname = '/abcdef'; +myURL = new URL("https://example.org/abc/xyz?123"); +myURL.pathname = "/abcdef"; assert.sameValue(myURL.href, "https://example.org/abcdef?123"); myURL.pathname = ""; @@ -84,10 +85,9 @@ myURL.pathname = "á"; assert.sameValue(myURL.pathname, "/%C3%A1"); assert.sameValue(myURL.href, "https://example.org/%C3%A1?123"); - // port -myURL = new URL('https://example.org:8888'); +myURL = new URL("https://example.org:8888"); assert.sameValue(myURL.port, "8888"); function testSetPort(port, expected) { @@ -119,8 +119,8 @@ testSetPort(-Infinity, "8888"); testSetPort(NaN, "8888"); // Leading numbers are treated as a port number -testSetPort('5678abcd', "5678"); -testSetPort('a5678abcd', "8888"); +testSetPort("5678abcd", "5678"); +testSetPort("a5678abcd", "8888"); // Non-integers are truncated testSetPort(1234.5678, "1234"); @@ -133,15 +133,17 @@ testSetPort(123456, "8888"); testSetPort(4.567e21, "4"); // toString() takes precedence over valueOf(), even if it returns a valid integer -testSetPort({ - toString() { - return "2"; +testSetPort( + { + toString() { + return "2"; + }, + valueOf() { + return 1; + }, }, - valueOf() { - return 1; - } -}, "2"); - + "2" +); // Protocol function testSetProtocol(url, protocol, expected) { @@ -158,11 +160,11 @@ testSetProtocol(new URL("https://example.org"), "foo", "https:"); testSetProtocol(new URL("fish://example.org"), "https", "fish:"); // Search -myURL = new URL('https://example.org/abc?123'); -myURL.search = 'abc=xyz'; +myURL = new URL("https://example.org/abc?123"); +myURL.search = "abc=xyz"; assert.sameValue(myURL.href, "https://example.org/abc?abc=xyz"); -myURL.search = 'a=1 2'; +myURL.search = "a=1 2"; assert.sameValue(myURL.href, "https://example.org/abc?a=1%202"); myURL.search = "á=ú"; @@ -176,16 +178,31 @@ assert.sameValue(myURL.search, "?a=%23b"); assert.sameValue(myURL.hash, "#hash"); // Username -myURL = new URL('https://abc:xyz@example.com/'); -myURL.username = '123'; +myURL = new URL("https://abc:xyz@example.com/"); +myURL.username = "123"; assert.sameValue(myURL.href, "https://123:xyz@example.com/"); // Origin, read-only -assert.throws(() => {myURL.origin = "abc"}, TypeError); +assert.throws(() => { + myURL.origin = "abc"; +}, TypeError); // href -myURL = new URL("https://example.org") +myURL = new URL("https://example.org"); myURL.href = "https://example.com"; assert.sameValue(myURL.href, "https://example.com/"); -assert.throws(() => {myURL.href = "test"}, TypeError); +assert.throws(() => { + myURL.href = "test"; +}, TypeError); + +// Search Params +myURL = new URL("https://example.com/"); +myURL.searchParams.append("user", "abc"); +assert.sameValue(myURL.toString(), "https://example.com/?user=abc"); +myURL.searchParams.append("first", "one"); +assert.sameValue(myURL.toString(), "https://example.com/?user=abc&first=one"); +myURL.searchParams = new URLSearchParams("query=something"); +assert.sameValue(myURL.toString(), "https://example.com/?query=something"); +myURL.searchParams.delete("query"); +assert.sameValue(myURL.toString(), "https://example.com/"); diff --git a/url/url.go b/url/url.go new file mode 100644 index 0000000..f49cb5e --- /dev/null +++ b/url/url.go @@ -0,0 +1,347 @@ +package url + +import ( + "math" + "net/url" + "reflect" + "strconv" + "strings" + + "github.com/dop251/goja" + "golang.org/x/net/idna" +) + +const ( + URLNotAbsolute = "URL is not absolute" + InvalidURL = "Invalid URL" + InvalidBaseURL = "Invalid base URL" + InvalidHostname = "Invalid hostname" +) + +var ( + reflectTypeURL = reflect.TypeOf((*nodeURL)(nil)) + reflectTypeInt = reflect.TypeOf(0) +) + +func newInvalidURLError(r *goja.Runtime, msg, input string) *goja.Object { + // when node's error module is added this should return a NodeError + o := r.NewTypeError(msg) + o.Set("input", r.ToValue(input)) + return o +} + +func valueToURLPort(v goja.Value) (portNum int, empty bool) { + portNum = -1 + if et := v.ExportType(); et == reflectTypeInt { + if num := v.ToInteger(); num >= 0 && num <= math.MaxUint16 { + portNum = int(num) + } + } else { + s := v.String() + if s == "" { + return 0, true + } + for i := 0; i < len(s); i++ { + if c := s[i]; c >= '0' && c <= '9' { + if portNum == -1 { + portNum = 0 + } + portNum = portNum*10 + int(c-'0') + if portNum > math.MaxUint16 { + portNum = -1 + break + } + } else { + break + } + } + } + return +} + +func isDefaultURLPort(protocol string, port int) bool { + switch port { + case 21: + if protocol == "ftp" { + return true + } + case 80: + if protocol == "http" || protocol == "ws" { + return true + } + case 443: + if protocol == "https" || protocol == "wss" { + return true + } + } + return false +} + +func isSpecialProtocol(protocol string) bool { + switch protocol { + case "ftp", "file", "http", "https", "ws", "wss": + return true + } + return false +} + +func clearURLPort(u *url.URL) { + u.Host = u.Hostname() +} + +func setURLPort(nu *nodeURL, v goja.Value) { + u := nu.url + if u.Scheme == "file" { + return + } + portNum, empty := valueToURLPort(v) + if empty { + clearURLPort(u) + return + } + if portNum == -1 { + return + } + if isDefaultURLPort(u.Scheme, portNum) { + clearURLPort(u) + } else { + u.Host = u.Hostname() + ":" + strconv.Itoa(portNum) + } +} + +func parseURL(r *goja.Runtime, s string, isBase bool) *url.URL { + u, err := url.Parse(s) + if err != nil { + if isBase { + panic(newInvalidURLError(r, InvalidBaseURL, s)) + } else { + panic(newInvalidURLError(r, InvalidURL, s)) + } + } + if isBase && !u.IsAbs() { + panic(newInvalidURLError(r, URLNotAbsolute, s)) + } + if portStr := u.Port(); portStr != "" { + if port, err := strconv.Atoi(portStr); err != nil || isDefaultURLPort(u.Scheme, port) { + u.Host = u.Hostname() // Clear port + } + } + fixURL(r, u) + return u +} + +func fixRawQuery(u *url.URL) { + if u.RawQuery != "" { + var u1 url.URL + u1.Fragment = u.RawQuery + u.RawQuery = u1.EscapedFragment() + } +} + +func fixURL(r *goja.Runtime, u *url.URL) { + switch u.Scheme { + case "https", "http", "ftp", "wss", "ws": + if u.Path == "" { + u.Path = "/" + } + hostname := u.Hostname() + lh := strings.ToLower(hostname) + ch, err := idna.Punycode.ToASCII(lh) + if err != nil { + panic(newInvalidURLError(r, InvalidHostname, lh)) + } + if ch != hostname { + if port := u.Port(); port != "" { + u.Host = ch + ":" + port + } else { + u.Host = ch + } + } + } +} + +func createURLPrototype(r *goja.Runtime) *goja.Object { + p := r.NewObject() + + // host + defineURLAccessorProp(r, p, "host", func(u *nodeURL) interface{} { + return u.url.Host + }, func(u *nodeURL, arg goja.Value) { + host := arg.String() + if _, err := url.ParseRequestURI(u.url.Scheme + "://" + host); err == nil { + u.url.Host = host + fixURL(r, u.url) + } + }) + + // hash + defineURLAccessorProp(r, p, "hash", func(u *nodeURL) interface{} { + if u.url.Fragment != "" { + return "#" + u.url.EscapedFragment() + } + return "" + }, func(u *nodeURL, arg goja.Value) { + h := arg.String() + if len(h) > 0 && h[0] == '#' { + h = h[1:] + } + u.url.Fragment = h + }) + + // hostname + defineURLAccessorProp(r, p, "hostname", func(u *nodeURL) interface{} { + return strings.Split(u.url.Host, ":")[0] + }, func(u *nodeURL, arg goja.Value) { + h := arg.String() + if strings.IndexByte(h, ':') >= 0 { + return + } + if _, err := url.ParseRequestURI(u.url.Scheme + "://" + h); err == nil { + if port := u.url.Port(); port != "" { + u.url.Host = h + ":" + port + } else { + u.url.Host = h + } + fixURL(r, u.url) + } + }) + + // href + defineURLAccessorProp(r, p, "href", func(u *nodeURL) interface{} { + return u.String() + }, func(u *nodeURL, arg goja.Value) { + url := parseURL(r, arg.String(), true) + *u.url = *url + }) + + // pathname + defineURLAccessorProp(r, p, "pathname", func(u *nodeURL) interface{} { + return u.url.EscapedPath() + }, func(u *nodeURL, arg goja.Value) { + p := arg.String() + if _, err := url.Parse(p); err == nil { + switch u.url.Scheme { + case "https", "http", "ftp", "ws", "wss": + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + } + u.url.Path = p + } + }) + + // origin + defineURLAccessorProp(r, p, "origin", func(u *nodeURL) interface{} { + return u.url.Scheme + "://" + u.url.Hostname() + }, nil) + + // password + defineURLAccessorProp(r, p, "password", func(u *nodeURL) interface{} { + p, _ := u.url.User.Password() + return p + }, func(u *nodeURL, arg goja.Value) { + user := u.url.User + u.url.User = url.UserPassword(user.Username(), arg.String()) + }) + + // username + defineURLAccessorProp(r, p, "username", func(u *nodeURL) interface{} { + return u.url.User.Username() + }, func(u *nodeURL, arg goja.Value) { + p, has := u.url.User.Password() + if !has { + u.url.User = url.User(arg.String()) + } else { + u.url.User = url.UserPassword(arg.String(), p) + } + }) + + // port + defineURLAccessorProp(r, p, "port", func(u *nodeURL) interface{} { + return u.url.Port() + }, func(u *nodeURL, arg goja.Value) { + setURLPort(u, arg) + }) + + // protocol + defineURLAccessorProp(r, p, "protocol", func(u *nodeURL) interface{} { + return u.url.Scheme + ":" + }, func(u *nodeURL, arg goja.Value) { + s := arg.String() + pos := strings.IndexByte(s, ':') + if pos >= 0 { + s = s[:pos] + } + s = strings.ToLower(s) + if isSpecialProtocol(u.url.Scheme) == isSpecialProtocol(s) { + if _, err := url.ParseRequestURI(s + "://" + u.url.Host); err == nil { + u.url.Scheme = s + } + } + }) + + // Search + defineURLAccessorProp(r, p, "search", func(u *nodeURL) interface{} { + if u.url.RawQuery != "" { + return "?" + u.url.RawQuery + } + return "" + }, func(u *nodeURL, arg goja.Value) { + u.url.RawQuery = arg.String() + fixRawQuery(u.url) + }) + + // search Params + defineURLAccessorProp(r, p, "searchParams", func(u *nodeURL) interface{} { + if u.url.RawQuery != "" && len(u.searchParams) == 0 { + sp := parseSearchQuery(u.url.RawQuery) + u.searchParams = sp + } + + o := r.ToValue(u).(*goja.Object) + o.SetPrototype(createURLSearchParamsPrototype(r)) + return o + }, func(u *nodeURL, arg goja.Value) { + nu := toURL(r, arg) + u.searchParams = nu.searchParams + u.syncSearchParams() + }) + + p.Set("toString", r.ToValue(func(call goja.FunctionCall) goja.Value { + u := toURL(r, call.This) + + // Search Parameters are lazy loaded + if u.url.RawQuery != "" && len(u.searchParams) == 0 { + sp := parseSearchQuery(u.url.RawQuery) + u.searchParams = sp + } + copy := u.url + copy.RawQuery = u.searchParams.Encode() + return r.ToValue(u.url.String()) + })) + + p.Set("toJSON", r.ToValue(func(call goja.FunctionCall) goja.Value { + return r.ToValue(toURL(r, call.This).String()) + })) + + return p +} + +func createURLConstructor(r *goja.Runtime) goja.Value { + f := r.ToValue(func(call goja.ConstructorCall) *goja.Object { + var u *url.URL + if baseArg := call.Argument(1); !goja.IsUndefined(baseArg) { + base := parseURL(r, baseArg.String(), true) + ref := parseURL(r, call.Argument(0).String(), false) + u = base.ResolveReference(ref) + } else { + u = parseURL(r, call.Argument(0).String(), true) + } + res := r.ToValue(&nodeURL{url: u}).(*goja.Object) + res.SetPrototype(call.This.Prototype()) + return res + }).(*goja.Object) + + f.Set("prototype", createURLPrototype(r)) + return f +} diff --git a/url/module_test.go b/url/url_test.go similarity index 100% rename from url/module_test.go rename to url/url_test.go diff --git a/url/urlsearchparams.go b/url/urlsearchparams.go new file mode 100644 index 0000000..40223f4 --- /dev/null +++ b/url/urlsearchparams.go @@ -0,0 +1,379 @@ +package url + +import ( + "fmt" + "net/url" + "reflect" + "sort" + + "github.com/dop251/goja" +) + +var ( + reflectTypeString = reflect.TypeOf("") + reflectTypeObject = reflect.TypeOf(map[string]interface{}{}) + reflectTypeArray = reflect.TypeOf([]interface{}{}) + reflectTypeMap = reflect.TypeOf([][2]interface{}{}) +) + +func newInvalidTupleError(r *goja.Runtime) *goja.Object { + return newError(r, "ERR_INVALID_TUPLE", "Each query pair must be an iterable [name, value] tuple") +} + +func newMissingArgsError(r *goja.Runtime, msg string) *goja.Object { + return newError(r, "ERR_MISSING_ARGS", msg) +} + +func newInvalidArgsError(r *goja.Runtime) *goja.Object { + return newError(r, "ERR_INVALID_ARG_TYPE", `The "callback" argument must be of type function. Received undefined`) +} + +func newError(r *goja.Runtime, code string, msg string) *goja.Object { + o := r.NewTypeError("[" + code + "]: " + msg) + o.Set("code", r.ToValue(code)) + return o +} + +func createURLSearchParamsConstructor(r *goja.Runtime) goja.Value { + f := r.ToValue(func(call goja.ConstructorCall) *goja.Object { + u, _ := url.Parse("") + v := call.Argument(0) + if !goja.IsUndefined(v) { + switch v.ExportType() { + case reflectTypeString: + u = buildParamsFromString(v.String()) + case reflectTypeObject: + u = buildParamsFromObject(r, v.ToObject(r)) + case reflectTypeArray: + u = buildParamsFromIterable(r, v.ToObject(r)) + case reflectTypeMap: + u = buildParamsFromMap(r, v.ToObject(r)) + } + } + + sp := parseSearchQuery(u.RawQuery) + res := r.ToValue(&nodeURL{url: u, searchParams: sp}).(*goja.Object) + res.SetPrototype(call.This.Prototype()) + return res + }).(*goja.Object) + + f.Set("prototype", createURLSearchParamsPrototype(r)) + return f +} + +// If Parsing results in a path, we move this to the RawQuery +func buildParamsFromString(s string) *url.URL { + u, err := url.Parse(s) + if err != nil { + return nil + } + + if u.Path != "" && u.RawQuery == "" { + v, err := url.Parse(fmt.Sprintf("?%s", u.Path)) + if err != nil { + return nil + } + return v + } + + return u +} + +func buildParamsFromObject(r *goja.Runtime, o *goja.Object) *url.URL { + query := searchParams{} + + // Covers usecase where object might be a function generator. + if o.GetSymbol(goja.SymIterator) != nil { + return buildParamsFromIterable(r, o) + } + + for _, k := range o.Keys() { + val := o.Get(k).String() + query = append(query, searchParam{name: k, value: val}) + } + + u, _ := url.Parse("") + u.RawQuery = query.String() + return u +} + +func buildParamsFromIterable(r *goja.Runtime, o *goja.Object) *url.URL { + query := searchParams{} + + r.ForOf(o, func(val goja.Value) bool { + obj := val.ToObject(r) + var name, value string + i := 0 + // Use ForOf to determine if the object is iterable + r.ForOf(obj, func(val goja.Value) bool { + if i == 0 { + name = fmt.Sprintf("%v", val) + i++ + return true + } + if i == 1 { + value = fmt.Sprintf("%v", val) + i++ + return true + } + // Array isn't a tuple + panic(newInvalidTupleError(r)) + }) + + // Ensure we have two values + if i <= 1 { + panic(newInvalidTupleError(r)) + } + + query = append(query, searchParam{ + name: name, + value: value, + }) + + return true + }) + + u, _ := url.Parse("") + u.RawQuery = query.String() + return u +} + +func buildParamsFromMap(r *goja.Runtime, o *goja.Object) *url.URL { + query := searchParams{} + + r.ForOf(o, func(val goja.Value) bool { + obj := val.ToObject(r) + query = append(query, searchParam{ + name: obj.Get("0").String(), + value: obj.Get("1").String(), + }) + return true + }) + + u, _ := url.Parse("") + u.RawQuery = query.String() + return u +} + +func createURLSearchParamsPrototype(r *goja.Runtime) *goja.Object { + p := r.NewObject() + + p.Set("append", r.ToValue(func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + panic(newMissingArgsError(r, `The "name" and "value" arguments must be specified`)) + } + + u := toURL(r, call.This) + u.searchParams = append(u.searchParams, searchParam{ + name: call.Argument(0).String(), + value: call.Argument(1).String(), + }) + u.syncSearchParams() + + return goja.Undefined() + })) + + p.Set("delete", r.ToValue(func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + panic(newMissingArgsError(r, `The "name" argument must be specified`)) + } + + u := toURL(r, call.This) + name := call.Argument(0).String() + isValid := func(v searchParam) bool { + if len(call.Arguments) == 1 { + return v.name != name + } else if v.name == name { + arg := call.Argument(1) + if !goja.IsUndefined(arg) && v.value == arg.String() { + return false + } + } + return true + } + + i := 0 + for _, v := range u.searchParams { + if isValid(v) { + u.searchParams[i] = v + i++ + } + } + u.searchParams = u.searchParams[:i] + u.syncSearchParams() + + return goja.Undefined() + })) + + p.Set("entries", r.ToValue(func(call goja.FunctionCall) goja.Value { + u := toURL(r, call.This) + entries := [][]string{} + for _, sp := range u.searchParams { + entries = append(entries, []string{sp.name, sp.value}) + } + + return r.ToValue(entries) + })) + + p.Set("forEach", r.ToValue(func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) != 1 { + panic(newInvalidArgsError(r)) + } + + u := toURL(r, call.This) + if fn, ok := goja.AssertFunction(call.Argument(0)); ok { + for _, pair := range u.searchParams { + // name, value, searchParams + for _, v := range pair.value { + query := u.url.RawQuery + _, err := fn( + nil, + r.ToValue(pair.name), + r.ToValue(v), + r.ToValue(query), + ) + + if err != nil { + panic(err) + } + } + } + } + + return goja.Undefined() + })) + + p.Set("get", r.ToValue(func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) == 0 { + panic(newMissingArgsError(r, `The "name" argument must be specified`)) + } + + p := call.Argument(0) + e := p.Export() + if n, ok := e.(string); ok { + u := toURL(r, call.This) + vals := u.getValues(n) + if len(vals) > 0 { + return r.ToValue(vals[0]) + } + } + + return goja.Null() + })) + + p.Set("getAll", r.ToValue(func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) == 0 { + panic(newMissingArgsError(r, `The "name" argument must be specified`)) + } + + p := call.Argument(0) + e := p.Export() + if n, ok := e.(string); ok { + u := toURL(r, call.This) + vals := u.getValues(n) + if len(vals) > 0 { + return r.ToValue(vals) + } + } + + return r.ToValue([]string{}) + })) + + p.Set("has", r.ToValue(func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) == 0 { + panic(newMissingArgsError(r, `The "name" argument must be specified`)) + } + + p := call.Argument(0) + e := p.Export() + if n, ok := e.(string); ok { + u := toURL(r, call.This) + vals := u.getValues(n) + param := call.Argument(1) + if !goja.IsUndefined(param) { + cmp := param.String() + for _, v := range vals { + if v == cmp { + return r.ToValue(true) + } + } + return r.ToValue(false) + } + + return r.ToValue(u.hasName(n)) + } + + return r.ToValue(false) + })) + + p.Set("keys", r.ToValue(func(call goja.FunctionCall) goja.Value { + u := toURL(r, call.This) + keys := []string{} + for _, sp := range u.searchParams { + keys = append(keys, sp.name) + } + + return r.ToValue(keys) + })) + + p.Set("set", r.ToValue(func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + panic(newMissingArgsError(r, `The "name" and "value" arguments must be specified`)) + } + + u := toURL(r, call.This) + name := call.Argument(0).String() + found := false + sps := searchParams{} + for _, sp := range u.searchParams { + if sp.name == name { + if found { + continue // Skip duplicates if present. + } + + sp.value = call.Argument(1).String() + found = true + } + sps = append(sps, sp) + } + + if found { + u.searchParams = sps + } else { + u.searchParams = append(u.searchParams, searchParam{ + name: name, + value: call.Argument(1).String(), + }) + } + u.syncSearchParams() + + return goja.Undefined() + })) + + p.Set("sort", r.ToValue(func(call goja.FunctionCall) goja.Value { + sort.Sort(toURL(r, call.This).searchParams) + return goja.Undefined() + })) + + defineURLAccessorProp(r, p, "size", func(u *nodeURL) interface{} { + return len(u.searchParams) + }, nil) + + p.Set("toString", r.ToValue(func(call goja.FunctionCall) goja.Value { + u := toURL(r, call.This) + str := u.searchParams.Encode() + return r.ToValue(str) + })) + + p.Set("values", r.ToValue(func(call goja.FunctionCall) goja.Value { + u := toURL(r, call.This) + values := []string{} + for _, sp := range u.searchParams { + values = append(values, sp.value) + } + + return r.ToValue(values) + })) + + return p +} diff --git a/url/urlsearchparams_test.go b/url/urlsearchparams_test.go new file mode 100644 index 0000000..62f5b6a --- /dev/null +++ b/url/urlsearchparams_test.go @@ -0,0 +1,53 @@ +package url + +import ( + _ "embed" + "testing" + + "github.com/dop251/goja" + "github.com/dop251/goja_nodejs/console" + "github.com/dop251/goja_nodejs/require" +) + +func createVM() *goja.Runtime { + vm := goja.New() + new(require.Registry).Enable(vm) + console.Enable(vm) + Enable(vm) + return vm +} + +func TestURLSearchParams(t *testing.T) { + vm := createVM() + + if c := vm.Get("URLSearchParams"); c == nil { + t.Fatal("URLSearchParams not found") + } + + script := `const params = new URLSearchParams();` + + if _, err := vm.RunString(script); err != nil { + t.Fatal("Failed to process url script.", err) + } +} + +//go:embed testdata/url_search_params.js +var url_search_params string + +func TestURLSearchParameters(t *testing.T) { + vm := createVM() + + if c := vm.Get("URLSearchParams"); c == nil { + t.Fatal("URLSearchParams not found") + } + + // Script will throw an error on failed validation + + _, err := vm.RunScript("testdata/url_search_params.js", url_search_params) + if err != nil { + if ex, ok := err.(*goja.Exception); ok { + t.Fatal(ex.String()) + } + t.Fatal("Failed to process url script.", err) + } +} From 7f96e644e0d7574a1dd4163332c67cbb74adc141 Mon Sep 17 00:00:00 2001 From: Dmitry Panov Date: Wed, 13 Sep 2023 21:25:09 +0100 Subject: [PATCH 2/2] Improved URLSearchParams implementation. --- assert.js | 20 ++ errors/errors.go | 2 + url/escape.go | 134 ++++++++++ url/module.go | 33 +-- url/nodeurl.go | 91 ++++--- url/testdata/url_search_params.js | 288 +++++++++++++++++++-- url/testdata/url_test.js | 27 +- url/url.go | 172 ++++++++----- url/urlsearchparams.go | 415 +++++++++++++++--------------- 9 files changed, 825 insertions(+), 357 deletions(-) create mode 100644 url/escape.go diff --git a/assert.js b/assert.js index baf182b..b00076d 100644 --- a/assert.js +++ b/assert.js @@ -57,6 +57,26 @@ const assert = { return; } throw new Error(message + "No exception was thrown"); + }, + + throwsNodeError(f, ctor, code, message) { + if (message === undefined) { + message = ''; + } else { + message += ' '; + } + try { + f(); + } catch (e) { + if (e.constructor !== ctor) { + throw new Error(message + "Wrong exception type was thrown: " + e.constructor.name); + } + if (e.code !== code) { + throw new Error(message + "Wrong exception code was thrown: " + e.code); + } + return; + } + throw new Error(message + "No exception was thrown"); } } diff --git a/errors/errors.go b/errors/errors.go index 9e451da..b3ae23a 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -8,6 +8,8 @@ import ( const ( ErrCodeInvalidArgType = "ERR_INVALID_ARG_TYPE" + ErrCodeInvalidThis = "ERR_INVALID_THIS" + ErrCodeMissingArgs = "ERR_MISSING_ARGS" ) func error_toString(call goja.FunctionCall, r *goja.Runtime) goja.Value { diff --git a/url/escape.go b/url/escape.go new file mode 100644 index 0000000..3d288c2 --- /dev/null +++ b/url/escape.go @@ -0,0 +1,134 @@ +package url + +import "strings" + +var tblEscapeURLQuery = [128]byte{ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 1, 0, 0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, +} + +var tblEscapeURLQueryParam = [128]byte{ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 1, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, + 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, + 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, +} + +// The code below is mostly borrowed from the standard Go url package + +const upperhex = "0123456789ABCDEF" + +func ishex(c byte) bool { + switch { + case '0' <= c && c <= '9': + return true + case 'a' <= c && c <= 'f': + return true + case 'A' <= c && c <= 'F': + return true + } + return false +} + +func unhex(c byte) byte { + switch { + case '0' <= c && c <= '9': + return c - '0' + case 'a' <= c && c <= 'f': + return c - 'a' + 10 + case 'A' <= c && c <= 'F': + return c - 'A' + 10 + } + return 0 +} + +func escape(s string, table *[128]byte, spaceToPlus bool) string { + spaceCount, hexCount := 0, 0 + for i := 0; i < len(s); i++ { + c := s[i] + if c > 127 || table[c] == 0 { + if c == ' ' && spaceToPlus { + spaceCount++ + } else { + hexCount++ + } + } + } + + if spaceCount == 0 && hexCount == 0 { + return s + } + + var sb strings.Builder + hexBuf := [3]byte{'%', 0, 0} + + sb.Grow(len(s) + 2*hexCount) + + for i := 0; i < len(s); i++ { + switch c := s[i]; { + case c == ' ' && spaceToPlus: + sb.WriteByte('+') + case c > 127 || table[c] == 0: + hexBuf[1] = upperhex[c>>4] + hexBuf[2] = upperhex[c&15] + sb.Write(hexBuf[:]) + default: + sb.WriteByte(c) + } + } + return sb.String() +} + +func unescapeSearchParam(s string) string { + n := 0 + hasPlus := false + for i := 0; i < len(s); { + switch s[i] { + case '%': + if i+2 >= len(s) || !ishex(s[i+1]) || !ishex(s[i+2]) { + i++ + continue + } + n++ + i += 3 + case '+': + hasPlus = true + i++ + default: + i++ + } + } + + if n == 0 && !hasPlus { + return s + } + + var t strings.Builder + t.Grow(len(s) - 2*n) + for i := 0; i < len(s); i++ { + switch s[i] { + case '%': + if i+2 >= len(s) || !ishex(s[i+1]) || !ishex(s[i+2]) { + t.WriteByte('%') + } else { + t.WriteByte(unhex(s[i+1])<<4 | unhex(s[i+2])) + i += 2 + } + case '+': + t.WriteByte(' ') + default: + t.WriteByte(s[i]) + } + } + return t.String() +} diff --git a/url/module.go b/url/module.go index 489c270..b26e388 100644 --- a/url/module.go +++ b/url/module.go @@ -7,35 +7,20 @@ import ( const ModuleName = "url" -func toURL(r *goja.Runtime, v goja.Value) *nodeURL { - if v.ExportType() == reflectTypeURL { - if u := v.Export().(*nodeURL); u != nil { - return u - } - } - panic(r.NewTypeError("Expected URL")) -} +type urlModule struct { + r *goja.Runtime -func defineURLAccessorProp(r *goja.Runtime, p *goja.Object, name string, getter func(*nodeURL) interface{}, setter func(*nodeURL, goja.Value)) { - var getterVal, setterVal goja.Value - if getter != nil { - getterVal = r.ToValue(func(call goja.FunctionCall) goja.Value { - return r.ToValue(getter(toURL(r, call.This))) - }) - } - if setter != nil { - setterVal = r.ToValue(func(call goja.FunctionCall) goja.Value { - setter(toURL(r, call.This), call.Argument(0)) - return goja.Undefined() - }) - } - p.DefineAccessorProperty(name, getterVal, setterVal, goja.FLAG_FALSE, goja.FLAG_TRUE) + URLSearchParamsPrototype *goja.Object + URLSearchParamsIteratorPrototype *goja.Object } func Require(runtime *goja.Runtime, module *goja.Object) { exports := module.Get("exports").(*goja.Object) - exports.Set("URL", createURLConstructor(runtime)) - exports.Set("URLSearchParams", createURLSearchParamsConstructor(runtime)) + m := &urlModule{ + r: runtime, + } + exports.Set("URL", m.createURLConstructor()) + exports.Set("URLSearchParams", m.createURLSearchParamsConstructor()) } func Enable(runtime *goja.Runtime) { diff --git a/url/nodeurl.go b/url/nodeurl.go index c7a5b7f..538cccf 100644 --- a/url/nodeurl.go +++ b/url/nodeurl.go @@ -1,7 +1,6 @@ package url import ( - "fmt" "net/url" "strings" ) @@ -15,11 +14,15 @@ func (sp *searchParam) Encode() string { return sp.string(true) } +func escapeSearchParam(s string) string { + return escape(s, &tblEscapeURLQueryParam, true) +} + func (sp *searchParam) string(encode bool) string { if encode { - return fmt.Sprintf("%s=%s", url.QueryEscape(sp.name), url.QueryEscape(sp.value)) + return escapeSearchParam(sp.name) + "=" + escapeSearchParam(sp.value) } else { - return fmt.Sprintf("%s=%s", sp.name, sp.value) + return sp.name + "=" + sp.value } } @@ -34,28 +37,29 @@ func (s searchParams) Swap(i, j int) { } func (s searchParams) Less(i, j int) bool { - return len(s[i].name) > len(s[j].name) + return strings.Compare(s[i].name, s[j].name) < 0 } func (s searchParams) Encode() string { - str := "" - sep := "" - for _, v := range s { - str = fmt.Sprintf("%s%s%s", str, sep, v.Encode()) - sep = "&" + var sb strings.Builder + for i, v := range s { + if i > 0 { + sb.WriteByte('&') + } + sb.WriteString(v.Encode()) } - return str + return sb.String() } func (s searchParams) String() string { - var b strings.Builder - sep := "" - for _, v := range s { - b.WriteString(sep) - b.WriteString(v.string(false)) // keep it raw - sep = "&" + var sb strings.Builder + for i, v := range s { + if i > 0 { + sb.WriteByte('&') + } + sb.WriteString(v.string(false)) } - return b.String() + return sb.String() } type nodeURL struct { @@ -63,18 +67,26 @@ type nodeURL struct { searchParams searchParams } +type urlSearchParams nodeURL + // This methods ensures that the url.URL has the proper RawQuery based on the searchParam // structs. If a change is made to the searchParams we need to keep them in sync. func (nu *nodeURL) syncSearchParams() { - nu.url.RawQuery = nu.searchParams.Encode() + if nu.rawQueryUpdateNeeded() { + nu.url.RawQuery = nu.searchParams.Encode() + } +} + +func (nu *nodeURL) rawQueryUpdateNeeded() bool { + return len(nu.searchParams) > 0 && nu.url.RawQuery == "" } func (nu *nodeURL) String() string { return nu.url.String() } -func (nu *nodeURL) hasName(name string) bool { - for _, v := range nu.searchParams { +func (sp *urlSearchParams) hasName(name string) bool { + for _, v := range sp.searchParams { if v.name == name { return true } @@ -82,9 +94,18 @@ func (nu *nodeURL) hasName(name string) bool { return false } -func (nu *nodeURL) getValues(name string) []string { - var vals []string - for _, v := range nu.searchParams { +func (sp *urlSearchParams) hasValue(name, value string) bool { + for _, v := range sp.searchParams { + if v.name == name && v.value == value { + return true + } + } + return false +} + +func (sp *urlSearchParams) getValues(name string) []string { + vals := make([]string, 0, len(sp.searchParams)) + for _, v := range sp.searchParams { if v.name == name { vals = append(vals, v.value) } @@ -93,23 +114,35 @@ func (nu *nodeURL) getValues(name string) []string { return vals } -func parseSearchQuery(query string) searchParams { - ret := searchParams{} +func (sp *urlSearchParams) getFirstValue(name string) (string, bool) { + for _, v := range sp.searchParams { + if v.name == name { + return v.value, true + } + } + + return "", false +} + +func parseSearchQuery(query string) (ret searchParams) { if query == "" { - return ret + return } query = strings.TrimPrefix(query, "?") for _, v := range strings.Split(query, "&") { + if v == "" { + continue + } pair := strings.SplitN(v, "=", 2) l := len(pair) if l == 1 { - ret = append(ret, searchParam{name: pair[0], value: ""}) + ret = append(ret, searchParam{name: unescapeSearchParam(pair[0]), value: ""}) } else if l == 2 { - ret = append(ret, searchParam{name: pair[0], value: pair[1]}) + ret = append(ret, searchParam{name: unescapeSearchParam(pair[0]), value: unescapeSearchParam(pair[1])}) } } - return ret + return } diff --git a/url/testdata/url_search_params.js b/url/testdata/url_search_params.js index 6afcd13..c974fc2 100644 --- a/url/testdata/url_search_params.js +++ b/url/testdata/url_search_params.js @@ -37,28 +37,66 @@ testCtor( ); // Each key-value pair must have exactly two elements -assert.throws(() => new URLSearchParams([["single_value"]]), TypeError); -assert.throws(() => new URLSearchParams([["too", "many", "values"]]), TypeError); +assert.throwsNodeError(() => new URLSearchParams([["single_value"]]), TypeError, "ERR_INVALID_TUPLE"); +assert.throwsNodeError(() => new URLSearchParams([["too", "many", "values"]]), TypeError, "ERR_INVALID_TUPLE"); -params = new URLSearchParams("https://example.org/?a=b&c=d"); +params = new URLSearchParams("a=b&cc=d"); params.forEach((value, name, searchParams) => { if (name === "a") { assert.sameValue(value, "b"); } - if (name === "c") { + if (name === "cc") { assert.sameValue(value, "d"); } - assert.sameValue(searchParams, "a=b&c=d"); + assert.sameValue(searchParams, params); }); +params.forEach((value, name, searchParams) => { + if (name === "a") { + assert.sameValue(value, "b"); + searchParams.set("cc", "d1"); + } + if (name === "cc") { + assert.sameValue(value, "d1"); + } + assert.sameValue(searchParams, params); +}); + +assert.throwsNodeError(() => params.forEach(123), TypeError, "ERR_INVALID_ARG_TYPE"); + +assert.throwsNodeError(() => params.forEach.call(1, 2), TypeError, "ERR_INVALID_THIS"); + +params = new URLSearchParams("a=1=2&b=3"); +assert.sameValue(params.size, 2); +assert.sameValue(params.get("a"), "1=2"); +assert.sameValue(params.get("b"), "3"); + +params = new URLSearchParams("&"); +assert.sameValue(params.size, 0); + +params = new URLSearchParams("& "); +assert.sameValue(params.size, 1); +assert.sameValue(params.get(" "), ""); + +params = new URLSearchParams(" &"); +assert.sameValue(params.size, 1); +assert.sameValue(params.get(" "), ""); + +params = new URLSearchParams("="); +assert.sameValue(params.size, 1); +assert.sameValue(params.get(""), ""); + +params = new URLSearchParams("&=2"); +assert.sameValue(params.size, 1); +assert.sameValue(params.get(""), "2"); + params = new URLSearchParams("?user=abc"); -assert.throws(() => params.append(), TypeError); -assert.throws(() => params.append(), TypeError); +assert.throwsNodeError(() => params.append(), TypeError, "ERR_MISSING_ARGS"); params.append("query", "first"); assert.sameValue(params.toString(), "user=abc&query=first"); params = new URLSearchParams("first=one&second=two&third=three"); -assert.throws(() => params.delete(), TypeError); +assert.throwsNodeError(() => params.delete(), TypeError, "ERR_MISSING_ARGS"); params.delete("second", "fake-value"); assert.sameValue(params.toString(), "first=one&second=two&third=three"); params.delete("third", "three"); @@ -67,12 +105,12 @@ params.delete("second"); assert.sameValue(params.toString(), "first=one"); params = new URLSearchParams("user=abc&query=xyz"); -assert.throws(() => params.get(), TypeError); +assert.throwsNodeError(() => params.get(), TypeError, "ERR_MISSING_ARGS"); assert.sameValue(params.get("user"), "abc"); assert.sameValue(params.get("non-existant"), null); params = new URLSearchParams("query=first&query=second"); -assert.throws(() => params.getAll(), TypeError); +assert.throwsNodeError(() => params.getAll(), TypeError, "ERR_MISSING_ARGS"); const all = params.getAll("query"); assert.sameValue(all.includes("first"), true); assert.sameValue(all.includes("second"), true); @@ -83,12 +121,13 @@ const getAllNonExistant = params.getAll("does_not_exists"); assert.sameValue(getAllNonExistant.length, 0); params = new URLSearchParams("user=abc&query=xyz"); -assert.throws(() => params.has(), TypeError); +assert.throwsNodeError(() => params.has(), TypeError, "ERR_MISSING_ARGS"); assert.sameValue(params.has(undefined), false); assert.sameValue(params.has("user"), true); assert.sameValue(params.has("user", "abc"), true); assert.sameValue(params.has("user", "abc", "extra-param"), true); assert.sameValue(params.has("user", "efg"), false); +assert.sameValue(params.has("user", undefined), true); params = new URLSearchParams(); params.append("foo", "bar"); @@ -100,26 +139,81 @@ params.set("xyz", "opq"); assert.sameValue(params.toString(), "foo=def&abc=def&xyz=opq"); params = new URLSearchParams("query=first&query=second&user=abc&double=first,second"); -const entries = params.entries(); -assert.sameValue(entries.length, 4); -assert.sameValue(entries[0].toString(), ["query", "first"].toString()); -assert.sameValue(entries[1].toString(), ["query", "second"].toString()); -assert.sameValue(entries[2].toString(), ["user", "abc"].toString()); -assert.sameValue(entries[3].toString(), ["double", "first,second"].toString()); +const URLSearchIteratorPrototype = params.entries().__proto__; +assert.sameValue(typeof URLSearchIteratorPrototype, "object"); + +assert.sameValue(params[Symbol.iterator], params.entries); + +{ + const entries = params.entries(); + assert.sameValue(entries.toString(), "[object URLSearchParams Iterator]"); + assert.sameValue(entries.__proto__, URLSearchIteratorPrototype); + + let item = entries.next(); + assert.sameValue(item.value.toString(), ["query", "first"].toString()); + assert.sameValue(item.done, false); + + item = entries.next(); + assert.sameValue(item.value.toString(), ["query", "second"].toString()); + assert.sameValue(item.done, false); + + item = entries.next(); + assert.sameValue(item.value.toString(), ["user", "abc"].toString()); + assert.sameValue(item.done, false); + + item = entries.next(); + assert.sameValue(item.value.toString(), ["double", "first,second"].toString()); + assert.sameValue(item.done, false); + + item = entries.next(); + assert.sameValue(item.value, undefined); + assert.sameValue(item.done, true); +} params = new URLSearchParams("query=first&query=second&user=abc"); -const keys = params.keys(); -assert.sameValue(keys.length, 3); -assert.sameValue(keys[0], "query"); -assert.sameValue(keys[1], "query"); -assert.sameValue(keys[2], "user"); +{ + const keys = params.keys(); + assert.sameValue(keys.__proto__, URLSearchIteratorPrototype); + + let item = keys.next(); + assert.sameValue(item.value, "query"); + assert.sameValue(item.done, false); + + item = keys.next(); + assert.sameValue(item.value, "query"); + assert.sameValue(item.done, false); + + item = keys.next(); + assert.sameValue(item.value, "user"); + assert.sameValue(item.done, false); + + item = keys.next(); + assert.sameValue(item.value, undefined); + assert.sameValue(item.done, true); +} params = new URLSearchParams("query=first&query=second&user=abc"); -const values = params.values(); -assert.sameValue(values.length, 3); -assert.sameValue(values[0], "first"); -assert.sameValue(values[1], "second"); -assert.sameValue(values[2], "abc"); +{ + const values = params.values(); + assert.sameValue(values.__proto__, URLSearchIteratorPrototype); + + let item = values.next(); + assert.sameValue(item.value, "first"); + assert.sameValue(item.done, false); + + item = values.next(); + assert.sameValue(item.value, "second"); + assert.sameValue(item.done, false); + + item = values.next(); + assert.sameValue(item.value, "abc"); + assert.sameValue(item.done, false); + + item = values.next(); + assert.sameValue(item.value, undefined); + assert.sameValue(item.done, true); +} + params = new URLSearchParams("query[]=abc&type=search&query[]=123"); params.sort(); @@ -128,6 +222,36 @@ assert.sameValue(params.toString(), "query%5B%5D=abc&query%5B%5D=123&type=search params = new URLSearchParams("query=first&query=second&user=abc"); assert.sameValue(params.size, 3); +params = new URLSearchParams("%"); +assert.sameValue(params.has("%"), true); +assert.sameValue(params.toString(), "%25="); + +{ + const params = new URLSearchParams(""); + assert.sameValue(params.size, 0); + assert.sameValue(params.toString(), ""); + assert.sameValue(params.get(undefined), null); + params.set(undefined, true); + assert.sameValue(params.has(undefined), true); + assert.sameValue(params.has("undefined"), true); + assert.sameValue(params.get("undefined"), "true"); + assert.sameValue(params.get(undefined), "true"); + assert.sameValue(params.getAll(undefined).toString(), ["true"].toString()); + params.delete(undefined); + assert.sameValue(params.has(undefined), false); + assert.sameValue(params.has("undefined"), false); + + assert.sameValue(params.has(null), false); + params.set(null, "nullval"); + assert.sameValue(params.has(null), true); + assert.sameValue(params.has("null"), true); + assert.sameValue(params.get(null), "nullval"); + assert.sameValue(params.get("null"), "nullval"); + params.delete(null); + assert.sameValue(params.has(null), false); + assert.sameValue(params.has("null"), false); +} + function* functionGeneratorExample() { yield ["user", "abc"]; yield ["query", "first"]; @@ -135,5 +259,113 @@ function* functionGeneratorExample() { } params = new URLSearchParams(functionGeneratorExample()); -console.log(params.toString()); assert.sameValue(params.toString(), "user=abc&query=first&query=second"); + +assert.sameValue(params.__proto__.constructor, URLSearchParams); +assert.sameValue(params instanceof URLSearchParams, true); + +{ + const params = new URLSearchParams("1=2&1=3"); + assert.sameValue(params.get(1), "2"); + assert.sameValue(params.getAll(1).toString(), ["2", "3"].toString()); + assert.sameValue(params.getAll("x").toString(), [].toString()); +} + +// Sync +{ + const url = new URL("https://test.com/"); + const params = url.searchParams; + assert.sameValue(params.size, 0); + url.search = "a=1"; + assert.sameValue(params.size, 1); + assert.sameValue(params.get("a"), "1"); +} + +{ + const url = new URL("https://test.com/?a=1"); + const params = url.searchParams; + assert.sameValue(params.size, 1); + url.search = ""; + assert.sameValue(params.size, 0); + url.search = "b=2"; + assert.sameValue(params.size, 1); +} + +{ + const url = new URL("https://test.com/"); + const params = url.searchParams; + params.append("a", "1"); + assert.sameValue(url.toString(), "https://test.com/?a=1"); +} + +{ + const url = new URL("https://test.com/"); + const params = url.searchParams; + params.append("a", "1"); + assert.sameValue(url.search, "?a=1"); +} + +{ + const url = new URL("https://test.com/?a=1"); + const params = url.searchParams; + params.append("a", "2"); + assert.sameValue(url.search, "?a=1&a=2"); +} + +{ + const url = new URL("https://test.com/"); + const params = url.searchParams; + params.set("a", "1"); + assert.sameValue(url.search, "?a=1"); +} + +{ + const url = new URL("https://test.com/?a=1&b=2"); + const params = url.searchParams; + params.delete("a"); + assert.sameValue(url.search, "?b=2"); +} + +{ + const url = new URL("https://test.com/?b=2&a=1"); + const params = url.searchParams; + params.sort(); + assert.sameValue(url.search, "?a=1&b=2"); +} + +{ + const url = new URL("https://test.com/?a=1"); + const params = url.searchParams; + params.delete("a"); + assert.sameValue(url.search, ""); + + params.set("a", 2); + assert.sameValue(url.search, "?a=2"); +} + +// FAILING: no custom properties on wrapped Go structs +/* +{ + const params = new URLSearchParams(""); + assert.sameValue(Object.isExtensible(params), true); + assert.sameValue(Reflect.defineProperty(params, "customField", {value: 42, configurable: true}), true); + assert.sameValue(params.customField, 42); + const desc = Reflect.getOwnPropertyDescriptor(params, "customField"); + assert.sameValue(desc.value, 42); + assert.sameValue(desc.writable, false); + assert.sameValue(desc.enumerable, false); + assert.sameValue(desc.configurable, true); +} +*/ + +// Escape +{ + const myURL = new URL('https://example.org/abc?fo~o=~ba r%z'); + + assert.sameValue(myURL.search, "?fo~o=~ba%20r%z"); + + // Modify the URL via searchParams... + myURL.searchParams.sort(); + + assert.sameValue(myURL.search, "?fo%7Eo=%7Eba+r%25z"); +} diff --git a/url/testdata/url_test.js b/url/testdata/url_test.js index f3951db..6fe3549 100644 --- a/url/testdata/url_test.js +++ b/url/testdata/url_test.js @@ -24,12 +24,23 @@ testURLCtor("HTTPS://á.com:123", "https://xn--1ca.com:123/"); testURLCtor("https://test.com#asdfá", "https://test.com/#asdf%C3%A1"); testURLCtor("HTTPS://á.com:123/á", "https://xn--1ca.com:123/%C3%A1"); testURLCtor("fish://á.com", "fish://%C3%A1.com"); -testURLCtor("https://test.com/?a=1 /2", "https://test.com/?a=1+%2F2"); +testURLCtor("https://test.com/?a=1 /2", "https://test.com/?a=1%20/2"); testURLCtor("https://test.com/á=1?á=1&ü=2#é", "https://test.com/%C3%A1=1?%C3%A1=1&%C3%BC=2#%C3%A9"); assert.throws(() => new URL("test"), TypeError); assert.throws(() => new URL("ssh://EEE:ddd"), TypeError); +{ + let u = new URL("https://example.org/"); + assert.sameValue(u.__proto__.constructor, URL); + assert.sameValue(u instanceof URL, true); +} + +{ + let u = new URL("https://example.org/"); + assert.sameValue(u.searchParams, u.searchParams); +} + let myURL; // Hash @@ -109,9 +120,9 @@ testSetPort("", ""); // Completely invalid port strings are ignored testSetPort("abcd", "8888"); -testSetPort("-123", "8888"); -testSetPort(-123, "8888"); -testSetPort(-123.45, "8888"); +testSetPort("-123", ""); +testSetPort(-123, ""); +testSetPort(-123.45, ""); testSetPort(undefined, "8888"); testSetPort(null, "8888"); testSetPort(+Infinity, "8888"); @@ -120,7 +131,7 @@ testSetPort(NaN, "8888"); // Leading numbers are treated as a port number testSetPort("5678abcd", "5678"); -testSetPort("a5678abcd", "8888"); +testSetPort("a5678abcd", ""); // Non-integers are truncated testSetPort(1234.5678, "1234"); @@ -202,7 +213,5 @@ myURL.searchParams.append("user", "abc"); assert.sameValue(myURL.toString(), "https://example.com/?user=abc"); myURL.searchParams.append("first", "one"); assert.sameValue(myURL.toString(), "https://example.com/?user=abc&first=one"); -myURL.searchParams = new URLSearchParams("query=something"); -assert.sameValue(myURL.toString(), "https://example.com/?query=something"); -myURL.searchParams.delete("query"); -assert.sameValue(myURL.toString(), "https://example.com/"); +myURL.searchParams.delete("user"); +assert.sameValue(myURL.toString(), "https://example.com/?first=one"); diff --git a/url/url.go b/url/url.go index f49cb5e..9b5c2d0 100644 --- a/url/url.go +++ b/url/url.go @@ -8,6 +8,8 @@ import ( "strings" "github.com/dop251/goja" + "github.com/dop251/goja_nodejs/errors" + "golang.org/x/net/idna" ) @@ -20,20 +22,48 @@ const ( var ( reflectTypeURL = reflect.TypeOf((*nodeURL)(nil)) - reflectTypeInt = reflect.TypeOf(0) + reflectTypeInt = reflect.TypeOf(int64(0)) ) -func newInvalidURLError(r *goja.Runtime, msg, input string) *goja.Object { - // when node's error module is added this should return a NodeError - o := r.NewTypeError(msg) - o.Set("input", r.ToValue(input)) +func toURL(r *goja.Runtime, v goja.Value) *nodeURL { + if v.ExportType() == reflectTypeURL { + if u := v.Export().(*nodeURL); u != nil { + return u + } + } + + panic(errors.NewTypeError(r, errors.ErrCodeInvalidThis, `Value of "this" must be of type URL`)) +} + +func (m *urlModule) newInvalidURLError(msg, input string) *goja.Object { + o := errors.NewTypeError(m.r, "ERR_INVALID_URL", msg) + o.Set("input", m.r.ToValue(input)) return o } +func (m *urlModule) defineURLAccessorProp(p *goja.Object, name string, getter func(*nodeURL) interface{}, setter func(*nodeURL, goja.Value)) { + var getterVal, setterVal goja.Value + if getter != nil { + getterVal = m.r.ToValue(func(call goja.FunctionCall) goja.Value { + return m.r.ToValue(getter(toURL(m.r, call.This))) + }) + } + if setter != nil { + setterVal = m.r.ToValue(func(call goja.FunctionCall) goja.Value { + setter(toURL(m.r, call.This), call.Argument(0)) + return goja.Undefined() + }) + } + p.DefineAccessorProperty(name, getterVal, setterVal, goja.FLAG_FALSE, goja.FLAG_TRUE) +} + func valueToURLPort(v goja.Value) (portNum int, empty bool) { portNum = -1 if et := v.ExportType(); et == reflectTypeInt { - if num := v.ToInteger(); num >= 0 && num <= math.MaxUint16 { + num := v.ToInteger() + if num < 0 { + empty = true + } else if num <= math.MaxUint16 { portNum = int(num) } } else { @@ -41,6 +71,22 @@ func valueToURLPort(v goja.Value) (portNum int, empty bool) { if s == "" { return 0, true } + firstDigitIdx := -1 + for i := 0; i < len(s); i++ { + if c := s[i]; c >= '0' && c <= '9' { + firstDigitIdx = i + break + } + } + + if firstDigitIdx == -1 { + return -1, false + } + + if firstDigitIdx > 0 { + return 0, true + } + for i := 0; i < len(s); i++ { if c := s[i]; c >= '0' && c <= '9' { if portNum == -1 { @@ -109,36 +155,34 @@ func setURLPort(nu *nodeURL, v goja.Value) { } } -func parseURL(r *goja.Runtime, s string, isBase bool) *url.URL { +func (m *urlModule) parseURL(s string, isBase bool) *url.URL { u, err := url.Parse(s) if err != nil { if isBase { - panic(newInvalidURLError(r, InvalidBaseURL, s)) + panic(m.newInvalidURLError(InvalidBaseURL, s)) } else { - panic(newInvalidURLError(r, InvalidURL, s)) + panic(m.newInvalidURLError(InvalidURL, s)) } } if isBase && !u.IsAbs() { - panic(newInvalidURLError(r, URLNotAbsolute, s)) + panic(m.newInvalidURLError(URLNotAbsolute, s)) } if portStr := u.Port(); portStr != "" { if port, err := strconv.Atoi(portStr); err != nil || isDefaultURLPort(u.Scheme, port) { u.Host = u.Hostname() // Clear port } } - fixURL(r, u) + m.fixURL(u) return u } func fixRawQuery(u *url.URL) { if u.RawQuery != "" { - var u1 url.URL - u1.Fragment = u.RawQuery - u.RawQuery = u1.EscapedFragment() + u.RawQuery = escape(u.RawQuery, &tblEscapeURLQuery, false) } } -func fixURL(r *goja.Runtime, u *url.URL) { +func (m *urlModule) fixURL(u *url.URL) { switch u.Scheme { case "https", "http", "ftp", "wss", "ws": if u.Path == "" { @@ -148,7 +192,7 @@ func fixURL(r *goja.Runtime, u *url.URL) { lh := strings.ToLower(hostname) ch, err := idna.Punycode.ToASCII(lh) if err != nil { - panic(newInvalidURLError(r, InvalidHostname, lh)) + panic(m.newInvalidURLError(InvalidHostname, lh)) } if ch != hostname { if port := u.Port(); port != "" { @@ -158,24 +202,25 @@ func fixURL(r *goja.Runtime, u *url.URL) { } } } + fixRawQuery(u) } -func createURLPrototype(r *goja.Runtime) *goja.Object { - p := r.NewObject() +func (m *urlModule) createURLPrototype() *goja.Object { + p := m.r.NewObject() // host - defineURLAccessorProp(r, p, "host", func(u *nodeURL) interface{} { + m.defineURLAccessorProp(p, "host", func(u *nodeURL) interface{} { return u.url.Host }, func(u *nodeURL, arg goja.Value) { host := arg.String() if _, err := url.ParseRequestURI(u.url.Scheme + "://" + host); err == nil { u.url.Host = host - fixURL(r, u.url) + m.fixURL(u.url) } }) // hash - defineURLAccessorProp(r, p, "hash", func(u *nodeURL) interface{} { + m.defineURLAccessorProp(p, "hash", func(u *nodeURL) interface{} { if u.url.Fragment != "" { return "#" + u.url.EscapedFragment() } @@ -189,7 +234,7 @@ func createURLPrototype(r *goja.Runtime) *goja.Object { }) // hostname - defineURLAccessorProp(r, p, "hostname", func(u *nodeURL) interface{} { + m.defineURLAccessorProp(p, "hostname", func(u *nodeURL) interface{} { return strings.Split(u.url.Host, ":")[0] }, func(u *nodeURL, arg goja.Value) { h := arg.String() @@ -202,20 +247,19 @@ func createURLPrototype(r *goja.Runtime) *goja.Object { } else { u.url.Host = h } - fixURL(r, u.url) + m.fixURL(u.url) } }) // href - defineURLAccessorProp(r, p, "href", func(u *nodeURL) interface{} { + m.defineURLAccessorProp(p, "href", func(u *nodeURL) interface{} { return u.String() }, func(u *nodeURL, arg goja.Value) { - url := parseURL(r, arg.String(), true) - *u.url = *url + u.url = m.parseURL(arg.String(), true) }) // pathname - defineURLAccessorProp(r, p, "pathname", func(u *nodeURL) interface{} { + m.defineURLAccessorProp(p, "pathname", func(u *nodeURL) interface{} { return u.url.EscapedPath() }, func(u *nodeURL, arg goja.Value) { p := arg.String() @@ -231,12 +275,12 @@ func createURLPrototype(r *goja.Runtime) *goja.Object { }) // origin - defineURLAccessorProp(r, p, "origin", func(u *nodeURL) interface{} { + m.defineURLAccessorProp(p, "origin", func(u *nodeURL) interface{} { return u.url.Scheme + "://" + u.url.Hostname() }, nil) // password - defineURLAccessorProp(r, p, "password", func(u *nodeURL) interface{} { + m.defineURLAccessorProp(p, "password", func(u *nodeURL) interface{} { p, _ := u.url.User.Password() return p }, func(u *nodeURL, arg goja.Value) { @@ -245,7 +289,7 @@ func createURLPrototype(r *goja.Runtime) *goja.Object { }) // username - defineURLAccessorProp(r, p, "username", func(u *nodeURL) interface{} { + m.defineURLAccessorProp(p, "username", func(u *nodeURL) interface{} { return u.url.User.Username() }, func(u *nodeURL, arg goja.Value) { p, has := u.url.User.Password() @@ -257,14 +301,14 @@ func createURLPrototype(r *goja.Runtime) *goja.Object { }) // port - defineURLAccessorProp(r, p, "port", func(u *nodeURL) interface{} { + m.defineURLAccessorProp(p, "port", func(u *nodeURL) interface{} { return u.url.Port() }, func(u *nodeURL, arg goja.Value) { setURLPort(u, arg) }) // protocol - defineURLAccessorProp(r, p, "protocol", func(u *nodeURL) interface{} { + m.defineURLAccessorProp(p, "protocol", func(u *nodeURL) interface{} { return u.url.Scheme + ":" }, func(u *nodeURL, arg goja.Value) { s := arg.String() @@ -281,7 +325,8 @@ func createURLPrototype(r *goja.Runtime) *goja.Object { }) // Search - defineURLAccessorProp(r, p, "search", func(u *nodeURL) interface{} { + m.defineURLAccessorProp(p, "search", func(u *nodeURL) interface{} { + u.syncSearchParams() if u.url.RawQuery != "" { return "?" + u.url.RawQuery } @@ -289,59 +334,56 @@ func createURLPrototype(r *goja.Runtime) *goja.Object { }, func(u *nodeURL, arg goja.Value) { u.url.RawQuery = arg.String() fixRawQuery(u.url) + if u.searchParams != nil { + u.searchParams = parseSearchQuery(u.url.RawQuery) + if u.searchParams == nil { + u.searchParams = make(searchParams, 0) + } + } }) // search Params - defineURLAccessorProp(r, p, "searchParams", func(u *nodeURL) interface{} { - if u.url.RawQuery != "" && len(u.searchParams) == 0 { - sp := parseSearchQuery(u.url.RawQuery) - u.searchParams = sp + m.defineURLAccessorProp(p, "searchParams", func(u *nodeURL) interface{} { + sp := parseSearchQuery(u.url.RawQuery) + if sp == nil { + sp = make(searchParams, 0) } + u.searchParams = sp + return m.newURLSearchParams((*urlSearchParams)(u)) + }, nil) - o := r.ToValue(u).(*goja.Object) - o.SetPrototype(createURLSearchParamsPrototype(r)) - return o - }, func(u *nodeURL, arg goja.Value) { - nu := toURL(r, arg) - u.searchParams = nu.searchParams + p.Set("toString", m.r.ToValue(func(call goja.FunctionCall) goja.Value { + u := toURL(m.r, call.This) u.syncSearchParams() - }) - - p.Set("toString", r.ToValue(func(call goja.FunctionCall) goja.Value { - u := toURL(r, call.This) - - // Search Parameters are lazy loaded - if u.url.RawQuery != "" && len(u.searchParams) == 0 { - sp := parseSearchQuery(u.url.RawQuery) - u.searchParams = sp - } - copy := u.url - copy.RawQuery = u.searchParams.Encode() - return r.ToValue(u.url.String()) + return m.r.ToValue(u.url.String()) })) - p.Set("toJSON", r.ToValue(func(call goja.FunctionCall) goja.Value { - return r.ToValue(toURL(r, call.This).String()) + p.Set("toJSON", m.r.ToValue(func(call goja.FunctionCall) goja.Value { + u := toURL(m.r, call.This) + u.syncSearchParams() + return m.r.ToValue(u.url.String()) })) return p } -func createURLConstructor(r *goja.Runtime) goja.Value { - f := r.ToValue(func(call goja.ConstructorCall) *goja.Object { +func (m *urlModule) createURLConstructor() goja.Value { + f := m.r.ToValue(func(call goja.ConstructorCall) *goja.Object { var u *url.URL if baseArg := call.Argument(1); !goja.IsUndefined(baseArg) { - base := parseURL(r, baseArg.String(), true) - ref := parseURL(r, call.Argument(0).String(), false) + base := m.parseURL(baseArg.String(), true) + ref := m.parseURL(call.Argument(0).String(), false) u = base.ResolveReference(ref) } else { - u = parseURL(r, call.Argument(0).String(), true) + u = m.parseURL(call.Argument(0).String(), true) } - res := r.ToValue(&nodeURL{url: u}).(*goja.Object) + res := m.r.ToValue(&nodeURL{url: u}).(*goja.Object) res.SetPrototype(call.This.Prototype()) return res }).(*goja.Object) - f.Set("prototype", createURLPrototype(r)) + proto := m.createURLPrototype() + f.Set("prototype", proto) + proto.DefineDataProperty("constructor", f, goja.FLAG_FALSE, goja.FLAG_FALSE, goja.FLAG_FALSE) return f } diff --git a/url/urlsearchparams.go b/url/urlsearchparams.go index 40223f4..3482094 100644 --- a/url/urlsearchparams.go +++ b/url/urlsearchparams.go @@ -1,90 +1,71 @@ package url import ( - "fmt" - "net/url" "reflect" "sort" + "github.com/dop251/goja_nodejs/errors" + "github.com/dop251/goja" ) var ( - reflectTypeString = reflect.TypeOf("") - reflectTypeObject = reflect.TypeOf(map[string]interface{}{}) - reflectTypeArray = reflect.TypeOf([]interface{}{}) - reflectTypeMap = reflect.TypeOf([][2]interface{}{}) + reflectTypeURLSearchParams = reflect.TypeOf((*urlSearchParams)(nil)) + reflectTypeURLSearchParamsIterator = reflect.TypeOf((*urlSearchParamsIterator)(nil)) ) func newInvalidTupleError(r *goja.Runtime) *goja.Object { - return newError(r, "ERR_INVALID_TUPLE", "Each query pair must be an iterable [name, value] tuple") + return errors.NewTypeError(r, "ERR_INVALID_TUPLE", "Each query pair must be an iterable [name, value] tuple") } func newMissingArgsError(r *goja.Runtime, msg string) *goja.Object { - return newError(r, "ERR_MISSING_ARGS", msg) + return errors.NewTypeError(r, errors.ErrCodeMissingArgs, msg) } func newInvalidArgsError(r *goja.Runtime) *goja.Object { - return newError(r, "ERR_INVALID_ARG_TYPE", `The "callback" argument must be of type function. Received undefined`) + return errors.NewTypeError(r, "ERR_INVALID_ARG_TYPE", `The "callback" argument must be of type function.`) +} + +func toUrlSearchParams(r *goja.Runtime, v goja.Value) *urlSearchParams { + if v.ExportType() == reflectTypeURLSearchParams { + if u := v.Export().(*urlSearchParams); u != nil { + return u + } + } + panic(errors.NewTypeError(r, errors.ErrCodeInvalidThis, `Value of "this" must be of type URLSearchParams`)) } -func newError(r *goja.Runtime, code string, msg string) *goja.Object { - o := r.NewTypeError("[" + code + "]: " + msg) - o.Set("code", r.ToValue(code)) - return o +func (m *urlModule) newURLSearchParams(sp *urlSearchParams) *goja.Object { + v := m.r.ToValue(sp).(*goja.Object) + v.SetPrototype(m.URLSearchParamsPrototype) + return v } -func createURLSearchParamsConstructor(r *goja.Runtime) goja.Value { - f := r.ToValue(func(call goja.ConstructorCall) *goja.Object { - u, _ := url.Parse("") +func (m *urlModule) createURLSearchParamsConstructor() goja.Value { + f := m.r.ToValue(func(call goja.ConstructorCall) *goja.Object { + var sp searchParams v := call.Argument(0) - if !goja.IsUndefined(v) { - switch v.ExportType() { - case reflectTypeString: - u = buildParamsFromString(v.String()) - case reflectTypeObject: - u = buildParamsFromObject(r, v.ToObject(r)) - case reflectTypeArray: - u = buildParamsFromIterable(r, v.ToObject(r)) - case reflectTypeMap: - u = buildParamsFromMap(r, v.ToObject(r)) - } + if o, ok := v.(*goja.Object); ok { + sp = m.buildParamsFromObject(o) + } else if !goja.IsUndefined(v) { + sp = parseSearchQuery(v.String()) } - sp := parseSearchQuery(u.RawQuery) - res := r.ToValue(&nodeURL{url: u, searchParams: sp}).(*goja.Object) - res.SetPrototype(call.This.Prototype()) - return res + return m.newURLSearchParams(&urlSearchParams{searchParams: sp}) }).(*goja.Object) - f.Set("prototype", createURLSearchParamsPrototype(r)) - return f -} - -// If Parsing results in a path, we move this to the RawQuery -func buildParamsFromString(s string) *url.URL { - u, err := url.Parse(s) - if err != nil { - return nil - } + m.URLSearchParamsPrototype = m.createURLSearchParamsPrototype() + f.Set("prototype", m.URLSearchParamsPrototype) + m.URLSearchParamsPrototype.DefineDataProperty("constructor", f, goja.FLAG_FALSE, goja.FLAG_FALSE, goja.FLAG_FALSE) - if u.Path != "" && u.RawQuery == "" { - v, err := url.Parse(fmt.Sprintf("?%s", u.Path)) - if err != nil { - return nil - } - return v - } - - return u + return f } -func buildParamsFromObject(r *goja.Runtime, o *goja.Object) *url.URL { - query := searchParams{} +func (m *urlModule) buildParamsFromObject(o *goja.Object) searchParams { + var query searchParams - // Covers usecase where object might be a function generator. if o.GetSymbol(goja.SymIterator) != nil { - return buildParamsFromIterable(r, o) + return m.buildParamsFromIterable(o) } for _, k := range o.Keys() { @@ -92,37 +73,35 @@ func buildParamsFromObject(r *goja.Runtime, o *goja.Object) *url.URL { query = append(query, searchParam{name: k, value: val}) } - u, _ := url.Parse("") - u.RawQuery = query.String() - return u + return query } -func buildParamsFromIterable(r *goja.Runtime, o *goja.Object) *url.URL { - query := searchParams{} +func (m *urlModule) buildParamsFromIterable(o *goja.Object) searchParams { + var query searchParams - r.ForOf(o, func(val goja.Value) bool { - obj := val.ToObject(r) + m.r.ForOf(o, func(val goja.Value) bool { + obj := val.ToObject(m.r) var name, value string i := 0 // Use ForOf to determine if the object is iterable - r.ForOf(obj, func(val goja.Value) bool { + m.r.ForOf(obj, func(val goja.Value) bool { if i == 0 { - name = fmt.Sprintf("%v", val) + name = val.String() i++ return true } if i == 1 { - value = fmt.Sprintf("%v", val) + value = val.String() i++ return true } // Array isn't a tuple - panic(newInvalidTupleError(r)) + panic(newInvalidTupleError(m.r)) }) // Ensure we have two values if i <= 1 { - panic(newInvalidTupleError(r)) + panic(newInvalidTupleError(m.r)) } query = append(query, searchParam{ @@ -133,52 +112,34 @@ func buildParamsFromIterable(r *goja.Runtime, o *goja.Object) *url.URL { return true }) - u, _ := url.Parse("") - u.RawQuery = query.String() - return u -} - -func buildParamsFromMap(r *goja.Runtime, o *goja.Object) *url.URL { - query := searchParams{} - - r.ForOf(o, func(val goja.Value) bool { - obj := val.ToObject(r) - query = append(query, searchParam{ - name: obj.Get("0").String(), - value: obj.Get("1").String(), - }) - return true - }) - - u, _ := url.Parse("") - u.RawQuery = query.String() - return u + return query } -func createURLSearchParamsPrototype(r *goja.Runtime) *goja.Object { - p := r.NewObject() +func (m *urlModule) createURLSearchParamsPrototype() *goja.Object { + p := m.r.NewObject() - p.Set("append", r.ToValue(func(call goja.FunctionCall) goja.Value { + p.Set("append", m.r.ToValue(func(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 2 { - panic(newMissingArgsError(r, `The "name" and "value" arguments must be specified`)) + panic(newMissingArgsError(m.r, `The "name" and "value" arguments must be specified`)) } - u := toURL(r, call.This) + u := toUrlSearchParams(m.r, call.This) u.searchParams = append(u.searchParams, searchParam{ name: call.Argument(0).String(), value: call.Argument(1).String(), }) - u.syncSearchParams() + u.markUpdated() return goja.Undefined() })) - p.Set("delete", r.ToValue(func(call goja.FunctionCall) goja.Value { + p.Set("delete", m.r.ToValue(func(call goja.FunctionCall) goja.Value { + u := toUrlSearchParams(m.r, call.This) + if len(call.Arguments) < 1 { - panic(newMissingArgsError(r, `The "name" argument must be specified`)) + panic(newMissingArgsError(m.r, `The "name" argument must be specified`)) } - u := toURL(r, call.This) name := call.Argument(0).String() isValid := func(v searchParam) bool { if len(call.Arguments) == 1 { @@ -192,188 +153,238 @@ func createURLSearchParamsPrototype(r *goja.Runtime) *goja.Object { return true } - i := 0 - for _, v := range u.searchParams { + j := 0 + for i, v := range u.searchParams { if isValid(v) { - u.searchParams[i] = v - i++ + if i != j { + u.searchParams[j] = v + } + j++ } } - u.searchParams = u.searchParams[:i] - u.syncSearchParams() + u.searchParams = u.searchParams[:j] + u.markUpdated() return goja.Undefined() })) - p.Set("entries", r.ToValue(func(call goja.FunctionCall) goja.Value { - u := toURL(r, call.This) - entries := [][]string{} - for _, sp := range u.searchParams { - entries = append(entries, []string{sp.name, sp.value}) - } + entries := m.r.ToValue(func(call goja.FunctionCall) goja.Value { + return m.newURLSearchParamsIterator(toUrlSearchParams(m.r, call.This), urlSearchParamsIteratorEntries) + }) + p.Set("entries", entries) + p.DefineDataPropertySymbol(goja.SymIterator, entries, goja.FLAG_TRUE, goja.FLAG_FALSE, goja.FLAG_TRUE) - return r.ToValue(entries) - })) + p.Set("forEach", m.r.ToValue(func(call goja.FunctionCall) goja.Value { + u := toUrlSearchParams(m.r, call.This) - p.Set("forEach", r.ToValue(func(call goja.FunctionCall) goja.Value { if len(call.Arguments) != 1 { - panic(newInvalidArgsError(r)) + panic(newInvalidArgsError(m.r)) } - u := toURL(r, call.This) if fn, ok := goja.AssertFunction(call.Argument(0)); ok { for _, pair := range u.searchParams { // name, value, searchParams - for _, v := range pair.value { - query := u.url.RawQuery - _, err := fn( - nil, - r.ToValue(pair.name), - r.ToValue(v), - r.ToValue(query), - ) - - if err != nil { - panic(err) - } + _, err := fn( + nil, + m.r.ToValue(pair.name), + m.r.ToValue(pair.value), + call.This, + ) + + if err != nil { + panic(err) } } + } else { + panic(newInvalidArgsError(m.r)) } return goja.Undefined() })) - p.Set("get", r.ToValue(func(call goja.FunctionCall) goja.Value { + p.Set("get", m.r.ToValue(func(call goja.FunctionCall) goja.Value { + u := toUrlSearchParams(m.r, call.This) + if len(call.Arguments) == 0 { - panic(newMissingArgsError(r, `The "name" argument must be specified`)) + panic(newMissingArgsError(m.r, `The "name" argument must be specified`)) } - p := call.Argument(0) - e := p.Export() - if n, ok := e.(string); ok { - u := toURL(r, call.This) - vals := u.getValues(n) - if len(vals) > 0 { - return r.ToValue(vals[0]) - } + if val, exists := u.getFirstValue(call.Argument(0).String()); exists { + return m.r.ToValue(val) } return goja.Null() })) - p.Set("getAll", r.ToValue(func(call goja.FunctionCall) goja.Value { - if len(call.Arguments) == 0 { - panic(newMissingArgsError(r, `The "name" argument must be specified`)) - } + p.Set("getAll", m.r.ToValue(func(call goja.FunctionCall) goja.Value { + u := toUrlSearchParams(m.r, call.This) - p := call.Argument(0) - e := p.Export() - if n, ok := e.(string); ok { - u := toURL(r, call.This) - vals := u.getValues(n) - if len(vals) > 0 { - return r.ToValue(vals) - } + if len(call.Arguments) == 0 { + panic(newMissingArgsError(m.r, `The "name" argument must be specified`)) } - return r.ToValue([]string{}) + vals := u.getValues(call.Argument(0).String()) + return m.r.ToValue(vals) })) - p.Set("has", r.ToValue(func(call goja.FunctionCall) goja.Value { + p.Set("has", m.r.ToValue(func(call goja.FunctionCall) goja.Value { + u := toUrlSearchParams(m.r, call.This) + if len(call.Arguments) == 0 { - panic(newMissingArgsError(r, `The "name" argument must be specified`)) + panic(newMissingArgsError(m.r, `The "name" argument must be specified`)) } - p := call.Argument(0) - e := p.Export() - if n, ok := e.(string); ok { - u := toURL(r, call.This) - vals := u.getValues(n) - param := call.Argument(1) - if !goja.IsUndefined(param) { - cmp := param.String() - for _, v := range vals { - if v == cmp { - return r.ToValue(true) - } - } - return r.ToValue(false) - } - - return r.ToValue(u.hasName(n)) + name := call.Argument(0).String() + value := call.Argument(1) + var res bool + if goja.IsUndefined(value) { + res = u.hasName(name) + } else { + res = u.hasValue(name, value.String()) } - - return r.ToValue(false) + return m.r.ToValue(res) })) - p.Set("keys", r.ToValue(func(call goja.FunctionCall) goja.Value { - u := toURL(r, call.This) - keys := []string{} - for _, sp := range u.searchParams { - keys = append(keys, sp.name) - } - - return r.ToValue(keys) + p.Set("keys", m.r.ToValue(func(call goja.FunctionCall) goja.Value { + return m.newURLSearchParamsIterator(toUrlSearchParams(m.r, call.This), urlSearchParamsIteratorKeys) })) - p.Set("set", r.ToValue(func(call goja.FunctionCall) goja.Value { + p.Set("set", m.r.ToValue(func(call goja.FunctionCall) goja.Value { + u := toUrlSearchParams(m.r, call.This) + if len(call.Arguments) < 2 { - panic(newMissingArgsError(r, `The "name" and "value" arguments must be specified`)) + panic(newMissingArgsError(m.r, `The "name" and "value" arguments must be specified`)) } - u := toURL(r, call.This) name := call.Argument(0).String() found := false - sps := searchParams{} - for _, sp := range u.searchParams { + j := 0 + for i, sp := range u.searchParams { if sp.name == name { if found { - continue // Skip duplicates if present. + continue // Remove all values } - sp.value = call.Argument(1).String() + u.searchParams[i].value = call.Argument(1).String() found = true } - sps = append(sps, sp) + if i != j { + u.searchParams[j] = sp + } + j++ } - if found { - u.searchParams = sps - } else { + if !found { u.searchParams = append(u.searchParams, searchParam{ name: name, value: call.Argument(1).String(), }) + } else { + u.searchParams = u.searchParams[:j] } - u.syncSearchParams() + + u.markUpdated() return goja.Undefined() })) - p.Set("sort", r.ToValue(func(call goja.FunctionCall) goja.Value { - sort.Sort(toURL(r, call.This).searchParams) + p.Set("sort", m.r.ToValue(func(call goja.FunctionCall) goja.Value { + u := toUrlSearchParams(m.r, call.This) + sort.Stable(u.searchParams) + u.markUpdated() return goja.Undefined() })) - defineURLAccessorProp(r, p, "size", func(u *nodeURL) interface{} { - return len(u.searchParams) - }, nil) + p.DefineAccessorProperty("size", m.r.ToValue(func(call goja.FunctionCall) goja.Value { + u := toUrlSearchParams(m.r, call.This) + return m.r.ToValue(len(u.searchParams)) + }), nil, goja.FLAG_FALSE, goja.FLAG_TRUE) - p.Set("toString", r.ToValue(func(call goja.FunctionCall) goja.Value { - u := toURL(r, call.This) + p.Set("toString", m.r.ToValue(func(call goja.FunctionCall) goja.Value { + u := toUrlSearchParams(m.r, call.This) str := u.searchParams.Encode() - return r.ToValue(str) + return m.r.ToValue(str) + })) + + p.Set("values", m.r.ToValue(func(call goja.FunctionCall) goja.Value { + return m.newURLSearchParamsIterator(toUrlSearchParams(m.r, call.This), urlSearchParamsIteratorValues) })) - p.Set("values", r.ToValue(func(call goja.FunctionCall) goja.Value { - u := toURL(r, call.This) - values := []string{} - for _, sp := range u.searchParams { - values = append(values, sp.value) + return p +} + +func (sp *urlSearchParams) markUpdated() { + if sp.url != nil && sp.url.RawQuery != "" { + sp.url.RawQuery = "" + } +} + +type urlSearchParamsIteratorType int + +const ( + urlSearchParamsIteratorKeys urlSearchParamsIteratorType = iota + urlSearchParamsIteratorValues + urlSearchParamsIteratorEntries +) + +type urlSearchParamsIterator struct { + typ urlSearchParamsIteratorType + sp *urlSearchParams + idx int +} + +func toURLSearchParamsIterator(r *goja.Runtime, v goja.Value) *urlSearchParamsIterator { + if v.ExportType() == reflectTypeURLSearchParamsIterator { + if u := v.Export().(*urlSearchParamsIterator); u != nil { + return u } + } - return r.ToValue(values) + panic(errors.NewTypeError(r, errors.ErrCodeInvalidThis, `Value of "this" must be of type URLSearchParamIterator`)) +} + +func (m *urlModule) getURLSearchParamsIteratorPrototype() *goja.Object { + if m.URLSearchParamsIteratorPrototype != nil { + return m.URLSearchParamsIteratorPrototype + } + + p := m.r.NewObject() + + p.Set("next", m.r.ToValue(func(call goja.FunctionCall) goja.Value { + it := toURLSearchParamsIterator(m.r, call.This) + res := m.r.NewObject() + if it.idx < len(it.sp.searchParams) { + param := it.sp.searchParams[it.idx] + switch it.typ { + case urlSearchParamsIteratorKeys: + res.Set("value", param.name) + case urlSearchParamsIteratorValues: + res.Set("value", param.value) + default: + res.Set("value", m.r.NewArray(param.name, param.value)) + } + res.Set("done", false) + it.idx++ + } else { + res.Set("value", goja.Undefined()) + res.Set("done", true) + } + return res })) + p.DefineDataPropertySymbol(goja.SymToStringTag, m.r.ToValue("URLSearchParams Iterator"), goja.FLAG_FALSE, goja.FLAG_FALSE, goja.FLAG_TRUE) + + m.URLSearchParamsIteratorPrototype = p return p } + +func (m *urlModule) newURLSearchParamsIterator(sp *urlSearchParams, typ urlSearchParamsIteratorType) goja.Value { + it := m.r.ToValue(&urlSearchParamsIterator{ + typ: typ, + sp: sp, + }).(*goja.Object) + + it.SetPrototype(m.getURLSearchParamsIteratorPrototype()) + + return it +}