[ Language & values ]
@sdxc/location
URL-like Location class for URL paths without an origin
- Used by
- uptime, blog, books
- Source
- packages/location
URL-like Location class for URL paths without an origin.
A Location keeps the URL members that
describe a path — pathname, search, searchParams and hash — and none that name a server, so
a link or a redirect target is built and edited without inventing a base URL for it.
Installation
npm add @sdxc/location
Usage
Build a path
import { Location } from "@sdxc/location";
let location = new Location({ pathname: "/users/123", search: "page=1&sort=name" });
location.searchParams.set("page", "2");
location.hash = "details";
location.toString(); // "/users/123?page=2&sort=name#details"
Parse a URL down to its path
import { Location } from "@sdxc/location";
Location.from("https://example.com/users?page=1").toString(); // "/users?page=1"
Location.from("/a/../b").toString(); // "/b"
Validate an untrusted redirect target
import { Location } from "@sdxc/location";
let target = new URL(request.url).searchParams.get("returnTo");
let returnTo = Location.safe(target, { fallback: "/dashboard" });
return new Response(null, { status: 302, headers: { Location: returnTo.toString() } });
Location.safe answers the target when it is a root-relative path on your own site, and the
fallback for anything else.
API
new Location(input: URL | Location | Location.Options)
Builds a location from a URL (its origin is discarded), another Location (a copy), or
{ pathname, search?, hash? }.
location.pathname, location.search, location.searchParams, location.hash
pathname is read and written verbatim. search reads with its leading ?, or "" with no
params, and assigning it (with or without the ?) replaces every param. searchParams is the live
URLSearchParams behind
search. hash reads and writes without its #.
location.toString(): string / location.toJSON(): string
The path, plus ?search and #hash when each is non-empty; JSON.stringify serializes a location
as that string.
Location.from(input: string | URL | Location): Location
Resolves a string against a placeholder origin, then keeps only the path, so an absolute URL loses
its origin and a relative one has its . and .. segments resolved. Throws a TypeError for any
other input type.
Location.safe(input, options: Location.SafeOptions): Location
Answers input as a location when it is an unambiguous root-relative path, or an absolute URL on
options.origin reduced to its path; otherwise answers options.fallback, or / when the fallback
itself fails the check. input is string | URL | Location | null | undefined.
It compares resolved origins, so //evil.com, /\evil.com, /..//evil.com, javascript: and
data: URLs, relative paths such as ok, empty values and values carrying whitespace or control
characters all fall back. Percent-encoding is preserved: /%2F%2Fevil.com stays a path.
Location.isSafe(input: unknown, options?: Location.OriginOptions): boolean
The Location.safe check as a boolean.
Location.canParse(input: unknown): boolean
Whether Location.from accepts the input: a URL, a Location, or a string that parses as an
absolute URL or a path. It says nothing about where the value points; isSafe does.
Types
Location.Options:{ pathname: string; search?: string | URLSearchParams; hash?: string }; a leading#onhashis dropped.Location.OriginOptions:{ origin?: string | URL }, the origin whose absolute URLs count as your own.Location.SafeOptions:OriginOptionsplusfallback: string | URL | Location.
Pattern: Round-Trip A Sign-In Return Target
Carry the requested path into the sign-in page as a param, then read it back through
Location.safe, because it returns from the browser.
import { Location } from "@sdxc/location";
function signInRedirect(request: Request) {
let current = Location.from(request.url);
let signIn = new Location({
pathname: "/login",
search: new URLSearchParams({ returnTo: current.toString() }),
});
return new Response(null, { status: 302, headers: { Location: signIn.toString() } });
}
function afterSignIn(request: Request) {
let returnTo = Location.safe(new URL(request.url).searchParams.get("returnTo"), {
fallback: "/dashboard",
});
return new Response(null, { status: 303, headers: { Location: returnTo.toString() } });
}
Pattern: Rewrite One Query Param And Keep The Rest
Filters, sorting and pagination links change one param of the current URL.
import { Location } from "@sdxc/location";
function pageLink(requestUrl: string, page: number) {
let location = Location.from(requestUrl);
location.searchParams.set("page", String(page));
location.searchParams.delete("cursor");
return location.toString();
}
pageLink("https://example.com/posts?tag=css&cursor=abc", 2); // "/posts?tag=css&page=2"
The result carries no origin, so it goes straight into an href.
Pattern: Send A Form Back Where It Came From
The Referer header is an absolute URL; origin accepts your own site's URLs and reduces them to
their path, while any other site's falls back.
import { Location } from "@sdxc/location";
let ORIGIN = "https://app.example.com";
function back(request: Request) {
let target = Location.safe(request.headers.get("Referer"), { fallback: "/", origin: ORIGIN });
return new Response(null, { status: 303, headers: { Location: target.toString() } });
}
Pattern: Reject An Unsafe Target Instead Of Replacing It
An API that stores a callback path answers 400 for a bad one rather than silently storing a
fallback.
import { Location } from "@sdxc/location";
async function saveCallback(request: Request) {
let form = await request.formData();
let callback = form.get("callback");
if (!Location.isSafe(callback)) return new Response("Invalid callback path", { status: 400 });
let path = Location.from(String(callback));
return Response.json({ callback: path });
}
Response.json serializes path through toJSON, as { "callback": "/the/path" }.