🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@amritk/resolve-refs

Package Overview
Dependencies
Maintainers
1
Versions
13
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@amritk/resolve-refs - npm Package Compare versions

Comparing version
0.3.0
to
0.4.0
+100
dist/resource-registry.d.ts
import type { RefKeyword } from './reference.js';
import type { JsonPath } from './types.js';
/**
* JSON Schema 2020-12 `$id` base-URI scoping, modelled as a pre-computed
* registry of the document's embedded resources and anchors.
*
* A subschema carrying `$id` is an *embedded resource*: it establishes a new
* base URI (its `$id` resolved against the enclosing base), refs inside it
* resolve against that base, and its anchors are scoped to it. Bundled schemas
* lean on this — each `$defs` entry declares an absolute `$id` and the rest of
* the document references it by URI rather than by `#/$defs/...` pointer.
*
* Supported subset (deliberate, documented in the package README):
*
* - Anchors (`$anchor`/`$dynamicAnchor`) resolve within the referencing
* resource's scope first, falling back to a document-global search — so
* duplicate anchor names under different `$id`s bind correctly.
* - A ref whose URI (resolved against the enclosing base) matches an embedded
* resource's `$id` resolves to that resource without any fetching; a pointer
* or anchor fragment on such a ref applies *within* that resource.
* - A plain `#/pointer` fragment stays **document-root-relative** (matching the
* previous behavior and what bundled real-world documents rely on), even when
* it appears inside an embedded resource.
* - `$dynamicRef` prefers a `$dynamicAnchor` in scope, then degrades to `$ref`
* semantics. The full dynamic-scope algorithm (outermost anchor along the
* *runtime* reference chain) is not modelled — for the single bundled
* document this resolver produces, the two agree unless the same
* `$dynamicAnchor` name is redeclared across resources *and* dispatched
* through recursive scopes.
* - Document *retrieval* is unaffected: which file/URL an external ref loads
* from is still derived from the referencing document's location, not from
* its `$id` — so a root `$id` naming a remote URL never turns a local
* sibling-file ref into a network fetch.
*/
/** A resolved scoped target: the node, its in-document path, and its base URI. */
export type ScopedTarget = {
value: unknown;
pointer: JsonPath;
base: string;
};
export type ResourceRegistry = {
/** Normalized absolute URI (fragment stripped) → embedded resource root. */
resources: Map<string, {
value: unknown;
pointer: JsonPath;
}>;
/** `${base}#${name}` → target, for `$anchor` and `$dynamicAnchor` alike. */
staticAnchors: Map<string, {
value: unknown;
pointer: JsonPath;
}>;
/** `${base}#${name}` → target, for `$dynamicAnchor` only. */
dynamicAnchors: Map<string, {
value: unknown;
pointer: JsonPath;
}>;
/** The document root's base URI (its root `$id` resolved, or the initial base). */
rootBase: string;
/** The document root value, for root-relative pointer lookups. */
root: unknown;
};
/**
* The synthetic base used when a document has no natural URI to resolve
* relative `$id`s against (an in-memory document, or a local file path — which
* is not a URL). Uses the reserved `.invalid` TLD so it can never collide with
* a real `$id`.
*/
export declare const SYNTHETIC_BASE = "https://mjst-internal.invalid/document";
/** `new URL(ref, base).href`, or `undefined` when the pair does not parse. */
export declare const resolveUri: (ref: string, base: string) => string | undefined;
/** Splits a ref string into its URI part and its fragment (pointer or anchor name). */
export declare const splitFragment: (ref: string) => {
uriPart: string;
fragment: string;
};
/**
* Walks the document once, registering every embedded resource (`$id`) and
* every anchor under the base URI it is scoped to. First declaration wins on
* a duplicate URI or anchor name, matching document order.
*/
export declare const buildResourceRegistry: (root: unknown, initialBase?: string) => ResourceRegistry;
/**
* The base URI `node` establishes via its `$id`, as assigned during the
* registry walk (identity lookup), or `enclosing` when it declares none that
* registered. Resolvers call this while walking so refs inside an embedded
* resource resolve against the right base.
*/
export declare const baseOfNode: (registry: ResourceRegistry, node: Record<string, unknown>, enclosing: string) => string;
/**
* Resolves a reference within the document's `$id` scope. Returns:
*
* - a {@link ScopedTarget} when the ref resolves to something in this document
* (a root-relative pointer, an anchor in scope, or an embedded resource);
* - `'external'` when the ref names a URI that matches no embedded resource —
* the caller decides whether to fetch it or leave it untouched;
* - `undefined` when the ref *should* resolve here but nothing matches (a
* missing anchor, a bad pointer into an embedded resource) — the caller may
* fall back to a document-global search for compatibility.
*/
export declare const resolveRefInScope: (registry: ResourceRegistry, keyword: RefKeyword, ref: string, currentBase: string) => ScopedTarget | 'external' | undefined;
import { getByPointer, pointerToPath } from './get-by-pointer.js';
/**
* The synthetic base used when a document has no natural URI to resolve
* relative `$id`s against (an in-memory document, or a local file path — which
* is not a URL). Uses the reserved `.invalid` TLD so it can never collide with
* a real `$id`.
*/
export const SYNTHETIC_BASE = 'https://mjst-internal.invalid/document';
// Keywords whose values are data, not subschemas — an `$id`/`$anchor` key
// inside an enum member or example value is instance data, not a declaration.
const NON_SCHEMA_KEYWORDS = new Set(['enum', 'const', 'default', 'examples']);
/** `new URL(ref, base).href`, or `undefined` when the pair does not parse. */
export const resolveUri = (ref, base) => {
try {
return new URL(ref, base).href;
}
catch {
return undefined;
}
};
/** Strips the fragment from an absolute URI string. */
const withoutFragment = (uri) => {
const hashIdx = uri.indexOf('#');
return hashIdx === -1 ? uri : uri.slice(0, hashIdx);
};
/** Splits a ref string into its URI part and its fragment (pointer or anchor name). */
export const splitFragment = (ref) => {
const hashIdx = ref.indexOf('#');
return {
uriPart: hashIdx === -1 ? ref : ref.slice(0, hashIdx),
fragment: hashIdx === -1 ? '' : ref.slice(hashIdx + 1),
};
};
/** The base URI a subschema establishes via `$id`, or the enclosing base. */
const baseAfterId = (node, enclosingBase) => {
const id = node['$id'];
if (typeof id !== 'string' || id === '')
return enclosingBase;
const resolved = resolveUri(id, enclosingBase);
// A draft-07-style `$id: "#name"` (or a malformed one) sets no new base.
if (resolved === undefined)
return enclosingBase;
const bare = withoutFragment(resolved);
return bare === '' ? enclosingBase : bare;
};
/**
* Walks the document once, registering every embedded resource (`$id`) and
* every anchor under the base URI it is scoped to. First declaration wins on
* a duplicate URI or anchor name, matching document order.
*/
export const buildResourceRegistry = (root, initialBase = SYNTHETIC_BASE) => {
const resources = new Map();
const staticAnchors = new Map();
const dynamicAnchors = new Map();
const rootBase = root !== null && typeof root === 'object' && !Array.isArray(root)
? baseAfterId(root, initialBase)
: initialBase;
const registerAnchors = (node, base, pointer) => {
const anchor = node['$anchor'];
if (typeof anchor === 'string') {
const key = `${base}#${anchor}`;
if (!staticAnchors.has(key))
staticAnchors.set(key, { value: node, pointer });
}
const dynamicAnchor = node['$dynamicAnchor'];
if (typeof dynamicAnchor === 'string') {
const key = `${base}#${dynamicAnchor}`;
// Per 2020-12 a `$dynamicAnchor` also creates an ordinary anchor.
if (!dynamicAnchors.has(key))
dynamicAnchors.set(key, { value: node, pointer });
if (!staticAnchors.has(key))
staticAnchors.set(key, { value: node, pointer });
}
};
const walk = (node, base, pointer) => {
if (node === null || typeof node !== 'object')
return;
if (Array.isArray(node)) {
for (let i = 0; i < node.length; i++)
walk(node[i], base, [...pointer, i]);
return;
}
const record = node;
const nodeBase = baseAfterId(record, base);
if (nodeBase !== base && !resources.has(nodeBase))
resources.set(nodeBase, { value: node, pointer });
registerAnchors(record, nodeBase, pointer);
for (const key of Object.keys(record)) {
if (NON_SCHEMA_KEYWORDS.has(key))
continue;
walk(record[key], nodeBase, [...pointer, key]);
}
};
// The root registers under its own `$id` (when it has one) and under the
// initial base, so both spellings of a self-reference resolve. `$id` first:
// `baseOfNode` scans in insertion order, and refs inside the root must
// resolve against its declared base, not the synthetic one.
if (rootBase !== initialBase)
resources.set(rootBase, { value: root, pointer: [] });
if (!resources.has(withoutFragment(initialBase))) {
resources.set(withoutFragment(initialBase), { value: root, pointer: [] });
}
walk(root, initialBase, []);
return { resources, staticAnchors, dynamicAnchors, rootBase, root };
};
/**
* Walks a JSON Pointer within `start`, tracking `$id` base changes along the
* way so the returned target carries the base URI refs inside it resolve
* against. Returns `undefined` when the pointer does not resolve.
*/
const getByPointerWithBase = (start, startBase, startPointer, fragment) => {
const value = getByPointer(start, fragment);
if (value === undefined)
return undefined;
// Re-walk the same path for base tracking only (cheap: fragment paths are short).
let base = startBase;
let current = start;
for (const segment of pointerToPath(fragment)) {
if (current === null || typeof current !== 'object')
break;
if (!Array.isArray(current))
base = baseAfterId(current, base);
current = current[segment];
}
if (current !== null && typeof current === 'object' && !Array.isArray(current)) {
base = baseAfterId(current, base);
}
return { value, pointer: [...startPointer, ...pointerToPath(fragment)], base };
};
/** A fragment is a JSON Pointer when it is empty or begins with `/`; otherwise an anchor name. */
const isPointerFragment = (fragment) => fragment === '' || fragment.startsWith('/');
/**
* The base URI `node` establishes via its `$id`, as assigned during the
* registry walk (identity lookup), or `enclosing` when it declares none that
* registered. Resolvers call this while walking so refs inside an embedded
* resource resolve against the right base.
*/
export const baseOfNode = (registry, node, enclosing) => {
for (const [uri, resource] of registry.resources) {
if (resource.value === node)
return uri;
}
return enclosing;
};
/** Looks up an anchor name under `base`, honoring `$dynamicRef`'s preference order. */
const lookupAnchor = (registry, keyword, base, name) => {
const key = `${base}#${name}`;
if (keyword === '$dynamicRef')
return registry.dynamicAnchors.get(key) ?? registry.staticAnchors.get(key);
return registry.staticAnchors.get(key);
};
/**
* Resolves a reference within the document's `$id` scope. Returns:
*
* - a {@link ScopedTarget} when the ref resolves to something in this document
* (a root-relative pointer, an anchor in scope, or an embedded resource);
* - `'external'` when the ref names a URI that matches no embedded resource —
* the caller decides whether to fetch it or leave it untouched;
* - `undefined` when the ref *should* resolve here but nothing matches (a
* missing anchor, a bad pointer into an embedded resource) — the caller may
* fall back to a document-global search for compatibility.
*/
export const resolveRefInScope = (registry, keyword, ref, currentBase) => {
const { uriPart, fragment } = splitFragment(ref);
if (uriPart === '') {
// A plain `#/pointer` stays document-root-relative (see module doc).
if (isPointerFragment(fragment)) {
return getByPointerWithBase(registry.root, registry.rootBase, [], fragment);
}
// An anchor name resolves within the current resource's scope.
const anchored = lookupAnchor(registry, keyword, currentBase, fragment);
return anchored === undefined ? undefined : { ...anchored, base: currentBase };
}
const absolute = resolveUri(ref, currentBase);
if (absolute === undefined)
return 'external';
const resource = registry.resources.get(withoutFragment(absolute));
if (resource === undefined)
return 'external';
if (isPointerFragment(fragment)) {
const resourceBase = withoutFragment(absolute);
return getByPointerWithBase(resource.value, resourceBase, resource.pointer, fragment);
}
const anchored = lookupAnchor(registry, keyword, withoutFragment(absolute), fragment);
return anchored === undefined ? undefined : { ...anchored, base: withoutFragment(absolute) };
};
+7
-1

