parse-nested-form-data 1.0.1 → 1.1.0

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.
@@ -1,464 +1,593 @@
1
- import { File } from '@remix-run/web-file';
2
-
3
1
  /**
4
2
  * @name parse-nested-form-data
5
3
  * @license MIT license.
6
4
  * @copyright (c) 2022 Christian Schurr
7
5
  * @author Christian Schurr <chris@schurr.dev>
8
6
  */
9
-
7
+ // src/errors.ts
10
8
  /**
11
- * Thrown when a path is used multiple times or has missmatching path parts.
12
- *
13
- * @example
14
- * ```ts
15
- * const formData = new FormData()
16
- * formData.append('a[0]', 'b')
17
- * formData.append('a[0]', 'c')
18
- * parseFormData(formData)
19
- * // throws DuplicateKeyError('a[0]')
20
- * ```
21
- *
22
- * @example
23
- * ```ts
24
- * const formData = new FormData()
25
- * formData.append('a', 'b')
26
- * formData.append('a', 'c')
27
- * parseFormData(formData)
28
- * // throws DuplicateKeyError('a')
29
- *
30
- * @example
31
- * ```ts
32
- * const formData = new FormData()
33
- * formData.append('a', 'b')
34
- * formData.append('a[]', 'c')
35
- * parseFormData(formData)
36
- * // throws DuplicateKeyError('a[]')
37
- * ```
38
- *
39
- */
40
- class DuplicateKeyError extends Error {
41
- constructor(key) {
42
- super(`Duplicate key at path part ${key}`);
43
- this.key = void 0;
44
- this.key = key;
45
- }
46
- }
9
+ * Thrown when a path is used multiple times or has mismatching path parts.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * const formData = new FormData()
14
+ * formData.append('a[0]', 'b')
15
+ * formData.append('a[0]', 'c')
16
+ * parseFormData(formData)
17
+ * // throws DuplicateKeyError('a[0]')
18
+ * ```
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * const formData = new FormData()
23
+ * formData.append('a', 'b')
24
+ * formData.append('a', 'c')
25
+ * parseFormData(formData)
26
+ * // throws DuplicateKeyError('a')
27
+ * ```
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * const formData = new FormData()
32
+ * formData.append('a', 'b')
33
+ * formData.append('a[]', 'c')
34
+ * parseFormData(formData)
35
+ * // throws DuplicateKeyError('a[]')
36
+ * ```
37
+ *
38
+ */
39
+ var DuplicateKeyError = class extends Error {
40
+ constructor(key) {
41
+ super(`Duplicate key at path part ${key}`);
42
+ this.name = "DuplicateKeyError";
43
+ this.key = key;
44
+ }
45
+ };
47
46
  /**
48
- * Thrown when an array is used at the same path with an order parameter and
49
- * without an order parameter.
50
- *
51
- * @example
52
- * ```ts
53
- * const formData = new FormData()
54
- * formData.append('a[0]', 'a')
55
- * formData.append('a[]', 'b')
56
- * parseFormData(formData)
57
- * // => throws `MixedArrayError(a[])`
58
- * ```
59
- *
60
- * @example
61
- * ```ts
62
- * const formData = new FormData()
63
- * formData.append('a[]', 'a')
64
- * formData.append('a[0]', 'b')
65
- * parseFormData(formData)
66
- * // => throws `MixedArrayError(a[0])`
67
- * ```
68
- */
69
- class MixedArrayError extends Error {
70
- constructor(key) {
71
- super(`Mixed array at path part ${key}`);
72
- this.key = void 0;
73
- this.key = key;
74
- }
47
+ * Thrown when an array is used at the same path with an order parameter and
48
+ * without an order parameter.
49
+ *
50
+ * @example
51
+ * ```ts
52
+ * const formData = new FormData()
53
+ * formData.append('a[0]', 'a')
54
+ * formData.append('a[]', 'b')
55
+ * parseFormData(formData)
56
+ * // => throws `MixedArrayError(a[])`
57
+ * ```
58
+ *
59
+ * @example
60
+ * ```ts
61
+ * const formData = new FormData()
62
+ * formData.append('a[]', 'a')
63
+ * formData.append('a[0]', 'b')
64
+ * parseFormData(formData)
65
+ * // => throws `MixedArrayError(a[0])`
66
+ * ```
67
+ */
68
+ var MixedArrayError = class extends Error {
69
+ constructor(key) {
70
+ super(`Mixed array at path part ${key}`);
71
+ this.name = "MixedArrayError";
72
+ this.key = key;
73
+ }
74
+ };
75
+ /**
76
+ * Cuts a path to 100 characters plus `...`, so a huge path from a client
77
+ * doesn't end up in full in logs or error responses.
78
+ */
79
+ function shortKey(key) {
80
+ return key.length > 100 ? `${key.slice(0, 100)}...` : key;
75
81
  }
76
82
  /**
77
- * Thrown when a path part would access or assign a property on
78
- * `Object.prototype` (`__proto__`, `constructor`, `prototype`). Rejected
79
- * regardless of whether pollution would actually occur, to keep input
80
- * unambiguous.
81
- *
82
- * @example
83
- * ```ts
84
- * const formData = new FormData()
85
- * formData.append('__proto__.polluted', 'yes')
86
- * parseFormData(formData)
87
- * // throws ForbiddenKeyError('__proto__')
88
- * ```
89
- */
90
- class ForbiddenKeyError extends Error {
91
- constructor(key) {
92
- super(`Forbidden key at path part ${key}`);
93
- this.key = void 0;
94
- this.key = key;
95
- }
83
+ * Thrown when an object path part is `__proto__`, `constructor` or
84
+ * `prototype`, which could modify `Object.prototype`. The names are rejected
85
+ * at any position in the path. Entries with other names on `Object.prototype`
86
+ * (`toString`, ...) are removed instead (see `parseFormData`).
87
+ * `key` is the path up to the forbidden part, cut to 100 characters plus `...`
88
+ * when it's longer.
89
+ *
90
+ * @example
91
+ * ```ts
92
+ * const formData = new FormData()
93
+ * formData.append('__proto__.polluted', 'yes')
94
+ * parseFormData(formData)
95
+ * // throws ForbiddenKeyError('__proto__')
96
+ * ```
97
+ */
98
+ var ForbiddenKeyError = class extends Error {
99
+ constructor(key) {
100
+ key = shortKey(key);
101
+ super(`Forbidden key at path part ${key}`);
102
+ this.name = "ForbiddenKeyError";
103
+ this.key = key;
104
+ }
105
+ };
106
+ /**
107
+ * Thrown when a path has more path parts than the `depth` option allows.
108
+ * `key` is the path up to the first path part over the limit, cut to
109
+ * 100 characters plus `...` when it's longer.
110
+ * Within one entry the depth is checked first, so a path that's too deep throws
111
+ * `DepthLimitError` even if it would also throw another error, like a
112
+ * `ForbiddenKeyError` for `__proto__`.
113
+ *
114
+ * @example
115
+ * ```ts
116
+ * const formData = new FormData()
117
+ * formData.append('a.b[0].c', 'x')
118
+ * parseFormData(formData, {depth: 2})
119
+ * // throws DepthLimitError('a.b[0]')
120
+ * ```
121
+ */
122
+ var DepthLimitError = class extends Error {
123
+ constructor(key) {
124
+ key = shortKey(key);
125
+ super(`Depth limit exceeded at path part ${key}`);
126
+ this.name = "DepthLimitError";
127
+ this.key = key;
128
+ }
129
+ };
130
+ /**
131
+ * Thrown when an array index in a path is larger than `4294967294`, the
132
+ * largest index a JavaScript array can hold.
133
+ * `key` is the path up to that array part, cut to 100 characters plus `...`
134
+ * when it's longer.
135
+ *
136
+ * @example
137
+ * ```ts
138
+ * const formData = new FormData()
139
+ * formData.append('a[4294967295]', 'b')
140
+ * parseFormData(formData)
141
+ * // throws InvalidPathError('a[4294967295]')
142
+ * ```
143
+ */
144
+ var InvalidPathError = class extends Error {
145
+ constructor(key) {
146
+ key = shortKey(key);
147
+ super(`Invalid path at path part ${key}`);
148
+ this.name = "InvalidPathError";
149
+ this.key = key;
150
+ }
151
+ };
152
+ // src/path-parts.ts
153
+ /**
154
+ * Finds the `]` that ends the bracket part starting at the `[` at `open`.
155
+ * Returns -1 if this `[` is not a bracket part but a normal character of the key.
156
+ *
157
+ * Brackets can be nested: in `a[b[c]]` the last `]` belongs to
158
+ * the first `[`.
159
+ *
160
+ * Special cases:
161
+ * - Some `[` are never closed. The part then ends at the last `]`:
162
+ * `a[[0]` => key `[0`.
163
+ * - If there is no `]` after the `[` at all, the `[` is a normal character:
164
+ * `a[0` => key `a[0`.
165
+ * - The same goes for a `]` without a `[`: `a]b` => key `a]b`, and
166
+ * `a[b]]` => key `]` inside `b`.
167
+ *
168
+ * The scan stops at the returned `]`. The caller continues from there, so no
169
+ * character of the path is scanned twice.
170
+ */
171
+ function closeOf(path, open, lastClose) {
172
+ if (open > lastClose) return -1;
173
+ let nesting = 0;
174
+ for (let idx = open; idx < lastClose; idx++) if (path[idx] === "[") nesting++;
175
+ else if (path[idx] === "]" && --nesting === 0) return idx;
176
+ return lastClose;
96
177
  }
97
- const FORBIDDEN_OBJECT_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
98
- function isJsonObject(val) {
99
- return typeof val === 'object' && !Array.isArray(val) && val !== null && !(val instanceof File);
178
+ /**
179
+ *
180
+ * Transforms a FormData path into an array of `PathPart`s.
181
+ *
182
+ * @param path - the path to extract the path parts from
183
+ * @param depth - the maximum number of path parts (default: `Infinity`)
184
+ * @returns {Array<PathPart>} the extracted path parts
185
+ * @throws `DepthLimitError` if the path has more path parts than `depth`, at
186
+ * the first part over the limit
187
+ *
188
+ * @example
189
+ * ```ts
190
+ * const path = 'a[0].b'
191
+ * const result = extractPathParts(path)
192
+ * // => [{path: 'a', type: 'object', default: [], pathToPart: 'a'},
193
+ * // {path: '0', type: 'array', default: {}, pathToPart: 'a[0]'},
194
+ * // {path: 'b', type: 'object', default: {}, pathToPart: 'a[0].b'}]
195
+ * ```
196
+ *
197
+ * @example
198
+ * ```ts
199
+ * const path = 'a.b'
200
+ * const result = extractPathParts(path)
201
+ * // => [{path: 'a', type: 'object', default: {}, pathToPart: 'a'},
202
+ * // {path: 'b', type: 'object', default: {}, pathToPart: 'a.b'}]
203
+ * ```
204
+ *
205
+ * @example
206
+ * ```ts
207
+ * const path = 'a[b][0]'
208
+ * const result = extractPathParts(path)
209
+ * // => [{path: 'a', type: 'object', default: {}, pathToPart: 'a'},
210
+ * // {path: 'b', type: 'object', default: [], pathToPart: 'a[b]'},
211
+ * // {path: '0', type: 'array', default: {}, pathToPart: 'a[b][0]'}]
212
+ * ```
213
+ *
214
+ * @example
215
+ * ```ts
216
+ * const path = 'a[][0]'
217
+ * const result = extractPathParts(path)
218
+ * // => [{path: 'a', type: 'object', default: [], pathToPart: 'a'},
219
+ * // {path: '', type: 'array', default: [], pathToPart: 'a[]'},
220
+ * // {path: '0', type: 'array', default: {}, pathToPart: 'a[][0]'}]
221
+ * ```
222
+ *
223
+ *
224
+ */
225
+ function extractPathParts(path, depth = Infinity) {
226
+ const lastClose = path.lastIndexOf("]");
227
+ const parts = [];
228
+ const addPart = (key, type, end) => {
229
+ if (key === "" && type === "object") return;
230
+ if (parts.length === depth) throw new DepthLimitError(path.slice(0, end));
231
+ parts.push({
232
+ path: key,
233
+ type,
234
+ default: {},
235
+ pathToPart: path.slice(0, end)
236
+ });
237
+ };
238
+ let start = 0;
239
+ for (let idx = 0; idx < path.length; idx++) {
240
+ const close = path[idx] === "[" ? closeOf(path, idx, lastClose) : -1;
241
+ if (path[idx] !== "." && close < idx) continue;
242
+ addPart(path.slice(start, idx), "object", idx);
243
+ if (close > idx) {
244
+ const key = path.slice(idx + 1, close);
245
+ addPart(key, /^\d*$/.test(key) ? "array" : "object", close + 1);
246
+ idx = close;
247
+ }
248
+ start = idx + 1;
249
+ }
250
+ addPart(path.slice(start), "object", path.length);
251
+ parts.forEach((part, idx) => {
252
+ if (parts[idx + 1]?.type === "array") part.default = [];
253
+ });
254
+ return parts;
100
255
  }
101
-
256
+ // src/transform.ts
102
257
  /**
103
- * Default Transformer for `parseFormData`.
104
- *
105
- * Transforms a FormData Entry into a path and a `JsonLeafValue`.
106
- *
107
- * - `path` starts with `+` -> transform value to `number`
108
- * - `path` starts with `&` -> transform value to `boolean`
109
- * - `path` starts with `-` -> transform value to `null`
110
- *
111
- * @example
112
- * ```ts
113
- * const entry = ['a[0]', 'b']
114
- * const result = defaultTransform(entry)
115
- * // => {path: 'a[0]', value: 'b'}
116
- * ```
117
- *
118
- * @example
119
- * ```ts
120
- * const entry = ['+a[0]', '1']
121
- * const result = defaultTransform(entry)
122
- * // => {path: 'a[0]', value: 1}
123
- * ```
124
- *
125
- * @example
126
- * ```ts
127
- * const entry = ['&a[0]', 'true']
128
- * const result = defaultTransform(entry)
129
- * // => {path: 'a[0]', value: true}
130
- * ```
131
- *
132
- * @example
133
- * ```ts
134
- * const entry = ['-a[0]', 'null']
135
- * const result = defaultTransform(entry)
136
- * // => {path: 'a[0]', value: null}
137
- * ```
138
- *
139
- * @example
140
- * ```ts
141
- * const entry = ['a[0]', new File([''], 'file.txt')]
142
- * const result = defaultTransform(entry)
143
- * // => {path: 'a[0]', value: File}
144
- * ```
145
- *
146
- *
147
- * @param entry [path, value]: the FormData entry
148
- * @returns the path and the transformed value
149
- */
258
+ * Default Transformer for `parseFormData`.
259
+ *
260
+ * Transforms a FormData Entry into a path and a `JsonLeafValue`.
261
+ *
262
+ * - `path` starts with `+` -> transform value to `number`
263
+ * - `path` starts with `&` -> transform value to `boolean`
264
+ * - `path` starts with `-` -> transform value to `null`
265
+ *
266
+ * @example
267
+ * ```ts
268
+ * const entry = ['a[0]', 'b']
269
+ * const result = defaultTransform(entry)
270
+ * // => {path: 'a[0]', value: 'b'}
271
+ * ```
272
+ *
273
+ * @example
274
+ * ```ts
275
+ * const entry = ['+a[0]', '1']
276
+ * const result = defaultTransform(entry)
277
+ * // => {path: 'a[0]', value: 1}
278
+ * ```
279
+ *
280
+ * @example
281
+ * ```ts
282
+ * const entry = ['&a[0]', 'true']
283
+ * const result = defaultTransform(entry)
284
+ * // => {path: 'a[0]', value: true}
285
+ * ```
286
+ *
287
+ * @example
288
+ * ```ts
289
+ * const entry = ['-a[0]', 'null']
290
+ * const result = defaultTransform(entry)
291
+ * // => {path: 'a[0]', value: null}
292
+ * ```
293
+ *
294
+ * @example
295
+ * ```ts
296
+ * const entry = ['a[0]', new File([''], 'file.txt')]
297
+ * const result = defaultTransform(entry)
298
+ * // => {path: 'a[0]', value: File}
299
+ * ```
300
+ *
301
+ *
302
+ * @param entry [path, value]: the FormData entry
303
+ * @returns the path and the transformed value
304
+ */
150
305
  function defaultTransform(entry) {
151
- let path = entry[0];
152
- let value = entry[1];
153
- if (path.startsWith('+')) {
154
- path = path.slice(1);
155
- value = Number(value);
156
- } else if (path.startsWith('&')) {
157
- path = path.slice(1);
158
- value = value === 'on' || value === 'true' || Boolean(Number(value));
159
- } else if (path.startsWith('-')) {
160
- path = path.slice(1);
161
- value = null;
162
- }
163
- return {
164
- path,
165
- value
166
- };
306
+ let path = entry[0];
307
+ let value = entry[1];
308
+ if (path.startsWith("+")) {
309
+ path = path.slice(1);
310
+ value = Number(value);
311
+ } else if (path.startsWith("&")) {
312
+ path = path.slice(1);
313
+ value = value === "on" || value === "true" || Boolean(Number(value));
314
+ } else if (path.startsWith("-")) {
315
+ path = path.slice(1);
316
+ value = null;
317
+ }
318
+ return {
319
+ path,
320
+ value
321
+ };
322
+ }
323
+ // src/index.ts
324
+ const FORBIDDEN_OBJECT_KEYS = /* @__PURE__ */ new Set([
325
+ "__proto__",
326
+ "constructor",
327
+ "prototype"
328
+ ]);
329
+ const MAX_ARRAY_INDEX = 4294967294;
330
+ function isPrototypeKey(key) {
331
+ return Object.getOwnPropertyDescriptor(Object.prototype, key) !== void 0;
167
332
  }
168
-
169
- /**
170
- * Options to change the behavior of `parseFormData`.
171
- * @param transformEntry - a function to transform the FormData entry into a path and a value
172
- * (default: `defaultTransform`)
173
- * @param removeEmptyString - skip empty values '' (default: `false`)
174
- *
175
- * @example
176
- * ```ts
177
- * const formData = new FormData()
178
- * formData.append('a', '')
179
- * formData.append('b', 'b')
180
- * const result = parseFormData(formData, {removeEmptyString: true})
181
- * // => {b: 'b'}
182
- * ```
183
- *
184
- * @example
185
- * ```ts
186
- * const formData = new FormData()
187
- * formData.append('a', 'a')
188
- * formData.append('b', 'b')
189
- * parseFormData(formData, {
190
- * transformEntry: ([path, value], defaultTransform) => {
191
- * return {
192
- * path,
193
- * value:
194
- * typeof value === 'string'
195
- * ? value.toUpperCase()
196
- * : defaultTransform(value),
197
- * }
198
- * },
199
- * })
200
- * // => {a: 'A', b: 'B'}
201
- * ```
202
- */
203
-
204
- /**
205
- * A parsed part of a FormData path. It consists of four parts:
206
- * @param path - the key to access the value in the intermediate result
207
- * @param type - the type of the path part (array - e.g. [\d*], or object .e.g. .key)
208
- * @param default - the default value of the next path part ([] for array, {} for object)
209
- * @param pathToPart - the full path to the current path part
210
- *
211
- * @example
212
- * ```ts
213
- * const path = 'a[0].b'
214
- * const result = extractPathParts(path)
215
- * // => [{path: 'a', type: 'object, default: [], pathToPart: 'a'},
216
- * // {path: '0', type: 'array', default: {}, pathToPart: 'a[0]'},
217
- * // {path: 'b', type: 'object', default: {}, pathToPart: 'a[0].b'}]
218
- * ```
219
- */
220
-
221
333
  /**
222
- *
223
- * Transforms a FormData path into an array of `PathPart`s.
224
- *
225
- * @param path - the path to extract the path parts from
226
- * @returns {Array<PathPart>} the extracted path parts
227
- *
228
- * @example
229
- * ```ts
230
- * const path = 'a[0].b'
231
- * const result = extractPathParts(path)
232
- * // => [{path: 'a', type: 'object, default: [], pathToPart: 'a'},
233
- * // {path: '0', type: 'array', default: {}, pathToPart: 'a[0]'},
234
- * // {path: 'b', type: 'object', default: {}, pathToPart: 'a[0].b'}]
235
- * ```
236
- *
237
- * @example
238
- * ```ts
239
- * const path = 'a.b'
240
- * const result = extractPathParts(path)
241
- * // => [{path: 'a', type: 'object, default: {}, pathToPart: 'a'},
242
- * // {path: 'b', type: 'object', default: {}, pathToPart: 'a.b'}]
243
- * ```
244
- *
245
- * @example
246
- * ```ts
247
- * const path = 'a[][0]'
248
- * const result = extractPathParts(path)
249
- * // => [{path: 'a', type: 'object, default: [], pathToPart: 'a'},
250
- * // {path: '', type: 'array', default: [], pathToPart: 'a[]'},
251
- * // {path: '0', type: 'array', default: {}, pathToPart: 'a[][0]'}]
252
- * ```
253
- *
254
- *
255
- */
256
- function extractPathParts(path) {
257
- const re = /((?<array>\d*)\]|(?<pathPart>[^.[]+))(?<nextType>\[|\.|$)/g;
258
- return Array.from(path.matchAll(re)).map(match => {
259
- // self casted RegexExpMatchArray to custom
260
- const typedMatch = match;
261
- const {
262
- array,
263
- pathPart,
264
- nextType
265
- } = typedMatch.groups;
266
- const type = array === undefined ? 'object' : 'array';
267
- const nextDefault = nextType === '[' ? [] : {};
268
- return {
269
- path: array ?? pathPart,
270
- type,
271
- default: nextDefault,
272
- pathToPart: path.slice(0, typedMatch.index + typedMatch[1].length)
273
- };
274
- });
334
+ * Detects `File` and `Blob` values by their `Symbol.toStringTag`, which works
335
+ * for native instances, polyfills (e.g. `@remix-run/web-file`) and values from
336
+ * other realms, where `instanceof` would fail.
337
+ */
338
+ function isFileLike(val) {
339
+ const tag = Object.prototype.toString.call(val);
340
+ return tag === "[object File]" || tag === "[object Blob]";
341
+ }
342
+ function isJsonObject(val) {
343
+ return typeof val === "object" && !Array.isArray(val) && val !== null && !isFileLike(val);
275
344
  }
276
-
277
345
  /**
278
- *
279
- * Returns the value accessed via `pathPart` in the `currentPathObject`
280
- * and a setter function to set the value in the `currentPathObject` via the
281
- * provided `pathPart`.
282
- *
283
- * @param pathPart - the path part to get the setter and getter for
284
- * @param currentPathObject - the object at the current path (before the path part)
285
- * @param arraysWithOrder - a set of arrays that have an order
286
- * @returns the setter and getter for the path part
287
- *
288
- * @example
289
- * ```ts
290
- * const pathPart = {path: 'a', type: 'object', default: {}, pathToPart: 'a'}
291
- * const currentPathObject = {}
292
- * const arraysWithOrder = new Set()
293
- * const [value, setValue] = getSetterAndGetter(pathPart, currentPathObject, arraysWithOrder)
294
- * setValue('b')
295
- * // => currentPathObject = {a: 'b'}
296
- * ```
297
- *
298
- * @example
299
- * ```ts
300
- * const pathPart = {path: '0', type: 'array', default: [], pathToPart: 'a[0]'}
301
- * const currentPathObject = {a: []}
302
- * const arraysWithOrder = new Set()
303
- * const [value, setValue] = getSetterAndGetter(pathPart, currentPathObject, arraysWithOrder)
304
- * setValue('b')
305
- * // => currentPathObject = {a: ['b']}
306
- * ```
307
- *
308
- */
346
+ *
347
+ * Returns the value accessed via `pathPart` in the `currentPathObject`,
348
+ * a setter function to set the value in the `currentPathObject` via the
349
+ * provided `pathPart`, and the key (object key or array index) it uses.
350
+ *
351
+ * @param pathPart - the path part to get the value, setter and key for
352
+ * @param currentPathObject - the object at the current path (before the path part)
353
+ * @param arraysWithOrder - a set of arrays that have an order
354
+ * @returns the current value, a setter for it, and the key (object key or
355
+ * array index) in `currentPathObject`
356
+ *
357
+ * @example
358
+ * ```ts
359
+ * const pathPart = {path: 'a', type: 'object', default: {}, pathToPart: 'a'}
360
+ * const currentPathObject = {}
361
+ * const arraysWithOrder = new Set()
362
+ * const [value, setValue, key] = handlePathPart(pathPart, currentPathObject, arraysWithOrder)
363
+ * setValue('b')
364
+ * // => currentPathObject = {a: 'b'}, key = 'a'
365
+ * ```
366
+ *
367
+ * @example
368
+ * ```ts
369
+ * const pathPart = {path: '0', type: 'array', default: [], pathToPart: 'a[0]'}
370
+ * const currentPathObject = {a: []}
371
+ * const arraysWithOrder = new Set()
372
+ * const [value, setValue, key] = handlePathPart(pathPart, currentPathObject.a, arraysWithOrder)
373
+ * setValue('b')
374
+ * // => currentPathObject = {a: ['b']}, key = 0
375
+ * ```
376
+ *
377
+ */
309
378
  function handlePathPart(pathPart, currentPathObject, arraysWithOrder) {
310
- if (FORBIDDEN_OBJECT_KEYS.has(pathPart.path)) {
311
- throw new ForbiddenKeyError(pathPart.pathToPart);
312
- }
313
- if (pathPart.type === 'object') {
314
- if (Array.isArray(currentPathObject)) {
315
- throw new DuplicateKeyError(pathPart.pathToPart);
316
- }
317
- const currentObject = currentPathObject;
318
- return [currentObject[pathPart.path], val => currentObject[pathPart.path] = val];
319
- }
320
- if (!Array.isArray(currentPathObject)) {
321
- throw new DuplicateKeyError(pathPart.pathToPart);
322
- }
323
- const currentArray = currentPathObject;
324
- const isOrdered = pathPart.path !== '';
325
- const isOrderedArray = arraysWithOrder.has(currentArray);
326
- if (isOrdered) {
327
- arraysWithOrder.add(currentArray);
328
- }
329
- if (!isOrdered && isOrderedArray || isOrdered && !isOrderedArray && currentArray.length > 0) {
330
- throw new MixedArrayError(pathPart.pathToPart);
331
- }
332
- const order = isOrdered ? Number(pathPart.path) : currentArray.length;
333
- return [currentArray[order], val => currentArray[order] = val];
379
+ if (pathPart.type === "object") {
380
+ const key = pathPart.path;
381
+ if (FORBIDDEN_OBJECT_KEYS.has(key)) throw new ForbiddenKeyError(pathPart.pathToPart);
382
+ if (Array.isArray(currentPathObject)) throw new DuplicateKeyError(pathPart.pathToPart);
383
+ const currentObject = currentPathObject;
384
+ return [
385
+ Object.prototype.hasOwnProperty.call(currentObject, key) ? currentObject[key] : void 0,
386
+ (val) => {
387
+ if (!isPrototypeKey(key)) {
388
+ currentObject[key] = val;
389
+ return;
390
+ }
391
+ Object.defineProperty(currentObject, key, {
392
+ value: val,
393
+ writable: true,
394
+ enumerable: true,
395
+ configurable: true
396
+ });
397
+ },
398
+ key
399
+ ];
400
+ }
401
+ const isOrdered = pathPart.path !== "";
402
+ if (isOrdered && Number(pathPart.path) > MAX_ARRAY_INDEX) throw new InvalidPathError(pathPart.pathToPart);
403
+ if (!Array.isArray(currentPathObject)) throw new DuplicateKeyError(pathPart.pathToPart);
404
+ const currentArray = currentPathObject;
405
+ const isOrderedArray = arraysWithOrder.has(currentArray);
406
+ if (isOrdered) arraysWithOrder.add(currentArray);
407
+ if (!isOrdered && isOrderedArray || isOrdered && !isOrderedArray && currentArray.length > 0) throw new MixedArrayError(pathPart.pathToPart);
408
+ const order = isOrdered ? Number(pathPart.path) : currentArray.length;
409
+ return [
410
+ currentArray[order],
411
+ (val) => currentArray[order] = val,
412
+ order
413
+ ];
334
414
  }
335
-
336
415
  /**
337
- *
338
- * Parses a FormData object to a JSON object. This is done by parsing the `name`
339
- * attribute of each `FormDataEntryValue` and then inserting the value at the
340
- * path. Also by default the start of the path is used to transform the value.
341
- *
342
- *
343
- * In front of the whole `key`:
344
- * - `+` => parse to `Number`
345
- * - `-` => set value to `null`
346
- * - `&` => parse to `Boolean`
347
- *
348
- * - `.` between path parts => nest into `objects`
349
- * - `[\d*]` after path part => push to array in order `\d` or push to end if `[]`
350
- *
351
-
352
- *
353
- * @example
354
- * ```ts
355
- * const formData = new FormData()
356
- * formData.append('+a', '1')
357
- * formData.append('&b', 'true')
358
- * formData.append('-c', 'null')
359
- * formData.append('d', 'foo')
360
- * parseFormData(formData, defaultTransform)
361
- * // => {a: 1, b: true, c: null, d: 'foo'}
362
- * ```
363
- *
364
- * @example
365
- * ```ts
366
- * const formData = new FormData()
367
- * formData.append('a.b', 'foo')
368
- * parseFormData(formData)
369
- * // => {a: {b: 'foo'}}
370
- * ```
371
- *
372
- * @example
373
- * ```ts
374
- * const formData = new FormData()
375
- * formData.append('a[0]', 'foo')
376
- * formData.append('a[1]', 'bar')
377
- * parseFormData(formData)
378
- * // => {a: ['foo', 'bar']}
379
- * ```
380
- *
381
- * @example
382
- * ```ts
383
- * const formData = new FormData()
384
- * formData.append('a[]', 'foo')
385
- * formData.append('a[]', 'bar')
386
- * parseFormData(formData)
387
- * // => {a: ['foo', 'bar']}
388
- * ```
389
- *
390
- * @example
391
- * ```ts
392
- * const formData = new FormData()
393
- * formData.append('a[0]', 'foo')
394
- * parseFormData(formData, {transformEntry: (path, value) => {path, value: value + 'bar'}})
395
- * // => {a: ['foobar']}
396
- * ```
397
- *
398
- * @example
399
- * ```ts
400
- * const formData = new FormData()
401
- * formData.append('a[0]', 'foo')
402
- * formData.append('a[1]', '')
403
- * parseFormData(formData, {removeEmptyString: true})
404
- * // => {a: ['foo']}
405
- * ```
406
- *
407
- * @param {Iterable<[string, string | File]>} formData - an iterator of an [`path`, `value`] tuple
408
- * - `path` := `^(\+|\-|\&)?([^\.]+?(\[\d*\])*)(\.[^\.]+?(\[\d*\])*)*$` (e.g. `+a[][1].b`)
409
- * - `value` := `string` or `File`
410
- * @param {ParseFormDataOptions} options - options for parsing the form data
411
- * - `transformEntry` - a function to transform the path and the value before
412
- * inserting the value at the path in the resulting object (default: `defaultTransform`)
413
- * - `removeEmptyString` - if `true` removes all entries where the value is an empty string
414
- * @returns {JsonObject} the parsed JSON object
415
- * @throws `DuplicateKeyError` if
416
- * - a path part is an object and the path part is already defined as an object
417
- * - a path part is an array and the path part is already defined as an array
418
- * @throws `MixedArrayError` if at a specific path part an unordered array is
419
- * defined and at a later path part an ordered array is defined or vice versa
420
- * - e.g. `a[0]` and `a[]`
421
- * - e.g. `a[]` and `a[0]`
422
- */
423
- function parseFormData(formData, {
424
- removeEmptyString = false,
425
- transformEntry = defaultTransform
426
- } = {}) {
427
- const result = {};
428
-
429
- // all arrays we need to squash (in place) later
430
- const arraysWithOrder = new Set();
431
- for (const entry of Array.from(formData)) {
432
- if (removeEmptyString && entry[1] === '') continue;
433
- const {
434
- path,
435
- value
436
- } = transformEntry(entry, defaultTransform);
437
- const pathParts = extractPathParts(path);
438
- let currentPathObject = result;
439
- pathParts.forEach((pathPart, idx) => {
440
- const [nextPathValue, setNextPathValue] = handlePathPart(pathPart, currentPathObject, arraysWithOrder);
441
- if (pathParts.length - 1 === idx) {
442
- if (nextPathValue !== undefined) {
443
- throw new DuplicateKeyError(pathPart.pathToPart);
444
- }
445
- setNextPathValue(value);
446
- } else {
447
- if (nextPathValue !== undefined && !isJsonObject(nextPathValue) && !Array.isArray(nextPathValue)) {
448
- throw new DuplicateKeyError(pathPart.pathToPart);
449
- }
450
- const nextPathObject = nextPathValue ?? pathPart.default;
451
- currentPathObject = nextPathObject;
452
- setNextPathValue(nextPathObject);
453
- }
454
- });
455
- }
456
- for (const orderedArray of Array.from(arraysWithOrder)) {
457
- // replace array with a squashed array
458
- // array.flat(0) will remove all empty slots (e.g. [0, , 1] => [0, 1])
459
- orderedArray.splice(0, orderedArray.length, ...orderedArray.flat(0));
460
- }
461
- return result;
416
+ *
417
+ * Parses a FormData object to a JSON object. This is done by parsing the `name`
418
+ * attribute of each `FormDataEntryValue` and then inserting the value at the
419
+ * path. Also by default the start of the path is used to transform the value.
420
+ *
421
+ *
422
+ * In front of the whole `key`:
423
+ * - `+` => parse to `Number`
424
+ * - `-` => set value to `null`
425
+ * - `&` => parse to `Boolean`
426
+ *
427
+ * - `.` between path parts => nest into `objects`
428
+ * - `[key]` after path part => nest into `objects`, same as `.key`; the text
429
+ * in brackets is the key as it is (`a[b.c]` => key `b.c`)
430
+ * - `[\d*]` after path part => push to array in order `\d` or push to end if `[]`
431
+ *
432
+ * @example
433
+ * ```ts
434
+ * const formData = new FormData()
435
+ * formData.append('+a', '1')
436
+ * formData.append('&b', 'true')
437
+ * formData.append('-c', 'null')
438
+ * formData.append('d', 'foo')
439
+ * parseFormData(formData)
440
+ * // => {a: 1, b: true, c: null, d: 'foo'}
441
+ * ```
442
+ *
443
+ * @example
444
+ * ```ts
445
+ * const formData = new FormData()
446
+ * formData.append('a.b', 'foo')
447
+ * parseFormData(formData)
448
+ * // => {a: {b: 'foo'}}
449
+ * ```
450
+ *
451
+ * @example
452
+ * ```ts
453
+ * const formData = new FormData()
454
+ * formData.append('a[b]', 'foo')
455
+ * formData.append('a.c', 'bar')
456
+ * parseFormData(formData)
457
+ * // => {a: {b: 'foo', c: 'bar'}}
458
+ * ```
459
+ *
460
+ * @example
461
+ * ```ts
462
+ * const formData = new FormData()
463
+ * formData.append('a[0]', 'foo')
464
+ * formData.append('a[1]', 'bar')
465
+ * parseFormData(formData)
466
+ * // => {a: ['foo', 'bar']}
467
+ * ```
468
+ *
469
+ * @example
470
+ * ```ts
471
+ * const formData = new FormData()
472
+ * formData.append('a[]', 'foo')
473
+ * formData.append('a[]', 'bar')
474
+ * parseFormData(formData)
475
+ * // => {a: ['foo', 'bar']}
476
+ * ```
477
+ *
478
+ * @example
479
+ * ```ts
480
+ * const formData = new FormData()
481
+ * formData.append('a[0]', 'foo')
482
+ * parseFormData(formData, {
483
+ * transformEntry: ([path, value]) => ({
484
+ * path,
485
+ * value: typeof value === 'string' ? value + 'bar' : value,
486
+ * }),
487
+ * })
488
+ * // => {a: ['foobar']}
489
+ * ```
490
+ *
491
+ * @example
492
+ * ```ts
493
+ * const formData = new FormData()
494
+ * formData.append('a[0]', 'foo')
495
+ * formData.append('a[1]', '')
496
+ * parseFormData(formData, {removeEmptyString: true})
497
+ * // => {a: ['foo']}
498
+ * ```
499
+ *
500
+ * @param {Iterable<[string, string | File]>} formData - an iterator of an [`path`, `value`] tuple
501
+ * - `path` := an optional prefix (`+`, `-`, `&`), then path parts: keys after
502
+ * `.` or in brackets (`[key]`), and array parts `[]` or `[\d+]` (e.g.
503
+ * `+a[][1].b[c]`)
504
+ * - `value` := `string` or `File` (native or polyfilled `File` values are kept
505
+ * as they are)
506
+ * @param {ParseFormDataOptions} options - options for parsing the form data
507
+ * - `transformEntry` - a function to transform the path and the value before
508
+ * inserting the value at the path in the resulting object (default: `defaultTransform`)
509
+ * - `removeEmptyString` - if `true` removes all entries where the value is an empty string
510
+ * - `depth` - the maximum number of path parts in a path, counting every part
511
+ * (default: `Infinity`)
512
+ * @returns {JsonObject} the parsed JSON object
513
+ * @throws `TypeError` if `depth` isn't a positive integer or `Infinity`
514
+ * @throws `DepthLimitError` if a path has more path parts than `depth`
515
+ * - e.g. `a.b[0]` with `depth: 2` => `DepthLimitError('a.b[0]')`
516
+ * @throws `DuplicateKeyError` if
517
+ * - two entries set a value at the same path
518
+ * - e.g. `a` and `a`, `a[0]` and `a[0]`, `a.b` and `a[b]`
519
+ * - an entry uses a path part as an object, array or value and another entry
520
+ * uses it differently
521
+ * - e.g. `a` and `a.b`, `a.b` and `a[]`, `a[]` and `a.b`
522
+ * - the path starts with an array part
523
+ * - e.g. `[0]` or `[]`
524
+ * @throws `MixedArrayError` if at a specific path part an unordered array is
525
+ * defined and at a later path part an ordered array is defined or vice versa
526
+ * - e.g. `a[0]` and `a[]`
527
+ * - e.g. `a[]` and `a[0]`
528
+ * @throws `ForbiddenKeyError` if an object path part is `__proto__`,
529
+ * `constructor` or `prototype`
530
+ * - e.g. `__proto__.polluted`
531
+ * @throws `InvalidPathError` if an array index is larger than `4294967294`
532
+ * - e.g. `a[4294967295]`
533
+ *
534
+ * Every error's `key` is the path after `transformEntry`, not the field name
535
+ * (e.g. `+a.b.c` with `depth: 2` => `DepthLimitError('a.b.c')`).
536
+ *
537
+ * An entry with any other object path part named like a property on
538
+ * `Object.prototype` (`toString`, `hasOwnProperty`, ...) is removed from the
539
+ * result. It's parsed like any other entry first, so it causes the same errors
540
+ * as an unused name: an error doesn't tell the client which properties exist
541
+ * on the server's `Object.prototype`.
542
+ * - e.g. `user.toString` => `{}`
543
+ * - e.g. `user[toString]` => `{}`
544
+ */
545
+ function parseFormData(formData, { removeEmptyString = false, transformEntry = defaultTransform, depth = Infinity } = {}) {
546
+ if (depth !== Infinity && !(Number.isInteger(depth) && depth > 0)) throw new TypeError(`depth must be a positive integer or Infinity, got ${String(depth)}`);
547
+ const result = {};
548
+ const arraysWithOrder = /* @__PURE__ */ new Set();
549
+ const createdByRemoved = [];
550
+ for (const entry of Array.from(formData)) {
551
+ if (removeEmptyString && entry[1] === "") continue;
552
+ const { path, value } = transformEntry(entry, defaultTransform);
553
+ const pathParts = extractPathParts(path, depth);
554
+ const isRemoved = pathParts.some((pathPart) => pathPart.type === "object" && isPrototypeKey(pathPart.path));
555
+ let currentPathObject = result;
556
+ pathParts.forEach((pathPart, idx) => {
557
+ const [nextPathValue, setNextPathValue, key] = handlePathPart(pathPart, currentPathObject, arraysWithOrder);
558
+ const parent = currentPathObject;
559
+ if (pathParts.length - 1 === idx) {
560
+ if (nextPathValue !== void 0) throw new DuplicateKeyError(pathPart.pathToPart);
561
+ setNextPathValue(value);
562
+ if (isRemoved) createdByRemoved.push([
563
+ parent,
564
+ key,
565
+ void 0
566
+ ]);
567
+ } else {
568
+ if (nextPathValue !== void 0 && !isJsonObject(nextPathValue) && !Array.isArray(nextPathValue)) throw new DuplicateKeyError(pathPart.pathToPart);
569
+ const nextPathObject = nextPathValue ?? pathPart.default;
570
+ currentPathObject = nextPathObject;
571
+ setNextPathValue(nextPathObject);
572
+ if (isRemoved && nextPathValue === void 0) createdByRemoved.push([
573
+ parent,
574
+ key,
575
+ nextPathObject
576
+ ]);
577
+ }
578
+ });
579
+ }
580
+ for (let idx = createdByRemoved.length - 1; idx >= 0; idx--) {
581
+ const [parent, key, container] = createdByRemoved[idx];
582
+ if (container && Object.keys(container).length > 0) continue;
583
+ Reflect.deleteProperty(parent, key);
584
+ if (Array.isArray(parent)) arraysWithOrder.add(parent);
585
+ }
586
+ for (const orderedArray of Array.from(arraysWithOrder)) {
587
+ const values = Object.values(orderedArray);
588
+ orderedArray.length = 0;
589
+ for (const value of values) orderedArray.push(value);
590
+ }
591
+ return result;
462
592
  }
463
-
464
- export { DuplicateKeyError, ForbiddenKeyError, MixedArrayError, parseFormData };
593
+ export { DepthLimitError, DuplicateKeyError, ForbiddenKeyError, InvalidPathError, MixedArrayError, parseFormData };