@verifyhash/query-string 0.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.
- package/LICENSE +21 -0
- package/README.md +135 -0
- package/index.d.ts +78 -0
- package/index.js +227 -0
- package/package.json +39 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 verifyhash
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# @verifyhash/query-string
|
|
2
|
+
|
|
3
|
+
Zero-dependency URL query-string **parse + stringify** for Node.js, typed
|
|
4
|
+
from birth (hand-written `index.d.ts` shipped with the package). It covers
|
|
5
|
+
the query-string shapes real web apps actually exchange — repeated keys,
|
|
6
|
+
bracket arrays, one level of `a[b]=c` nesting — and it **never throws on
|
|
7
|
+
malformed input to `parse()`** (a bad `%GG` sequence falls back to the raw
|
|
8
|
+
token instead of crashing your request handler).
|
|
9
|
+
|
|
10
|
+
**Who it's for:** Node.js developers who want predictable query-string
|
|
11
|
+
behavior without pulling in `qs` (which has 5+ transitive behaviors around
|
|
12
|
+
deep nesting and allocation limits) and without the `URLSearchParams`
|
|
13
|
+
limitations (no arrays-by-default, no nesting, iterator-based API).
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm install @verifyhash/query-string
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Example
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
const qs = require('@verifyhash/query-string');
|
|
25
|
+
|
|
26
|
+
qs.parse('?tag=a&tag=b&filter[color]=red&page=');
|
|
27
|
+
// { tag: ['a', 'b'], filter: { color: 'red' }, page: '' }
|
|
28
|
+
|
|
29
|
+
qs.parse('ids[]=7&ids[]=9&q=caf%C3%A9+au+lait');
|
|
30
|
+
// { ids: ['7', '9'], q: 'café au lait' }
|
|
31
|
+
|
|
32
|
+
qs.stringify({ tag: ['a', 'b'], filter: { color: 'red' }, page: '' });
|
|
33
|
+
// 'tag[]=a&tag[]=b&filter[color]=red&page='
|
|
34
|
+
|
|
35
|
+
qs.stringify({ q: 'two words' }, { spaceAsPlus: true });
|
|
36
|
+
// 'q=two+words' (default is %20: 'q=two%20words')
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Round-trip property: `qs.parse(qs.stringify(x))` deep-equals `x` for the
|
|
40
|
+
supported shape set (see limits below) — the test suite proves it on a table
|
|
41
|
+
of shapes including single-element arrays, which survive because arrays
|
|
42
|
+
default to bracket format (`a[]=only` parses back to `['only']`, whereas
|
|
43
|
+
`repeat` format would collapse it to the string `'only'`).
|
|
44
|
+
|
|
45
|
+
## API
|
|
46
|
+
|
|
47
|
+
### `parse(str, opts?)`
|
|
48
|
+
|
|
49
|
+
Parses a query string (a leading `?` is tolerated and stripped) into a
|
|
50
|
+
**null-prototype object** — keys like `__proto__` are plain data and cannot
|
|
51
|
+
pollute `Object.prototype`, but it also means you should use `Object.keys(obj)`
|
|
52
|
+
rather than `obj.hasOwnProperty(...)` on the result.
|
|
53
|
+
|
|
54
|
+
- **Repeated keys become arrays:** `a=1&a=2` → `{ a: ['1', '2'] }`. The first
|
|
55
|
+
occurrence stays a scalar; the second converts the slot to an array.
|
|
56
|
+
- **Bracket array syntax:** `a[]=1&a[]=2` → `{ a: ['1', '2'] }`, and a single
|
|
57
|
+
`a[]=1` is still `{ a: ['1'] }` (an array, unlike a plain `a=1`).
|
|
58
|
+
- **One level of nesting:** `a[b]=c` → `{ a: { b: 'c' } }`. Repeated nested
|
|
59
|
+
keys array-merge too: `a[b]=1&a[b]=2` → `{ a: { b: ['1', '2'] } }`.
|
|
60
|
+
Bracket syntax is recognized *after* percent-decoding, so `b%5Bc%5D=d`
|
|
61
|
+
also nests.
|
|
62
|
+
- **Decoding:** `+` means space (in keys and values), then percent-decoding
|
|
63
|
+
is applied. **Malformed percent-sequences never throw** — the token falls
|
|
64
|
+
back to its raw text with `+` already converted (`a=%GG+x` → `{ a: '%GG x' }`).
|
|
65
|
+
- **Empty and valueless keys:** `a=` → `{ a: '' }` always. A bare `a` (no `=`)
|
|
66
|
+
is also `{ a: '' }` by default; pass `{ nullForBare: true }` to get
|
|
67
|
+
`{ a: null }` instead so you can distinguish `?flag` from `?flag=`.
|
|
68
|
+
- **Empty pairs are skipped:** `a=1&&b=2` parses like `a=1&b=2`.
|
|
69
|
+
- **`=` inside a value survives:** `a=b=c` → `{ a: 'b=c' }` (split on the
|
|
70
|
+
first `=` only).
|
|
71
|
+
- Non-string input returns `{}`.
|
|
72
|
+
|
|
73
|
+
Options (`ParseOptions`):
|
|
74
|
+
|
|
75
|
+
| option | default | effect |
|
|
76
|
+
|---------------|---------|----------------------------------------------------|
|
|
77
|
+
| `nullForBare` | `false` | bare keys parse to `null` instead of `''` |
|
|
78
|
+
|
|
79
|
+
### `stringify(obj, opts?)`
|
|
80
|
+
|
|
81
|
+
Serializes a plain object to a query string (no leading `?`), using
|
|
82
|
+
`encodeURIComponent` (spaces become `%20` by default). Output key order is
|
|
83
|
+
the object's insertion order — stable and predictable.
|
|
84
|
+
|
|
85
|
+
- **Values:** `string` as-is; `number` / `boolean` / `bigint` via `String()`;
|
|
86
|
+
`null` emits a bare key (`{ a: null }` → `'a'`); `undefined` is skipped
|
|
87
|
+
entirely.
|
|
88
|
+
- **Arrays:** default `arrayFormat: 'bracket'` emits `a[]=1&a[]=2`. Opt into
|
|
89
|
+
`'repeat'` for `a=1&a=2` — but note a single-element array then round-trips
|
|
90
|
+
to a plain string.
|
|
91
|
+
- **One level of nesting:** `{ a: { b: 'c' } }` → `'a[b]=c'`. Arrays inside a
|
|
92
|
+
nested object emit repeated nested keys (`a[b]=1&a[b]=2`).
|
|
93
|
+
- **Anything deeper throws `TypeError`** — see limits.
|
|
94
|
+
|
|
95
|
+
Options (`StringifyOptions`):
|
|
96
|
+
|
|
97
|
+
| option | default | effect |
|
|
98
|
+
|---------------|-------------|--------------------------------------------------|
|
|
99
|
+
| `spaceAsPlus` | `false` | encode `' '` as `+` instead of `%20` (keys too) |
|
|
100
|
+
| `arrayFormat` | `'bracket'` | `'bracket'` → `a[]=1`; `'repeat'` → `a=1` |
|
|
101
|
+
|
|
102
|
+
## Limits (honest)
|
|
103
|
+
|
|
104
|
+
- **Nesting is exactly one level deep, on purpose.** `parse('a[b][c]=d')`
|
|
105
|
+
does NOT build `{ a: { b: { c: 'd' } } }` — the key stays literal:
|
|
106
|
+
`{ 'a[b][c]': 'd' }`. `stringify({ a: { b: { c: 'd' } } })` throws a
|
|
107
|
+
`TypeError` rather than half-implementing recursion. If you need deep
|
|
108
|
+
structures in a URL, JSON-encode the value instead.
|
|
109
|
+
- **Bracket characters in data keys are ambiguous.** Because bracket syntax
|
|
110
|
+
is recognized after decoding, a literal key `'a[b]'` stringifies to
|
|
111
|
+
`a%5Bb%5D=...` but parses back as nesting `{ a: { b: ... } }`. Such keys
|
|
112
|
+
are outside the round-trip set.
|
|
113
|
+
- **Conflicting shapes fall back to literal keys.** `a=1&a[b]=2` parses to
|
|
114
|
+
`{ a: '1', 'a[b]': '2' }` — the nested pair keeps its literal key rather
|
|
115
|
+
than silently destroying the scalar.
|
|
116
|
+
- **Empty arrays vanish.** `stringify({ a: [] })` emits nothing for `a`, so
|
|
117
|
+
they do not round-trip (query strings have no way to say "empty list").
|
|
118
|
+
- All parsed leaves are **strings** (or `null` with `nullForBare`) — there is
|
|
119
|
+
no number/boolean coercion on parse, so `stringify({ a: 1 })` round-trips
|
|
120
|
+
to `{ a: '1' }`.
|
|
121
|
+
|
|
122
|
+
## Tests
|
|
123
|
+
|
|
124
|
+
```sh
|
|
125
|
+
node test/index.test.js
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
65 checks: a golden-vector table for `parse` and `stringify` (repeated keys,
|
|
129
|
+
bracket arrays, nesting, `+`/percent decoding, empty/valueless keys, malformed
|
|
130
|
+
`%GG`/`%E4%` sequences, `&&` pairs, unicode, `=` inside values, a 500×
|
|
131
|
+
repeated key, prototype-pollution safety) plus a round-trip property table.
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
MIT
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for @verifyhash/query-string — zero-dependency URL
|
|
3
|
+
* query-string parse + stringify (leading '?' tolerance, repeated keys ->
|
|
4
|
+
* arrays, bracket arrays a[]=, ONE level of bracket nesting a[b]=c,
|
|
5
|
+
* '+'-as-space decoding, malformed %-sequences never throw).
|
|
6
|
+
*
|
|
7
|
+
* Hand-written against index.js — every runtime export is declared here.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Options for parse(). */
|
|
11
|
+
export interface ParseOptions {
|
|
12
|
+
/**
|
|
13
|
+
* When true, a valueless key ('a' with no '=') parses to null instead of
|
|
14
|
+
* the default ''. An explicit 'a=' is ALWAYS ''.
|
|
15
|
+
*/
|
|
16
|
+
nullForBare?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** How stringify() serializes top-level arrays. */
|
|
20
|
+
export type ArrayFormat = 'bracket' | 'repeat';
|
|
21
|
+
|
|
22
|
+
/** Options for stringify(). */
|
|
23
|
+
export interface StringifyOptions {
|
|
24
|
+
/** Encode ' ' as '+' instead of the default %20. */
|
|
25
|
+
spaceAsPlus?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* 'bracket' (default): a[]=1&a[]=2 — round-trip safe even for
|
|
28
|
+
* single-element arrays. 'repeat': a=1&a=2 — a single-element array then
|
|
29
|
+
* parses back as a plain string.
|
|
30
|
+
*/
|
|
31
|
+
arrayFormat?: ArrayFormat;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A parsed leaf: decoded string, or null for bare keys under nullForBare. */
|
|
35
|
+
export type ParsedLeaf = string | null;
|
|
36
|
+
|
|
37
|
+
/** A parsed slot: leaf, repeated-key/bracket array, or one nested level. */
|
|
38
|
+
export type ParsedValue =
|
|
39
|
+
| ParsedLeaf
|
|
40
|
+
| ParsedLeaf[]
|
|
41
|
+
| { [innerKey: string]: ParsedLeaf | ParsedLeaf[] };
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The result of parse(): a null-prototype object (keys like '__proto__' are
|
|
45
|
+
* plain data), so do not rely on Object.prototype methods being reachable
|
|
46
|
+
* directly on it (use Object.keys()/JSON.stringify() etc.).
|
|
47
|
+
*/
|
|
48
|
+
export interface ParsedQuery {
|
|
49
|
+
[key: string]: ParsedValue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Scalars stringify() accepts (null = bare key, undefined = skipped). */
|
|
53
|
+
export type StringifyScalar = string | number | boolean | bigint | null | undefined;
|
|
54
|
+
|
|
55
|
+
/** A top-level stringify() value: scalar, array, or ONE level of nesting. */
|
|
56
|
+
export type StringifyValue =
|
|
57
|
+
| StringifyScalar
|
|
58
|
+
| StringifyScalar[]
|
|
59
|
+
| { [innerKey: string]: StringifyScalar | StringifyScalar[] };
|
|
60
|
+
|
|
61
|
+
/** The input shape stringify() accepts. */
|
|
62
|
+
export interface StringifyInput {
|
|
63
|
+
[key: string]: StringifyValue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse a URL query string (with or without a leading '?') into an object.
|
|
68
|
+
* Never throws on malformed input: bad %-sequences fall back to the raw
|
|
69
|
+
* token ('+' still becomes a space).
|
|
70
|
+
*/
|
|
71
|
+
export function parse(str: string, opts?: ParseOptions): ParsedQuery;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Serialize a plain object into a query string (no leading '?'), with
|
|
75
|
+
* encodeURIComponent-based encoding and stable insertion key order.
|
|
76
|
+
* Throws TypeError on non-object input or nesting deeper than one level.
|
|
77
|
+
*/
|
|
78
|
+
export function stringify(obj: StringifyInput, opts?: StringifyOptions): string;
|
package/index.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @verifyhash/query-string — zero-dependency URL query-string parse + stringify.
|
|
5
|
+
*
|
|
6
|
+
* Scope (deliberately small and fully documented):
|
|
7
|
+
* - parse(): leading '?' tolerance, repeated keys -> arrays, bracket array
|
|
8
|
+
* syntax (a[]=1&a[]=2), ONE level of bracket nesting (a[b]=c), '+'-as-space
|
|
9
|
+
* and percent-decoding, malformed %-sequences fall back to the raw token
|
|
10
|
+
* (never throws).
|
|
11
|
+
* - stringify(): encodeURIComponent-based encoding (space as %20 by default,
|
|
12
|
+
* opt-in '+'), stable insertion key order, arrays as a[]= by default so
|
|
13
|
+
* single-element arrays survive a round trip, one level of object nesting.
|
|
14
|
+
*
|
|
15
|
+
* Deeper nesting (a[b][c]=d) is intentionally OUT of scope: parse() keeps such
|
|
16
|
+
* keys as literal flat keys, and stringify() throws a TypeError rather than
|
|
17
|
+
* half-implementing recursion.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Regex for a supported structured key AFTER percent-decoding:
|
|
22
|
+
* base[] -> bracket array push
|
|
23
|
+
* base[inner] -> one-level nesting
|
|
24
|
+
* The base and inner segments must themselves be bracket-free, so deeper
|
|
25
|
+
* nesting like a[b][c] deliberately fails to match and stays a literal key.
|
|
26
|
+
*/
|
|
27
|
+
const STRUCTURED_KEY_RE = /^([^[\]]+)\[([^[\]]*)\]$/;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Decode one token ('+' means space, then percent-decode). If the
|
|
31
|
+
* percent-decoding is malformed (e.g. '%GG', truncated '%E4%'), the token is
|
|
32
|
+
* returned with '+' already converted to spaces but percent-sequences left
|
|
33
|
+
* exactly as they were — parse() never throws on bad input.
|
|
34
|
+
*/
|
|
35
|
+
function safeDecode(token) {
|
|
36
|
+
const plusReplaced = token.replace(/\+/g, ' ');
|
|
37
|
+
try {
|
|
38
|
+
return decodeURIComponent(plusReplaced);
|
|
39
|
+
} catch (e) {
|
|
40
|
+
return plusReplaced;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Is `v` a non-array, non-null object (a candidate nesting container)? */
|
|
45
|
+
function isNestObject(v) {
|
|
46
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Repeated-key merge: first value stays scalar, a second value converts the
|
|
51
|
+
* slot to an array, further values append.
|
|
52
|
+
*/
|
|
53
|
+
function assign(container, key, value) {
|
|
54
|
+
if (!(key in container)) {
|
|
55
|
+
container[key] = value;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const existing = container[key];
|
|
59
|
+
if (Array.isArray(existing)) {
|
|
60
|
+
existing.push(value);
|
|
61
|
+
} else {
|
|
62
|
+
container[key] = [existing, value];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse a URL query string into an object.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} str Query string, with or without a leading '?'.
|
|
70
|
+
* @param {{nullForBare?: boolean}} [opts]
|
|
71
|
+
* nullForBare: when true, a valueless key ('a' with no '=') parses to null
|
|
72
|
+
* instead of the default '' (an explicit 'a=' is ALWAYS '').
|
|
73
|
+
* @returns {Object} A null-prototype object (so keys like '__proto__' are
|
|
74
|
+
* plain data and cannot pollute Object.prototype).
|
|
75
|
+
*/
|
|
76
|
+
function parse(str, opts) {
|
|
77
|
+
opts = opts || {};
|
|
78
|
+
const bare = opts.nullForBare ? null : '';
|
|
79
|
+
const result = Object.create(null);
|
|
80
|
+
if (typeof str !== 'string') return result;
|
|
81
|
+
|
|
82
|
+
let s = str;
|
|
83
|
+
if (s.charCodeAt(0) === 63 /* '?' */) s = s.slice(1);
|
|
84
|
+
if (s === '') return result;
|
|
85
|
+
|
|
86
|
+
for (const pair of s.split('&')) {
|
|
87
|
+
if (pair === '') continue; // tolerate '&&' and trailing '&'
|
|
88
|
+
|
|
89
|
+
const eq = pair.indexOf('=');
|
|
90
|
+
let rawKey, value;
|
|
91
|
+
if (eq === -1) {
|
|
92
|
+
rawKey = pair;
|
|
93
|
+
value = bare;
|
|
94
|
+
} else {
|
|
95
|
+
rawKey = pair.slice(0, eq);
|
|
96
|
+
value = safeDecode(pair.slice(eq + 1)); // '=' inside the value survives
|
|
97
|
+
}
|
|
98
|
+
const key = safeDecode(rawKey);
|
|
99
|
+
|
|
100
|
+
const m = STRUCTURED_KEY_RE.exec(key);
|
|
101
|
+
if (m !== null && m[2] === '') {
|
|
102
|
+
// Bracket array syntax: base[]=v
|
|
103
|
+
const base = m[1];
|
|
104
|
+
if (Array.isArray(result[base])) {
|
|
105
|
+
result[base].push(value);
|
|
106
|
+
} else if (!(base in result)) {
|
|
107
|
+
result[base] = [value];
|
|
108
|
+
} else {
|
|
109
|
+
// base already holds a scalar/object: keep the literal key instead of
|
|
110
|
+
// destroying data (documented conflict fallback).
|
|
111
|
+
assign(result, key, value);
|
|
112
|
+
}
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (m !== null) {
|
|
116
|
+
// One-level nesting: base[inner]=v
|
|
117
|
+
const base = m[1];
|
|
118
|
+
const inner = m[2];
|
|
119
|
+
if (!(base in result)) result[base] = Object.create(null);
|
|
120
|
+
if (isNestObject(result[base])) {
|
|
121
|
+
assign(result[base], inner, value);
|
|
122
|
+
} else {
|
|
123
|
+
// base already holds a scalar/array: literal-key fallback.
|
|
124
|
+
assign(result, key, value);
|
|
125
|
+
}
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
// Plain key (this includes deeper-nested-looking keys like 'a[b][c]',
|
|
129
|
+
// which are intentionally treated as literal flat keys).
|
|
130
|
+
assign(result, key, value);
|
|
131
|
+
}
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** encodeURIComponent, optionally with spaces as '+' instead of %20. */
|
|
136
|
+
function encode(str, spaceAsPlus) {
|
|
137
|
+
let out = encodeURIComponent(str);
|
|
138
|
+
if (spaceAsPlus) out = out.replace(/%20/g, '+');
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isScalar(v) {
|
|
143
|
+
const t = typeof v;
|
|
144
|
+
return t === 'string' || t === 'number' || t === 'boolean' || t === 'bigint';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function scalarToString(v) {
|
|
148
|
+
return typeof v === 'string' ? v : String(v);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Serialize an object into a URL query string (no leading '?').
|
|
153
|
+
*
|
|
154
|
+
* @param {Object} obj A plain object. Values may be: string | number |
|
|
155
|
+
* boolean | bigint | null (bare key, no '=') | undefined (skipped) |
|
|
156
|
+
* arrays of those | ONE level of plain-object nesting whose values are
|
|
157
|
+
* again scalars/null/undefined/arrays. Anything deeper throws a TypeError.
|
|
158
|
+
* @param {{spaceAsPlus?: boolean, arrayFormat?: 'bracket'|'repeat'}} [opts]
|
|
159
|
+
* spaceAsPlus: encode ' ' as '+' instead of the default %20.
|
|
160
|
+
* arrayFormat: 'bracket' (default, a[]=1&a[]=2 — round-trip safe even for
|
|
161
|
+
* single-element arrays) or 'repeat' (a=1&a=2 — note a single-element array
|
|
162
|
+
* then parses back as a plain string).
|
|
163
|
+
* @returns {string}
|
|
164
|
+
*/
|
|
165
|
+
function stringify(obj, opts) {
|
|
166
|
+
opts = opts || {};
|
|
167
|
+
const spaceAsPlus = !!opts.spaceAsPlus;
|
|
168
|
+
const arrayFormat = opts.arrayFormat || 'bracket';
|
|
169
|
+
if (arrayFormat !== 'bracket' && arrayFormat !== 'repeat') {
|
|
170
|
+
throw new TypeError(
|
|
171
|
+
"opts.arrayFormat must be 'bracket' or 'repeat' (got " + JSON.stringify(arrayFormat) + ')'
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
if (!isNestObject(obj)) {
|
|
175
|
+
throw new TypeError('stringify expects a plain object (got ' + (obj === null ? 'null' : typeof obj) + ')');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const parts = [];
|
|
179
|
+
// Emit one pair; null -> bare key (no '='), undefined -> nothing.
|
|
180
|
+
function emit(encodedKey, value, where) {
|
|
181
|
+
if (value === undefined) return;
|
|
182
|
+
if (value === null) {
|
|
183
|
+
parts.push(encodedKey);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (!isScalar(value)) {
|
|
187
|
+
throw new TypeError('unsupported value at ' + where + ' (' + typeof value + ')');
|
|
188
|
+
}
|
|
189
|
+
parts.push(encodedKey + '=' + encode(scalarToString(value), spaceAsPlus));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for (const key of Object.keys(obj)) {
|
|
193
|
+
// Insertion order of Object.keys IS the output order (stable).
|
|
194
|
+
const v = obj[key];
|
|
195
|
+
const ek = encode(key, spaceAsPlus);
|
|
196
|
+
if (v === undefined) continue;
|
|
197
|
+
if (v === null || isScalar(v)) {
|
|
198
|
+
emit(ek, v, JSON.stringify(key));
|
|
199
|
+
} else if (Array.isArray(v)) {
|
|
200
|
+
const arrKey = arrayFormat === 'bracket' ? ek + '[]' : ek;
|
|
201
|
+
for (const item of v) emit(arrKey, item, JSON.stringify(key) + '[]');
|
|
202
|
+
} else if (isNestObject(v)) {
|
|
203
|
+
for (const innerKey of Object.keys(v)) {
|
|
204
|
+
const iv = v[innerKey];
|
|
205
|
+
const nk = ek + '[' + encode(innerKey, spaceAsPlus) + ']';
|
|
206
|
+
const where = JSON.stringify(key) + '[' + JSON.stringify(innerKey) + ']';
|
|
207
|
+
if (iv === undefined) continue;
|
|
208
|
+
if (iv === null || isScalar(iv)) {
|
|
209
|
+
emit(nk, iv, where);
|
|
210
|
+
} else if (Array.isArray(iv)) {
|
|
211
|
+
// Arrays inside a nested object serialize as repeated nested keys
|
|
212
|
+
// (a[b]=1&a[b]=2); see README for the single-element caveat.
|
|
213
|
+
for (const item of iv) emit(nk, item, where);
|
|
214
|
+
} else {
|
|
215
|
+
throw new TypeError(
|
|
216
|
+
'deeper than one level of nesting is intentionally unsupported: key ' + where
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
} else {
|
|
221
|
+
throw new TypeError('unsupported value for key ' + JSON.stringify(key) + ' (' + typeof v + ')');
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return parts.join('&');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
module.exports = { parse, stringify };
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@verifyhash/query-string",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Zero-dependency URL query-string parse + stringify for Node.js: leading-'?' tolerance, repeated keys to arrays, bracket arrays (a[]=), one documented level of bracket nesting (a[b]=c), '+'-as-space decoding, and malformed percent-sequences that never throw.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"query-string",
|
|
7
|
+
"querystring",
|
|
8
|
+
"url",
|
|
9
|
+
"parse",
|
|
10
|
+
"stringify",
|
|
11
|
+
"urlencoded",
|
|
12
|
+
"zero-dependency",
|
|
13
|
+
"typescript"
|
|
14
|
+
],
|
|
15
|
+
"main": "index.js",
|
|
16
|
+
"types": "index.d.ts",
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node test/index.test.js"
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"files": [
|
|
22
|
+
"index.js",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE",
|
|
25
|
+
"index.d.ts"
|
|
26
|
+
],
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/verifyhash/libs.git",
|
|
33
|
+
"directory": "query-string"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/verifyhash/libs/tree/main/query-string#readme",
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/verifyhash/libs/issues"
|
|
38
|
+
}
|
|
39
|
+
}
|