Skip to content

URL Inspector — Go source

Break any URL into its components - protocol, host, port, path, query params, hash, and credentials. Detects default ports and security at a glance, with a decode toggle for query values. Runs entirely in your browser.

This is the Go implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Package urlinspector is the Go twin of CosmoDev's src/lib/url-inspector.ts
// (dual source: the web lib is TypeScript, the CLI lib is Go — kept in
// lock-step). Pure + deterministic, never panics: InspectUrl returns a report
// with Valid=false instead of throwing, mirroring the TS lib's never-throwing
// contract. The table-driven tests in urlinspector_test.go share vectors with
// src/lib/url-inspector.test.ts so the two implementations are held to the
// same contract.
//
// The twin mirrors the TS lib exactly: a flat, serialisable report of every
// URL component — including the signals the WHATWG URL API hides (credentials,
// default-vs-explicit ports, root-only/fragment-only URLs). Go's net/url is
// RFC-3986 rather than WHATWG, so the same edge cases are reproduced by hand:
// the input is trimmed before parsing, an empty hierarchical path normalises
// to "/", an explicit port is recovered straight from the raw input, IPv6
// hostnames keep their brackets, the scheme-default port is stripped from the
// origin, and the order of query parameters is preserved.
package urlinspector

import (
	"net/url"
	"regexp"
	"strings"
	"unicode/utf8"
)

// UrlParam is a single decoded query parameter, preserving insertion order.
type UrlParam struct {
	Key   string
	Value string
}

// UrlReport is the flat, serialisable decomposition of a URL. Pointer fields
// mirror the TS lib's optional (undefined-when-absent) fields: a nil pointer
// means "not present", exactly like the TS `|| undefined` semantics. Plain
// string/bool fields are always populated for a valid report.
type UrlReport struct {
	Valid        bool
	Protocol     string
	Username     *string
	Password     *string
	Host         string
	Hostname     string
	Port         *string
	Pathname     string
	Search       *string
	Hash         *string
	SearchParams []UrlParam
	Origin       string
	IsSecure     bool
	DefaultPort  *bool
	Warnings     []string
}

// defaultPorts mirrors DEFAULT_PORTS in the TS lib. TS keys carry a trailing
// ':' (e.g. "https:"); here the bare scheme is the key and ':' is re-appended
// only when a warning is emitted, so the warning text matches the TS verbatim.
var defaultPorts = map[string]string{
	"http":  "80",
	"https": "443",
	"ftp":   "21",
	"ws":    "80",
	"wss":   "443",
}

var (
	// schemeRe mirrors /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\/(.*)$/s — the (?s) flag
	// makes '.' match newlines like the TS 's' (dotAll) flag.
	schemeRe = regexp.MustCompile(`(?s)^([a-zA-Z][a-zA-Z0-9+.-]*)://(.*)$`)
	digitsRe = regexp.MustCompile(`^\d+$`)
)

// DecodeParam percent-decodes a query value, treating '+' as a space; it never
// returns an error — malformed percent-encoding yields the original string. It
// is the Go twin of decodeParam() in src/lib/url-inspector.ts.
//
// Go's url.PathUnescape returns the decoded bytes without UTF-8 validation,
// whereas the TS decodeURIComponent THROWS on a byte sequence that is not valid
// UTF-8 (e.g. the incomplete "%E0%A4"). We therefore validate the result and
// fall back to the original input whenever the decoded bytes are not a valid
// UTF-8 string — mirroring the TS try/catch exactly.
func DecodeParam(v string) string {
	s := strings.ReplaceAll(v, "+", " ")
	out, err := url.PathUnescape(s)
	if err != nil || !utf8.ValidString(out) {
		return v // malformed percent-encoding — return the original
	}
	return out
}

// rawPort reads an explicitly-written port straight from the raw input. Go's
// net/url does not normalise default ports the way the WHATWG URL API does, but
// re-parsing the authority keeps the twin byte-for-byte faithful to the TS
// rawPort() helper (which exists precisely to recover what WHATWG hides).
// Handles userinfo (user:pass@) and IPv6 literals ([::1]:8080). ok is false
// when no port is present or it is non-numeric.
func rawPort(trimmed string) (port string, ok bool) {
	m := schemeRe.FindStringSubmatch(trimmed)
	if m == nil {
		return "", false
	}
	rest := m[2]
	authorityEnd := strings.IndexAny(rest, "/?#")
	var authority string
	if authorityEnd == -1 {
		authority = rest
	} else {
		authority = rest[:authorityEnd]
	}
	atIdx := strings.LastIndex(authority, "@")
	var hostport string
	if atIdx == -1 {
		hostport = authority
	} else {
		hostport = authority[atIdx+1:]
	}

	var portCandidate string
	found := false
	if strings.HasPrefix(hostport, "[") {
		closeBracket := strings.IndexByte(hostport, ']')
		if closeBracket == -1 {
			return "", false
		}
		tail := hostport[closeBracket+1:]
		if strings.HasPrefix(tail, ":") {
			portCandidate = tail[1:]
			found = true
		}
	} else {
		colon := strings.IndexByte(hostport, ':')
		if colon != -1 {
			portCandidate = hostport[colon+1:]
			found = true
		}
	}
	if !found {
		return "", false
	}
	if digitsRe.MatchString(portCandidate) {
		return portCandidate, true
	}
	return "", false
}

