@mitranim/ur v0.1.6
Overview
URL and query implementation for JS. Like built-in URL but actually usable. Features:
- Somewhat aligned with
URLAPI. - Almost everything is optional. In particular:
.protocolis optional..pathnameis optional.
- Various common-sense shortcuts.
- Fluent builder-style API.
- Support for correctly joining/appending URL paths.
- Support for traditional "query dictionaries" like
{key: ['val']}. - Support for patching/merging queries.
- Better compatibility with custom URL schemes.
- Less information loss.
- No magic defaults, fallbacks, automatic appending, or automatic prepending.
.pathnameis preserved from input exactly as-is.- Empty
.originis'', not'null'.
- Stricter validation of input types and string formats.
- Nil is considered
'', not'null'or'undefined'. - Accidental stringification of junk like
'[object Object]'is forbidden and causes exceptions. - Query keys must be strings. Nil keys are considered missing.
- Invalid inputs for various URL components cause exceptions instead of being silently converted to garbage, truncated, or ignored.
- Nil is considered
- Subclassable.
- Can subclass
Searchand override it for yourUrlvariant. - Can override any getter, setter, or method.
- Compatible with proxies and
Object.create. - No "illegal invocation" exceptions.
- Can subclass
- No special cases for "known" URL schemes.
SearchisMap<string, string[]>as it should be.- Automatically stringable as it should be.
- Decent test coverage.
- Decent benchmark coverage.
- Tuned for #performance.
- Browser compatibility: evergreen, Safari 11+.
- Tiny, dependency-free, single file, native module.
TOC
Why
The JS built-in URL implementation is insane. I have no other words for it.
Various issues:
- Requires
.protocol. WTF. In real app code, both on client and server, many URLs are relative to website origin, without a protocol.- This alone can force app authors to either avoid
URL, or use hacks involving a fake protocol likefile:.
- This alone can force app authors to either avoid
- Empty
.originis'null'rather than''. WTF.- Even worse:
.originis'null'for any custom scheme. It works only for a small special-cased whitelist.
- Even worse:
- Unwanted garbage by default:
- Forces empty
.pathnamefor some schemes to be'/'rather than''.- But only for some schemes!
- Non-empty
.hashstarts with#, which is often undesirable. - Non-empty
.searchstarts with?, which is often undesirable. - I always end up with utility functions for stripping this away.
- But non-empty
.portdoesn't start with:because lolgic!
- Forces empty
URLproperty setters andURLSearchParamsmethods stringify nil values as some junk rather than''.nullbecomes'null',undefinedbecomes'undefined'. In JS, where nil is an automatic fallback for a missing value, this is asinine. Nil should be considered''.- No support for appending path segments, which is an extremely common use case. WTF.
new URL(<path>, <base>)is not good enough. It requires<base>to have an origin (real website links often don't), and works only if path and base begin/end with the right amount of slashes, forcing app authors to write utility functions for stripping/appending/prepending slashes.
- Made-up component
.protocolis unusable.- The URI standard defines "scheme" which does not include
:or//. The JSURLlacks.scheme; its.protocolincludes:but not//, which is the worst possible choice. - The lack of
//makes it impossible to programmatically differentiate protocols likehttp://from protocols likemailto:without a special-case whitelist, which is of course not exposed by this implementation. URLs are a general-purpose structured data format which is extensible, and custom protocols are frequently used. Special-case whitelists should not be required for using your API, or at the very least they must be exposed. - The no-less-atrocious Go
net/url.URLcorrectly uses a "scheme" field without:, but makes the same mistake of hiding the knowledge of whether the original string had//in its protocol.
- The URI standard defines "scheme" which does not include
URLSearchParamsis nearly unusable:- Garbage inputs → garbage outputs. Nil is converted to
'null'or'undefined'. Various non-stringable objects are converted to'[object Object]'. This insanity has to stop. - Lacks support for traditional "query dictionaries" which are extremely popular in actual apps.
- Lacks support for patching and merging. Can be emulated by spreading
.entries()into constructors which is bulky and inefficient. - Lacks various common-sense methods:
.setAll,.appendAll,.clear. - Can't override
url.searchParamswith a custom subclass. - Instead of being a normal
Map<string, string[]>, its iteration methods are bizarre and made-up just for this. Nobody needs this weirdness. This just makes things slower and more surprising.
- Garbage inputs → garbage outputs. Nil is converted to
- Many operations are much slower than possible.
Perf
- Checked with benchmarks.
- Uses various optimizations such as lazy query parsing, string caching, structural copying instead of reparsing.
- Most operations seem to perform significantly better than corresponding built-ins in Deno 1.17 / V8 9.7+.
Usage
In browsers and Deno, import by URL:
import * as u from 'https://cdn.jsdelivr.net/npm/@mitranim/ur@0.1.6/ur.mjs'When using Node or NPM-oriented bundlers like Esbuild:
npm i -E @mitranim/urExample parsing:
const url = u.url(`https://example.com/path?key=val#hash`)
url.pathname // '/path'
url.search // 'key=val'
url.hash // 'hash'
url.query.get(`key`) // 'val'
url.query.dict() // {key: 'val'}
url.query.dictAll() // {key: ['val']}Example segmented path:
u.url(`https://example.com`).setPath(`/api/msgs`, 123, `get`) + ``
// 'https://example.com/api/msgs/123/get'Example without scheme/protocol:
u.url(`/api`).addPath(`msgs`, 123, `get`) + ``
// '/api/msgs/123/get'Example query dict support:
u.url(`/profile`).addQuery({action: `edit`}) + ``
// `'/profile?action=edit'API
function url
Same as #new Url but syntactically shorter.
function search
Same as #new Search but syntactically shorter.
class Url
Like URL but much better. See #Overview for some differences.
type UrlLike = string | Url | URL | Location
type StrLike = boolean | number | string
type SearchDictLax = Record<string, string | string[]>
type SearchLike = string | Search | URLSearchParams | SearchDictLax
class Url {
constructor(src?: UrlLike)
// All of the following are getter/setters.
// Many are covariant with each other.
scheme: string // Without ':' or '//'.
slash: string // Either '' or '//'.
username: string // Without '@'.
password: string // Without ':' or '@'.
hostname: string
port: string
pathname: string
search: string // Without leading '?'.
searchParams: Search
query: Search
hash: string // Without leading '#'.
protocol: string
host: string
origin: string
href: string
// All of the following set the corresponding property,
// mutating and returning the same `Url` reference.
// Passing nil clears the corresponding property.
setScheme (val?: string): Url
setSlash (val?: string): Url
setUsername (val?: string): Url
setPassword (val?: string): Url
setHostname (val?: string): Url
setPort (val?: number | string): Url
setPathname (val?: string): Url
setSearch (val?: string): Url
setSearchParams (val?: SearchLike): Url
setQuery (val?: SearchLike): Url
setHash (val?: string): Url
setHashExact (val?: string): Url
setProtocol (val?: string): Url
setHost (val?: string): Url
setOrigin (val?: string): Url
setHref (val?: string): Url
// All of these return a clone with the corresponding property updated.
withScheme (val?: string): Url
withSlash (val?: string): Url
withUsername (val?: string): Url
withPassword (val?: string): Url
withHostname (val?: string): Url
withPort (val?: number | string): Url
withPathname (val?: string): Url
withSearch (val?: string): Url
withSearchParams (val?: SearchLike): Url
withQuery (val?: SearchLike): Url
withHash (val?: string): Url
withHashExact (val?: string): Url
withProtocol (val?: string): Url
withHost (val?: string): Url
withOrigin (val?: string): Url
withHref (val?: string): Url
// Replace `.pathname` with slash-separated segments.
// Empty or non-stringable segments cause an exception.
setPath(...vals: StrLike[]): Url
// Like `.setPath` but appends to an existing path.
addPath(...vals: StrLike[]): Url
// Reinitializes the `Url` object from the input.
// Mutates and returns the same reference.
// Passing nil is equivalent to `.clear`.
mut(src?: UrlLike): Url
// Clears all properties. Mutates and returns the same reference.
clear(): Url
// Returns a cloned version.
// Future mutations are not shared.
// Cheaper than reparsing.
clone(): Url
// Converts to built-in `URL`, for compatibility with APIs that require it.
toURL(): URL
// Same as `.href`. Enables automatic JS stringification.
toString(): string
// Enables automatic JSON string encoding.
// As a special case, empty url is considered null.
toJSON(): string | null
// All of these are equivalent to `.toString()`. This object may be considered
// a primitive/scalar, equivalent to a string in some contexts.
valueOf(): string
[Symbol.toPrimitive](hint?: string): string
// Class used internally for instantiating `.searchParams`.
// Can override in subclass.
get Search(): {new(): Search}
// Shortcut for `new this(val).setPath(...vals)`.
static join(val: UrlLike, ...vals: StrLike[]): Url
}Warning: this library does not support parsing bare-domain URLs like example.com without a scheme. They cannot be syntactically distinguished from a bare pathname, which is a more important use case. However, Url does provide a shortcut for generating a string like this:
u.url(`https://example.com/path`).hostPath() === `example.com/path`
u.url(`scheme://host:123/path?key=val#hash`).hostPath() === `host:123/path`class Search
Like URLSearchParams but much better. See #Overview for some differences.
type StrLike = boolean | number | string
type SearchDictLax = Record<string, string | string[]>
type SearchDictSingle = Record<string, string>
type SearchDictMulti = Record<string, string[]>
type SearchLike = string | Search | URLSearchParams | SearchDictLax
class Search extends Map<string, string[]> {
constructor(src?: SearchLike)
// Similar to the corresponding methods of `URLSearchParams`,
// but with stricter input validation. In addition, instead of
// returning void, they return the same reference for chaining.
// A nil key is considered missing, and the operation is a nop.
// A nil val is considered to be ''.
has(key?: string): boolean
get(key?: string): string | undefined
getAll(key?: string): string[]
set(key?: string, val?: StrLike): Search
append(key?: string, val?: StrLike): Search
delete(key?: string): boolean
// Common-sense methods missing from `URLSearchParams`.
// Names and signatures are self-explanatory.
setAll(key?: string, vals?: StrLike[]): Search
setAny(key?: string, val?: StrLike | StrLike[]): Search
appendAll(key?: string, vals?: StrLike[]): Search
appendAny(key?: string, val?: StrLike | StrLike[]): Search
// Reinitializes the `Search` object from the input.
// Mutates and returns the same reference.
// Passing nil is equivalent to `.clear`.
mut(src?: SearchLike): Search
// Appends the input's content to the current `Search` object.
// Mutates and returns the same reference.
add(src?: SearchLike): Search
// Combination of `.get` and type conversion.
// Nil if property is missing or can't be converted.
bool(key?: string): boolean | undefined
int(key?: string): number | undefined
fin(key?: string): number | undefined
// Conversion to a traditional "query dictionary".
dict(): SearchDictSingle
dictAll(): SearchDictMulti
// Returns a cloned version.
// Future mutations are not shared.
// Cheaper than reparsing.
clone(): Search
// Converts to built-in search params.
// Note that `new URLSearchParams(<u.Search>)` should be avoided.
toURLSearchParams(): URLSearchParams
// Same as `.toString` but prepends '?' when non-empty.
toStringFull(): string
// Encodes to a string like 'key=val'.
// Enables automatic JS stringification.
// Uses caching: if not mutated between calls, this is nearly free.
toString(): string
// Enables automatic JSON string encoding.
// As a special case, empty url is considered null.
toJSON(): string | null
}Warning: while Search is mostly compatible with URLSearchParams, it has different iteration methods. The iteration methods of URLSearchParams are something bizarre and made-up just for this type:
[...new URLSearchParams(`one=two&one=three&four=five`)]
// [[`one`, `two`], [`one`, `three`], [`four`, `five`]]Meanwhile Search is Map<string, string[]>:
[...new u.Search(`one=two&one=three&four=five`)]
// [[`one`, [`two`, `three`]], [`four`, [`five`]]]The following works properly:
new u.Search(new URLSearchParams(`one=two&one=three&four=five`))
new u.Search(`one=two&one=three&four=five`).toURLSearchParams()But the following does not work properly and should be avoided:
new URLSearchParams(new u.Search(`one=two&one=three&four=five`))Undocumented
Some APIs are exported but undocumented to avoid bloating the docs. Check the source files and look for export.
Limitations
Urllacks support for optional base URL. Constructor takes only 1 value.Searchiterates asMap<string, string[]>, not asURLSearchParams.
License
Misc
I'm receptive to suggestions. If this library almost satisfies you but needs changes, open an issue or chat me up. Contacts: https://mitranim.com/#contacts