Link Resolver Functions
Widgets used to store link URLs as plain strings baked at edit time. Renaming a category or changing a page slug broke every widget pointing at it. The link resolver fixes that: widgets store a URN, and the URN is resolved to the entity's current URL at request time — batched, so a page with N internal links costs at most one query per link kind.
Plain URLs still pass straight through, so pre-URN settings and hand-typed custom links keep working.
Import
import {
registerLinkLoader,
linkLoaderFromBatch,
createLinkLoaders,
resolveLink
} from '@evershop/evershop/lib/widget/linkResolver';
import type {
LinkBatchFn,
LinkLoader,
LinkLoaderFactory,
LinkLoaders
} from '@evershop/evershop/lib/widget/linkResolver';
Types
type LinkBatchFn = (
ids: readonly string[],
pool: Pool
) => Promise<(string | null)[]>;
type LinkLoader = {
load: (id: string) => Promise<string | null>;
};
type LinkLoaderFactory = (pool: Pool) => LinkLoader;
/** Loaders keyed by `${service}:${type}`, matching the URN registry's composite key. */
type LinkLoaders = Record<string, LinkLoader>;
Built-in loaders
| Key | Resolves via | Fallback |
|---|---|---|
catalog:product | url_rewrite (entity_type = 'product') | buildUrl('productView', { uuid }) |
catalog:category | url_rewrite (entity_type = 'category') | buildUrl('categoryView', { uuid }) |
cms:page | url_rewrite (entity_type = 'cms_page'), then localizeUrl() | null — the widget suppresses the anchor |
The blog module adds blog:post, blog:category and blog:tag; the promotion module adds promotion:landing_page. There is no catalog:collection loader — collections have no public page.
registerLinkLoader
registerLinkLoader(
service: string,
type: string,
factory: LinkLoaderFactory
): void
Register a loader for a custom URN type. Internally an addProcessor('linkLoaderFactories', …) call.
Parameters
| Parameter | Type | Description |
|---|---|---|
service | string | The URN service segment. Must match a registered URN schema. |
type | string | The URN type segment. Must match a registered URN schema. |
factory | LinkLoaderFactory | Called once per request with the pg Pool; returns the request-scoped loader. Build it with linkLoaderFromBatch. |
Return Value
void.
Throws
The value registry locks once bootstrap completes. Calling registerLinkLoader from a middleware, resolver or API handler throws Registry is locked. …. Register from bootstrap.ts.
registerLinkLoader does not register the URN schema. Without a matching registerUrnSchema(...) call, resolveLink fails to parse the URN and returns null before your loader is ever consulted.
Example
import { registerUrnSchema } from '@evershop/evershop/lib/urn';
import {
registerLinkLoader,
linkLoaderFromBatch
} from '@evershop/evershop/lib/widget/linkResolver';
import { select } from '@evershop/evershop/lib/postgres/query';
export default () => {
registerUrnSchema({
service: 'reviews',
type: 'review',
description: 'Product review'
});
registerLinkLoader(
'reviews',
'review',
linkLoaderFromBatch(async (uuids, pool) => {
if (uuids.length === 0) return [];
const rows = await select('uuid', 'slug')
.from('review')
.where('uuid', 'IN', [...uuids])
.execute(pool);
const m = new Map(rows.map((r) => [r.uuid, `/reviews/${r.slug}`]));
return uuids.map((u) => m.get(u) ?? null);
})
);
};
linkLoaderFromBatch
linkLoaderFromBatch(batchFn: LinkBatchFn): LinkLoaderFactory
Wrap a batch function into a loader factory. The returned factory builds a tiny request-scoped batcher that coalesces every .load() call made in the same microtask into one batchFn invocation, and memoizes results for the life of the request.
Parameters
batchFn
Type: LinkBatchFn
Receives (ids, pool) and must return an array of URLs (or null) in the same order and of the same length as ids. A short array is padded with null — a reordered array silently mislabels links.
Return Value
A LinkLoaderFactory ready to pass to registerLinkLoader.
Example
import { linkLoaderFromBatch } from '@evershop/evershop/lib/widget/linkResolver';
import { select } from '@evershop/evershop/lib/postgres/query';
const factory = linkLoaderFromBatch(async (uuids, pool) => {
if (uuids.length === 0) return [];
const rows = await select('uuid', 'slug')
.from('blog_post')
.where('uuid', 'IN', [...uuids])
.execute(pool);
const m = new Map(rows.map((r) => [r.uuid, `/blog/${r.slug}`]));
// Same order as `uuids` — this is the contract.
return uuids.map((u) => m.get(u) ?? null);
});
createLinkLoaders
createLinkLoaders(pool: Pool): LinkLoaders
Instantiate every registered factory into a fresh set of request-scoped loaders. Core calls this once per request in the GraphQL middleware and puts the result on the GraphQL context as linkLoaders.
You rarely call it yourself — reach for it only when resolving links outside a GraphQL request (a cron job rendering an email, a script).
Example
import { pool } from '@evershop/evershop/lib/postgres';
import {
createLinkLoaders,
resolveLink
} from '@evershop/evershop/lib/widget/linkResolver';
const loaders = createLinkLoaders(pool);
const href = await resolveLink(storedValue, loaders);
The loaders memoize results forever. Their cache is only correct because it dies with the request. A module-level createLinkLoaders(pool) will serve stale URLs after the first rename.
resolveLink
resolveLink(
value: string | null | undefined,
loaders: LinkLoaders | undefined
): Promise<string | null>
Resolve a stored link value to a current URL.
Parameters
| Parameter | Type | Description |
|---|---|---|
value | string | null | undefined | A URN, a plain URL, or nothing. |
loaders | LinkLoaders | undefined | The request-scoped loader set, normally context.linkLoaders. |
Return Value
Promise<string | null>:
| Input | Result |
|---|---|
| Empty / null / undefined | null |
| Plain URL with a safe scheme | Returned unchanged |
Plain URL with an unsafe scheme (javascript:, data:, …) | null — the anchor is suppressed rather than becoming an href XSS |
| Valid URN with a registered loader | The loader's answer — the current URL, or null when the entity is gone |
| Valid URN with no registered loader | null |
Malformed URN, or a URN whose (service, type) is unregistered | Treated as a plain URL and run through the safe-scheme check |
resolveLink never throws.
Example
import { resolveLink } from '@evershop/evershop/lib/widget/linkResolver';
export default {
MyWidget: {
ctaUrl: async ({ settings }, _, { linkLoaders }) =>
(await resolveLink(settings.ctaLink, linkLoaders)) ?? null
}
};
Failure mode: a throwing loader renders a link with no href
The batcher deliberately swallows loader errors:
try {
const values = await batchFn(ids, pool);
batch.forEach(({ resolve }, i) => resolve(values[i] ?? null));
} catch {
// A loader failure should never break the page — return null for all
// in-flight ids so widgets render with a missing link instead of a 500.
batch.forEach(({ resolve }) => resolve(null));
}
A bad column name, a dropped table or a transient DB error inside your batchFn therefore produces no error, no log line, and no 500 — every id in that batch simply resolves to null and the widget renders without an href. This is the intended trade (a broken link beats a broken storefront), but it means link-loader bugs are invisible from the outside.
This exact bug shipped once in core: the cms:page loader selected url_key from cms_page, but url_key lives on cms_page_description. The query threw on every request, every page link silently resolved to null, and the only symptom was unclickable menu items.
When links come back empty, debug the loader directly rather than looking at the widget:
import { pool } from '@evershop/evershop/lib/postgres';
import { createLinkLoaders } from '@evershop/evershop/lib/widget/linkResolver';
const loaders = createLinkLoaders(pool);
console.log(await loaders['reviews:review'].load(someUuid));
Checklist when a link resolves to null:
- Is the
(service, type)registered withregisterUrnSchema? If not,resolveLinkbails before your loader. - Is the loader registered under exactly the same
(service, type)? - Does
batchFnreturn an array the same length and order asids? - Does the entity row still exist?
- Run
batchFn's query by hand — the batcher hid the error.
See Also
- URN — The identifier format and its registry
- Widget Link Fields — The admin link picker that produces these values
- Widget Development — Building widgets
- registerWidget — Widget registration