location-context-resolver
location-context-resolver 1.0.0

Resolve where your app runs — and where its remote counterpart lives. Typed, SSR-friendly, zero dependencies.

Contents

Contents

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.

typed end to end fresh snapshot per call frozen contexts SSR-friendly zero dependencies
Keep the resolver, not the context

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#

sh
npm i @dmytromykhailiuk/location-context-resolver
Requirements

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.

ts
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.

tslocation.ts — the single place where the topology lives
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:

ts
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#

ts
function createLocationContextResolver(options: {
  remoteOrigin: string;
  applicationBasePath?: string;
  location?: LocationLike | (() => LocationLike);
  onBasePathMismatch?: (pathname: string, applicationBasePath: string) => void;
}): LocationContextResolver;

type LocationContextResolver = () => LocationContext;

Options#

remoteOriginstring — required

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.

applicationBasePathstring

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.

locationLocationLike | (() => LocationLike)

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.

onBasePathMismatch(pathname: string, applicationBasePath: string) => void

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.

originstring

Origin the document is served from right now — location.origin, e.g. https://host.example.com.

remoteOriginstring

The configured remote origin, normalized — trailing slashes stripped.

applicationOriginstring

Current origin + base path — where this application lives here: https://host.example.com/my-app. Without a base path it equals origin.

remoteApplicationOriginstring

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.

pathnamestring

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.

queryParamsURLSearchParams

Parsed query string. A fresh instance on every resolve — mutating one snapshot's params never leaks into the next. See Query params.

Frozen, on purpose

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:

ts
// 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:

ts
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:

ts
// 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.

ts
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:

tsSSR — one resolver per request, from the request URL
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
tsa function — for a live source, read at every resolve
const resolveLocationContext = createLocationContextResolver({
  remoteOrigin: "https://remote.example.com",
  location: () => currentRequest.location, // whatever is current at call time
});
LocationLike is three fields

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.

ts
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