sdxc

Type to search, or start from one of these:

@sdxc/location

URL-like Location class for URL paths without an origin

npm add @sdxc/location
pnpm add @sdxc/location
yarn add @sdxc/location
bun add @sdxc/location
Used by
uptime, auth-saas, blog, books

URL-like Location class for URL paths without an origin.

It keeps the URL members that describe a path — pathname, search, searchParams, hash — and drops the ones that name a server, origin, protocol, host, port and href among them. A redirect target or a link is then built and mutated without inventing a base URL for it.

Installation

npm add @sdxc/location

No dependencies, and a single entry point.

Usage

Building A Path

import { Location } from "@sdxc/location";

let location = new Location({ pathname: "/users/123", search: "page=1&sort=name" });

location.toString(); // "/users/123?page=1&sort=name"

location.searchParams.set("page", "2");
location.hash = "details";

location.toString(); // "/users/123?page=2&sort=name#details"

Validating An Untrusted Redirect Target

A ?returnTo= value arrives from the browser, so it can name another origin. Location.safe answers with the value or with the fallback, never with an attacker's destination.

let returnTo = Location.safe(url.searchParams.get("returnTo"), { fallback: "/dashboard" });

return new Response(null, { status: 302, headers: { Location: returnTo.toString() } });

API

new Location(input)

Builds a location from a URL, another Location, or Location.Options{ pathname: string; search?: string | URLSearchParams; hash?: string }. An origin on a URL input is discarded, and a leading # on hash is dropped.

Properties

  • pathname: the path, read and written verbatim — nothing is normalized or encoded on assignment.

  • search: the query string with its leading ?, or "" when there are no params. Assigning takes the string with or without the ? and replaces every param.

  • searchParams: the live URLSearchParams behind search, so set, append and delete on it change what toString() returns. Read-only as a property; assign search to replace the whole query.

  • hash: the fragment without its #, in both directions. "" leaves the fragment off the string.

location.toString() / location.toJSON()

The path, plus ?search and #hash when either is non-empty. toJSON returns the same string, so a location inside JSON.stringify serializes as its path.

Location.from(input)

Parses a string | URL | Location, resolving a relative string against a base URL and then discarding the origin — which normalizes the path. Throws a TypeError for anything else.

Location.from("https://example.com/users?page=1").toString(); // "/users?page=1"
Location.from("/a/../b").toString(); // "/b"
Location.from(location); // a clone

Location.safe(input, options)

Validates an untrusted redirect target and returns options.fallback for anything that could send a browser to another origin. It always returns a usable Location, so a caller cannot forward an attacker's value by accident, and a fallback that is itself off-origin degrades to /.

  • input: the untrusted value, as string | URL | Location | null | undefined

  • options.fallback: the destination used whenever input fails validation

  • options.origin: an origin whose absolute URLs count as ours, reduced to their path

Location.safe("/dashboard?tab=1", { fallback: "/" }).toString(); // "/dashboard?tab=1"
Location.safe("//evil.com", { fallback: "/" }).toString(); // "/"
Location.safe(null, { fallback: "/" }).toString(); // "/"

let options = { fallback: "/", origin: "https://app.example.com" };
Location.safe("https://app.example.com/foo", options).toString(); // "/foo"
Location.safe("https://evil.com/foo", options).toString(); // "/"

A startsWith("/") check is not enough: //evil.com, /\evil.com and /..//evil.com all pass it and still resolve to https://evil.com. Location.safe resolves the value against a base URL and compares origins instead, then rejects the result unless it is an unambiguous root-relative path.

Rejected: absolute URLs on an origin that was not configured, protocol-relative URLs, backslash variants, non-HTTP schemes such as javascript: and data:, relative paths with no leading slash, empty values, null, undefined, and any value carrying whitespace or a control character — new URL strips those before parsing, so they hide the real destination from string-level checks, and a newline would split a Location header. Preserved: the pathname, search and hash of a root-relative path, including percent-encoding, so /%2F%2Fevil.com stays encoded rather than becoming a host.

Location.isSafe(input, options?)

The same validation as a boolean, for a caller that branches rather than substitutes. Takes an unknown input and the same optional origin.

Location.isSafe("/dashboard"); // true
Location.isSafe("//evil.com"); // false
Location.isSafe("https://app.example.com/foo", { origin: "https://app.example.com" }); // true

Location.canParse(input)

Whether Location.from would accept the input: a URL, a Location, or a string that parses as either an absolute URL or a path. Unlike isSafe, it says nothing about where the value points.

Location.canParse("/users"); // true
Location.canParse("https://example.com"); // true
Location.canParse({}); // false

Pattern: Round-Tripping A Sign-In Return Target

Carry the path the visitor asked for into the sign-in page as a param, then read it back through Location.safe before redirecting. The write side needs no validation; the read side always does, because the value comes back from the browser.

import { Location } from "@sdxc/location";

let current = Location.from(request.url);

let signIn = new Location({
	pathname: "/login",
	search: new URLSearchParams({ returnTo: current.toString() }),
});

// On the way back, from the sign-in page's own request.
let returnTo = Location.safe(new URL(request.url).searchParams.get("returnTo"), {
	fallback: "/dashboard",
});

Pattern: Editing The Current Request's Query String

Filters, sorting and pagination rewrite one param and keep the rest, which is Location.from on the request URL plus a searchParams call.

import { Location } from "@sdxc/location";

let location = Location.from("https://example.com/posts?tag=css&cursor=abc");

location.searchParams.set("page", "2");
location.searchParams.delete("cursor");

location.toString(); // "/posts?tag=css&page=2"

The origin is gone from the result, so it goes into an href or a Location header without a second check.