@scalar/json-magic
A collection of utilities for working with JSON objects, including diffing, conflict resolution, bundling and more.
Scalar is an open-source API platform for teams who want beautiful developer interfaces without vendor lock-in.
- API References — Interactive API documentation from OpenAPI and AsyncAPI specs.
- Developer Docs — Write in Markdown/MDX, generate API references, sync with two-way Git.
- SDK Generator — Type-safe SDKs and CLIs in TypeScript, Python, Go, PHP, Java, and Ruby.
- API Client — Open-source, offline-first Postman alternative built on OpenAPI.
20M+ monthly npm installs · 15,500+ GitHub stars · MIT licensed · scalar.com
Installation
npm add @scalar/json-magic
Entry points
There is no root export. Every module is imported from its own entry point, so you only pay for what you use.
| Entry point | Exports |
|---|---|
@scalar/json-magic/bundle |
bundle, isLocalRef, prefixInternalRef, prefixInternalRefRecursive, resolveAndCopyReferences, plus the Plugin, LoaderPlugin, LifecyclePlugin and ResolveResult types |
@scalar/json-magic/bundle/plugins/browser |
fetchUrls, parseJson, parseYaml |
@scalar/json-magic/bundle/plugins/node |
fetchUrls, parseJson, parseYaml, readFiles |
@scalar/json-magic/bundle/value-generator |
getHash, generateUniqueValue, uniqueValueGeneratorFactory |
@scalar/json-magic/dereference |
dereference |
@scalar/json-magic/join |
join, plus the JoinOptions, JoinStrategy, JoinContext, JoinConflict and JoinResult types |
@scalar/json-magic/diff |
diff, merge, apply, the Difference type |
@scalar/json-magic/magic-proxy |
createMagicProxy, getRaw |
@scalar/json-magic/helpers/* |
Small standalone helpers, see Helpers |
Which module do I need?
| I want to … | Use |
|---|---|
Pull external $ref documents into a single self-contained document |
bundle |
Read through $ref pointers without rewriting the document |
magic-proxy |
Resolve every $ref, internal and external, in one call |
dereference |
| Compare two documents and merge concurrent edits | diff |
join
join combines JSON objects without assuming a document standard. It merges objects recursively and replaces arrays and scalar values with those from later inputs. It does not mutate inputs, upgrade document versions, resolve references, or rename definitions. Literal keys such as __proto__, constructor, and prototype are preserved as own data properties without changing object prototypes.
import { join } from '@scalar/json-magic/join'
const result = join(
[
{
title: 'First',
catalog: { apple: { price: 2 } },
labels: [{ name: 'fruit' }],
},
{
title: 'Second',
catalog: { pear: { price: 3 } },
labels: [{ name: 'fruit' }],
},
],
{
strategy: ({ path }) => {
if (path[0] === 'catalog' && path.length === 2) {
return 'conflict'
}
if (path[0] === 'labels') {
return { uniqueBy: 'name' }
}
return 'merge'
},
},
)
if (result.ok) {
console.log(result.document) // Both catalog entries, title "Second", one fruit label
} else {
console.log(result.conflicts) // Example: [{ path: ['catalog', 'apple'] }]
}
The optional strategy callback receives { path, current, incoming } for each visited field. Paths are arrays of literal keys, so a key containing / remains one segment. The root always merges; returning replace or conflict for an object treats that object as a whole and does not visit its children.
| Strategy | Behavior |
|---|---|
merge (default) |
Recursively merge objects; replace other values using the later input. |
merge-by-index |
Recursively merge objects and arrays, combining array entries at matching indexes and retaining trailing entries. |
skip |
Ignore this incoming field, leaving any existing value unchanged. |
replace |
Replace the entire value, including objects. |
conflict |
Report a duplicate key, even if both values are equal or the earlier value is null. |
{ uniqueBy: 'name' } |
Combine arrays, retaining the first item for each identity property value. Items without that property remain distinct. Use scalar identity values. Non-array incoming values replace the existing value. |
An empty input list returns { ok: true, document: {} }. Conflicts return { ok: false, conflicts } without a partial document. Inputs must be acyclic JSON objects.
For OpenAPI, use join from @scalar/openapi-parser, which supplies OpenAPI rules, version upgrades, and optional component prefixes. Other formats can supply their own rules; this module does not include an AsyncAPI adapter.
bundle
Preserve selected references
Pass the synchronous preserveReference(ref, { origin }) option to leave selected
$ref edges authored as-is. ref includes the original fragment; origin is the
effective source URI, including enclosing $id values and any resolveDocument
identity. Returning true bypasses target lookup, loading, cache insertion, and
reference rewriting, even for already indexed or cached targets. Sibling fields
are still traversed. The callback is not used for loading the input root.
import { bundle } from '@scalar/json-magic/bundle'
import { fetchUrls, readFiles } from '@scalar/json-magic/bundle/plugins/node'
const result = await bundle('https://example.test/api/root.json', {
plugins: [fetchUrls(), readFiles()],
treeShake: true,
preserveReference: (ref) => /^https?:\/\//i.test(ref),
})
This loads the URL root and bundles local files, internal references, and relative
references such as ./child.json inside remote documents. Only authored absolute
HTTP(S) references are preserved by this predicate; keep the HTTP loader enabled.
Preservation emits no resolution start, success, or error hooks. Real failures on
other edges retain normal error reporting. Node-processing hooks still run.
When this option is present, indexed $id values are resolved against their
original effective bases so serialization at another location retains schema
identity and anchor scope. Like normal bundling, this can mutate input and cached
objects. URL maps include only bundled resources. Omit the option to retain the
existing bundling behavior. Predicates preserving relative references are
responsible for ensuring those references remain meaningful at the output base;
use the absolute HTTP(S) predicate above for portable external references.
Known limitation: an embedded schema with its own $id cannot reliably reference
another bundled resource through the outer document when that outer document has
no declared identity. For example, a fragment-only #/x-ext/... reference inside
that schema resolves against the schema, not the outer bundle. Supplying a
format-appropriate root identity through resolveDocument avoids this case.
Preserving selected references does not currently fix anonymous-root cross-scope
bundling; $id normalization alone does not make that output portable.
bundle walks a JSON object, resolves every external $ref (URLs, local files, or anything a custom loader plugin can handle) and embeds the result into the document itself. The original $ref values are rewritten to point at the embedded copies, so the output is a single self-contained document.
Document formats can supply a resolveDocument lifecycle hook returning { baseUri, metadata }. The bundler resolves relative references against that base URI and retains the supplied root metadata when tree shaking. The hook also applies to cached and previously bundled documents. Without a hook, the retrieval URI remains the document base. JSON Schema $id values resolve against their enclosing base.
When reading qualified root references with createMagicProxy, pass the canonical URI as documentUri. Interpretation of format-specific identity fields belongs in the caller or a plugin.
URI resolution applies to every bundling path, including descriptions without $self:
- Absolute scheme-bearing references (
https:,file:,urn:,mailto:, or custom schemes) retain their identity. This does not enable fetching those schemes; loader plugins still decide what they support. - Hierarchical URI bases use URL resolution. Root-relative references replace the pathname; protocol-relative references replace the host. A new document path drops the base query and fragment, while query-only or fragment-only references keep the applicable parts of the base.
- A base ending in
/denotes a directory. Without the slash, the final segment is a document name. Scheme-less paths continue to use filesystem resolution, including Windows drive paths. - Opaque identities such as URNs support absolute and fragment references, but cannot provide a directory for a relative document path. Such a resolution throws rather than inventing a local path.
- Relativization preserves query strings, fragments, and directory slashes. HTTP references become relative only when resolving them again reproduces the original URL. Non-HTTP URIs remain absolute: the generic helper has no document registry, so limiting this rule to known
$selfvalues would corrupt other identifiers.
These are intentional URI compatibility changes, rather than behavior limited to OpenAPI $self.
External documents are stored under the x-ext key, and the mapping between the generated keys and their original URLs is stored under x-ext-urls. Both keys are configurable, see Options.
Quick start
import { bundle } from '@scalar/json-magic/bundle'
import { fetchUrls } from '@scalar/json-magic/bundle/plugins/browser'
const result = await bundle(
{ $ref: 'http://example.com/document.json' },
{
plugins: [fetchUrls()],
treeShake: false,
},
)
// The bundled document
console.log(result)
When the input is an object it is modified in place, and the same object is returned. When the input is a string it is resolved with the given plugins first.
Loaders
A loader plugin teaches the bundler how to read one kind of reference. Import loaders from @scalar/json-magic/bundle/plugins/browser in the browser, or from @scalar/json-magic/bundle/plugins/node in Node.js. The Node entry point is a superset: it adds readFiles, which needs filesystem access.
| Loader | Handles | Available in |
|---|---|---|
fetchUrls |
http:// and https:// references |
browser, node |
readFiles |
Local file paths | node |
parseJson |
A raw JSON string passed as the input | browser, node |
parseYaml |
A raw YAML string passed as the input | browser, node |
You can combine as many loaders as you need. The first one whose validate returns true wins.
fetchUrls
Resolves remote documents over HTTP. It works in both Node.js and the browser.
import { bundle } from '@scalar/json-magic/bundle'
import { fetchUrls } from '@scalar/json-magic/bundle/plugins/browser'
const document = {
openapi: '3.1.0',
info: { title: 'Bundled API', version: '1.0.0' },
paths: {},
components: {
schemas: {
User: { $ref: 'https://example.com/user-schema.json#' },
},
},
}
// This bundles all external documents and turns external references into internal ones
await bundle(document, {
plugins: [fetchUrls()],
// Removes the parts of the external documents that are not referenced
treeShake: true,
})
console.log(document)
Limiting the number of concurrent requests
import { bundle } from '@scalar/json-magic/bundle'
import { fetchUrls } from '@scalar/json-magic/bundle/plugins/browser'
await bundle(document, {
plugins: [
fetchUrls({
// Run at most 10 requests at the same time
limit: 10,
}),
],
treeShake: false,
})
Custom headers
Headers are matched against the host of the reference, so a token is only ever sent to the domains you list.
import { bundle } from '@scalar/json-magic/bundle'
import { fetchUrls } from '@scalar/json-magic/bundle/plugins/browser'
await bundle(document, {
plugins: [
fetchUrls({
headers: [
{
domains: ['example.com'],
headers: {
Authorization: 'Bearer <TOKEN>',
},
},
],
}),
],
treeShake: false,
})
Custom fetch function
For advanced use cases like proxying requests or implementing custom network logic, you can provide your own fetch implementation. This allows you to handle things like CORS restrictions, custom authentication flows, or request and response transformations.
import { bundle } from '@scalar/json-magic/bundle'
import { fetchUrls } from '@scalar/json-magic/bundle/plugins/browser'
await bundle(document, {
plugins: [
fetchUrls({
fetch: async (input, init) => {
console.log('Custom fetch logic')
return fetch(input, init)
},
}),
],
treeShake: false,
})
Bundle from a remote URL
The input itself can be a URL, as long as a loader can handle it.
import { bundle } from '@scalar/json-magic/bundle'
import { fetchUrls } from '@scalar/json-magic/bundle/plugins/browser'
const result = await bundle('https://example.com/openapi.json', {
plugins: [fetchUrls()],
treeShake: false,
})
// The bundled document
console.log(result)
readFiles
Resolves local files. This loader only works in a Node.js environment.
import { bundle } from '@scalar/json-magic/bundle'
import { readFiles } from '@scalar/json-magic/bundle/plugins/node'
const document = {
openapi: '3.1.0',
info: { title: 'Bundled API', version: '1.0.0' },
paths: {},
components: {
schemas: {
User: { $ref: './user-schema.json#' },
},
},
}
await bundle(document, {
plugins: [readFiles()],
treeShake: false,
})
console.log(document)
Bundle from a local file
You can pass the file path directly, as long as readFiles is registered to read it.
import { bundle } from '@scalar/json-magic/bundle'
import { readFiles } from '@scalar/json-magic/bundle/plugins/node'
const result = await bundle('./input.json', {
plugins: [readFiles()],
treeShake: false,
})
// The bundled document
console.log(result)
A document that mixes remote and local references needs both loaders:
import { bundle } from '@scalar/json-magic/bundle'
import { fetchUrls, readFiles } from '@scalar/json-magic/bundle/plugins/node'
await bundle('./openapi.json', {
plugins: [readFiles(), fetchUrls()],
treeShake: false,
})
parseJson
Accepts a raw JSON string as the input.
import { bundle } from '@scalar/json-magic/bundle'
import { parseJson } from '@scalar/json-magic/bundle/plugins/browser'
const result = await bundle('{ "openapi": "3.1.1" }', {
plugins: [parseJson()],
treeShake: false,
})
// The bundled document
console.log(result)
parseYaml
Accepts a raw YAML string as the input.
import { bundle } from '@scalar/json-magic/bundle'
import { parseYaml } from '@scalar/json-magic/bundle/plugins/browser'
const result = await bundle('openapi: "3.1.1"\n', {
plugins: [parseYaml()],
treeShake: false,
})
// The bundled document
console.log(result)
Writing a custom loader
A loader is a plain object, so you can resolve references from anywhere: a database, an in-memory map, or a custom protocol.
import { bundle, type LoaderPlugin } from '@scalar/json-magic/bundle'
const workspaceFiles: LoaderPlugin = {
type: 'loader',
validate: (value) => value.startsWith('workspace:'),
exec: async (value) => {
const raw = await readFromWorkspace(value.replace('workspace:', ''))
if (raw === undefined) {
return { ok: false }
}
return { ok: true, data: JSON.parse(raw), raw }
},
}
await bundle(document, {
plugins: [workspaceFiles],
treeShake: false,
})
exec returns { ok: true, data, raw } on success and { ok: false } on failure. A failed resolution is reported through the onResolveError hook and logged as a warning, it does not throw.
Options
| Option | Type | Default | Description |
|---|---|---|---|
plugins |
Plugin[] |
— | Loader and lifecycle plugins used during bundling. Required. |
treeShake |
boolean |
— | Only keep the parts of external documents that are actually referenced. Required. |
depth |
number |
unlimited | How deeply nested $ref pointers are followed. See the note below. |
root |
UnknownObject |
the input | Base document to write external documents into, used for partial bundling. |
origin |
string |
the input | Base path used to resolve relative references. |
cache |
Map<string, Promise<ResolveResult>> |
new map | Cache of in-flight resolutions, reused across bundle calls to avoid refetching. |
visitedNodes |
Set<unknown> |
new set | Nodes that have already been bundled, used to avoid re-bundling during partial bundles. |
urlMap |
boolean |
false |
Keep the mapping of generated keys to their original URLs in the output. |
externalDocumentsKey |
string |
'x-ext' |
Key that holds the bundled external documents. |
externalDocumentsMappingsKey |
string |
'x-ext-urls' |
Key that holds the mapping between generated keys and original URLs. |
compress |
(value: string) => string | Promise<string> |
hash | Function used to shorten URLs and file paths into document keys. |
hooks |
object |
— | Lifecycle hooks, see Hooks. |
depth
The depth option controls how deeply the bundler resolves $ref references. When you set depth to a number, the bundler only follows references up to that level of nesting. This is useful for creating partial bundles or limiting resource usage.
Note: When using depth, the resulting bundle may not be fully self-contained, since nested references deeper than the given depth stay unresolved. If you use depth together with visitedNodes, be aware that parent nodes may be marked as visited even if their child references have not been fully resolved yet. Use this option with care if you require a complete bundle.
import { bundle } from '@scalar/json-magic/bundle'
import { fetchUrls } from '@scalar/json-magic/bundle/plugins/browser'
await bundle(input, {
plugins: [fetchUrls()],
treeShake: false,
depth: 2,
})
externalDocumentsKey
The externalDocumentsKey option controls the key used to store external references. This key contains all bundled external documents, which keeps a clean separation between the main document and its bundled references. Defaults to x-ext.
import { bundle } from '@scalar/json-magic/bundle'
import { fetchUrls } from '@scalar/json-magic/bundle/plugins/browser'
await bundle(input, {
plugins: [fetchUrls()],
treeShake: false,
externalDocumentsKey: 'x-external-docs',
})
externalDocumentsMappingsKey
The externalDocumentsMappingsKey option controls the key used to maintain a mapping between generated keys and their original URLs. This mapping is essential for tracking the source of bundled references. It is removed from the output of a full bundle unless urlMap is enabled. Defaults to x-ext-urls.
import { bundle } from '@scalar/json-magic/bundle'
import { fetchUrls } from '@scalar/json-magic/bundle/plugins/browser'
await bundle(input, {
plugins: [fetchUrls()],
treeShake: false,
urlMap: true,
externalDocumentsMappingsKey: 'x-external-urls',
})
Hooks
Hooks let you observe and extend the bundling process, which is handy for progress reporting and error tracking.
| Hook | Called when |
|---|---|
onResolveStart |
The bundler starts resolving a $ref |
onResolveSuccess |
A $ref was resolved successfully |
onResolveError |
A $ref could not be resolved |
onBeforeNodeProcess |
Before a node is processed, may be async |
onAfterNodeProcess |
After a node is processed, may be async |
import { bundle } from '@scalar/json-magic/bundle'
import { fetchUrls } from '@scalar/json-magic/bundle/plugins/browser'
const errors: string[] = []
await bundle(document, {
plugins: [fetchUrls()],
treeShake: true,
hooks: {
onResolveStart: (node) => console.log('Resolving:', node.$ref),
onResolveSuccess: (node) => console.log('Resolved:', node.$ref),
onResolveError: (node) => errors.push(`Failed to resolve ${node.$ref}`),
},
})
The same hooks can be shipped as a reusable lifecycle plugin, which is then passed alongside the loaders:
import { bundle, type LifecyclePlugin } from '@scalar/json-magic/bundle'
import { fetchUrls } from '@scalar/json-magic/bundle/plugins/browser'
const logProgress: LifecyclePlugin = {
type: 'lifecycle',
onResolveSuccess: (node) => console.log('Resolved:', node.$ref),
}
await bundle(document, {
plugins: [fetchUrls(), logProgress],
treeShake: false,
})
dereference
dereference resolves the $ref pointers of a document and returns the result wrapped in a magic proxy, so referenced values are read through the $ref-value property.
It works in two modes:
- Synchronous (
sync: true): only internal references are resolved, no network requests are made, and the result is returned directly. - Asynchronous (
sync: false, the default): external references are bundled into the document first, so both internal and external references resolve. A Promise is returned.
Options
| Option | Type | Default | Description |
|---|---|---|---|
sync |
boolean |
false |
Resolve internal references only, without returning a Promise. |
plugins |
Plugin[] |
[fetchUrls()] |
Loaders used to resolve external references in async mode. |
The result is an object with a success property. On success it carries the dereferenced data, and on failure it carries an errors array describing the references that could not be resolved.
import { dereference } from '@scalar/json-magic/dereference'
const result = dereference({ a: 'hello', b: { $ref: '#/a' } }, { sync: true })
if (result.success) {
// 'hello'
console.log(result.data.b['$ref-value'])
}
To resolve external references as well, drop sync (or set it to false) and await the result.
import { dereference } from '@scalar/json-magic/dereference'
const result = await dereference({
a: 'hello',
b: { $ref: 'http://example.com/document.json#/somepath' },
})
if (result.success) {
console.log(result.data)
} else {
console.error(result.errors)
}
To resolve references from somewhere other than HTTP, pass your own loaders:
import { fetchUrls, readFiles } from '@scalar/json-magic/bundle/plugins/node'
import { dereference } from '@scalar/json-magic/dereference'
const result = await dereference(document, {
plugins: [readFiles(), fetchUrls()],
})
diff
Compare two JSON objects, merge changes made in parallel, and surface the conflicts that need to be resolved manually.
| Function | Description |
|---|---|
diff(base, updated) |
Returns the list of add, update and delete changes that turn base into updated |
merge(diffA, diffB) |
Combines two changesets made against the same base into { diffs, conflicts } |
apply(base, diffs) |
Applies a changeset to a document and returns it |
These three functions work on the documents themselves, not on copies of them. apply mutates the document it is given, merge merges one changeset into the entries of the other, and the changes a diff carries are live references into the documents that were compared. That means an applied document keeps sharing subtrees with the document it was diffed against, and a merge writes into the document behind its second changeset. Deep clone the documents before diffing them whenever you need the originals to stay untouched — cloning only the document you apply to is not enough.
apply throws an InvalidChangesDetectedError when a change points at a path that does not exist, when a change targets the document itself (an empty path, which a diff produces whenever the two documents differ at the root), and when a path reaches the prototype chain through __proto__, constructor or prototype.
Quickstart
import { apply, diff, merge } from '@scalar/json-magic/diff'
const baseObject = {
openapi: '3.0.0',
info: {
title: 'Simple API',
description: 'A small OpenAPI specification example',
version: '1.0.0',
},
}
const objectV1 = {
openapi: '3.0.0',
info: {
title: 'Simple API',
description: 'A small OpenAPI specification example',
version: '1.0.0',
},
change: 'This is a new property',
}
const objectV2 = {
openapi: '3.0.0',
info: {
title: 'Simple API',
description: 'A small OpenAPI specification example',
version: '1.0.1',
},
}
// Merge the changes of both versions with the same parent object
const { diffs, conflicts } = merge(
diff(baseObject, objectV1),
diff(baseObject, objectV2),
)
// Apply changes from v1 and v2 to the parent object to get the final object
const finalDocument = apply(baseObject, diffs)
Conflicts
merge only returns the changes it can combine safely. When both sides touch the same path, the pair is returned in conflicts for you to resolve, and it is left out of diffs.
import { diff, merge } from '@scalar/json-magic/diff'
const { diffs, conflicts } = merge(diff(base, mine), diff(base, theirs))
for (const [mineChanges, theirChanges] of conflicts) {
// Decide which side wins, then push the winner onto diffs
diffs.push(...mineChanges)
}
magic-proxy
A JavaScript proxy that resolves internal references as you access properties. Nothing is copied or rewritten: the underlying document keeps its $ref pointers, and referenced values are read on demand through the virtual $ref-value property. Resolved values are cached, and proxies are stable for the same target object, which makes it safe to use with reactive frameworks like Vue.
Quick start
import { createMagicProxy, getRaw } from '@scalar/json-magic/magic-proxy'
const result = createMagicProxy({
a: 'hello',
b: {
$ref: '#/a',
},
})
// 'hello', resolved on access
console.log(result.b['$ref-value'])
// '#/a', the original pointer is still there
console.log(result.b.$ref)
/**
* getRaw returns the untouched object:
* {
* a: 'hello',
* b: {
* $ref: '#/a'
* }
* }
*/
const rawObject = getRaw(result)
console.log(rawObject)
Deeply nested values are proxied too, so $ref-value works at any depth. Writes pass through to the underlying object, and writing to $ref-value updates the value the reference points at.
Properties prefixed with __scalar_ are treated as internal: they read as undefined, they are left out of Object.keys, and in checks return false. Pass { showInternal: true } to expose them.
const proxy = createMagicProxy(
{ __scalar_meta: 'hidden' },
{ showInternal: true },
)
// 'hidden'
console.log(proxy.__scalar_meta)
Helpers
Standalone helpers, each with its own entry point.
| Helper | Description |
|---|---|
@scalar/json-magic/helpers/escape-json-pointer |
Escape ~ and / in a JSON pointer segment |
@scalar/json-magic/helpers/unescape-json-pointer |
Reverse of escapeJsonPointer |
@scalar/json-magic/helpers/get-segments-from-path |
Split a JSON pointer into unescaped segments |
@scalar/json-magic/helpers/get-value-by-path |
Read the value at a list of path segments |
@scalar/json-magic/helpers/set-value-at-path |
Write a value at a JSON pointer, creating missing nodes |
@scalar/json-magic/helpers/is-file-path |
Check whether a string looks like a file path |
@scalar/json-magic/helpers/is-http-url |
Check whether a string is an HTTP or HTTPS URL |
@scalar/json-magic/helpers/is-json-object |
Check whether a string is a JSON object |
@scalar/json-magic/helpers/is-yaml |
Check whether a string is valid YAML |
@scalar/json-magic/helpers/normalize |
Parse a JSON or YAML string into an object |
import { getValueByPath } from '@scalar/json-magic/helpers/get-value-by-path'
const { value } = getValueByPath(
{ components: { schemas: { User: { type: 'object' } } } },
['components', 'schemas', 'User'],
)
Community
We are API nerds. You too? Let's chat on Discord: https://discord.gg/scalar
License
The source code in this repository is licensed under MIT.