// hostnameOf returns the WHATWG-style hostname: a trailing port is stripped and
// IPv6 literals keep their brackets (Go's Hostname() drops the brackets that
// WHATWG url.hostname preserves).
func hostnameOf(u *url.URL) string {
	if strings.HasPrefix(u.Host, "[") {
		if idx := strings.IndexByte(u.Host, ']'); idx != -1 {
			return u.Host[:idx+1]
		}
	}
	return u.Hostname()
}

// invalidReport mirrors the TS lib's { valid: false, warnings: [...] } shape.
func invalidReport(warning string) UrlReport {
	return UrlReport{Valid: false, Warnings: []string{warning}}
}

// InspectUrl parses and decomposes a URL into a structured report; it never
// panics. It is the Go twin of inspectUrl() in src/lib/url-inspector.ts and
// must agree with it on every shared vector.
func InspectUrl(raw string) UrlReport {
	warnings := []string{}
	trimmed := strings.TrimSpace(raw)

	if trimmed == "" {
		return invalidReport("URL is empty")
	}

	u, err := url.Parse(trimmed)
	if err != nil {
		return invalidReport("Invalid URL — could not be parsed (include the scheme, e.g. https://)")
	}
	// WHATWG treats a URL as valid only when it has an absolute scheme and an
	// authority; Go's lenient url.Parse accepts scheme-less / host-less input
	// (e.g. "example.com/path", "https://") that WHATWG rejects.
	if u.Scheme == "" || u.Host == "" {
		return invalidReport("Invalid URL — could not be parsed (include the scheme, e.g. https://)")
	}

	// Decode query params preserving insertion order (url.Values loses order).
	params := []UrlParam{}
	if u.RawQuery != "" {
		for _, pair := range strings.Split(u.RawQuery, "&") {
			var k, v string
			if i := strings.IndexByte(pair, '='); i >= 0 {
				k = pair[:i]
				v = pair[i+1:]
			} else {
				k = pair
			}
			dk, derr := url.QueryUnescape(k)
			dv, verr := url.QueryUnescape(v)
			if derr != nil || verr != nil {
				dk, dv = k, v // fall back to the raw pair on malformed encoding
			}
			params = append(params, UrlParam{Key: dk, Value: dv})
		}
	}

	// pathname — WHATWG normalises an empty hierarchical path to "/".
	pathname := u.EscapedPath()
	if pathname == "" {
		pathname = "/"
	}

	var (
		explicitPort string
		hasPort      bool
		defaultPort  *bool
	)

	report := UrlReport{
		Valid:        true,
		Protocol:     u.Scheme + ":",
		Host:         u.Host,
		Hostname:     hostnameOf(u),
		Pathname:     pathname,
		SearchParams: params,
		IsSecure:     u.Scheme == "https" || u.Scheme == "wss",
	}

	// Credentials — present only when non-empty (TS `|| undefined`).
	if u.User != nil {
		if un := u.User.Username(); un != "" {
			report.Username = &un
			warnings = append(warnings, "URL contains a username credential")
		}
		if pw, ok := u.User.Password(); ok && pw != "" {
			report.Password = &pw
			warnings = append(warnings, "URL contains a password credential")
		}
	}

	// Explicit port recovered from the raw input, mirroring TS rawPort().
	explicitPort, hasPort = rawPort(trimmed)
	if hasPort {
		ep := explicitPort
		report.Port = &ep
		expected, known := defaultPorts[u.Scheme]
		def := known && explicitPort == expected
		defaultPort = &def
		report.DefaultPort = defaultPort
		if def {
			warnings = append(warnings, "Port "+explicitPort+" is the default for "+u.Scheme+":")
		}
	}

	// Root-only URL warning (mirrors the TS pathname/search/params condition).
	if pathname == "/" && u.RawQuery == "" && len(params) == 0 {
		warnings = append(warnings, "URL points to the site root (no path or query)")
	}

	if u.RawQuery != "" {
		s := "?" + u.RawQuery
		report.Search = &s
	}
	// EscapedFragment() preserves the original encoding, matching WHATWG url.hash
	// (the raw fragment field is not reliably populated by url.Parse).
	if frag := u.EscapedFragment(); frag != "" {
		h := "#" + frag
		report.Hash = &h
	}

	// origin — scheme://host, with a scheme-default port stripped (WHATWG).
	originHost := u.Host
	if defaultPort != nil && *defaultPort {
		originHost = strings.TrimSuffix(originHost, ":"+explicitPort)
	}
	report.Origin = u.Scheme + "://" + originHost

	report.Warnings = warnings
	return report
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →