@scalar/json-magic 0.13.0 → 0.13.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,50 @@
1
1
  # @scalar/json-magic
2
2
 
3
+ ## 0.13.3
4
+
5
+ ## 0.13.2
6
+
7
+ ### Patch Changes
8
+
9
+ - [#9941](https://github.com/scalar/scalar/pull/9941): Republish every package through npm trusted publishing. No functional changes.
10
+
11
+ ## 0.13.1
12
+
13
+ ### Patch Changes
14
+
15
+ - [#9900](https://github.com/scalar/scalar/pull/9900): Emit array deletions highest-index-first in diff so removing more than one element applies correctly. Before, `diff({ items: [1, 2, 3, 4] }, { items: [1] })` emitted the deletes in ascending index order, and because `apply` removes array elements with `splice`, every delete after the first hit a stale index and left elements behind (`{ items: [1, 3] }`). Deletes on arrays are now ordered from the end of the array, so `apply(a, diff(a, b))` round-trips for arrays that lose any number of elements.
16
+ - [#9906](https://github.com/scalar/scalar/pull/9906): Treat an array and a plain object on the same path as a merge conflict. `isKeyCollisions` compared the two containers key by key, so an array and an object holding the same values under the same indices looked mergeable:
17
+
18
+ ```ts
19
+ isKeyCollisions([1, 2], { 0: 1, 1: 2 }) // → false, so the two were merged
20
+ mergeObjects([1, 2], { 0: 1, 1: 2 }) // → [1, 2], the object silently absorbed
21
+ ```
22
+
23
+ In practice that meant one side turning `servers: ['https://example.com']` into `servers: { 0: 'https://example.com' }` merged cleanly and lost the container the user picked. Both sides now surface as a conflict to resolve, matching the guard `diff` already applies when a container changes type.
24
+
25
+ - [#9904](https://github.com/scalar/scalar/pull/9904): Stop the diff utilities from writing through the prototype chain. Documents reach them from remote fetches and user files, and `JSON.parse` turns `__proto__` into a real own property, so `apply({}, diff({}, JSON.parse('{"__proto__": {"polluted": "yes"}}')))` used to write onto `Object.prototype` and poison every object in the runtime. `diff`, `isKeyCollisions` and `mergeObjects` now skip the `__proto__`, `constructor` and `prototype` keys, the trie backing `merge` keys its children on a null prototype, and `apply` rejects any changeset containing one of those segments with an `InvalidChangesDetectedError` before it touches the document.
26
+
27
+ This does change behaviour for documents that legitimately carry a property with one of those names, which JSON allows and a schema is free to describe. `diff` no longer compares such a property, so editing it on its own is not reported as a change, and a merge that previously surfaced it as a conflict now merges cleanly. The property still travels along when its parent object is added or updated wholesale, because a new subtree is emitted as a single value. This trade-off matches `preventPollution`, which the rest of the codebase already applies to untrusted keys.
28
+
29
+ `@scalar/helpers` gains an `isPollutionKey` predicate next to the existing `preventPollution`, so the list of dangerous keys lives in one place.
30
+
31
+ - [#9907](https://github.com/scalar/scalar/pull/9907): Explain the root-level change `apply` cannot handle, and document that `diff`, `merge` and `apply` share references with the documents they work on.
32
+
33
+ A change with an empty path asks to replace the document itself, which `apply` cannot do since it writes through the parent container of each path. It used to fail with `Process aborted. Path at depth 0 is undefined, check diff object` halfway through a changeset, and now says so up front and leaves the document untouched:
34
+
35
+ ```ts
36
+ // `diff` emits a change with an empty path whenever the two documents differ at the root
37
+ apply({ name: 'John' }, [{ path: [], changes: [1, 2], type: 'update' }])
38
+ // InvalidChangesDetectedError: Process aborted. Root-level replacement is not supported, the
39
+ // change targets the document itself instead of a property inside it
40
+ ```
41
+
42
+ The changes a diff carries are live references into the documents it compared, so `merge` writes into the document behind its second diff list and an applied document stays structurally shared with the diff. That contract is now spelled out on `diff`, `merge` and `apply` — deep clone the documents when a caller needs isolation.
43
+
44
+ - [#9902](https://github.com/scalar/scalar/pull/9902): Skip the correct entry when `merge` resolves a delete against a delete. The matching entry was skipped using an index into the first diff list to address the second one, so whenever the two matching deletes sat at different positions in their lists, an unrelated change was silently dropped and the shared delete was duplicated. Merging `[delete x, delete a]` with `[delete a, delete y]` returned `[delete x, delete a, delete a]` instead of `[delete x, delete a, delete y]`.
45
+
46
+ One case changes for the worse and is pinned by a test: when a delete is subsumed by a delete on the other side that itself ends up in `conflicts`, the subsumed delete is dropped from the result. That gap already existed, but skipping the wrong index used to hide it for some orderings of the second diff list, so it is now consistent rather than order-dependent.
47
+
3
48
  ## 0.13.0
4
49
 
5
50
  ### Minor Changes
package/README.md CHANGED
@@ -1,11 +1,10 @@
1
- # json-magic
1
+ # @scalar/json-magic
2
2
 
3
3
  [![Version](https://img.shields.io/npm/v/%40scalar/json-magic)](https://www.npmjs.com/package/@scalar/json-magic)
4
4
  [![Downloads](https://img.shields.io/npm/dm/%40scalar/json-magic)](https://www.npmjs.com/package/@scalar/json-magic)
5
5
  [![License](https://img.shields.io/npm/l/%40scalar%2Fjson-magic)](https://www.npmjs.com/package/@scalar/json-magic)
6
6
  [![Discord](https://img.shields.io/discord/1135330207960678410?style=flat&color=5865F2)](https://discord.gg/scalar)
7
7
 
8
-
9
8
  A collection of utilities for working with JSON objects, including diffing, conflict resolution, bundling and more.
10
9
 
11
10
  ---
@@ -21,9 +20,41 @@ Scalar is an open-source API platform for teams who want beautiful developer int
21
20
 
22
21
  ---
23
22
 
23
+ ## Installation
24
+
25
+ ```bash
26
+ npm add @scalar/json-magic
27
+ ```
28
+
29
+ ## Entry points
30
+
31
+ There is no root export. Every module is imported from its own entry point, so you only pay for what you use.
32
+
33
+ | Entry point | Exports |
34
+ | --- | --- |
35
+ | `@scalar/json-magic/bundle` | `bundle`, `isLocalRef`, `prefixInternalRef`, `prefixInternalRefRecursive`, `resolveAndCopyReferences`, plus the `Plugin`, `LoaderPlugin`, `LifecyclePlugin` and `ResolveResult` types |
36
+ | `@scalar/json-magic/bundle/plugins/browser` | `fetchUrls`, `parseJson`, `parseYaml` |
37
+ | `@scalar/json-magic/bundle/plugins/node` | `fetchUrls`, `parseJson`, `parseYaml`, `readFiles` |
38
+ | `@scalar/json-magic/bundle/value-generator` | `getHash`, `generateUniqueValue`, `uniqueValueGeneratorFactory` |
39
+ | `@scalar/json-magic/dereference` | `dereference` |
40
+ | `@scalar/json-magic/diff` | `diff`, `merge`, `apply`, the `Difference` type |
41
+ | `@scalar/json-magic/magic-proxy` | `createMagicProxy`, `getRaw` |
42
+ | `@scalar/json-magic/helpers/*` | Small standalone helpers, see [Helpers](#helpers) |
43
+
44
+ ## Which module do I need?
45
+
46
+ | I want to … | Use |
47
+ | --- | --- |
48
+ | Pull external `$ref` documents into a single self-contained document | [`bundle`](#bundle) |
49
+ | Read through `$ref` pointers without rewriting the document | [`magic-proxy`](#magic-proxy) |
50
+ | Resolve every `$ref`, internal and external, in one call | [`dereference`](#dereference) |
51
+ | Compare two documents and merge concurrent edits | [`diff`](#diff) |
52
+
24
53
  ## bundle
25
54
 
26
- Bundle external references in a json object
55
+ `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.
56
+
57
+ 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](#options).
27
58
 
28
59
  ### Quick start
29
60
 
@@ -31,23 +62,36 @@ Bundle external references in a json object
31
62
  import { bundle } from '@scalar/json-magic/bundle'
32
63
  import { fetchUrls } from '@scalar/json-magic/bundle/plugins/browser'
33
64
 
34
- const result = await bundle({
35
- $ref: 'http://example.com/document.json'
36
- }, {
37
- plugins: [fetchUrls()],
38
- treeShake: false,
39
- })
65
+ const result = await bundle(
66
+ { $ref: 'http://example.com/document.json' },
67
+ {
68
+ plugins: [fetchUrls()],
69
+ treeShake: false,
70
+ },
71
+ )
40
72
 
41
- // get the bundled json object
73
+ // The bundled document
42
74
  console.log(result)
43
75
  ```
44
76
 
45
- #### Plugins
77
+ 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.
78
+
79
+ ### Loaders
80
+
81
+ 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.
82
+
83
+ | Loader | Handles | Available in |
84
+ | --- | --- | --- |
85
+ | `fetchUrls` | `http://` and `https://` references | browser, node |
86
+ | `readFiles` | Local file paths | node |
87
+ | `parseJson` | A raw JSON string passed as the input | browser, node |
88
+ | `parseYaml` | A raw YAML string passed as the input | browser, node |
46
89
 
47
- If you are on a browser environment import plugins from `@scalar/json-magic/bundle/plugins/browser` while if you are on a node environment you can import from `@scalar/json-magic/bundle/plugins/node`
90
+ You can combine as many loaders as you need. The first one whose `validate` returns `true` wins.
48
91
 
49
- ##### fetchUrls
50
- This plugins handles all external urls. It works for both node.js and browser environment
92
+ #### fetchUrls
93
+
94
+ Resolves remote documents over HTTP. It works in both Node.js and the browser.
51
95
 
52
96
  ```ts
53
97
  import { bundle } from '@scalar/json-magic/bundle'
@@ -59,102 +103,104 @@ const document = {
59
103
  paths: {},
60
104
  components: {
61
105
  schemas: {
62
- User: { $ref: 'https://example.com/user-schema.json#' }
63
- }
64
- }
106
+ User: { $ref: 'https://example.com/user-schema.json#' },
107
+ },
108
+ },
65
109
  }
66
110
 
67
- // This will bundle all external documents and turn all references from external into internal
111
+ // This bundles all external documents and turns external references into internal ones
68
112
  await bundle(document, {
69
113
  plugins: [fetchUrls()],
70
- treeShake: true // <------ This flag will try to remove any unused part of the external document
114
+ // Removes the parts of the external documents that are not referenced
115
+ treeShake: true,
71
116
  })
72
117
 
73
118
  console.log(document)
74
119
  ```
75
120
 
76
- ###### Limiting the number of concurrent requests
121
+ ##### Limiting the number of concurrent requests
77
122
 
78
123
  ```ts
124
+ import { bundle } from '@scalar/json-magic/bundle'
125
+ import { fetchUrls } from '@scalar/json-magic/bundle/plugins/browser'
126
+
79
127
  await bundle(document, {
80
128
  plugins: [
81
129
  fetchUrls({
82
- limit: 10, // it should run at most 10 requests at the same time
130
+ // Run at most 10 requests at the same time
131
+ limit: 10,
83
132
  }),
84
133
  ],
85
- treeShake: false
134
+ treeShake: false,
86
135
  })
87
-
88
136
  ```
89
137
 
90
- ###### Custom headers
91
- To pass custom headers to requests for specific domains you can configure the fetch plugin like the example
138
+ ##### Custom headers
139
+
140
+ Headers are matched against the host of the reference, so a token is only ever sent to the domains you list.
92
141
 
93
142
  ```ts
94
- await bundle(
95
- document,
96
- {
97
- plugins: [
98
- fetchUrls({
99
- // Pass custom headers
100
- // The header will only be attached to the list of domains
101
- headers: [
102
- {
103
- domains: ['example.com'],
104
- headers: {
105
- 'Authorization': 'Bearer <TOKEN>'
106
- }
107
- }
108
- ]
109
- }),
110
- readFiles(),
111
- ],
112
- treeShake: false
113
- },
114
- )
143
+ import { bundle } from '@scalar/json-magic/bundle'
144
+ import { fetchUrls } from '@scalar/json-magic/bundle/plugins/browser'
145
+
146
+ await bundle(document, {
147
+ plugins: [
148
+ fetchUrls({
149
+ headers: [
150
+ {
151
+ domains: ['example.com'],
152
+ headers: {
153
+ Authorization: 'Bearer <TOKEN>',
154
+ },
155
+ },
156
+ ],
157
+ }),
158
+ ],
159
+ treeShake: false,
160
+ })
115
161
  ```
116
162
 
117
- ###### Custom fetch function
118
- 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/response transformations.
163
+ ##### Custom fetch function
164
+
165
+ 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.
119
166
 
120
167
  ```ts
121
- await bundle(
122
- document,
123
- {
124
- plugins: [
125
- fetchUrls({
126
- // Custom fetcher function
127
- fetch: async (input, init) => {
128
- console.log('Custom fetch logic')
129
- return fetch(input, init)
130
- },
131
- })
132
- readFiles(),
133
- ],
134
- treeShake: false
135
- },
136
- )
168
+ import { bundle } from '@scalar/json-magic/bundle'
169
+ import { fetchUrls } from '@scalar/json-magic/bundle/plugins/browser'
170
+
171
+ await bundle(document, {
172
+ plugins: [
173
+ fetchUrls({
174
+ fetch: async (input, init) => {
175
+ console.log('Custom fetch logic')
176
+ return fetch(input, init)
177
+ },
178
+ }),
179
+ ],
180
+ treeShake: false,
181
+ })
137
182
  ```
138
183
 
139
- ###### Bundle from remote url
184
+ ##### Bundle from a remote URL
185
+
186
+ The input itself can be a URL, as long as a loader can handle it.
140
187
 
141
188
  ```ts
142
- const result = await bundle(
143
- 'https://example.com/openapi.json',
144
- {
145
- plugins: [
146
- fetchUrls(),
147
- ],
148
- treeShake: false
149
- },
150
- )
189
+ import { bundle } from '@scalar/json-magic/bundle'
190
+ import { fetchUrls } from '@scalar/json-magic/bundle/plugins/browser'
151
191
 
152
- // Bundled document
192
+ const result = await bundle('https://example.com/openapi.json', {
193
+ plugins: [fetchUrls()],
194
+ treeShake: false,
195
+ })
196
+
197
+ // The bundled document
153
198
  console.log(result)
154
199
  ```
155
200
 
156
- ##### readFiles
157
- This plugins handles local files. Only works on node.js environment
201
+ #### readFiles
202
+
203
+ Resolves local files. This loader only works in a Node.js environment.
158
204
 
159
205
  ```ts
160
206
  import { bundle } from '@scalar/json-magic/bundle'
@@ -166,91 +212,137 @@ const document = {
166
212
  paths: {},
167
213
  components: {
168
214
  schemas: {
169
- User: { $ref: './user-schema.json#' }
170
- }
171
- }
215
+ User: { $ref: './user-schema.json#' },
216
+ },
217
+ },
172
218
  }
173
219
 
174
- // This will bundle all external documents and turn all references from external into internal
175
220
  await bundle(document, {
176
221
  plugins: [readFiles()],
177
- treeShake: false
222
+ treeShake: false,
178
223
  })
179
224
 
180
225
  console.log(document)
181
226
  ```
182
227
 
183
- ###### Bundle from local file
184
- You can pass the file path directly but make sure to have the correct plugins to handle reading from the local files
228
+ ##### Bundle from a local file
229
+
230
+ You can pass the file path directly, as long as `readFiles` is registered to read it.
185
231
 
186
232
  ```ts
187
- const result = await bundle(
188
- './input.json',
189
- {
190
- plugins: [
191
- readFiles(),
192
- ],
193
- treeShake: false
194
- },
195
- )
233
+ import { bundle } from '@scalar/json-magic/bundle'
234
+ import { readFiles } from '@scalar/json-magic/bundle/plugins/node'
196
235
 
197
- // Bundled document
236
+ const result = await bundle('./input.json', {
237
+ plugins: [readFiles()],
238
+ treeShake: false,
239
+ })
240
+
241
+ // The bundled document
198
242
  console.log(result)
199
243
  ```
200
244
 
201
- ##### parseJson
245
+ A document that mixes remote and local references needs both loaders:
246
+
247
+ ```ts
248
+ import { bundle } from '@scalar/json-magic/bundle'
249
+ import { fetchUrls, readFiles } from '@scalar/json-magic/bundle/plugins/node'
250
+
251
+ await bundle('./openapi.json', {
252
+ plugins: [readFiles(), fetchUrls()],
253
+ treeShake: false,
254
+ })
255
+ ```
256
+
257
+ #### parseJson
258
+
259
+ Accepts a raw JSON string as the input.
202
260
 
203
- You can pass raw json string as input
204
261
  ```ts
205
262
  import { bundle } from '@scalar/json-magic/bundle'
206
263
  import { parseJson } from '@scalar/json-magic/bundle/plugins/browser'
207
264
 
208
- const result = await bundle(
209
- '{ "openapi": "3.1.1" }',
210
- {
211
- plugins: [
212
- parseJson(),
213
- ],
214
- treeShake: false
215
- },
216
- )
265
+ const result = await bundle('{ "openapi": "3.1.1" }', {
266
+ plugins: [parseJson()],
267
+ treeShake: false,
268
+ })
217
269
 
218
- // Bundled document
270
+ // The bundled document
219
271
  console.log(result)
220
272
  ```
221
273
 
222
- ##### parseYaml
274
+ #### parseYaml
275
+
276
+ Accepts a raw YAML string as the input.
223
277
 
224
- You can pass raw yaml string as input
225
278
  ```ts
226
279
  import { bundle } from '@scalar/json-magic/bundle'
227
280
  import { parseYaml } from '@scalar/json-magic/bundle/plugins/browser'
228
281
 
229
- const result = await bundle(
230
- 'openapi: "3.1.1"\n',
231
- {
232
- plugins: [
233
- parseYaml(),
234
- ],
235
- treeShake: false
236
- },
237
- )
282
+ const result = await bundle('openapi: "3.1.1"\n', {
283
+ plugins: [parseYaml()],
284
+ treeShake: false,
285
+ })
238
286
 
239
- // Bundled document
287
+ // The bundled document
240
288
  console.log(result)
241
289
  ```
242
290
 
243
- #### Bundler Options
291
+ #### Writing a custom loader
292
+
293
+ A loader is a plain object, so you can resolve references from anywhere: a database, an in-memory map, or a custom protocol.
294
+
295
+ ```ts
296
+ import { bundle, type LoaderPlugin } from '@scalar/json-magic/bundle'
297
+
298
+ const workspaceFiles: LoaderPlugin = {
299
+ type: 'loader',
300
+ validate: (value) => value.startsWith('workspace:'),
301
+ exec: async (value) => {
302
+ const raw = await readFromWorkspace(value.replace('workspace:', ''))
303
+
304
+ if (raw === undefined) {
305
+ return { ok: false }
306
+ }
307
+
308
+ return { ok: true, data: JSON.parse(raw), raw }
309
+ },
310
+ }
311
+
312
+ await bundle(document, {
313
+ plugins: [workspaceFiles],
314
+ treeShake: false,
315
+ })
316
+ ```
317
+
318
+ `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.
319
+
320
+ ### Options
321
+
322
+ | Option | Type | Default | Description |
323
+ | --- | --- | --- | --- |
324
+ | `plugins` | `Plugin[]` | — | Loader and lifecycle plugins used during bundling. Required. |
325
+ | `treeShake` | `boolean` | — | Only keep the parts of external documents that are actually referenced. Required. |
326
+ | `depth` | `number` | unlimited | How deeply nested `$ref` pointers are followed. See the note below. |
327
+ | `root` | `UnknownObject` | the input | Base document to write external documents into, used for partial bundling. |
328
+ | `origin` | `string` | the input | Base path used to resolve relative references. |
329
+ | `cache` | `Map<string, Promise<ResolveResult>>` | new map | Cache of in-flight resolutions, reused across `bundle` calls to avoid refetching. |
330
+ | `visitedNodes` | `Set<unknown>` | new set | Nodes that have already been bundled, used to avoid re-bundling during partial bundles. |
331
+ | `urlMap` | `boolean` | `false` | Keep the mapping of generated keys to their original URLs in the output. |
332
+ | `externalDocumentsKey` | `string` | `'x-ext'` | Key that holds the bundled external documents. |
333
+ | `externalDocumentsMappingsKey` | `string` | `'x-ext-urls'` | Key that holds the mapping between generated keys and original URLs. |
334
+ | `compress` | `(value: string) => string \| Promise<string>` | hash | Function used to shorten URLs and file paths into document keys. |
335
+ | `hooks` | `object` | — | Lifecycle hooks, see [Hooks](#hooks). |
244
336
 
245
- ##### depth
337
+ #### depth
246
338
 
247
- The `depth` option controls how deeply the bundler will resolve `$ref` references. When you set `depth` to a number, the bundler will only follow references up to that level of nesting. This is useful for creating partial bundles or limiting resource usage.
339
+ 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.
248
340
 
249
- **Note:** When using `depth`, the resulting bundle may not be fully self-contained—some nested references deeper than the specified depth may remain unresolved. If you use `depth` together with the `visitedNodes` option, 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.
341
+ **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.
250
342
 
251
343
  ```ts
252
- import { bundle } from '@scalar/openapi-parser'
253
- import { fetchUrls } from '@scalar/openapi-parser/plugins-browser'
344
+ import { bundle } from '@scalar/json-magic/bundle'
345
+ import { fetchUrls } from '@scalar/json-magic/bundle/plugins/browser'
254
346
 
255
347
  await bundle(input, {
256
348
  plugins: [fetchUrls()],
@@ -259,13 +351,13 @@ await bundle(input, {
259
351
  })
260
352
  ```
261
353
 
262
- ##### externalDocumentsKey
354
+ #### externalDocumentsKey
263
355
 
264
- The `externalDocumentsKey` option controls the key used to store external references. This key will contain all bundled external documents. The key is used to maintain a clean separation between the main OpenAPI document and its bundled external references. **Defaults** to `x-ext`.
356
+ 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`.
265
357
 
266
358
  ```ts
267
- import { bundle } from '@scalar/openapi-parser'
268
- import { fetchUrls } from '@scalar/openapi-parser/plugins-browser'
359
+ import { bundle } from '@scalar/json-magic/bundle'
360
+ import { fetchUrls } from '@scalar/json-magic/bundle/plugins/browser'
269
361
 
270
362
  await bundle(input, {
271
363
  plugins: [fetchUrls()],
@@ -274,65 +366,140 @@ await bundle(input, {
274
366
  })
275
367
  ```
276
368
 
277
- ##### externalDocumentsMappingsKey
369
+ #### externalDocumentsMappingsKey
278
370
 
279
- The `externalDocumentsMappingsKey` option controls the key used to maintain a mapping between hashed keys and their original URLs. This mapping is essential for tracking the source of bundled references. **Defaults** to `x-ext-urls`.
371
+ 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`.
280
372
 
281
373
  ```ts
282
- import { bundle } from '@scalar/openapi-parser'
283
- import { fetchUrls } from '@scalar/openapi-parser/plugins-browser'
374
+ import { bundle } from '@scalar/json-magic/bundle'
375
+ import { fetchUrls } from '@scalar/json-magic/bundle/plugins/browser'
284
376
 
285
377
  await bundle(input, {
286
378
  plugins: [fetchUrls()],
287
379
  treeShake: false,
288
380
  urlMap: true,
289
- externalDocumentsKey: 'x-external-urls',
381
+ externalDocumentsMappingsKey: 'x-external-urls',
382
+ })
383
+ ```
384
+
385
+ ### Hooks
386
+
387
+ Hooks let you observe and extend the bundling process, which is handy for progress reporting and error tracking.
388
+
389
+ | Hook | Called when |
390
+ | --- | --- |
391
+ | `onResolveStart` | The bundler starts resolving a `$ref` |
392
+ | `onResolveSuccess` | A `$ref` was resolved successfully |
393
+ | `onResolveError` | A `$ref` could not be resolved |
394
+ | `onBeforeNodeProcess` | Before a node is processed, may be async |
395
+ | `onAfterNodeProcess` | After a node is processed, may be async |
396
+
397
+ ```ts
398
+ import { bundle } from '@scalar/json-magic/bundle'
399
+ import { fetchUrls } from '@scalar/json-magic/bundle/plugins/browser'
400
+
401
+ const errors: string[] = []
402
+
403
+ await bundle(document, {
404
+ plugins: [fetchUrls()],
405
+ treeShake: true,
406
+ hooks: {
407
+ onResolveStart: (node) => console.log('Resolving:', node.$ref),
408
+ onResolveSuccess: (node) => console.log('Resolved:', node.$ref),
409
+ onResolveError: (node) => errors.push(`Failed to resolve ${node.$ref}`),
410
+ },
411
+ })
412
+ ```
413
+
414
+ The same hooks can be shipped as a reusable lifecycle plugin, which is then passed alongside the loaders:
415
+
416
+ ```ts
417
+ import { bundle, type LifecyclePlugin } from '@scalar/json-magic/bundle'
418
+ import { fetchUrls } from '@scalar/json-magic/bundle/plugins/browser'
419
+
420
+ const logProgress: LifecyclePlugin = {
421
+ type: 'lifecycle',
422
+ onResolveSuccess: (node) => console.log('Resolved:', node.$ref),
423
+ }
424
+
425
+ await bundle(document, {
426
+ plugins: [fetchUrls(), logProgress],
427
+ treeShake: false,
290
428
  })
291
429
  ```
292
430
 
293
431
  ## dereference
294
432
 
295
- Dereference all `$ref` pointers in a JSON object, resolving both internal and external references.
433
+ `dereference` resolves the `$ref` pointers of a document and returns the result wrapped in a [magic proxy](#magic-proxy), so referenced values are read through the `$ref-value` property.
296
434
 
297
- The `dereference` function can operate in two modes:
435
+ It works in two modes:
298
436
 
299
- - **Synchronous (`sync: true`)**: Only internal references (within the same object) are resolved. The result is wrapped in a magic proxy for reactive access. No network requests are made.
300
- - **Asynchronous (`sync: false` or omitted)**: Both internal and external references (e.g., URLs) are resolved. The function returns a Promise that resolves to the fully dereferenced object, also wrapped in a magic proxy.
437
+ - **Synchronous (`sync: true`)**: only internal references are resolved, no network requests are made, and the result is returned directly.
438
+ - **Asynchronous (`sync: false`, the default)**: external references are bundled into the document first, so both internal and external references resolve. A Promise is returned.
301
439
 
302
440
  ### Options
303
441
 
304
- - `sync` (`boolean`):
305
- - If `true`, resolves only internal references synchronously.
306
- - If `false` (default), resolves both internal and external references asynchronously and returns a Promise.
442
+ | Option | Type | Default | Description |
443
+ | --- | --- | --- | --- |
444
+ | `sync` | `boolean` | `false` | Resolve internal references only, without returning a Promise. |
445
+ | `plugins` | `Plugin[]` | `[fetchUrls()]` | Loaders used to resolve external references in async mode. |
307
446
 
308
- The result is an object with a `success` property. If dereferencing fails (e.g., due to unresolved external references), the result will include an `errors` array describing the issues encountered.
447
+ 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.
309
448
 
310
449
  ```ts
311
450
  import { dereference } from '@scalar/json-magic/dereference'
312
451
 
313
452
  const result = dereference({ a: 'hello', b: { $ref: '#/a' } }, { sync: true })
314
453
 
315
- // Resolve internal references synchronously
316
- console.log(result)
454
+ if (result.success) {
455
+ // 'hello'
456
+ console.log(result.data.b['$ref-value'])
457
+ }
317
458
  ```
318
459
 
319
- To resolve also external references you need to set `sync: false`
460
+ To resolve external references as well, drop `sync` (or set it to `false`) and await the result.
320
461
 
321
462
  ```ts
322
463
  import { dereference } from '@scalar/json-magic/dereference'
323
464
 
324
- const result = await dereference({ a: 'hello', b: { $ref: 'http://example.com/document.json#/somepath' } }, { sync: false })
465
+ const result = await dereference({
466
+ a: 'hello',
467
+ b: { $ref: 'http://example.com/document.json#/somepath' },
468
+ })
325
469
 
326
- // Result with all internal and external references resolved
327
- console.log(result)
470
+ if (result.success) {
471
+ console.log(result.data)
472
+ } else {
473
+ console.error(result.errors)
474
+ }
475
+ ```
476
+
477
+ To resolve references from somewhere other than HTTP, pass your own loaders:
478
+
479
+ ```ts
480
+ import { dereference } from '@scalar/json-magic/dereference'
481
+ import { fetchUrls, readFiles } from '@scalar/json-magic/bundle/plugins/node'
482
+
483
+ const result = await dereference(document, {
484
+ plugins: [readFiles(), fetchUrls()],
485
+ })
328
486
  ```
329
487
 
330
488
  ## diff
331
489
 
332
- This package provides a way to compare two json objects and get the differences, resolve conflicts and return conflicts that need to be resolved manually.
490
+ Compare two JSON objects, merge changes made in parallel, and surface the conflicts that need to be resolved manually.
333
491
 
334
- ### Quickstart
492
+ | Function | Description |
493
+ | --- | --- |
494
+ | `diff(base, updated)` | Returns the list of `add`, `update` and `delete` changes that turn `base` into `updated` |
495
+ | `merge(diffA, diffB)` | Combines two changesets made against the same base into `{ diffs, conflicts }` |
496
+ | `apply(base, diffs)` | Applies a changeset to a document and returns it |
335
497
 
498
+ 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.
499
+
500
+ `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`.
501
+
502
+ ### Quickstart
336
503
 
337
504
  ```ts
338
505
  import { apply, diff, merge } from '@scalar/json-magic/diff'
@@ -366,19 +533,30 @@ const objectV2 = {
366
533
  }
367
534
 
368
535
  // Merge the changes of both versions with the same parent object
369
- const { diffs, conflicts } = merge(
370
- diff(baseObject, objectV1),
371
- diff(baseObject, objectV2),
372
- )
536
+ const { diffs, conflicts } = merge(diff(baseObject, objectV1), diff(baseObject, objectV2))
373
537
 
374
538
  // Apply changes from v1 and v2 to the parent object to get the final object
375
539
  const finalDocument = apply(baseObject, diffs)
376
540
  ```
377
541
 
542
+ ### Conflicts
543
+
544
+ `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`.
545
+
546
+ ```ts
547
+ import { diff, merge } from '@scalar/json-magic/diff'
548
+
549
+ const { diffs, conflicts } = merge(diff(base, mine), diff(base, theirs))
550
+
551
+ for (const [mineChanges, theirChanges] of conflicts) {
552
+ // Decide which side wins, then push the winner onto diffs
553
+ diffs.push(...mineChanges)
554
+ }
555
+ ```
378
556
 
379
557
  ## magic-proxy
380
558
 
381
- A javascript proxy which resolves internal references when accessing a property
559
+ 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.
382
560
 
383
561
  ### Quick start
384
562
 
@@ -388,30 +566,71 @@ import { createMagicProxy, getRaw } from '@scalar/json-magic/magic-proxy'
388
566
  const result = createMagicProxy({
389
567
  a: 'hello',
390
568
  b: {
391
- $ref: '#/a'
392
- }
569
+ $ref: '#/a',
570
+ },
393
571
  })
394
572
 
395
- /**
396
- * Output:
397
- * {
398
- * a: 'hello',
399
- * b: {
400
- * $ref: '#/a',
401
- * '$ref-value': 'hello'
402
- * }
403
- * }
404
- */
405
- console.log(result)
573
+ // 'hello', resolved on access
574
+ console.log(result.b['$ref-value'])
575
+
576
+ // '#/a', the original pointer is still there
577
+ console.log(result.b.$ref)
406
578
 
407
- const rawObject = getRaw(result)
408
579
  /**
580
+ * getRaw returns the untouched object:
409
581
  * {
410
- * a: 'hello',
411
- * b: {
412
- * $ref: '#/a'
413
- * }
582
+ * a: 'hello',
583
+ * b: {
584
+ * $ref: '#/a'
585
+ * }
414
586
  * }
415
587
  */
588
+ const rawObject = getRaw(result)
416
589
  console.log(rawObject)
417
- ```
590
+ ```
591
+
592
+ 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.
593
+
594
+ 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.
595
+
596
+ ```ts
597
+ const proxy = createMagicProxy({ __scalar_meta: 'hidden' }, { showInternal: true })
598
+
599
+ // 'hidden'
600
+ console.log(proxy.__scalar_meta)
601
+ ```
602
+
603
+ ## Helpers
604
+
605
+ Standalone helpers, each with its own entry point.
606
+
607
+ | Helper | Description |
608
+ | --- | --- |
609
+ | `@scalar/json-magic/helpers/escape-json-pointer` | Escape `~` and `/` in a JSON pointer segment |
610
+ | `@scalar/json-magic/helpers/unescape-json-pointer` | Reverse of `escapeJsonPointer` |
611
+ | `@scalar/json-magic/helpers/get-segments-from-path` | Split a JSON pointer into unescaped segments |
612
+ | `@scalar/json-magic/helpers/get-value-by-path` | Read the value at a list of path segments |
613
+ | `@scalar/json-magic/helpers/set-value-at-path` | Write a value at a JSON pointer, creating missing nodes |
614
+ | `@scalar/json-magic/helpers/is-file-path` | Check whether a string looks like a file path |
615
+ | `@scalar/json-magic/helpers/is-http-url` | Check whether a string is an HTTP or HTTPS URL |
616
+ | `@scalar/json-magic/helpers/is-json-object` | Check whether a string is a JSON object |
617
+ | `@scalar/json-magic/helpers/is-yaml` | Check whether a string is valid YAML |
618
+ | `@scalar/json-magic/helpers/normalize` | Parse a JSON or YAML string into an object |
619
+
620
+ ```ts
621
+ import { getValueByPath } from '@scalar/json-magic/helpers/get-value-by-path'
622
+
623
+ const { value } = getValueByPath({ components: { schemas: { User: { type: 'object' } } } }, [
624
+ 'components',
625
+ 'schemas',
626
+ 'User',
627
+ ])
628
+ ```
629
+
630
+ ## Community
631
+
632
+ We are API nerds. You too? Let's chat on Discord: <https://discord.gg/scalar>
633
+
634
+ ## License
635
+
636
+ The source code in this repository is licensed under [MIT](https://github.com/scalar/scalar/blob/main/LICENSE).
@@ -1,3 +1,3 @@
1
1
  export type { LifecyclePlugin, LoaderPlugin, Plugin, ResolveResult } from './bundle.js';
2
- export { bundle, isLocalRef, prefixInternalRef, prefixInternalRefRecursive, resolveAndCopyReferences } from './bundle.js';
2
+ export { bundle, extensions, isLocalRef, prefixInternalRef, prefixInternalRefRecursive, resolveAndCopyReferences, } from './bundle.js';
3
3
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/bundle/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AACpF,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,iBAAiB,EAAE,0BAA0B,EAAE,wBAAwB,EAAE,MAAM,UAAU,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/bundle/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AACpF,OAAO,EACL,MAAM,EACN,UAAU,EACV,UAAU,EACV,iBAAiB,EACjB,0BAA0B,EAC1B,wBAAwB,GACzB,MAAM,UAAU,CAAA"}
@@ -1 +1 @@
1
- export { bundle, isLocalRef, prefixInternalRef, prefixInternalRefRecursive, resolveAndCopyReferences } from './bundle.js';
1
+ export { bundle, extensions, isLocalRef, prefixInternalRef, prefixInternalRefRecursive, resolveAndCopyReferences, } from './bundle.js';
@@ -7,9 +7,25 @@ export declare class InvalidChangesDetectedError extends Error {
7
7
  * The function traverses the document structure following the paths specified in the differences
8
8
  * and applies the corresponding changes (add, update, or delete) at each location.
9
9
  *
10
- * @param document - The original document to apply changes to
10
+ * Paths that reach the prototype chain (`__proto__`, `constructor` or `prototype`) are rejected
11
+ * before anything is written, so a hostile changeset cannot poison `Object.prototype`.
12
+ *
13
+ * A change with an empty path asks to replace the document itself, which is not supported: the
14
+ * function writes through the parent container of each path and the root has no parent. `diff`
15
+ * emits such a change whenever the two documents differ at the root, which covers a different
16
+ * `typeof`, `null` against an object, and an array on one side against a plain object on the
17
+ * other. Those changesets have to be handled by the caller instead of being applied.
18
+ *
19
+ * ⚠️ `document` is mutated in place and the result shares structure with the document the diff was
20
+ * built from: every `add` and `update` writes the change into the document by reference, and those
21
+ * changes are live references into the target document `diff` compared (see `diff`). A later write
22
+ * into the result can therefore be seen through that document, and the other way around. Callers
23
+ * that need an isolated result have to deep clone the document and the changes first.
24
+ *
25
+ * @param document - The original document to apply changes to, mutated in place
11
26
  * @param diff - Array of differences to apply, each containing a path and change type
12
- * @returns The modified document with all changes applied
27
+ * @returns The modified document with all changes applied, structurally shared with the changes
28
+ * @throws {InvalidChangesDetectedError} When a path is unusable, empty or reaches the prototype chain
13
29
  *
14
30
  * @example
15
31
  * const original = {
@@ -1 +1 @@
1
- {"version":3,"file":"apply.d.ts","sourceRoot":"","sources":["../../src/diff/apply.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAE7C,qBAAa,2BAA4B,SAAQ,KAAK;gBACxC,OAAO,EAAE,MAAM;CAI5B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,eAAO,MAAM,KAAK,GAAI,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACrD,UAAU,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,MAAM,UAAU,CAAC,CAAC,CAAC,EAAE,KACpB,CAyCF,CAAA"}
1
+ {"version":3,"file":"apply.d.ts","sourceRoot":"","sources":["../../src/diff/apply.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAE7C,qBAAa,2BAA4B,SAAQ,KAAK;gBACxC,OAAO,EAAE,MAAM;CAI5B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AACH,eAAO,MAAM,KAAK,GAAI,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACrD,UAAU,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,MAAM,UAAU,CAAC,CAAC,CAAC,EAAE,KACpB,CAkEF,CAAA"}
@@ -1,3 +1,4 @@
1
+ import { isPollutionKey } from '@scalar/helpers/object/prevent-pollution';
1
2
  export class InvalidChangesDetectedError extends Error {
2
3
  constructor(message) {
3
4
  super(message);
@@ -9,9 +10,25 @@ export class InvalidChangesDetectedError extends Error {
9
10
  * The function traverses the document structure following the paths specified in the differences
10
11
  * and applies the corresponding changes (add, update, or delete) at each location.
11
12
  *
12
- * @param document - The original document to apply changes to
13
+ * Paths that reach the prototype chain (`__proto__`, `constructor` or `prototype`) are rejected
14
+ * before anything is written, so a hostile changeset cannot poison `Object.prototype`.
15
+ *
16
+ * A change with an empty path asks to replace the document itself, which is not supported: the
17
+ * function writes through the parent container of each path and the root has no parent. `diff`
18
+ * emits such a change whenever the two documents differ at the root, which covers a different
19
+ * `typeof`, `null` against an object, and an array on one side against a plain object on the
20
+ * other. Those changesets have to be handled by the caller instead of being applied.
21
+ *
22
+ * ⚠️ `document` is mutated in place and the result shares structure with the document the diff was
23
+ * built from: every `add` and `update` writes the change into the document by reference, and those
24
+ * changes are live references into the target document `diff` compared (see `diff`). A later write
25
+ * into the result can therefore be seen through that document, and the other way around. Callers
26
+ * that need an isolated result have to deep clone the document and the changes first.
27
+ *
28
+ * @param document - The original document to apply changes to, mutated in place
13
29
  * @param diff - Array of differences to apply, each containing a path and change type
14
- * @returns The modified document with all changes applied
30
+ * @returns The modified document with all changes applied, structurally shared with the changes
31
+ * @throws {InvalidChangesDetectedError} When a path is unusable, empty or reaches the prototype chain
15
32
  *
16
33
  * @example
17
34
  * const original = {
@@ -64,6 +81,24 @@ export const apply = (document, diff) => {
64
81
  }
65
82
  applyChange(current[path[depth]], path, d, depth + 1);
66
83
  };
84
+ // Reject the two kinds of unusable entry we can spot without walking the document - a root level
85
+ // change and a prototype reaching path - before any entry touches it. A path that does not exist
86
+ // is only found while traversing, so that one can still leave the document half updated.
87
+ for (const d of diff) {
88
+ // An empty path targets the document itself. We only ever write through the parent container of
89
+ // a path, so there is nothing to write into for the root, and the caller has to swap the
90
+ // document out on its own.
91
+ if (d.path.length === 0) {
92
+ throw new InvalidChangesDetectedError('Process aborted. Root-level replacement is not supported, the change targets the document itself instead of a property inside it');
93
+ }
94
+ // A path segment such as `__proto__` would make the traversal walk onto `Object.prototype` and
95
+ // write there, poisoning every object in the runtime. `diff` never emits these segments, so
96
+ // only a hand-crafted changeset reaches this guard.
97
+ const unsafeSegment = d.path.find(isPollutionKey);
98
+ if (unsafeSegment !== undefined) {
99
+ throw new InvalidChangesDetectedError(`Process aborted. Path ${d.path.join('.')} contains the unsafe segment "${unsafeSegment}", which can modify the prototype chain`);
100
+ }
101
+ }
67
102
  for (const d of diff) {
68
103
  applyChange(document, d.path, d);
69
104
  }
@@ -22,6 +22,16 @@ export type Difference<_T> = {
22
22
  * This function performs a breadth-first comparison between two objects and returns
23
23
  * a list of operations needed to transform the first object into the second.
24
24
  *
25
+ * Keys that reach the prototype chain (`__proto__`, `constructor` and `prototype`) are skipped, so
26
+ * an untrusted document cannot produce a diff that poisons `Object.prototype` once applied.
27
+ *
28
+ * ⚠️ The returned `changes` are live references into the documents, not clones. An `add` or an
29
+ * `update` carries the very subtree `doc2` holds, and a `delete` carries the subtree from `doc1`,
30
+ * so writing into a change writes into the document it came from. This matters downstream:
31
+ * `merge` merges values into these objects, and `apply` writes them into its target document,
32
+ * which leaves the result structurally shared with `doc2`. Callers that need isolation have to
33
+ * deep clone the documents before diffing them, or the changes afterwards.
34
+ *
25
35
  * @param doc1 - The source object to compare from
26
36
  * @param doc2 - The target object to compare to
27
37
  * @returns A list of operations (add/update/delete) with their paths and changes
@@ -1 +1 @@
1
- {"version":3,"file":"diff.d.ts","sourceRoot":"","sources":["../../src/diff/diff.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,KAAK,UAAU,GAAG,KAAK,GAAG,QAAQ,GAAG,QAAQ,CAAA;AAE7C;;;;;GAKG;AACH,MAAM,MAAM,UAAU,CAAC,EAAE,IAAI;IAAE,IAAI,EAAE,MAAM,EAAE,CAAC;IAAC,OAAO,EAAE,GAAG,CAAC;IAAC,IAAI,EAAE,UAAU,CAAA;CAAE,CAAA;AAE/E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,eAAO,MAAM,IAAI,GAAI,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,oBAiD7F,CAAA"}
1
+ {"version":3,"file":"diff.d.ts","sourceRoot":"","sources":["../../src/diff/diff.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,KAAK,UAAU,GAAG,KAAK,GAAG,QAAQ,GAAG,QAAQ,CAAA;AAE7C;;;;;GAKG;AACH,MAAM,MAAM,UAAU,CAAC,EAAE,IAAI;IAAE,IAAI,EAAE,MAAM,EAAE,CAAC;IAAC,OAAO,EAAE,GAAG,CAAC;IAAC,IAAI,EAAE,UAAU,CAAA;CAAE,CAAA;AAE/E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AACH,eAAO,MAAM,IAAI,GAAI,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,oBAkE7F,CAAA"}
package/dist/diff/diff.js CHANGED
@@ -1,9 +1,20 @@
1
+ import { isPollutionKey } from '@scalar/helpers/object/prevent-pollution';
1
2
  /**
2
3
  * Get the difference between two objects.
3
4
  *
4
5
  * This function performs a breadth-first comparison between two objects and returns
5
6
  * a list of operations needed to transform the first object into the second.
6
7
  *
8
+ * Keys that reach the prototype chain (`__proto__`, `constructor` and `prototype`) are skipped, so
9
+ * an untrusted document cannot produce a diff that poisons `Object.prototype` once applied.
10
+ *
11
+ * ⚠️ The returned `changes` are live references into the documents, not clones. An `add` or an
12
+ * `update` carries the very subtree `doc2` holds, and a `delete` carries the subtree from `doc1`,
13
+ * so writing into a change writes into the document it came from. This matters downstream:
14
+ * `merge` merges values into these objects, and `apply` writes them into its target document,
15
+ * which leaves the result structurally shared with `doc2`. Callers that need isolation have to
16
+ * deep clone the documents before diffing them, or the changes afterwards.
17
+ *
7
18
  * @param doc1 - The source object to compare from
8
19
  * @param doc2 - The target object to compare to
9
20
  * @returns A list of operations (add/update/delete) with their paths and changes
@@ -59,8 +70,24 @@ export const diff = (doc1, doc2) => {
59
70
  diff.push({ path: prefix, changes: el2, type: 'update' });
60
71
  return;
61
72
  }
62
- const keys = new Set([...Object.keys(el1), ...Object.keys(el2)]);
63
- for (const key of keys) {
73
+ // Keys that reach `Object.prototype` are dropped before we recurse. `JSON.parse` turns
74
+ // `__proto__` into a real own property that `Object.keys` reports, so an untrusted document
75
+ // would otherwise make us walk the prototype chain and emit a diff that poisons every object
76
+ // in the runtime once applied. `apply` rejects the same segments, so nothing emitted here can
77
+ // be turned away later. Note that this only covers the keys compared position by position: a
78
+ // brand new subtree is emitted as a single value and carries its own keys along untouched.
79
+ const keys = [...new Set([...Object.keys(el1), ...Object.keys(el2)])].filter((key) => !isPollutionKey(key));
80
+ // Removed array elements are applied with `splice` (see `apply`), which re-indexes every
81
+ // element after the removed one. Emitting the highest index first keeps the remaining indices
82
+ // valid, so an array that loses more than one element still applies correctly. Please keep
83
+ // this ordering in place, applying the same deletes in ascending order corrupts the array.
84
+ // Only an array that shrinks can lose elements, so an array that grows keeps the natural
85
+ // ascending order. Equal length arrays cannot lose elements either, they take the reversed
86
+ // branch to keep the guard simple. Deletes nested inside elements are object keys rather than
87
+ // array indices, so they never reach `splice` and are unaffected by the order.
88
+ // `keys` is a fresh array, so reversing it in place is safe.
89
+ const orderedKeys = Array.isArray(el1) && Array.isArray(el2) && el1.length >= el2.length ? keys.reverse() : keys;
90
+ for (const key of orderedKeys) {
64
91
  bfs(el1[key], el2[key], [...prefix, key]);
65
92
  }
66
93
  return;
@@ -5,8 +5,15 @@ import type { Difference } from '../diff/diff.js';
5
5
  * that arise when both diffs modify the same paths. It uses a trie data structure for
6
6
  * efficient path matching and conflict detection.
7
7
  *
8
+ * ⚠️ This function mutates the entries of `diff2`. When two changes on the same path can be folded
9
+ * together without a collision, the value from `diff1` is merged into the `changes` of the `diff2`
10
+ * entry, in place. Diffs built by `diff` carry live references into the documents they came from
11
+ * (see `diff`), so folding two changes together also writes into the document behind `diff2`.
12
+ * Callers that need the source documents to stay untouched have to deep clone them before diffing,
13
+ * or clone the changes afterwards.
14
+ *
8
15
  * @param diff1 - First list of differences
9
- * @param diff2 - Second list of differences
16
+ * @param diff2 - Second list of differences, whose entries are mutated, see the note above
10
17
  * @returns Object containing:
11
18
  * - diffs: Combined list of non-conflicting differences
12
19
  * - conflicts: Array of conflicting difference pairs that need manual resolution
@@ -1 +1 @@
1
- {"version":3,"file":"merge.d.ts","sourceRoot":"","sources":["../../src/diff/merge.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAI7C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,eAAO,MAAM,KAAK,GAAI,CAAC,EAAE,OAAO,UAAU,CAAC,CAAC,CAAC,EAAE,EAAE,OAAO,UAAU,CAAC,CAAC,CAAC,EAAE;;;CA8FtE,CAAA"}
1
+ {"version":3,"file":"merge.d.ts","sourceRoot":"","sources":["../../src/diff/merge.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAI7C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,eAAO,MAAM,KAAK,GAAI,CAAC,EAAE,OAAO,UAAU,CAAC,CAAC,CAAC,EAAE,EAAE,OAAO,UAAU,CAAC,CAAC,CAAC,EAAE;;;CAkGtE,CAAA"}
@@ -6,8 +6,15 @@ import { isArrayEqual, isKeyCollisions, mergeObjects } from '../diff/utils.js';
6
6
  * that arise when both diffs modify the same paths. It uses a trie data structure for
7
7
  * efficient path matching and conflict detection.
8
8
  *
9
+ * ⚠️ This function mutates the entries of `diff2`. When two changes on the same path can be folded
10
+ * together without a collision, the value from `diff1` is merged into the `changes` of the `diff2`
11
+ * entry, in place. Diffs built by `diff` carry live references into the documents they came from
12
+ * (see `diff`), so folding two changes together also writes into the document behind `diff2`.
13
+ * Callers that need the source documents to stay untouched have to deep clone them before diffing,
14
+ * or clone the changes afterwards.
15
+ *
9
16
  * @param diff1 - First list of differences
10
- * @param diff2 - Second list of differences
17
+ * @param diff2 - Second list of differences, whose entries are mutated, see the note above
11
18
  * @returns Object containing:
12
19
  * - diffs: Combined list of non-conflicting differences
13
20
  * - conflicts: Array of conflicting difference pairs that need manual resolution
@@ -65,12 +72,16 @@ export const merge = (diff1, diff2) => {
65
72
  trie.findMatch(diff.path, (value) => {
66
73
  if (diff.type === 'delete') {
67
74
  if (value.changes.type === 'delete') {
68
- // Keep the highest depth delete operation and skip the other
75
+ // Keep the shallowest delete operation and skip the other, since deleting an
76
+ // ancestor already removes everything the deeper delete would have removed.
77
+ // On equal paths the first list keeps the entry and the second one is skipped.
78
+ // Note the two sets are indexed differently: `value.index` points into `diff1`,
79
+ // while `index` points into `diff2`.
69
80
  if (value.changes.path.length > diff.path.length) {
70
81
  skipDiff1.add(value.index);
71
82
  }
72
83
  else {
73
- skipDiff2.add(value.index);
84
+ skipDiff2.add(index);
74
85
  }
75
86
  }
76
87
  else {
@@ -11,8 +11,14 @@
11
11
  */
12
12
  export declare class TrieNode<Value> {
13
13
  value: Value | null;
14
+ /**
15
+ * Children are keyed by path segments taken from untrusted documents, so the map has a null
16
+ * prototype. A plain object would resolve `children['__proto__']` to `Object.prototype`, and
17
+ * `addPath` would then write onto the prototype of every object in the runtime. The map is built
18
+ * here rather than taken as an argument, so a caller cannot hand back a polluting one.
19
+ */
14
20
  children: Record<string, TrieNode<Value>>;
15
- constructor(value: Value | null, children: Record<string, TrieNode<Value>>);
21
+ constructor(value: Value | null);
16
22
  }
17
23
  /**
18
24
  * A trie (prefix tree) data structure implementation.
@@ -1 +1 @@
1
- {"version":3,"file":"trie.d.ts","sourceRoot":"","sources":["../../src/diff/trie.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;;;;GAKG;AACH,qBAAa,QAAQ,CAAC,KAAK;IAEhB,KAAK,EAAE,KAAK,GAAG,IAAI;IACnB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,KAAK,CAAC,CAAC;gBADzC,KAAK,EAAE,KAAK,GAAG,IAAI,EACnB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,KAAK,CAAC,CAAC;CAEnD;AAED;;;;;;;;;;;GAWG;AACH,qBAAa,IAAI,CAAC,KAAK;IACrB,OAAO,CAAC,IAAI,CAAiB;;IAK7B;;;;;;;;;;OAUG;IACH,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,KAAK;IAcpC;;;;;;;;;;;;;;;;;OAiBG;IACH,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI;CAgC3D"}
1
+ {"version":3,"file":"trie.d.ts","sourceRoot":"","sources":["../../src/diff/trie.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;;;;GAKG;AACH,qBAAa,QAAQ,CAAC,KAAK;IASN,KAAK,EAAE,KAAK,GAAG,IAAI;IARtC;;;;;OAKG;IACI,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAsB;gBAEnD,KAAK,EAAE,KAAK,GAAG,IAAI;CACvC;AAED;;;;;;;;;;;GAWG;AACH,qBAAa,IAAI,CAAC,KAAK;IACrB,OAAO,CAAC,IAAI,CAAiB;;IAK7B;;;;;;;;;;OAUG;IACH,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,KAAK;IAcpC;;;;;;;;;;;;;;;;;OAiBG;IACH,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI;CAgC3D"}
package/dist/diff/trie.js CHANGED
@@ -11,10 +11,15 @@
11
11
  */
12
12
  export class TrieNode {
13
13
  value;
14
- children;
15
- constructor(value, children) {
14
+ /**
15
+ * Children are keyed by path segments taken from untrusted documents, so the map has a null
16
+ * prototype. A plain object would resolve `children['__proto__']` to `Object.prototype`, and
17
+ * `addPath` would then write onto the prototype of every object in the runtime. The map is built
18
+ * here rather than taken as an argument, so a caller cannot hand back a polluting one.
19
+ */
20
+ children = Object.create(null);
21
+ constructor(value) {
16
22
  this.value = value;
17
- this.children = children;
18
23
  }
19
24
  }
20
25
  /**
@@ -32,7 +37,7 @@ export class TrieNode {
32
37
  export class Trie {
33
38
  root;
34
39
  constructor() {
35
- this.root = new TrieNode(null, {});
40
+ this.root = new TrieNode(null);
36
41
  }
37
42
  /**
38
43
  * Adds a value to the trie at the specified path.
@@ -52,7 +57,7 @@ export class Trie {
52
57
  current = current.children[dir];
53
58
  }
54
59
  else {
55
- current.children[dir] = new TrieNode(null, {});
60
+ current.children[dir] = new TrieNode(null);
56
61
  current = current.children[dir];
57
62
  }
58
63
  }
@@ -18,6 +18,9 @@
18
18
  *
19
19
  * // Nested objects with collision
20
20
  * isKeyCollisions({ a: { b: 1 } }, { a: { b: 2 } }) // true
21
+ *
22
+ * // An array against a plain object
23
+ * isKeyCollisions([1, 2], { 0: 1, 1: 2 }) // true
21
24
  */
22
25
  export declare const isKeyCollisions: (a: unknown, b: unknown) => boolean;
23
26
  /**
@@ -26,8 +29,14 @@ export declare const isKeyCollisions: (a: unknown, b: unknown) => boolean;
26
29
  * ⚠️ Note: This operation assumes there are no key collisions between the objects.
27
30
  * Use isKeyCollisions() to check for collisions before merging.
28
31
  *
29
- * @param a - Target object to merge into
30
- * @param b - Source object to merge from
32
+ * ⚠️ Note: `a` is mutated in place and the subtrees `b` contributes are attached by reference, not
33
+ * cloned. Those subtrees stay shared with `b`, so a later write into one of them is seen through
34
+ * `b` as well. A key both objects already hold keeps the subtree of `a` and merges into it, so
35
+ * only what `b` brings along is shared. `merge` relies on this to fold two changes into one, which
36
+ * is how a merge ends up writing into the documents its diffs were built from.
37
+ *
38
+ * @param a - Target object to merge into, mutated in place
39
+ * @param b - Source object to merge from, whose subtrees are shared with the result
31
40
  * @returns The merged object (mutates and returns a)
32
41
  *
33
42
  * @example
@@ -1 +1 @@
1
- {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/diff/utils.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,eAAe,GAAI,GAAG,OAAO,EAAE,GAAG,OAAO,YAoBrD,CAAA;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,YAAY,GAAI,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAe3G,CAAA;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,YAAY,GAAI,CAAC,EAAE,GAAG,CAAC,EAAE,EAAE,GAAG,CAAC,EAAE,YAY7C,CAAA"}
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/diff/utils.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,eAAe,GAAI,GAAG,OAAO,EAAE,GAAG,OAAO,KAAG,OAoCxD,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,eAAO,MAAM,YAAY,GAAI,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAsB3G,CAAA;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,YAAY,GAAI,CAAC,EAAE,GAAG,CAAC,EAAE,EAAE,GAAG,CAAC,EAAE,YAY7C,CAAA"}
@@ -1,3 +1,4 @@
1
+ import { isPollutionKey } from '@scalar/helpers/object/prevent-pollution';
1
2
  /**
2
3
  * Deep check for objects for collisions
3
4
  * Check primitives if their values are different
@@ -18,14 +19,31 @@
18
19
  *
19
20
  * // Nested objects with collision
20
21
  * isKeyCollisions({ a: { b: 1 } }, { a: { b: 2 } }) // true
22
+ *
23
+ * // An array against a plain object
24
+ * isKeyCollisions([1, 2], { 0: 1, 1: 2 }) // true
21
25
  */
22
26
  export const isKeyCollisions = (a, b) => {
23
27
  if (typeof a !== typeof b) {
24
28
  return true;
25
29
  }
26
30
  if (typeof a === 'object' && typeof b === 'object' && a !== null && b !== null) {
31
+ // An array on one side and a plain object on the other is always a collision. Comparing them
32
+ // key by key matches array indices against object keys, so two containers that hold the same
33
+ // values look mergeable, and `mergeObjects` then absorbs one into the other and drops its type.
34
+ // The same guard lives in `diff`, which reports a container type change as a single update.
35
+ if (Array.isArray(a) !== Array.isArray(b)) {
36
+ return true;
37
+ }
27
38
  const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
28
39
  for (const key of keys) {
40
+ // Skip the keys that reach `Object.prototype`, so this stays in step with `mergeObjects`,
41
+ // which drops them. Without the skip, an own `__proto__` on one side is compared against the
42
+ // inherited prototype of the other and reports a collision that is not really there, turning
43
+ // an otherwise auto-mergeable change into a manual conflict.
44
+ if (isPollutionKey(key)) {
45
+ continue;
46
+ }
29
47
  if (a[key] !== undefined && b[key] !== undefined) {
30
48
  if (isKeyCollisions(a[key], b[key])) {
31
49
  return true;
@@ -43,8 +61,14 @@ export const isKeyCollisions = (a, b) => {
43
61
  * ⚠️ Note: This operation assumes there are no key collisions between the objects.
44
62
  * Use isKeyCollisions() to check for collisions before merging.
45
63
  *
46
- * @param a - Target object to merge into
47
- * @param b - Source object to merge from
64
+ * ⚠️ Note: `a` is mutated in place and the subtrees `b` contributes are attached by reference, not
65
+ * cloned. Those subtrees stay shared with `b`, so a later write into one of them is seen through
66
+ * `b` as well. A key both objects already hold keeps the subtree of `a` and merges into it, so
67
+ * only what `b` brings along is shared. `merge` relies on this to fold two changes into one, which
68
+ * is how a merge ends up writing into the documents its diffs were built from.
69
+ *
70
+ * @param a - Target object to merge into, mutated in place
71
+ * @param b - Source object to merge from, whose subtrees are shared with the result
48
72
  * @returns The merged object (mutates and returns a)
49
73
  *
50
74
  * @example
@@ -60,6 +84,12 @@ export const isKeyCollisions = (a, b) => {
60
84
  */
61
85
  export const mergeObjects = (a, b) => {
62
86
  for (const key in b) {
87
+ // Merging into a prototype-reaching key writes straight onto the prototype of every object in
88
+ // the runtime, so these keys are dropped rather than merged. `diff` skips them as well, which
89
+ // keeps both sides of a merge consistent.
90
+ if (isPollutionKey(key)) {
91
+ continue;
92
+ }
63
93
  if (!(key in a)) {
64
94
  a[key] = b[key];
65
95
  }
package/package.json CHANGED
@@ -10,7 +10,7 @@
10
10
  "url": "git+https://github.com/scalar/scalar.git",
11
11
  "directory": "packages/json-magic"
12
12
  },
13
- "version": "0.13.0",
13
+ "version": "0.13.3",
14
14
  "engines": {
15
15
  "node": ">=22"
16
16
  },
@@ -109,10 +109,10 @@
109
109
  "dependencies": {
110
110
  "pathe": "^2.0.3",
111
111
  "yaml": "^2.9.0",
112
- "@scalar/helpers": "0.10.0"
112
+ "@scalar/helpers": "0.11.2"
113
113
  },
114
114
  "devDependencies": {
115
- "fastify": "^5.8.1",
115
+ "fastify": "^5.11.2",
116
116
  "vite": "8.1.5"
117
117
  },
118
118
  "scripts": {