@@ -71,3 +71,9 @@ /**

export const isPrivateHost = (hostname) => {
const host = hostname.replace(/^\[|\]$/g, '').toLowerCase();
// Strip IPv6 brackets and any trailing dot(s): `localhost.` is the FQDN-root
// form of `localhost` and resolves to the same loopback address, so it must
// not slip past the by-name checks below.
const host = hostname
.replace(/^\[|\]$/g, '')
.replace(/\.+$/, '')
.toLowerCase();
if (host === 'localhost' || host.endsWith('.localhost'))

@@ -74,0 +80,0 @@ return true;

+4
-4

@@ -37,7 +37,7 @@ import type { JsonPath } from './types.js';

* Anchor search is document-global: we bind to the single matching anchor in
* `root` rather than walking the dynamic scope. For one bundled document — what a
* linter dereferences — there is exactly one anchor per name, so this agrees with
* the full dynamic-scope algorithm. Nested `$id` base-URI re-scoping is not
* modelled (the common bundled case does not rely on it).
* `root` rather than walking the dynamic scope. This is the *fallback* path —
* the resolvers first try `$id`-scoped resolution via `resource-registry.ts`,
* which binds duplicate anchor names to the resource they are declared in, and
* only land here for anchors declared outside any registered scope.
*/
export declare const resolveFragment: (root: unknown, keyword: RefKeyword, fragment: string) => ResolvedTarget | undefined;

@@ -62,6 +62,6 @@ import { getByPointer, pointerToPath } from './get-by-pointer.js';

* Anchor search is document-global: we bind to the single matching anchor in
* `root` rather than walking the dynamic scope. For one bundled document — what a
* linter dereferences — there is exactly one anchor per name, so this agrees with
* the full dynamic-scope algorithm. Nested `$id` base-URI re-scoping is not
* modelled (the common bundled case does not rely on it).
* `root` rather than walking the dynamic scope. This is the *fallback* path —
* the resolvers first try `$id`-scoped resolution via `resource-registry.ts`,
* which binds duplicate anchor names to the resource they are declared in, and
* only land here for anchors declared outside any registered scope.
*/

