@amritk/resolve-refs

中文文档
0.10.0发布于 2026/10/27MITTypeScriptES Module零依赖构建来源已验证近 30 天下载 5.9万

Resolve and inline JSON Schema / OpenAPI $refs — internal, cross-file, and remote — with session caching and a default-deny SSRF guard.

原始使用文档
文档目录7 节
  1. Usage
  2. Options ( resolveRefsFromFile )
  3. Security posture
  4. $id scoping
  5. Conformance, measured
  6. Documents
  7. Benchmarks

@amritk/resolve-refs

Resolve and inline JSON Schema / OpenAPI $refs — internal pointers, cross-file refs, and remote (http/https) documents — into a single dereferenced document.

  • 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 $refs, 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 in (a ref inside a remote doc stays remote, one inside a local file stays local). Fetched remote documents are cached for the session, keyed by URL and by the credentials they were fetched with.
  • Default-deny SSRF guard. Remote refs to loopback, private, link-local, and cloud-metadata hosts — by IP (169.254.169.254) and by name (metadata.google.internal, anything under .internal) — are refused unless you opt in. Hostnames are additionally resolved and refused when any address they point at is non-public, so 127.0.0.1.nip.io does not slip through.
  • Default-deny local reads. A local $ref may only resolve under the root document's directory. {"$ref": "../../../secrets.env"} is refused; widen it with allowedRoots when your spec legitimately spans folders.
  • 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.

Usage

import { resolveRefs, resolveRefsFromFile } from '@amritk/resolve-refs'

// 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)

// From disk or a URL, including cross-file and remote refs:
const result = await resolveRefsFromFile('./schema.json')
const remote = await resolveRefsFromFile('https://api.example.com/schema.json', {
  allowedHosts: ['api.example.com'],
})

Options (resolveRefsFromFile)

OptionDefaultDescription
remotetrueWhether http(s) refs may be fetched at all.
localRefstrueWhether $refs to other files on disk may be read. false still reads the root document you named — it refuses everything a ref reaches out to.
allowedRoots[dirname(root)]Directories a local $ref must resolve inside. The default confines a ref to the folder holding the root document; for a root loaded from a URL the default is [], so no local file is read at all.
allowedHosts[]If non-empty, only these hosts may be fetched. Matched case-insensitively; an entry without a port matches any port, one with a port must match it (a URL that omits the port counts as its protocol default). An explicit entry bypasses the private-host and DNS guards.
allowPrivateHostsfalseAllow loopback/private/link-local targets. Left off, these are refused as an SSRF guard.
verifyDnstrueResolve each remote host and refuse it when any address it resolves to is non-public. Pass false where names resolve at an egress proxy rather than locally.
headers—Extra headers for remote requests (record, or (url) => headers for per-host credentials). Never sent across a cross-origin redirect.
fetchglobal fetchCustom fetch implementation. The SSRF guard still evaluates every hop before it is called.
timeoutMs30_000Abort an unresponsive remote fetch after this many milliseconds.
totalTimeoutMs60_000Wall-clock budget for the whole resolve, across every document.
maxDocuments500Documents (root included) a single resolve may load.
maxRedirects5Redirect hops to follow per remote document (each hop re-runs the SSRF guard).
maxBytes16 MiBRefuse to buffer a remote document larger than this.
maxDepth512How deep to walk before leaving a subtree unresolved and recording an error.
cachetruePass false to bypass the process-wide session cache for this call — everything is re-fetched, nothing is stored.
parseJSON.parseCustom content parser (e.g. YAML-aware).
rootDocument—The root's already-parsed value. The named file is then neither read nor parsed; its location still anchors relative refs, allowedRoots, and origins. The value is only read, never written or aliased into the result.
trackOriginsfalseRecord a per-node origin map on the result.
bundlefalseBundle instead of inlining: hoist each target from another document into the root's $defs once and point its refs there, keeping refs into the root as written. The output is one document with only internal refs — what a code generator wants, since a shared schema stays one definition instead of a copy at every use. A document with an absolute $id is embedded whole with its $id, and refs to anything in an embedded resource name it by that URI; a document with a relative $id is inlined.

Errors (a missing file, a refused host, a refused path, a bad URL, a document too deeply nested) are collected on result.errors rather than thrown; the referencing node is left in place as its $ref (nothing is inlined) so the rest of the document still resolves. Each error carries the path to the reference that caused it — a path in the document you named, ending in the keyword (['properties', 'pet', '$ref']) — so a caller can anchor a diagnostic on the $ref a reader has to fix. It is empty when there is nowhere to point: a budget the resolve as a whole overran, or a reference written in some other document.

clearRemoteCache() drops every cached remote document — useful in tests or long-lived sessions where remote schemas may change. Pass a URL (clearRemoteCache(url)) to drop just that one. The cache expires entries after 10 minutes and evicts the least recently used past 256 documents, so a long-lived process does not accumulate every schema it has ever seen.

Security posture

The remote path has always been default-deny; the local path now matches it.

  • Local reads are confined to dirname(rootLocation) unless you pass allowedRoots. This is deliberately strict: a ref like ../common/schemas.json — an ordinary split-spec layout — is refused until you opt in, because the same shape is what reads /etc/passwd out of a document you did not write. Both the lexical and the symlink-resolved path must land inside a root, so a symlink planted in the tree cannot be used to escape it. The root document itself is exempt: it is the file you explicitly named.
  • The session cache is credential-scoped. Its key covers the request headers, the fetch/parse callbacks, the transfer limits, and the host guards (allowPrivateHosts, allowedHosts, verifyDns), so a document fetched with one tenant's token is invisible to a call carrying different (or no) credentials — and no caller inherits another's fetch. In-flight coalescing uses the same key.
  • The SSRF guard runs in two passes. isPrivateHost is synchronous and reads only the URL (every IPv4 encoding, IPv4-in-IPv6 embeddings, ULA/link-local/ site-local IPv6, plus a name denylist for cloud-metadata endpoints). assertPublicHost then resolves the name and refuses it when any address is non-public. Both run on every redirect hop.
  • Known residual gap: DNS rebinding. A record that changes between the check and the connection still wins; closing that requires pinning the socket to the verified address, which Node's fetch does not let us do. What the DNS pass buys is that a name statically pointing at a private address is refused — which is the case that actually shows up.
  • A resolve is bounded by maxDocuments, totalTimeoutMs, and maxDepth, so a hostile document cannot use the resolver as an egress amplifier, hold the process for hours, or crash it with nesting.

$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 resolves within the resource in scope first, and falls back to the document root when it matches nothing there — which is the behavior bundled real-world documents rely on (a bundled OpenAPI file points at #/components/schemas/… from inside an $id scope). When both could match, the resource wins, which is the order the spec asks for.
  • $dynamicRef prefers a $dynamicAnchor in scope, then degrades to $ref semantics; a pointer-form $dynamicRef (#/$defs/items) resolves exactly like a $ref, which is what the spec says it is. The full dynamic-scope algorithm (outermost anchor along the runtime reference chain) is not modelled, and cannot be by a resolver that inlines — see Conformance, measured.
  • 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.

Conformance, measured

Inlining a document must not change what it accepts, and that is checked against the corpus of $ref shapes the spec authors wrote for exactly this purpose. src/conformance.test.ts takes every reference-carrying case in the official JSON Schema Test Suite (required Draft 2020-12 tests), validates the instance twice with @amritk/runtime-validators — once through the original schema, once through the resolved one — and requires both to agree with the spec:

170 / 170 cases pass (100%).

The corpus is the reference-carrying cases the interpreter already answers correctly before resolution, so a disagreement afterwards is the resolver's and nobody else's.

Getting the last ten meant not answering them: a $dynamicRef whose $dynamicAnchor name is declared more than once binds at evaluation time, and inlining collapses that to one target by construction. Those are now kept in the output with the scaffolding they need, exactly as a reference cycle is — see $id scoping above. The expected-failure list is empty and kept in place, so the build names the first case that regresses rather than letting a percentage tick down. The corpus is vendored under fixtures/json-schema-test-suite; none of it is published.

Documents

Every document — local file or remote — is parsed as JSON by default, so the resolver stays dependency-free. Other formats plug in through the parse option: mjst lint passes a YAML-aware parser there, which is how a .lint.yaml document's cross-file $refs resolve without this package depending on a YAML parser.

Benchmarks

resolveRefs memoizes: every unique $ref string is resolved once per scope, with a sentinel that breaks cycles by keeping the reference node in place. But it is no longer only a memoized inliner — before resolving anything it walks the whole document once to build a resource registry ($id/$anchor scoping), keeps recursive cycles intact, and records a diagnostic for every external ref. The bench/ suite pits it against a bare naive inliner that does none of that — no registry, no scoping, no diagnostics — and re-resolves each ref on every encounter, so the gap is the production resolver's total per-call cost against the cheapest thing that produces the same inlined shape. Both are asserted to produce byte-identical output before either is timed. Run it with bun run bench (Bun) or bun run bench:node (Node); the shapes are a long $ref → $ref chain, many refs reusing one definition, a cyclic tree, and many distinct definitions each used once. Bun and Node land close together on every shape, which is what you would expect from a resolver whose cost is one document walk rather than a hot inner loop.

Memoization overtakes the naive walk only on the chain shape, where a long indirection path is expensive to re-resolve and the cache collapses it to one pass. On every other shape here the fixed cost of the up-front registry walk (paid once per call, no matter how the refs reuse) outweighs what memoization saves, and the bare inliner is faster — these are small schemas where that one document walk dominates. The takeaway is practical: the resolver's per-call floor is a full document traversal, so in a hot loop resolve a document once and reuse the result rather than re-resolving. The reuse-heavy, cyclic, and wide-distinct shapes are kept in the bench precisely to show that trade honestly rather than cherry-picking the one shape the cache wins.

Opting into trackOrigins (which records where each inlined value came from) costs nothing measurable on these small schemas, on either runtime: the differences straddle zero, which is run-to-run noise rather than a price.

版本与发布记录

@amritk/resolve-refs 共发布 21 个版本,首次发布于 2026/05/30;过去 12 个月发布了 21 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.10.0latest2026/10/2710 天前
0.9.02026/09/261 个月前
0.8.02026/09/241 个月前
0.7.12026/09/012 个月前
0.7.02026/08/122 个月前
0.6.02026/08/112 个月前

关于 @amritk/resolve-refs 的常见问题

@amritk/resolve-refs 是什么?

Resolve and inline JSON Schema / OpenAPI $refs — internal, cross-file, and remote — with session caching and a default-deny SSRF guard.

如何安装 @amritk/resolve-refs?

在项目目录运行 npm install @amritk/resolve-refs,或使用 pnpm add / yarn add / bun add。

@amritk/resolve-refs 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@amritk/resolve-refs 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@amritk/resolve-refs 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=20。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@amritk/resolve-refs 可以商用吗?

@amritk/resolve-refs 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@amritk/resolve-refs 的下载量有多少?

根据 npm 官方下载统计,@amritk/resolve-refs 在 2026/10/06 – 2026/11/04 这 30 天内被下载 58,960 次,日均约 1,965 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@amritk/resolve-refs 还在维护吗?

最新版本 0.10.0 发布于 2026/10/27(10 天前);至今共发布 21 个版本,其中过去 12 个月发布了 21 个;首次发布于 2026/05/30。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。