@amritk/resolve-refs
Advanced tools
| 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) }; | ||
| }; |
@@ -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; |
@@ -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>; |
+355
-146
@@ -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; |
+118
-31
| 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 }; | ||
| }; |
+34
-0
@@ -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; | ||
| }; |
+1
-1
| { | ||
| "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", |
+79
-9
@@ -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. |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
81737
84.31%20
11.11%1513
70.96%124
129.63%2
100%3
200%