@@ -68,0 +68,0 @@ export const resolveFragment = (root, keyword, fragment) => {

@@ -7,4 +7,6 @@ import type { ResolveOptions, ResolveResult } from './types.js';

* and remote refs. Remote documents are fetched on the fly and cached in memory
* for the session; `options` governs whether/which remote hosts are allowed.
* for the session; `options` governs whether/which remote hosts are allowed,
* how they are fetched (`headers`, `fetch`, `timeoutMs`, `maxRedirects`,
* `maxBytes`), and whether the session cache is used (`cache`).
*/
export declare const resolveRefsFromFile: (filename: string, options?: ResolveOptions) => Promise<ResolveResult>;

@@ -5,6 +5,11 @@ import { readFileSync } from 'node:fs';

import { readReference, resolveFragment } from './reference.js';
import { baseOfNode, buildResourceRegistry, resolveRefInScope, SYNTHETIC_BASE, } from './resource-registry.js';
import { assignKey } from './safe-assign.js';
// A ref currently mid-resolution is marked with this sentinel; revisiting it
// means a cycle, so we return `{}` instead of recursing forever.
// means a cycle, so the reference is kept (rewritten to a root-document ref,
// hoisting the target into `$defs` when it lives in another file) instead of
// recursing forever.
const CYCLE = Symbol('cycle');
/** See {@link ANNOTATION_ONLY_SIBLINGS} in resolve-refs.ts — same rule here. */
const ANNOTATION_ONLY_SIBLINGS = new Set(['summary', 'description']);
// --- Document location helpers ---------------------------------------------

@@ -16,2 +21,4 @@ //

// resolves to another remote URL, and one inside a local file to another path.
// A document's `$id` never changes which file/URL a ref loads from — it only
// scopes *in-document* resolution (see resource-registry.ts).
const isRemote = (location) => /^https?:\/\//i.test(location);

@@ -40,3 +47,4 @@ /** Resolves the location of `ref` (its file/URL part) relative to `base`. */

// an editor/LSP), so each pass re-reads them. Remote documents are assumed
// stable for the session; call `clearRemoteCache()` to drop them.
// stable for the session; call `clearRemoteCache()` to drop them, or pass
// `cache: false` to bypass the session cache for a single call.
const remoteCache = new Map();

@@ -59,10 +67,10 @@ // In-flight remote loads, keyed by location. Two resolve passes that start at

/**
* Reads a response body, refusing to buffer more than {@link MAX_REMOTE_BYTES}.
* A `Content-Length` over the limit is rejected up front; a missing/lying header
* Reads a response body, refusing to buffer more than `maxBytes`. A
* `Content-Length` over the limit is rejected up front; a missing/lying header
* is caught while streaming so a chunked response can't exhaust memory either.
*/
const readCapped = async (response) => {
const readCapped = async (response, maxBytes) => {
const declared = Number(response.headers.get('content-length'));
if (Number.isFinite(declared) && declared > MAX_REMOTE_BYTES) {
throw new Error(`remote document exceeds ${MAX_REMOTE_BYTES} bytes`);
if (Number.isFinite(declared) && declared > maxBytes) {
throw new Error(`remote document exceeds ${maxBytes} bytes`);
}

@@ -77,4 +85,4 @@ const body = response.body;

total += chunk.byteLength;
if (total > MAX_REMOTE_BYTES)
throw new Error(`remote document exceeds ${MAX_REMOTE_BYTES} bytes`);
if (total > maxBytes)
throw new Error(`remote document exceeds ${maxBytes} bytes`);
text += decoder.decode(chunk, { stream: true });

@@ -84,2 +92,8 @@ }

};
/** The caller-supplied headers for `url`, if any. */
const headersFor = (url, options) => {
if (options.headers === undefined)
return undefined;
return typeof options.headers === 'function' ? options.headers(url) : options.headers;
};
/**

@@ -91,13 +105,22 @@ * Fetches and parses a remote document, following redirects manually so the

* `redirect: 'manual'` and re-run {@link denialReason} on each `Location`.
* Caller-supplied headers are only sent on hops that share the original URL's
* origin, so a cross-origin redirect can't exfiltrate credentials.
*/
const fetchRemote = async (location, options) => {
const maxRedirects = options.maxRedirects ?? MAX_REDIRECTS;
const timeoutMs = options.timeoutMs ?? FETCH_TIMEOUT_MS;
const maxBytes = options.maxBytes ?? MAX_REMOTE_BYTES;
const doFetch = options.fetch ?? fetch;
const origin = new URL(location).origin;
let current = location;
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
for (let hop = 0; hop <= maxRedirects; hop++) {
const reason = denialReason(current, options);
if (reason !== null)
throw new Error(`refusing to follow redirect (${reason}): ${current}`);
const response = await fetch(current, {
const headers = new URL(current).origin === origin ? headersFor(location, options) : undefined;
const response = await doFetch(current, {
redirect: 'manual',
// Cap the wait for a response so a stalling host can't hang resolution forever.
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
signal: AbortSignal.timeout(timeoutMs),
...(headers !== undefined ? { headers } : {}),
});

@@ -116,5 +139,5 @@ if (response.status >= 300 && response.status < 400) {

// (e.g. `.yaml` vs `.json`) and any relative refs key off a stable identity.
return parse(await readCapped(response), location);
return parse(await readCapped(response, maxBytes), location);
}
throw new Error(`too many redirects (>${MAX_REDIRECTS}): ${location}`);
throw new Error(`too many redirects (>${maxRedirects}): ${location}`);
};

@@ -151,6 +174,6 @@ /** Returns why a remote location may not be fetched, or `null` if it is allowed. */

* Loads a document into `docCache`, fetching/reading it if needed. Remote
* documents are additionally cached for the session in `remoteCache`. On
* failure an error is recorded and the location is cached as `{}` so that
* pointer lookups degrade gracefully instead of throwing. Returns whether the
* document loaded successfully.
* documents are additionally cached for the session in `remoteCache` (unless
* `cache: false`). On failure an error is recorded and the location is cached
* as `{}` so that pointer lookups degrade gracefully instead of throwing.
* Returns whether the document loaded successfully.
*/

@@ -161,6 +184,7 @@ const loadDoc = async (location, docCache, options, errors) => {

if (isRemote(location)) {
if (remoteCache.has(location)) {
docCache.set(location, remoteCache.get(location));
return true;
}
// Evaluate the policy before serving anything for a remote location —
// including a cache hit. `remoteCache` is process-global and outlives a
// single resolve call, so a URL fetched earlier under permissive options
// must not be handed to a later call whose options (a disabled `remote`,
// a stricter `allowedHosts`, no `allowPrivateHosts`) would refuse it.
const reason = denialReason(location, options);

@@ -172,2 +196,21 @@ if (reason !== null) {

}
const useSessionCache = options.cache !== false;
if (useSessionCache && remoteCache.has(location)) {
docCache.set(location, remoteCache.get(location));
return true;
}
if (!useSessionCache) {
// A cache-bypassing call must not serve (or poison) concurrent callers'
// in-flight loads either — fetch independently.
try {
const doc = await fetchRemote(location, options);
docCache.set(location, doc);
return true;
}
catch (err) {
errors.push({ message: `Failed to fetch ${location}: ${String(err)}`, path: [] });
docCache.set(location, {});
return false;
}
}
// Coalesce concurrent loads of the same URL onto one in-flight request; the

@@ -208,128 +251,28 @@ // owner (first caller) clears the slot once it settles.

};
/** Collects the distinct document parts of every `$ref` directly under `node`. */
const collectRefTargets = (node, out) => {
if (node === null || typeof node !== 'object')
return out;
if (Array.isArray(node)) {
for (const item of node)
collectRefTargets(item, out);
return out;
}
const obj = node;
const reference = readReference(obj);
if (reference) {
const { filePart } = splitRef(reference.value);
if (filePart !== '')
out.add(filePart);
}
// Recurse into every key — including a reference node's siblings, which apply
// alongside the referenced schema (2020-12) and may carry their own refs.
for (const key of Object.keys(obj)) {
if (reference && key === reference.keyword)
continue;
collectRefTargets(obj[key], out);
}
return out;
/** Escapes a JSON Pointer segment (RFC 6901): `~` → `~0`, `/` → `~1`. */
const escapeSegment = (segment) => segment.replace(/~/g, '~0').replace(/\//g, '~1');
/** Renders a {@link JsonPath} as a `#/...` ref string. */
const pathToRef = (path) => path.length === 0 ? '#' : `#/${path.map((segment) => escapeSegment(String(segment))).join('/')}`;
/** Derives a readable `$defs` key for a hoisted cycle target. */
const hoistName = (targetLocation, fragment, taken) => {
const fragmentTail = fragment.split('/').filter(Boolean).pop();
const locationTail = targetLocation
.split('/')
.filter(Boolean)
.pop()
?.replace(/\.[a-z0-9]+$/i, '');
const raw = fragmentTail || locationTail || 'cycle';
const sanitized = raw.replace(/[^A-Za-z0-9_.-]/g, '-') || 'cycle';
let name = sanitized;
for (let n = 2; taken.has(name); n++)
name = `${sanitized}-${n}`;
taken.add(name);
return name;
};
/**
* Walks every reachable document starting from `rootLocation`, loading each one
* so the synchronous resolve pass can look them up. This is the only async part
* of resolution: remote documents are fetched here (in dependency order) and
* cached for the session.
*/
const prefetch = async (rootLocation, docCache, options, errors) => {
const seen = new Set([rootLocation]);
const queue = [rootLocation];
while (queue.length > 0) {
const location = queue.shift();
for (const filePart of collectRefTargets(docCache.get(location), new Set())) {
const target = joinLocation(location, filePart);
if (seen.has(target))
continue;
seen.add(target);
await loadDoc(target, docCache, options, errors);
queue.push(target);
}
}
};
/**
* Single-pass resolver that inlines internal and external (`$ref` to other
* file/URL) references. Every reachable document has already been loaded into
* `docCache` by `prefetch`, so this stays synchronous. Refs are resolved once
* (`refCache`); the CYCLE sentinel short-circuits re-entrant resolution.
*
* `baseLocation` is the location of the document `node` belongs to, used to
* resolve relative refs and `#/...` pointers within it.
*/
const resolveAt = (node, baseLocation, docCache, refCache, origins) => {
if (node === null || typeof node !== 'object')
return node;
if (Array.isArray(node)) {
return node.map((item) => resolveAt(item, baseLocation, docCache, refCache, origins));
}
const obj = node;
const reference = readReference(obj);
if (reference) {
const { keyword, value } = reference;
const { filePart, fragment } = splitRef(value);
const targetLocation = filePart === '' ? baseLocation : joinLocation(baseLocation, filePart);
const targetRoot = docCache.get(targetLocation) ?? {};
// Cache/cycle key includes the keyword: `$ref #x` and `$dynamicRef #x` can
// bind to different targets, so they must not share a cache slot.
const cacheKey = `${keyword} ${targetLocation}#${fragment}`;
let resolved;
let pointer;
const cached = refCache.get(cacheKey);
if (cached === CYCLE) {
resolved = {};
pointer = [];
}
else if (cached !== undefined) {
resolved = cached.value;
pointer = cached.pointer;
}
else {
refCache.set(cacheKey, CYCLE);
// `$anchor`/`$dynamicAnchor`/`$recursiveAnchor` are resolved within the
// target document; a plain pointer is a direct lookup. A fragment that
// resolves to nothing inlines as `undefined` (kept as-is for parity).
const found = resolveFragment(targetRoot, keyword, fragment);
pointer = found?.pointer ?? [];
resolved = resolveAt(found?.value, targetLocation, docCache, refCache, origins);
refCache.set(cacheKey, { value: resolved, pointer });
// Stamp the inlined node with where it was defined so a consumer can map a
// resolved-tree node back to its source document/path. Only objects/arrays are
// stamped (primitives can't key the map). First-write-wins: resolution recurses
// to the deepest ref before returning, so the *definition* site stamps first;
// an outer ref that merely points (transitively) at the same object must not
// overwrite it with an intermediate location.
if (origins && resolved !== null && typeof resolved === 'object' && !origins.has(resolved)) {
origins.set(resolved, { location: targetLocation, pointer });
}
}
// Keywords sibling to a reference apply alongside the referenced schema
// (2020-12), so preserve them by combining both in an `allOf`.
const siblingKeys = Object.keys(obj).filter((key) => key !== keyword);
if (siblingKeys.length === 0)
return resolved;
const siblings = {};
for (const key of siblingKeys)
assignKey(siblings, key, resolveAt(obj[key], baseLocation, docCache, refCache, origins));
const existingAllOf = Array.isArray(siblings['allOf']) ? siblings['allOf'] : [];
const merged = { ...siblings, allOf: [...existingAllOf, resolved] };
// Stamp the wrapper too, so origin lookups resolve for a ref-with-siblings node.
if (origins && !origins.has(merged))
origins.set(merged, { location: targetLocation, pointer });
return merged;
}
const result = {};
for (const key of Object.keys(obj)) {
assignKey(result, key, resolveAt(obj[key], baseLocation, docCache, refCache, origins));
}
return result;
};
/**
* Resolves `$ref`s in a document on disk (or at a URL), including cross-file
* and remote refs. Remote documents are fetched on the fly and cached in memory
* for the session; `options` governs whether/which remote hosts are allowed.
* for the session; `options` governs whether/which remote hosts are allowed,
* how they are fetched (`headers`, `fetch`, `timeoutMs`, `maxRedirects`,
* `maxBytes`), and whether the session cache is used (`cache`).
*/

@@ -343,6 +286,272 @@ export const resolveRefsFromFile = async (filename, options = {}) => {

}
await prefetch(rootLocation, docCache, options, errors);
// Per-document `$id` registries, built lazily. A document's registry scopes
// anchor lookups and lets URI refs match embedded resources without fetching.
const registries = new Map();
const registryFor = (location) => {
let registry = registries.get(location);
if (registry === undefined) {
registry = buildResourceRegistry(docCache.get(location), isRemote(location) ? location : SYNTHETIC_BASE);
registries.set(location, registry);
}
return registry;
};
/** Collects the document parts of refs under `node` that are NOT `$id`-internal. */
const collectRefTargets = (node, location, base, out) => {
if (node === null || typeof node !== 'object')
return;
if (Array.isArray(node)) {
for (const item of node)
collectRefTargets(item, location, base, out);
return;
}
const obj = node;
const registry = registryFor(location);
const nodeBase = typeof obj['$id'] === 'string' ? baseOfNode(registry, obj, base) : base;
const reference = readReference(obj);
if (reference && reference.keyword !== '$recursiveRef') {
const scoped = resolveRefInScope(registry, reference.keyword, reference.value, nodeBase);
if (scoped === 'external') {
const { filePart } = splitRef(reference.value);
if (filePart !== '')
out.add(filePart);
}
}
// Recurse into every key — including a reference node's siblings, which apply
// alongside the referenced schema (2020-12) and may carry their own refs.
for (const key of Object.keys(obj)) {
if (reference && key === reference.keyword)
continue;
collectRefTargets(obj[key], location, nodeBase, out);
}
};
/**
* Walks every reachable document starting from the root, loading each one so
* the synchronous resolve pass can look them up. This is the only async part
* of resolution: remote documents are fetched here (in dependency order) and
* cached for the session.
*/
const prefetch = async () => {
const seen = new Set([rootLocation]);
const queue = [rootLocation];
while (queue.length > 0) {
const location = queue.shift();
const out = new Set();
collectRefTargets(docCache.get(location), location, registryFor(location).rootBase, out);
for (const filePart of out) {
const target = joinLocation(location, filePart);
if (seen.has(target))
continue;
seen.add(target);
await loadDoc(target, docCache, options, errors);
queue.push(target);
}
}
};
await prefetch();
const origins = options.trackOrigins ? new Map() : undefined;
const resolved = resolveAt(docCache.get(rootLocation), rootLocation, docCache, new Map(), origins);
const refCache = new Map();
// Cycle targets living in other documents are hoisted into the root's `$defs`
// under these names once resolution completes (see the CYCLE branch).
const hoists = new Map();
const hoistTaken = new Set();
/**
* Single-pass resolver that inlines internal and external (`$ref` to other
* file/URL) references. Every reachable document has already been loaded into
* `docCache` by `prefetch`, so this stays synchronous. Refs are resolved once
* (`refCache`); the CYCLE sentinel short-circuits re-entrant resolution.
*
* `baseLocation` is the location of the document `node` belongs to; `base` is
* the current `$id` base URI within that document.
*/
const resolveAt = (node, baseLocation, base) => {
if (node === null || typeof node !== 'object')
return node;
if (Array.isArray(node)) {
return node.map((item) => resolveAt(item, baseLocation, base));
}
const obj = node;
const registry = registryFor(baseLocation);
const nodeBase = typeof obj['$id'] === 'string' ? baseOfNode(registry, obj, base) : base;
const reference = readReference(obj);
if (reference) {
const { keyword, value } = reference;
// Classify the ref: `$id`-internal (this document), or external (another
// document, or a fragment resolved the legacy way against this one).
// A resolved in-scope target, or undefined when the ref is external / an
// anchor to look up in the target document below. Typed narrowly (never
// `'external'`) so the later `scoped !== undefined` branches read as
// `ScopedTarget`; the `'external'` case is handled here and never escapes.
let scoped;
let targetLocation = baseLocation;
let fragment = '';
if (keyword === '$recursiveRef') {
fragment = value.startsWith('#') ? value.slice(1) : value;
}
else {
const inScope = resolveRefInScope(registry, keyword, value, nodeBase);
if (inScope === 'external' || inScope === undefined) {
const parts = splitRef(value);
fragment = parts.fragment;
if (inScope === 'external' && parts.filePart !== '') {
targetLocation = joinLocation(baseLocation, parts.filePart);
}
}
else {
scoped = inScope;
}
}
// Cache/cycle key includes the keyword (`$ref #x` and `$dynamicRef #x`
// can bind to different targets) and, for scoped refs, the base URI (the
// same anchor name can bind differently inside different resources).
const cacheKey = scoped !== undefined
? `${keyword} ${baseLocation} ${nodeBase} ${value}`
: `${keyword} ${targetLocation}#${fragment}`;
let resolved;
let pointer;
const cached = refCache.get(cacheKey);
if (cached === CYCLE) {
// Mid-resolution revisit — a reference cycle. Keep a reference instead
// of collapsing the branch to `{}`:
// - target in the ROOT document → a root-relative ref (the target still
// exists in the resolved output);
// - target in ANOTHER document → `#/$defs/<name>`, and the resolved
// target is attached there once resolution completes.
let keptRef;
if (scoped !== undefined && baseLocation === rootLocation) {
keptRef = pathToRef(scoped.pointer);
}
else if (scoped === undefined && targetLocation === rootLocation) {
keptRef = `#${fragment}`;
}
else {
let name = hoists.get(cacheKey);
if (name === undefined) {
name = hoistName(targetLocation, fragment, hoistTaken);
hoists.set(cacheKey, name);
}
keptRef = `#/$defs/${name}`;
}
const kept = {};
for (const key of Object.keys(obj)) {
if (key === keyword)
continue;
assignKey(kept, key, resolveAt(obj[key], baseLocation, nodeBase));
}
// Always rewritten to a static `$ref`: the dynamic binding was already
// decided when the cycle was entered.
assignKey(kept, '$ref', keptRef);
return kept;
}
if (cached !== undefined) {
resolved = cached.value;
pointer = cached.pointer;
}
else {
refCache.set(cacheKey, CYCLE);
let found;
let targetBase;
if (scoped !== undefined) {
found = scoped;
targetBase = scoped.base;
}
else {
// `$anchor`/`$dynamicAnchor`/`$recursiveAnchor` resolve within the
// target document — scoped by its own `$id`s where possible, falling
// back to the document-global search. A fragment that resolves to
// nothing inlines as `undefined` (kept as-is for parity).
const targetRegistry = registryFor(targetLocation);
const targetRoot = docCache.get(targetLocation) ?? {};
if (keyword === '$recursiveRef') {
found = resolveFragment(targetRoot, keyword, fragment);
targetBase = targetRegistry.rootBase;
}
else {
const scopedFragment = resolveRefInScope(targetRegistry, keyword, `#${fragment}`, targetRegistry.rootBase);
if (scopedFragment !== 'external' && scopedFragment !== undefined) {
found = scopedFragment;
targetBase = scopedFragment.base;
}
else {
found = resolveFragment(targetRoot, keyword, fragment);
targetBase = targetRegistry.rootBase;
}
}
}
pointer = found?.pointer ?? [];
resolved = resolveAt(found?.value, targetLocation, targetBase);
refCache.set(cacheKey, { value: resolved, pointer });
// Stamp the inlined node with where it was defined so a consumer can map a
// resolved-tree node back to its source document/path. Only objects/arrays are
// stamped (primitives can't key the map). First-write-wins: resolution recurses
// to the deepest ref before returning, so the *definition* site stamps first;
// an outer ref that merely points (transitively) at the same object must not
// overwrite it with an intermediate location.
if (origins && resolved !== null && typeof resolved === 'object' && !origins.has(resolved)) {
origins.set(resolved, { location: targetLocation, pointer });
}
}
const siblingKeys = Object.keys(obj).filter((key) => key !== keyword);
if (siblingKeys.length === 0)
return resolved;
const siblings = {};
for (const key of siblingKeys)
assignKey(siblings, key, resolveAt(obj[key], baseLocation, nodeBase));
// Annotation-only siblings (OpenAPI Reference Objects): inline the target
// with the annotations overriding — never wrap in `allOf`, which is not
// valid where those references appear (Path Item, Response, Parameter).
if (siblingKeys.every((key) => ANNOTATION_ONLY_SIBLINGS.has(key))) {
if (resolved !== null && typeof resolved === 'object' && !Array.isArray(resolved)) {
const overridden = {};
for (const key of Object.keys(resolved)) {
assignKey(overridden, key, resolved[key]);
}
for (const key of Object.keys(siblings))
assignKey(overridden, key, siblings[key]);
if (origins && !origins.has(overridden))
origins.set(overridden, { location: targetLocation, pointer });
return overridden;
}
// A non-object target (boolean schema, primitive) has no members to
// override; the annotations have nowhere to live, so return the target.
return resolved;
}
// Keywords sibling to a reference apply alongside the referenced schema
// (2020-12), so preserve them by combining both in an `allOf`.
const existingAllOf = Array.isArray(siblings['allOf']) ? siblings['allOf'] : [];
const merged = { ...siblings, allOf: [...existingAllOf, resolved] };
// Stamp the wrapper too, so origin lookups resolve for a ref-with-siblings node.
if (origins && !origins.has(merged))
origins.set(merged, { location: targetLocation, pointer });
return merged;
}
const result = {};
for (const key of Object.keys(obj)) {
assignKey(result, key, resolveAt(obj[key], baseLocation, nodeBase));
}
return result;
};
const rootRegistry = registryFor(rootLocation);
const resolved = resolveAt(docCache.get(rootLocation), rootLocation, rootRegistry.rootBase);
// Attach cross-document cycle targets under `$defs` so every `#/$defs/<name>`
// ref emitted by the CYCLE branch resolves within the output document.
if (hoists.size > 0) {
if (resolved !== null && typeof resolved === 'object' && !Array.isArray(resolved)) {
const container = resolved;
const defs = container['$defs'] !== null && typeof container['$defs'] === 'object' && !Array.isArray(container['$defs'])
? container['$defs']
: {};
for (const [cacheKey, name] of hoists) {
const cached = refCache.get(cacheKey);
assignKey(defs, name, cached !== undefined && cached !== CYCLE ? (cached.value ?? {}) : {});
}
assignKey(container, '$defs', defs);
}
else {
errors.push({
message: 'Cannot attach hoisted cycle definitions: the resolved root document is not an object',
path: [],
});
}
}
return origins ? { resolved, errors, origins } : { resolved, errors };
};

@@ -13,6 +13,10 @@ import type { ResolveResult } from './types.js';

/**
* Resolves all internal (`#/...`) `$ref`s in an in-memory document, inlining
* each target. External and remote refs are left as-is. Cycles are broken with
* an empty object so the result is always finite.
* Resolves all internal `$ref`s in an in-memory document — plain `#/...`
* pointers, `$anchor`/`$dynamicAnchor` names (scoped by `$id`), and refs whose
* URI matches an embedded resource's `$id` — inlining each target. Refs to
* other files/URLs are left in place and reported on `errors` (this resolver
* can't load other documents — use `resolveRefsFromFile` for those). Cycles are
* broken by keeping the original reference node, so recursive schemas keep
* their recursive branch.
*/
export declare const resolveRefs: (data: unknown, options?: ResolveRefsOptions) => ResolveResult;
import { pointerToPath } from './get-by-pointer.js';
import { readReference, resolveFragment } from './reference.js';
import { baseOfNode, buildResourceRegistry, resolveRefInScope } from './resource-registry.js';
import { assignKey } from './safe-assign.js';
// A ref currently mid-resolution is marked with this sentinel; revisiting it
// means we have looped, so we return `{}` instead of recursing forever.
// means we have looped, so we keep the original reference instead of recursing
// forever (see the CYCLE branch below).
const CYCLE = Symbol('cycle');

@@ -10,22 +12,77 @@ // A ref that resolved to nothing; cached so a repeated bad ref reports once.

/**
* Single-pass internal-only `$ref` resolver. Each unique ref string is resolved
* exactly once (the `cache`); revisiting a ref that is still mid-resolution
* means a cycle, so we return `{}` (the CYCLE sentinel). Non-`#` refs (external
* files, HTTP) are left untouched — callers that need those should use
* `resolveRefsFromFile`.
* OpenAPI 3.1 Reference Objects allow only these annotation keywords beside a
* `$ref`, and they *override* the target's — an `allOf` wrapper is not valid in
* those positions (Path Item, Response, Parameter references). They carry no
* validation semantics in plain JSON Schema either, so overriding is safe there
* too; every other sibling keyword keeps the spec-correct `allOf` combination.
*/
const resolveInternal = (node, root, cache, origins, errors) => {
const ANNOTATION_ONLY_SIBLINGS = new Set(['summary', 'description']);
/**
* Single-pass internal `$ref` resolver. Each unique ref string is resolved
* exactly once per scope (the `cache`); revisiting a ref that is still
* mid-resolution means a cycle, so the original reference node is kept — its
* target still exists in the resolved document, preserving the recursive
* branch instead of collapsing it to `{}`. Refs that resolve to nothing in
* this document (external files, HTTP) are left in place but recorded as an
* error — callers that need those should use `resolveRefsFromFile`.
*
* `base` is the current `$id` base URI, used to scope anchor lookups and to
* match refs against embedded resources (see `resource-registry.ts`).
*/
const resolveInternal = (node, root, registry, base, cache, origins, errors) => {
if (node === null || typeof node !== 'object')
return node;
if (Array.isArray(node))
return node.map((item) => resolveInternal(item, root, cache, origins, errors));
if (Array.isArray(node)) {
return node.map((item) => resolveInternal(item, root, registry, base, cache, origins, errors));
}
const obj = node;
// A subschema's `$id` re-bases everything inside it, its own `$ref` included.
const nodeBase = typeof obj['$id'] === 'string' ? baseOfNode(registry, obj, base) : base;
const reference = readReference(obj);
if (reference) {
const { keyword, value: ref } = reference;
if (!ref.startsWith('#'))
return obj; // external ref — leave as-is
// Cache/cycle key includes the keyword: `$ref #x` and `$dynamicRef #x` can
// bind to different targets, so they must not share a cache slot.
const cacheKey = `${keyword} ${ref}`;
// An external ref (another file or an http(s) URL) can't be dereferenced by
// the in-memory resolver — it has no access to other documents. Record a
// diagnostic and keep the original node, so callers see a half-resolved
// document flagged rather than a silently-unresolved ref. Note this is
// decided *after* `$id`-scope matching below: a non-`#` ref whose URI
// matches an embedded resource's `$id` is internal, not external.
const externalRef = () => {
errors.push({
message: `Cannot resolve external ${keyword} "${ref}": external ref requires resolveRefsFromFile`,
path: [],
});
return obj;
};
// Resolve within the document's `$id` scope. `$recursiveRef` ignores its
// fragment and binds document-globally, so it skips the scoped path.
let found;
let targetBase = nodeBase;
if (keyword === '$recursiveRef') {
found = resolveFragment(root, keyword, ref.startsWith('#') ? ref.slice(1) : ref);
targetBase = registry.rootBase;
}
else {
const scoped = resolveRefInScope(registry, keyword, ref, nodeBase);
if (scoped === 'external')
return externalRef();
if (scoped !== undefined) {
found = scoped;
targetBase = scoped.base;
}
else if (ref.startsWith('#')) {
// Scope-aware lookup found nothing; fall back to the document-global
// search for compatibility with documents that reference an anchor
// declared in a sibling resource.
found = resolveFragment(root, keyword, ref.slice(1));
targetBase = registry.rootBase;
}
else {
return externalRef();
}
}
// Cache/cycle key includes the keyword (`$ref #x` and `$dynamicRef #x` can
// bind differently) and the base URI (the same anchor name can bind
// differently inside different embedded resources).
const cacheKey = `${keyword} ${nodeBase} ${ref}`;
let target;

@@ -37,6 +94,13 @@ let pointer;

if (cached === CYCLE) {
target = {};
pointer = [];
// Mid-resolution revisit — a reference cycle. Keep the reference node
// (siblings resolved, the ref itself verbatim): its target still exists
// in the resolved document, so consumers resolve the recursion locally
// rather than finding an empty `{}` where the recursive branch was.
const kept = {};
for (const key of Object.keys(obj)) {
assignKey(kept, key, key === keyword ? obj[key] : resolveInternal(obj[key], root, registry, nodeBase, cache, origins, errors));
}
return kept;
}
else if (cached !== undefined) {
if (cached !== undefined) {
target = cached.target;

@@ -47,3 +111,2 @@ pointer = cached.pointer;

cache.set(cacheKey, CYCLE);
const found = resolveFragment(root, keyword, ref.slice(1));
if (found === undefined) {

@@ -53,3 +116,3 @@ // A reference that resolves to nothing (a typo'd pointer or a missing

// Record it and keep the original node so the failure is visible.
const fragment = ref.slice(1);
const fragment = ref.startsWith('#') ? ref.slice(1) : ref;
errors.push({

@@ -63,3 +126,3 @@ message: `Cannot resolve internal ${keyword} "${ref}"`,

pointer = found.pointer;
target = resolveInternal(found.value, root, cache, origins, errors);
target = resolveInternal(found.value, root, registry, targetBase, cache, origins, errors);
cache.set(cacheKey, { target, pointer });

@@ -73,2 +136,27 @@ // Stamp the inlined node with the path it was defined at (see resolveAt).

}
const siblingKeys = Object.keys(obj).filter((key) => key !== keyword);
if (siblingKeys.length === 0)
return target;
const siblings = {};
for (const key of siblingKeys) {
assignKey(siblings, key, resolveInternal(obj[key], root, registry, nodeBase, cache, origins, errors));
}
// Annotation-only siblings (OpenAPI Reference Objects): inline the target
// with the annotations overriding — never wrap in `allOf`, which is invalid
// where those references appear (see ANNOTATION_ONLY_SIBLINGS).
if (siblingKeys.every((key) => ANNOTATION_ONLY_SIBLINGS.has(key))) {
if (target !== null && typeof target === 'object' && !Array.isArray(target)) {
const overridden = {};
for (const key of Object.keys(target))
assignKey(overridden, key, target[key]);
for (const key of Object.keys(siblings))
assignKey(overridden, key, siblings[key]);
if (origins && !origins.has(overridden))
origins.set(overridden, { location: '', pointer });
return overridden;
}
// A non-object target (boolean schema, primitive) has no members to
// override; the annotations have nowhere to live, so return the target.
return target;
}
// Per JSON Schema 2020-12, keywords sibling to `$ref` are *not* ignored: they

@@ -79,8 +167,2 @@ // apply alongside the referenced schema. Preserve them by combining both in an

// sibling-free target so each occurrence applies its own siblings.
const siblingKeys = Object.keys(obj).filter((key) => key !== keyword);
if (siblingKeys.length === 0)
return target;
const siblings = {};
for (const key of siblingKeys)
assignKey(siblings, key, resolveInternal(obj[key], root, cache, origins, errors));
const existingAllOf = Array.isArray(siblings['allOf']) ? siblings['allOf'] : [];

@@ -96,3 +178,3 @@ const merged = { ...siblings, allOf: [...existingAllOf, target] };

for (const key of Object.keys(obj)) {
assignKey(result, key, resolveInternal(obj[key], root, cache, origins, errors));
assignKey(result, key, resolveInternal(obj[key], root, registry, nodeBase, cache, origins, errors));
}

@@ -102,5 +184,9 @@ return result;

/**
* Resolves all internal (`#/...`) `$ref`s in an in-memory document, inlining
* each target. External and remote refs are left as-is. Cycles are broken with
* an empty object so the result is always finite.
* Resolves all internal `$ref`s in an in-memory document — plain `#/...`
* pointers, `$anchor`/`$dynamicAnchor` names (scoped by `$id`), and refs whose
* URI matches an embedded resource's `$id` — inlining each target. Refs to
* other files/URLs are left in place and reported on `errors` (this resolver
* can't load other documents — use `resolveRefsFromFile` for those). Cycles are
* broken by keeping the original reference node, so recursive schemas keep
* their recursive branch.
*/

@@ -110,4 +196,5 @@ export const resolveRefs = (data, options = {}) => {

const errors = [];
const resolved = resolveInternal(data, data, new Map(), origins, errors);
const registry = buildResourceRegistry(data);
const resolved = resolveInternal(data, data, registry, registry.rootBase, new Map(), origins, errors);
return origins ? { resolved, errors, origins } : { resolved, errors };
};

@@ -83,2 +83,36 @@ /**

trackOrigins?: boolean;
/**
* Extra HTTP headers sent with remote `$ref` requests (e.g. an `Authorization`
* token for a private schema registry) — a static record, or a function
* returning headers per URL so different hosts can carry different
* credentials. To avoid leaking credentials, headers are only sent on redirect
* hops whose origin matches the originally requested URL — the same policy
* browsers apply to `Authorization` across cross-origin redirects.
*/
headers?: Record<string, string> | ((url: string) => Record<string, string> | undefined);
/**
* Custom fetch implementation for remote documents (an instrumented client, a
* proxy-aware one, a test stub). Called once per redirect hop with
* `redirect: 'manual'` and a timeout signal. The SSRF guard still evaluates
* every hop before this is called — a custom fetch widens *how* documents are
* fetched, never *which* hosts may be. Defaults to the global `fetch`.
*/
fetch?: (url: string, init: {
redirect: 'manual';
signal: AbortSignal;
headers?: Record<string, string>;
}) => Promise<Response>;
/** Milliseconds before an unresponsive remote fetch is aborted. Defaults to `30_000`. */
timeoutMs?: number;
/** Maximum redirect hops to follow per remote document. Defaults to `5`. */
maxRedirects?: number;
/** Maximum bytes buffered per remote document. Defaults to `16` MiB. */
maxBytes?: number;
/**
* Whether fetched remote documents may be served from (and stored into) the
* process-wide session cache. Pass `false` to bypass it for one call: every
* remote document is re-fetched and nothing new is cached — useful when a
* remote schema is known to have changed mid-session. Defaults to `true`.
*/
cache?: boolean;
};
{
"name": "@amritk/resolve-refs",
"version": "0.3.0",
"version": "0.4.0",
"description": "Resolve and inline JSON Schema / OpenAPI $refs — internal, cross-file, and remote — with session caching and a default-deny SSRF guard.",

@@ -5,0 +5,0 @@ "module": "./dist/index.js",

@@ -6,9 +6,15 @@ # @amritk/resolve-refs

- **One-pass, cached.** Every unique ref is resolved once; cycles are broken with
an empty object so the result is always finite.
- **Anchors + dynamic refs.** Beyond JSON-pointer `$ref`s, plain-name `$anchor`
references (`#node`), `$dynamicRef`/`$dynamicAnchor` (2020-12), and
`$recursiveRef`/`$recursiveAnchor` (2019-09) are dereferenced too. The dynamic
forms bind to their document-global anchor — the single-bundle case; nested
`$id` base-URI re-scoping is not modelled.
- **One-pass, cached.** Every unique ref is resolved once; the result is always
finite. At a reference **cycle** the recursive branch is *kept*, not lost: the
cycle point stays a `$ref` that resolves within the output document (a
cross-file cycle target is hoisted into the root's `$defs`), so recursive
schemas survive dereferencing intact.
- **Anchors + dynamic refs, `$id`-scoped.** Beyond JSON-pointer `$ref`s,
plain-name `$anchor` references (`#node`), `$dynamicRef`/`$dynamicAnchor`
(2020-12), and `$recursiveRef`/`$recursiveAnchor` (2019-09) are dereferenced
too. `$id` base-URI scoping is modelled for the bundled-document case: a ref
whose URI matches an embedded resource's `$id` resolves to it without
fetching, and anchors bind within the resource that declares them (falling
back to a document-global search). See *`$id` scoping* below for the exact
subset.
- **Cross-file + remote.** Relative refs resolve against the document they appear

@@ -19,2 +25,6 @@ in (a ref inside a remote doc stays remote, one inside a local file stays

cloud-metadata (`169.254.169.254`) hosts are refused unless you opt in.
- **OpenAPI Reference Objects.** A `$ref` whose only siblings are `summary` /
`description` inlines the target with those annotations overriding — matching
OpenAPI 3.1 Reference Object semantics, where an `allOf` wrapper would be
invalid. Any other sibling keyword keeps the spec-correct `allOf` combination.

@@ -26,4 +36,6 @@ ## Usage

// In-memory, internal (#/...) refs only:
const { resolved } = resolveRefs(myDocument)
// In-memory, internal (#/...) refs only. External refs (another file or an
// http(s) URL) are left in place and reported on `errors`, since this resolver
// can't load other documents — use resolveRefsFromFile for those:
const { resolved, errors } = resolveRefs(myDocument)

@@ -44,2 +56,10 @@ // From disk or a URL, including cross-file and remote refs:

| `allowPrivateHosts` | `false` | Allow loopback/private/link-local targets. Left off, these are refused as an SSRF guard. |
| `headers` | — | Extra headers for remote requests (record, or `(url) => headers` for per-host credentials). Never sent across a cross-origin redirect. |
| `fetch` | global `fetch` | Custom fetch implementation. The SSRF guard still evaluates every hop before it is called. |
| `timeoutMs` | `30_000` | Abort an unresponsive remote fetch after this many milliseconds. |
| `maxRedirects` | `5` | Redirect hops to follow per remote document (each hop re-runs the SSRF guard). |
| `maxBytes` | `16` MiB | Refuse to buffer a remote document larger than this. |
| `cache` | `true` | Pass `false` to bypass the process-wide session cache for this call — everything is re-fetched, nothing is stored. |
| `parse` | `JSON.parse` | Custom content parser (e.g. YAML-aware). |
| `trackOrigins` | `false` | Record a per-node origin map on the result. |

@@ -53,2 +73,25 @@ Errors (a missing file, a refused host, a bad URL) are collected on

## `$id` scoping
The supported subset, chosen for the bundled-document reality rather than the
full spec:
- A subschema with `$id` is an **embedded resource**: its `$id` (resolved
against the enclosing base) becomes the base URI for everything inside it.
- A ref whose URI — resolved against the enclosing base — **matches an embedded
resource's `$id`** resolves to that resource without fetching. A pointer or
anchor fragment on such a ref applies *within* that resource.
- **Anchors** bind within the resource that declares them first; an anchor not
found in scope falls back to a document-global search (compatibility with
documents that reference across sibling resources).
- A plain `#/pointer` fragment stays **document-root-relative** — the behavior
bundled real-world documents rely on — even inside an embedded resource.
- `$dynamicRef` prefers a `$dynamicAnchor` in scope, then degrades to `$ref`
semantics. The full dynamic-scope algorithm (outermost anchor along the
runtime reference chain) is not modelled.
- Document **retrieval is unaffected**: which file/URL an external ref loads
from is derived from the referencing document's *location*, never its `$id` —
a root `$id` naming a remote URL cannot turn a local sibling-file ref into a
network fetch.
## Documents

@@ -59,1 +102,28 @@

(The Loupe linter's sibling resolver additionally accepts YAML.)
## Benchmarks
`resolveRefs` is single-pass: every unique `$ref` string is resolved exactly
once and memoized, with a sentinel that breaks cycles. The `bench/` suite isolates
what that memoization buys by pitting it against a naive inliner that re-resolves
each ref every time it is encountered — same inlined output, the cache is the only
difference. Representative numbers (Bun 1.3, Linux x64 — your hardware will
differ, run `bun run bench` yourself):
| schema | cached | naive | speedup |
|:---|---:|---:|---:|
| reuse-heavy (50 refs → 1 def) | ~35k ops/s | ~6k ops/s | **~5.8×** |
| chain (40 `$ref` → `$ref` links) | ~5k ops/s | ~0.6k ops/s | **~8.2×** |
| cyclic tree | ~122k ops/s | ~117k ops/s | ~1.05× |
| wide-distinct (60 defs, each used once) | ~3.3k ops/s | ~6.1k ops/s | ~0.54× |
The cache earns its keep exactly where you'd expect: a `$def` referenced from
many sites, or a long indirection chain, is resolved once instead of re-walked
every time. On a document where every ref is distinct — so the cache never hits —
its bookkeeping is pure overhead and the naive walk is faster; that `wide-distinct`
row is kept in the table precisely to show the trade honestly. Real API schemas
lean heavily on shared `$def`s, which is the case the resolver is tuned for.
Opting into `trackOrigins` (which records where each inlined value came from) adds
roughly **5–20%** on top. Both strategies are asserted to produce byte-identical
output before either is timed.