location-context-resolver
Resolve where your app runs — origin, base-path-relative pathname, query params — and where its remote counterpart lives. Typed, SSR-friendly, zero dependencies.
Built for apps that are served from more than one place —
a micro-frontend mounted under /my-app on a host
shell, mirrored on its own remote origin; a white-label product
deployed under a different base path per customer; any setup where
"what is my URL?" has two answers. At that point hand-rolled
window.location parsing starts to hurt:
base-path stripping via split()
breaks on paths it doesn't expect, remote URLs
are glued together with fragile string concatenation, and none of
it runs during SSR.
This library answers with one small, deliberate API. You declare the topology once — the remote origin and the base path — and get back a resolver: a function that, at call time, reads the location and returns a typed, frozen context: both origins, both application origins, the pathname relative to the base path, and parsed query params.
A context is a snapshot — it describes the
location at the moment of the call and is frozen so it can't
drift after the fact. In an SPA the location changes without a
reload, so a cached context goes stale the moment the user
navigates. Pass the resolver around and call it where
the current location is needed; treat each context as
short-lived. Resolving is cheap — a few string operations and
one URLSearchParams — there is nothing worth
caching.
Getting started
Install#
npm i @dmytromykhailiuk/location-context-resolver
None. No runtime dependencies, no framework — plain TypeScript that works in any browser and in Node ≥ 18. Ships ESM and CJS with type declarations for both.
Quick start#
Create the resolver once at module scope. Call it wherever the current location is needed — every call reads the location afresh.
import { createLocationContextResolver } from "@dmytromykhailiuk/location-context-resolver";
const resolveLocationContext = createLocationContextResolver({
remoteOrigin: "https://remote.example.com",
applicationBasePath: "/my-app",
});
// current URL: https://host.example.com/my-app/users/42?tab=posts
const context = resolveLocationContext();
context.origin; // "https://host.example.com"
context.remoteOrigin; // "https://remote.example.com"
context.applicationOrigin; // "https://host.example.com/my-app"
context.remoteApplicationOrigin; // "https://remote.example.com/my-app"
context.pathname; // "/users/42" — relative to the base path
context.queryParams.get("tab"); // "posts"
The context is frozen and fully typed — see
The context object for every field,
and Base path for exactly how
pathname is derived.
One topology, two origins#
The library's one opinion: where the application lives — its remote origin, its base path — is configuration, declared once; where the user is right now is state, read at call time. The resolver is the border between the two.
import { createLocationContextResolver } from "@dmytromykhailiuk/location-context-resolver";
export const resolveLocationContext = createLocationContextResolver({
remoteOrigin: import.meta.env.VITE_REMOTE_ORIGIN,
applicationBasePath: import.meta.env.BASE_URL,
});
// anywhere else in the app
import { resolveLocationContext } from "./location";
const { remoteApplicationOrigin, pathname } = resolveLocationContext();
fetch(`${remoteApplicationOrigin}/api${pathname}`);
Creating the resolver never reads the location — module scope is safe in any environment, SSR included. Only the call does; see SSR & custom locations. The one thing validated at creation is the configuration itself:
createLocationContextResolver({ remoteOrigin: "" });
// Error: "remoteOrigin" must be a non-empty string
A resolver that can't name its remote is a configuration bug — an env variable that didn't load — worth failing loudly on, at startup, not at the first navigation.
Reference
createLocationContextResolver#
function createLocationContextResolver(options: {
remoteOrigin: string;
applicationBasePath?: string;
location?: LocationLike | (() => LocationLike);
onBasePathMismatch?: (pathname: string, applicationBasePath: string) => void;
}): LocationContextResolver;
type LocationContextResolver = () => LocationContext;
Options#
Origin — optionally with a path prefix of its own, like
https://cdn.example.com/apps — where the
application's remote counterpart is served. Trailing slashes
are ignored. An empty string throws at
creation.
Path prefix the application is mounted under, e.g.
/my-app. Leading and trailing slashes are
optional. When set, pathname in the resolved
context is relative to it, and both application origins
include it. See Base path.
Where to read the current location from. Defaults to the
browser's location. Pass an object for a fixed
location (a request URL during SSR) or a function to read a
live source on every resolve. See
SSR & custom locations.
Called when the current pathname is not under
applicationBasePath — the context then carries
the full pathname unchanged. Defaults to a
console.warn. See
Matching & mismatches.
The context object#
Every call returns a fresh, frozen LocationContext —
a snapshot of the location at that moment. All fields are
readonly.
Origin the document is served from right now —
location.origin, e.g.
https://host.example.com.
The configured remote origin, normalized — trailing slashes stripped.
Current origin + base path — where this application lives
here: https://host.example.com/my-app.
Without a base path it equals origin.
Remote origin + base path — where this application lives
remotely:
https://remote.example.com/my-app. The natural
base for asset URLs and cross-origin API calls.
Pathname relative to the base path, always with a leading slash — the pathname your router thinks in. Without a base path — the full pathname. See Base path.
Parsed query string. A fresh instance on every resolve — mutating one snapshot's params never leaks into the next. See Query params.
The context is Object.freeze-d: a snapshot that
could be patched after the fact would silently disagree with the
location it claims to describe. Need different values? Resolve
again.
Base path#
When an application is mounted under a prefix, two views of the
pathname coexist: the document's —
/my-app/users/42 — and the
application's — /users/42. Routers,
analytics and links inside the app think in the second one;
window.location only offers the first.
applicationBasePath is the bridge: declare the prefix
once and pathname comes back relative.
Normalization#
Slashes on either end are optional — all four spellings configure the same base path, so it can come straight from an env variable or a bundler constant without ceremony:
// all equivalent:
applicationBasePath: "/my-app"
applicationBasePath: "my-app"
applicationBasePath: "my-app/"
applicationBasePath: "/my-app/"
// multi-segment prefixes work the same way:
applicationBasePath: "/org/my-app"
Matching & mismatches#
Stripping happens only at a segment boundary — a pathname that merely starts with the base path as a string belongs to a different app and is left alone:
| location.pathname | context.pathname | why |
|---|---|---|
/my-app/users/42 |
/users/42 |
under the base path — stripped |
/my-app |
/ |
the base path itself — the app's root |
/my-app-admin/x |
/my-app-admin/x |
no segment boundary — a different app |
/other/users |
/other/users |
mismatch — reported, returned unchanged |
A pathname outside the base path is never mangled
— the naive pathname.split(basePath)[1] would hand
you undefined or a fragment cut in the middle of a
word. Instead the full pathname is returned unchanged, and the
mismatch is reported through onBasePathMismatch:
const resolveLocationContext = createLocationContextResolver({
remoteOrigin: "https://remote.example.com",
applicationBasePath: "/my-app",
onBasePathMismatch: (pathname, basePath) => report({ pathname, basePath }),
});
// at /other/users:
resolveLocationContext().pathname; // "/other/users" — unchanged, mismatch reported
The default handler is a console.warn naming the
library, the pathname and the base path — a misconfigured mount is
visible in development even when you configure nothing, and
navigation keeps working either way.
Query params#
queryParams is a standard
URLSearchParams built from the current query string —
decoding, repeated keys and iteration all behave exactly as the
platform defines them:
// at /my-app/search?q=hello%20world&tag=a&tag=b
const { queryParams } = resolveLocationContext();
queryParams.get("q"); // "hello world"
queryParams.getAll("tag"); // ["a", "b"]
queryParams.has("missing"); // false
[...queryParams.entries()]; // [["q", "hello world"], ["tag", "a"], ["tag", "b"]]
Each resolve builds a fresh instance.
URLSearchParams is mutable by design — the freeze on
the context can't reach inside it — so the fresh copy is what
keeps snapshots independent: mutate one to build a link, and the
next resolve still reflects the URL, not your edits.
const a = resolveLocationContext();
a.queryParams.set("page", "2"); // building the "next page" link
const href = `?${a.queryParams}`; // "?q=hello+world&tag=a&tag=b&page=2"
resolveLocationContext().queryParams.get("page"); // null — the URL didn't change
SSR & custom locations#
By default the resolver reads globalThis.location —
the browser's. The split matters for everywhere else: creating a
resolver never touches the location, so a module
that calls createLocationContextResolver at top level
imports cleanly in Node, workers and tests. Only
calling the resolver needs a location — and where there
is none, it throws a clear error naming the fix rather than
resolving to something made up.
To run outside the browser, pass the location in — an object for a fixed snapshot, or a function read on every resolve:
const url = new URL(request.url);
const resolveLocationContext = createLocationContextResolver({
remoteOrigin: "https://remote.example.com",
applicationBasePath: "/my-app",
location: { origin: url.origin, pathname: url.pathname, search: url.search },
});
resolveLocationContext().pathname; // relative to /my-app — same as in the browser
const resolveLocationContext = createLocationContextResolver({
remoteOrigin: "https://remote.example.com",
location: () => currentRequest.location, // whatever is current at call time
});
origin, pathname, search —
nothing else is read. The browser's location
satisfies it automatically, and so does a plain object, a
URL, or anything you can project those three fields
from.
TypeScript#
Everything is typed end to end: the options object requires
remoteOrigin — forgetting it is a compile error, not
a runtime surprise — and every context field is
readonly, so the type system enforces the same
immutability the runtime freeze does.
import type {
LocationContext,
LocationContextResolver,
LocationContextResolverOptions,
LocationLike,
} from "@dmytromykhailiuk/location-context-resolver";
const resolve: LocationContextResolver = createLocationContextResolver({
remoteOrigin: "https://remote.example.com",
});
const context: LocationContext = resolve();
context.pathname; // string
context.queryParams; // URLSearchParams
// @ts-expect-error — readonly
context.pathname = "/other";
LocationLike is deliberately the smallest usable
shape — origin, pathname,
search. Structural typing does the rest: the DOM's
Location is assignable to it, and in tests a plain
object literal is all a fake needs.
Exports#
Values
createLocationContextResolver
Types
LocationContext ·
LocationContextResolver ·
LocationContextResolverOptions ·
LocationLike