Skip to content

Commit 20dad1d

Browse files
committed
feat: 🎸 Add parse_UNSAFE, which does not parse functions by default
1 parent b60c38a commit 20dad1d

5 files changed

Lines changed: 38 additions & 12 deletions

File tree

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,19 @@ const restored = jsonWeb3.parse(text)
4949
headers: Map(1) {...},
5050
re: /([^\s]+)/g,
5151
url: URL(...),
52-
fn: [Function: echo]
52+
fn: { "__@json.function__": "function echo(arg) { return arg }" }
5353
}
5454
*/
55+
56+
const restoredUnsafe = jsonWeb3.parse_UNSAFE(text)
57+
// restoredUnsafe.fn is a callable function
5558
```
5659

5760
## API (Fully compatible with native globalThis.JSON)
5861

5962
- `stringify(value, replacer?, space?)`
6063
- `parse(text, reviver?)`
64+
- `parse_UNSAFE(text, reviver?)` (revives `Function` payloads via `new Function(...)`)
6165

6266
## Note
6367

@@ -67,7 +71,7 @@ const restored = jsonWeb3.parse(text)
6771
- `Map` values are encoded as `{"__@json.map__":[[k,v],...]}` and `Set` values as `{"__@json.set__":[...]}`.
6872
- `RegExp` values are encoded as `{"__@json.regexp__":{"source":"...","flags":"..."}}`.
6973
- `URL` values are encoded as `{"__@json.url__":"..."}`.
70-
- `Function` values are encoded as `{"__@json.function__":"<source>"}` and revived with `new Function(...)` (only do this with trusted input).
74+
- `Function` values are encoded as `{"__@json.function__":"<source>"}` and are only revived by `parse_UNSAFE` using `new Function(...)` (only do this with trusted input).
7175
- `ArrayBuffer`, Node `Buffer` JSON shapes, and typed arrays are encoded as `{"__@json.typedarray__":{"type":"<Name>","bytes":"0x..."}}` and decoded back to the original typed array (`Uint8Array`, `Uint8ClampedArray`, `Uint16Array`, `Uint32Array`, `Int8Array`, `Int16Array`, `Int32Array`, `Float32Array`, `Float64Array`, `BigInt64Array`, `BigUint64Array`).
7276

7377
Compared to libraries that require eval-based parsing (for example, `serialize-javascript`), this approach is generally safer and more efficient.

src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,14 @@ export const parse = <T = any>(text: string, reviver: Reviver = null): T =>
6464
return isFunction(reviver) ? reviver(key, decoded) : decoded
6565
})
6666

67+
export const parse_UNSAFE = <T = any>(text: string, reviver: Reviver = null): T =>
68+
RAW_JSON.parse(text, (key, v) => {
69+
const decoded = fromSerializable(v, { allowFunction: true })
70+
return isFunction(reviver) ? reviver(key, decoded) : decoded
71+
})
72+
6773
export default {
6874
stringify,
6975
parse,
76+
parse_UNSAFE,
7077
}

src/utils.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ export const toSerializable = (value: any): any => {
149149
return value
150150
}
151151

152-
export const fromSerializable = (value: any): any => {
152+
export const fromSerializable = (value: any, options: { allowFunction?: boolean } = {}): any => {
153153
if (value && isObject(value)) {
154154
if (hasOwnProperty(value, BIGINT_TAG)) {
155155
return BigInt(value[BIGINT_TAG])
@@ -193,6 +193,9 @@ export const fromSerializable = (value: any): any => {
193193
return new URL(payload)
194194
}
195195
if (hasOwnProperty(value, FUNCTION_TAG)) {
196+
if (!options.allowFunction) {
197+
return value
198+
}
196199
const payload = value[FUNCTION_TAG]
197200
if (!isString(payload)) {
198201
throw new Error('Invalid function payload')

test/index.test.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Script, createContext } from 'node:vm'
22
import { describe, expect, it } from 'vitest'
3-
import jsonWeb3, { parse, stringify } from '../src/index'
3+
import jsonWeb3, { parse, parse_UNSAFE, stringify } from '../src/index'
44
import { getTypedArrayName, isBuffer } from '../src/utils'
55

66
describe('json-web3', () => {
@@ -176,7 +176,7 @@ describe('json-web3', () => {
176176
expect(output.d.getTime()).toBe(d.getTime())
177177
})
178178

179-
it('round-trips Map, Set, RegExp, URL, and Function', () => {
179+
it('round-trips Map, Set, RegExp, URL, and Function with parse_UNSAFE', () => {
180180
function echo(arg: string) {
181181
return arg
182182
}
@@ -188,7 +188,7 @@ describe('json-web3', () => {
188188
fn: echo,
189189
}
190190
const text = stringify(input)
191-
const output = parse(text)
191+
const output = parse_UNSAFE(text)
192192

193193
expect(output.map).toBeInstanceOf(Map)
194194
expect(Array.from(output.map.entries())).toEqual([['hello', 'world']])
@@ -237,7 +237,7 @@ describe('json-web3', () => {
237237
fn: (arg: string) => arg,
238238
}
239239
const text = stringify(input, ['d', 'inf', 'map', 'set', 're', 'url', 'fn'])
240-
const output = parse(text)
240+
const output = parse_UNSAFE(text)
241241

242242
expect(output.d).toBeInstanceOf(Date)
243243
expect(output.d.getTime()).toBe(input.d.getTime())
@@ -279,21 +279,25 @@ describe('json-web3', () => {
279279
expect(() => parse(bad)).toThrowError(/Invalid hex string for bytes/)
280280
})
281281

282-
it('parse throws on invalid payloads for new types', () => {
282+
it('parse throws on invalid payloads for new types (except function)', () => {
283283
const cases = [
284284
'{"x":{"__@json.number__":"bad"}}',
285285
'{"x":{"__@json.map__":123}}',
286286
'{"x":{"__@json.set__":123}}',
287287
'{"x":{"__@json.regexp__":{"source":1,"flags":[]}}}',
288288
'{"x":{"__@json.url__":123}}',
289-
'{"x":{"__@json.function__":123}}',
290289
]
291290

292291
for (const text of cases) {
293292
expect(() => parse(text)).toThrowError()
294293
}
295294
})
296295

296+
it('parse_UNSAFE throws on invalid function payloads', () => {
297+
const text = '{"x":{"__@json.function__":123}}'
298+
expect(() => parse_UNSAFE(text)).toThrowError()
299+
})
300+
297301
it('falls back to raw bytes when typed array type is unknown', () => {
298302
const text = '{"__@json.typedarray__":{"type":"UnknownArray","bytes":"0x01"}}'
299303
const output = parse(text)
@@ -411,17 +415,25 @@ describe('json-web3', () => {
411415
expect(output.bytes[256]).toBe(0)
412416
})
413417

414-
it('symbol is dropped while function is preserved', () => {
418+
it('symbol is dropped while function is preserved with parse_UNSAFE', () => {
415419
const sym = Symbol('x')
416420
const input: any = { a: 1, s: sym, f: (arg: string) => arg }
417421
const text = stringify(input)
418-
const output = parse(text)
422+
const output = parse_UNSAFE(text)
419423
expect(output.a).toBe(1)
420424
expect(output.s).toBeUndefined()
421425
expect(typeof output.f).toBe('function')
422426
expect(output.f('ok')).toBe('ok')
423427
})
424428

429+
it('parse keeps function payloads as tagged objects', () => {
430+
const input = { fn: (arg: string) => arg }
431+
const text = stringify(input)
432+
const output = parse(text)
433+
434+
expect(output.fn).toEqual({ '__@json.function__': expect.any(String) })
435+
})
436+
425437
it('BigInt in array with replacer array filtering works as expected', () => {
426438
const input = { list: [1n, 2n], other: 3n }
427439
const text = stringify(input, ['list'])

test/test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,5 @@ const payload = {
1717

1818
const text = jsonWeb3.stringify(payload, null, 2)
1919
console.log('text', text)
20-
const restored = jsonWeb3.parse(text)
20+
const restored = jsonWeb3.parse_UNSAFE(text)
2121
console.log('restored', restored)

0 commit comments

Comments
 (0)