Introduced Query String module implementation.

In collaboration with Dmitry Volyntsev.

This closes #288 issue on GitHub.
diff --git a/auto/sources b/auto/sources
index d910976..547e153 100644
--- a/auto/sources
+++ b/auto/sources
@@ -55,6 +55,7 @@
    src/njs_array_buffer.c \
    src/njs_typed_array.c \
    src/njs_promise.c \
+   src/njs_query_string.c \
 "
 
 NJS_LIB_TEST_SRCS=" \
diff --git a/src/njs_builtin.c b/src/njs_builtin.c
index bd41b76..b0826dd 100644
--- a/src/njs_builtin.c
+++ b/src/njs_builtin.c
@@ -50,6 +50,7 @@
 static const njs_object_init_t  *njs_module_init[] = {
     &njs_fs_object_init,
     &njs_crypto_object_init,
+    &njs_query_string_object_init,
     NULL
 };
 
diff --git a/src/njs_main.h b/src/njs_main.h
index 83aeb8a..f2afa57 100644
--- a/src/njs_main.h
+++ b/src/njs_main.h
@@ -79,6 +79,7 @@
 
 #include <njs_fs.h>
 #include <njs_crypto.h>
+#include <njs_query_string.h>
 
 #include <njs_event.h>
 #include <njs_module.h>
diff --git a/src/njs_query_string.c b/src/njs_query_string.c
new file mode 100644
index 0000000..b961ddc
--- /dev/null
+++ b/src/njs_query_string.c
@@ -0,0 +1,912 @@
+
+/*
+ * Copyright (C) Alexander Borisov
+ * Copyright (C) Dmitry Volyntsev
+ * Copyright (C) NGINX, Inc.
+ */
+
+
+#include <njs_main.h>
+
+
+static const njs_value_t  njs_escape_str = njs_string("escape");
+static const njs_value_t  njs_unescape_str = njs_string("unescape");
+static const njs_value_t  njs_encode_uri_str =
+                                         njs_long_string("encodeURIComponent");
+static const njs_value_t  njs_decode_uri_str =
+                                         njs_long_string("decodeURIComponent");
+static const njs_value_t  njs_max_keys_str = njs_string("maxKeys");
+
+
+static njs_int_t njs_query_string_escape(njs_vm_t *vm, njs_value_t *args,
+    njs_uint_t nargs, njs_index_t unused);
+static njs_int_t njs_query_string_unescape(njs_vm_t *vm, njs_value_t *args,
+    njs_uint_t nargs, njs_index_t unused);
+
+
+static njs_object_t *
+njs_query_string_object_alloc(njs_vm_t *vm)
+{
+    njs_object_t  *obj;
+
+    obj = njs_mp_alloc(vm->mem_pool, sizeof(njs_object_t));
+
+    if (njs_fast_path(obj != NULL)) {
+        njs_lvlhsh_init(&obj->hash);
+        njs_lvlhsh_init(&obj->shared_hash);
+        obj->type = NJS_OBJECT;
+        obj->shared = 0;
+        obj->extensible = 1;
+        obj->error_data = 0;
+        obj->fast_array = 0;
+
+        obj->__proto__ = NULL;
+        obj->slots = NULL;
+
+        return obj;
+    }
+
+    njs_memory_error(vm);
+
+    return NULL;
+}
+
+
+static njs_int_t
+njs_query_string_decode(njs_vm_t *vm, njs_value_t *value, const u_char *start,
+    size_t size)
+{
+    u_char                *dst;
+    size_t                length;
+    ssize_t               str_size;
+    uint32_t              cp;
+    njs_int_t             ret;
+    njs_chb_t             chain;
+    const u_char          *p, *end;
+    njs_unicode_decode_t  ctx;
+
+    static const int8_t  hex[256]
+        njs_aligned(32) =
+    {
+        -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+        -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+        -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+         0,  1,  2,  3,  4,  5,  6,  7,  8,  9, -1, -1, -1, -1, -1, -1,
+        -1, 10, 11, 12, 13, 14, 15, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+        -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+        -1, 10, 11, 12, 13, 14, 15, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+        -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+        -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+        -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+        -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+        -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+        -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+        -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+        -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+        -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+    };
+
+    njs_chb_init(&chain, vm->mem_pool);
+    njs_utf8_decode_init(&ctx);
+
+    cp = 0;
+    length = 0;
+    ret = NJS_ERROR;
+
+    p = start;
+    end = p + size;
+
+    while (p < end) {
+        if (*p == '%' && end - p > 2 && hex[p[1]] >= 0 && hex[p[2]] >= 0) {
+            cp = njs_utf8_consume(&ctx, (hex[p[1]] << 4) | hex[p[2]]);
+            p += 3;
+
+        } else {
+            if (*p == '+') {
+                cp = ' ';
+                p++;
+
+            } else {
+                cp = njs_utf8_decode(&ctx, &p, end);
+            }
+        }
+
+        if (cp > NJS_UNICODE_MAX_CODEPOINT) {
+            if (cp == NJS_UNICODE_CONTINUE) {
+                continue;
+            }
+
+            cp = NJS_UNICODE_REPLACEMENT;
+        }
+
+        dst = njs_chb_reserve(&chain, 4);
+        if (njs_slow_path(dst == NULL)) {
+            return NJS_ERROR;
+        }
+
+        njs_chb_written(&chain, njs_utf8_encode(dst, cp) - dst);
+
+        length++;
+    }
+
+    if (njs_slow_path(cp == NJS_UNICODE_CONTINUE)) {
+        dst = njs_chb_reserve(&chain, 3);
+        if (njs_slow_path(dst == NULL)) {
+            return NJS_ERROR;
+        }
+
+        njs_chb_written(&chain,
+                        njs_utf8_encode(dst, NJS_UNICODE_REPLACEMENT) - dst);
+
+        length++;
+    }
+
+    str_size = njs_chb_size(&chain);
+    if (njs_slow_path(str_size < 0)) {
+        goto failed;
+    }
+
+    dst = njs_string_alloc(vm, value, str_size, length);
+    if (njs_slow_path(dst == NULL)) {
+        goto failed;
+    }
+
+    njs_chb_join_to(&chain, dst);
+
+    ret = NJS_OK;
+
+failed:
+
+    njs_chb_destroy(&chain);
+
+    return ret;
+}
+
+
+njs_inline njs_bool_t
+njs_query_string_is_native_decoder(njs_function_t *decoder)
+{
+    return decoder->native && decoder->u.native == njs_query_string_unescape;
+}
+
+
+njs_inline njs_int_t
+njs_query_string_append(njs_vm_t *vm, njs_value_t *object, const u_char *key,
+    size_t key_size, const u_char *val, size_t val_size,
+    njs_function_t *decoder)
+{
+    uint32_t     key_length, val_length;
+    njs_int_t    ret;
+    njs_array_t  *array;
+    njs_value_t  name, value, retval;
+
+    if (njs_query_string_is_native_decoder(decoder)) {
+        ret = njs_query_string_decode(vm, &name, key, key_size);
+        if (njs_slow_path(ret != NJS_OK)) {
+            return ret;
+        }
+
+        ret = njs_query_string_decode(vm, &value, val, val_size);
+        if (njs_slow_path(ret != NJS_OK)) {
+            return ret;
+        }
+
+    } else {
+
+        key_length = njs_max(njs_utf8_length(key, key_size), 0);
+        ret = njs_string_new(vm, &name, key, key_size, key_length);
+        if (njs_slow_path(ret != NJS_OK)) {
+            return ret;
+        }
+
+        if (key_size > 0) {
+            ret = njs_function_call(vm, decoder, &njs_value_undefined, &name, 1,
+                                    &name);
+            if (njs_slow_path(ret != NJS_OK)) {
+                return ret;
+            }
+
+            if (!njs_is_string(&name)) {
+                njs_value_to_string(vm, &name, &name);
+            }
+        }
+
+        val_length = njs_max(njs_utf8_length(val, val_size), 0);
+        ret = njs_string_new(vm, &value, val, val_size, val_length);
+        if (njs_slow_path(ret != NJS_OK)) {
+            return ret;
+        }
+
+        if (val_size > 0) {
+            ret = njs_function_call(vm, decoder, &njs_value_undefined, &value,
+                                    1, &value);
+            if (njs_slow_path(ret != NJS_OK)) {
+                return ret;
+            }
+
+            if (!njs_is_string(&value)) {
+                njs_value_to_string(vm, &value, &value);
+            }
+        }
+    }
+
+    ret = njs_value_property(vm, object, &name, &retval);
+
+    if (ret == NJS_OK) {
+        if (njs_is_array(&retval)) {
+            return njs_array_add(vm, njs_array(&retval), &value);
+        }
+
+        array = njs_array_alloc(vm, 1, 2, 0);
+        if (njs_slow_path(array == NULL)) {
+            return NJS_ERROR;
+        }
+
+        array->start[0] = retval;
+        array->start[1] = value;
+
+        njs_set_array(&value, array);
+    }
+
+    return njs_value_property_set(vm, object, &name, &value);
+}
+
+
+static u_char *
+njs_query_string_match(u_char *p, u_char *end, njs_str_t *v)
+{
+    size_t  length;
+
+    length = v->length;
+
+    if (njs_fast_path(length == 1)) {
+        p = njs_strlchr(p, end, v->start[0]);
+
+        if (p == NULL) {
+            p = end;
+        }
+
+        return p;
+    }
+
+    while (p < (end - length)) {
+        if (memcmp(p, v->start, length) == 0) {
+            return p;
+        }
+
+        p++;
+    }
+
+    return end;
+}
+
+
+static njs_int_t
+njs_query_string_parse(njs_vm_t *vm, njs_value_t *args, njs_uint_t nargs,
+    njs_index_t unused)
+{
+    size_t          size;
+    u_char          *end, *part, *key, *val;
+    int64_t         max_keys, count;
+    njs_int_t       ret;
+    njs_str_t       str;
+    njs_value_t     obj, value, *this, *string, *options, *arg;
+    njs_value_t     val_sep, val_eq;
+    njs_object_t    *object;
+    njs_function_t  *decode;
+
+    njs_str_t  sep = njs_str("&");
+    njs_str_t  eq = njs_str("=");
+
+    count = 0;
+    decode = NULL;
+    max_keys = 1000;
+
+    object = njs_query_string_object_alloc(vm);
+    if (njs_slow_path(object == NULL)) {
+        return NJS_ERROR;
+    }
+
+    njs_set_object(&obj, object);
+
+    this = njs_arg(args, nargs, 0);
+    string = njs_arg(args, nargs, 1);
+
+    if (njs_slow_path(!njs_is_string(string)
+                      || njs_string_length(string) == 0))
+    {
+        goto done;
+    }
+
+    njs_string_get(string, &str);
+
+    arg = njs_arg(args, nargs, 2);
+    if (!njs_is_null_or_undefined(arg)) {
+        ret = njs_value_to_string(vm, &val_sep, arg);
+        if (njs_slow_path(ret != NJS_OK)) {
+            return ret;
+        }
+
+        if (njs_string_length(&val_sep) != 0) {
+            njs_string_get(&val_sep, &sep);
+        }
+    }
+
+    arg = njs_arg(args, nargs, 3);
+    if (!njs_is_null_or_undefined(arg)) {
+        ret = njs_value_to_string(vm, &val_eq, arg);
+        if (njs_slow_path(ret != NJS_OK)) {
+            return ret;
+        }
+
+        if (njs_string_length(&val_eq) != 0) {
+            njs_string_get(&val_eq, &eq);
+        }
+    }
+
+    options = njs_arg(args, nargs, 4);
+
+    if (njs_is_object(options)) {
+        ret = njs_value_property(vm, options, njs_value_arg(&njs_max_keys_str),
+                                 &value);
+        if (njs_slow_path(ret == NJS_ERROR)) {
+            return ret;
+        }
+
+        ret = njs_value_to_integer(vm, &value, &max_keys);
+        if (njs_slow_path(ret != NJS_OK)) {
+            return ret;
+        }
+
+        if (max_keys == 0) {
+            max_keys = INT64_MAX;
+        }
+
+        ret = njs_value_property(vm, options,
+                                 njs_value_arg(&njs_decode_uri_str), &value);
+
+        if (ret == NJS_OK) {
+            if (njs_slow_path(!njs_is_function(&value))) {
+                njs_type_error(vm,
+                               "option decodeURIComponent is not a function");
+                return NJS_ERROR;
+            }
+
+            decode = njs_function(&value);
+        }
+    }
+
+    if (decode == NULL) {
+        ret = njs_value_property(vm, this, njs_value_arg(&njs_unescape_str),
+                                 &value);
+
+        if (ret != NJS_OK || !njs_is_function(&value)) {
+            njs_type_error(vm, "QueryString.unescape is not a function");
+            return NJS_ERROR;
+        }
+
+        decode = njs_function(&value);
+    }
+
+    key = str.start;
+    end = str.start + str.length;
+
+    do {
+        if (count++ == max_keys) {
+            break;
+        }
+
+        part = njs_query_string_match(key, end, &sep);
+
+        if (part == key) {
+            goto next;
+        }
+
+        val = njs_query_string_match(key, end, &eq);
+
+        size = val - key;
+
+        if (val != end) {
+            val += eq.length;
+        }
+
+        ret = njs_query_string_append(vm, &obj, key, size, val, part - val,
+                                      decode);
+        if (njs_slow_path(ret != NJS_OK)) {
+            return ret;
+        }
+
+    next:
+
+        key = part + sep.length;
+
+    } while (key < end);
+
+done:
+
+    njs_set_object(&vm->retval, object);
+
+    return NJS_OK;
+}
+
+
+njs_inline njs_int_t
+njs_query_string_encode(njs_chb_t *chain, njs_str_t *str)
+{
+    size_t  size;
+    u_char  *p, *start, *end;
+
+    static const uint32_t  escape[] = {
+        0xffffffff,  /* 1111 1111 1111 1111  1111 1111 1111 1111 */
+
+                     /* ?>=< ;:98 7654 3210  /.-, +*)( '&%$ #"!  */
+        0xfc00987d,  /* 1111 1100 0000 0000  1001 1000 0111 1101 */
+
+                     /* _^]\ [ZYX WVUT SRQP  ONML KJIH GFED CBA@ */
+        0x78000001,  /* 0111 1000 0000 0000  0000 0000 0000 0001 */
+
+                     /*  ~}| {zyx wvut srqp  onml kjih gfed cba` */
+        0xb8000001,  /* 1011 1000 0000 0000  0000 0000 0000 0001 */
+
+        0xffffffff,  /* 1111 1111 1111 1111  1111 1111 1111 1111 */
+        0xffffffff,  /* 1111 1111 1111 1111  1111 1111 1111 1111 */
+        0xffffffff,  /* 1111 1111 1111 1111  1111 1111 1111 1111 */
+        0xffffffff,  /* 1111 1111 1111 1111  1111 1111 1111 1111 */
+    };
+
+    if (chain->error) {
+        return NJS_ERROR;
+    }
+
+    if (str->length == 0) {
+        return 0;
+    }
+
+    p = str->start;
+    end = p + str->length;
+    size = str->length;
+
+    while (p < end) {
+        if (njs_need_escape(escape, *p++)) {
+            size += 2;
+        }
+    }
+
+    start = njs_chb_reserve(chain, size);
+    if (njs_slow_path(start == NULL)) {
+        return NJS_ERROR;
+    }
+
+    if (size == str->length) {
+        memcpy(start, str->start, str->length);
+        njs_chb_written(chain, str->length);
+        return str->length;
+    }
+
+    (void) njs_string_encode(escape, str->length, str->start, start);
+
+    njs_chb_written(chain, size);
+
+    return size;
+}
+
+
+njs_inline njs_bool_t
+njs_query_string_is_native_encoder(njs_function_t *encoder)
+{
+    return encoder->native && encoder->u.native == njs_query_string_escape;
+}
+
+
+njs_inline njs_int_t
+njs_query_string_encoder_call(njs_vm_t *vm, njs_chb_t *chain,
+    njs_function_t *encoder, njs_value_t *string)
+{
+    njs_str_t    str;
+    njs_int_t    ret;
+    njs_value_t  retval;
+
+    if (njs_slow_path(!njs_is_string(string))) {
+        ret = njs_value_to_string(vm, string, string);
+        if (njs_slow_path(ret != NJS_OK)) {
+            return NJS_ERROR;
+        }
+    }
+
+    if (njs_fast_path(njs_query_string_is_native_encoder(encoder))) {
+        njs_string_get(string, &str);
+        return njs_query_string_encode(chain, &str);
+    }
+
+    ret = njs_function_call(vm, encoder, &njs_value_undefined, string, 1,
+                            &retval);
+    if (njs_slow_path(ret != NJS_OK)) {
+        return NJS_ERROR;
+    }
+
+    if (njs_slow_path(!njs_is_string(&retval))) {
+        ret = njs_value_to_string(vm, &retval, &retval);
+        if (njs_slow_path(ret != NJS_OK)) {
+            return NJS_ERROR;
+        }
+    }
+
+    njs_string_get(&retval, &str);
+
+    ret = njs_utf8_length(str.start, str.length);
+    if (ret < 0) {
+        njs_type_error(vm, "got non-UTF8 string from encoder");
+        return NJS_ERROR;
+    }
+
+    njs_chb_append_str(chain, &str);
+
+    return ret;
+}
+
+
+njs_inline njs_int_t
+njs_query_string_push(njs_vm_t *vm, njs_chb_t *chain, njs_value_t *key,
+    njs_value_t *value, njs_string_prop_t *eq, njs_function_t *encoder)
+{
+    njs_int_t  ret, length;
+    njs_str_t  str;
+
+    length = 0;
+
+    ret = njs_query_string_encoder_call(vm, chain, encoder, key);
+    if (njs_slow_path(ret < 0)) {
+        return NJS_ERROR;
+    }
+
+    length += ret;
+
+    if (!njs_is_string(value)) {
+        ret = njs_value_to_string(vm, value, value);
+        if (njs_slow_path(ret != NJS_OK)) {
+            return NJS_ERROR;
+        }
+    }
+
+    njs_string_get(value, &str);
+
+    if (str.length > 0) {
+        njs_chb_append(chain, eq->start, eq->size);
+        length += eq->length;
+
+        ret = njs_query_string_encoder_call(vm, chain, encoder, value);
+        if (njs_slow_path(ret < 0)) {
+            return NJS_ERROR;
+        }
+
+        length += ret;
+    }
+
+    return length;
+}
+
+
+static njs_int_t
+njs_query_string_stringify(njs_vm_t *vm, njs_value_t *args, njs_uint_t nargs,
+    njs_index_t unused)
+{
+    u_char             *p;
+    int64_t            len;
+    ssize_t            size;
+    uint32_t           n, i;
+    uint64_t           length;
+    njs_int_t          ret;
+    njs_chb_t          chain;
+    njs_value_t        value, retval, *string, *this, *object, *arg, *options;
+    njs_array_t        *keys, *array;
+    njs_function_t     *encode;
+    njs_string_prop_t  sep, eq;
+
+    njs_value_t  val_sep = njs_string("&");
+    njs_value_t  val_eq = njs_string("=");
+
+    (void) njs_string_prop(&sep, &val_sep);
+    (void) njs_string_prop(&eq, &val_eq);
+
+    encode = NULL;
+    this = njs_arg(args, nargs, 0);
+    object = njs_arg(args, nargs, 1);
+
+    if (njs_slow_path(!njs_is_object(object))) {
+        vm->retval = njs_string_empty;
+        return NJS_OK;
+    }
+
+    arg = njs_arg(args, nargs, 2);
+    if (!njs_is_null_or_undefined(arg)) {
+        ret = njs_value_to_string(vm, arg, arg);
+        if (njs_slow_path(ret != NJS_OK)) {
+            return ret;
+        }
+
+        if (njs_string_length(arg) > 0) {
+            (void) njs_string_prop(&sep, arg);
+        }
+    }
+
+    arg = njs_arg(args, nargs, 3);
+    if (!njs_is_null_or_undefined(arg)) {
+        ret = njs_value_to_string(vm, arg, arg);
+        if (njs_slow_path(ret != NJS_OK)) {
+            return ret;
+        }
+
+        if (njs_string_length(arg) > 0) {
+            (void) njs_string_prop(&eq, arg);
+        }
+    }
+
+    options = njs_arg(args, nargs, 4);
+
+    if (njs_is_object(options)) {
+        ret = njs_value_property(vm, options,
+                                 njs_value_arg(&njs_encode_uri_str), &value);
+
+        if (ret == NJS_OK) {
+            if (njs_slow_path(!njs_is_function(&value))) {
+                njs_type_error(vm,
+                               "option encodeURIComponent is not a function");
+                return NJS_ERROR;
+            }
+
+            encode = njs_function(&value);
+        }
+    }
+
+    if (encode == NULL) {
+        ret = njs_value_property(vm, this, njs_value_arg(&njs_escape_str),
+                                 &value);
+
+        if (ret != NJS_OK || !njs_is_function(&value)) {
+            njs_type_error(vm, "QueryString.escape is not a function");
+            return NJS_ERROR;
+        }
+
+        encode = njs_function(&value);
+    }
+
+    njs_chb_init(&chain, vm->mem_pool);
+
+    keys = njs_value_own_enumerate(vm, object, NJS_ENUM_KEYS, NJS_ENUM_STRING,
+                                   1);
+    if (njs_slow_path(keys == NULL)) {
+        return NJS_ERROR;
+    }
+
+    for (n = 0, length = 0; n < keys->length; n++) {
+        string = &keys->start[n];
+
+        ret = njs_value_property(vm, object, string, &value);
+        if (njs_slow_path(ret == NJS_ERROR)) {
+            goto failed;
+        }
+
+        if (njs_is_array(&value)) {
+
+            if (njs_is_fast_array(&value)) {
+                array = njs_array(&value);
+
+                for (i = 0; i < array->length; i++) {
+                    if (i != 0) {
+                        njs_chb_append(&chain, sep.start, sep.size);
+                        length += sep.length;
+                    }
+
+                    ret = njs_query_string_push(vm, &chain, string,
+                                                &array->start[i], &eq, encode);
+                    if (njs_slow_path(ret < 0)) {
+                        ret = NJS_ERROR;
+                        goto failed;
+                    }
+
+                    length += ret;
+                }
+
+                continue;
+            }
+
+            ret = njs_object_length(vm, &value, &len);
+            if (njs_slow_path(ret == NJS_ERROR)) {
+                goto failed;
+            }
+
+            for (i = 0; i < len; i++) {
+                ret = njs_value_property_i64(vm, &value, i, &retval);
+                if (njs_slow_path(ret == NJS_ERROR)) {
+                    goto failed;
+                }
+
+                if (i != 0) {
+                    njs_chb_append(&chain, sep.start, sep.size);
+                    length += sep.length;
+                }
+
+                ret = njs_query_string_push(vm, &chain, string, &retval, &eq,
+                                            encode);
+                if (njs_slow_path(ret < 0)) {
+                    ret = NJS_ERROR;
+                    goto failed;
+                }
+
+                length += ret;
+            }
+
+            continue;
+        }
+
+        if (n != 0) {
+            njs_chb_append(&chain, sep.start, sep.size);
+            length += sep.length;
+        }
+
+        ret = njs_query_string_push(vm, &chain, string, &value, &eq, encode);
+        if (njs_slow_path(ret < 0)) {
+            ret = NJS_ERROR;
+            goto failed;
+        }
+
+        length += ret;
+    }
+
+    size = njs_chb_size(&chain);
+    if (njs_slow_path(size < 0)) {
+        njs_memory_error(vm);
+        return NJS_ERROR;
+    }
+
+    p = njs_string_alloc(vm, &vm->retval, size, length);
+    if (njs_slow_path(p == NULL)) {
+        return NJS_ERROR;
+    }
+
+    njs_chb_join_to(&chain, p);
+
+    ret = NJS_OK;
+
+failed:
+
+    njs_chb_destroy(&chain);
+
+    return ret;
+}
+
+
+static njs_int_t
+njs_query_string_escape(njs_vm_t *vm, njs_value_t *args, njs_uint_t nargs,
+    njs_index_t unused)
+{
+    u_char       *p;
+    ssize_t      size, length;
+    njs_int_t    ret;
+    njs_str_t    str;
+    njs_chb_t    chain;
+    njs_value_t  *string, value;
+
+    string = njs_arg(args, nargs, 1);
+
+    if (!njs_is_string(string)) {
+        ret = njs_value_to_string(vm, &value, string);
+        if (njs_slow_path(ret != NJS_OK)) {
+            return ret;
+        }
+
+        string = &value;
+    }
+
+    njs_string_get(string, &str);
+
+    njs_chb_init(&chain, vm->mem_pool);
+
+    length = njs_query_string_encode(&chain, &str);
+    if (njs_slow_path(length < 0)) {
+        return NJS_ERROR;
+    }
+
+    size = njs_chb_size(&chain);
+
+    p = njs_string_alloc(vm, &vm->retval, size, length);
+    if (njs_slow_path(p == NULL)) {
+        return NJS_ERROR;
+    }
+
+    njs_chb_join_to(&chain, p);
+
+    njs_chb_destroy(&chain);
+
+    return NJS_OK;
+}
+
+
+static njs_int_t
+njs_query_string_unescape(njs_vm_t *vm, njs_value_t *args, njs_uint_t nargs,
+    njs_index_t unused)
+{
+    njs_int_t    ret;
+    njs_str_t    str;
+    njs_value_t  *string, value;
+
+    string = njs_arg(args, nargs, 1);
+
+    if (!njs_is_string(string)) {
+        ret = njs_value_to_string(vm, &value, string);
+        if (njs_slow_path(ret != NJS_OK)) {
+            return ret;
+        }
+
+        string = &value;
+    }
+
+    njs_string_get(string, &str);
+
+    return njs_query_string_decode(vm, &vm->retval, str.start, str.length);
+}
+
+
+static const njs_object_prop_t  njs_query_string_object_properties[] =
+{
+    {
+        .type = NJS_PROPERTY,
+        .name = njs_string("name"),
+        .value = njs_string("querystring"),
+        .configurable = 1,
+    },
+
+    {
+        .type = NJS_PROPERTY,
+        .name = njs_string("parse"),
+        .value = njs_native_function(njs_query_string_parse, 4),
+        .writable = 1,
+        .configurable = 1,
+    },
+
+    {
+        .type = NJS_PROPERTY,
+        .name = njs_string("stringify"),
+        .value = njs_native_function(njs_query_string_stringify, 4),
+        .writable = 1,
+        .configurable = 1,
+    },
+
+    {
+        .type = NJS_PROPERTY,
+        .name = njs_string("escape"),
+        .value = njs_native_function(njs_query_string_escape, 1),
+        .writable = 1,
+        .configurable = 1,
+    },
+
+    {
+        .type = NJS_PROPERTY,
+        .name = njs_string("unescape"),
+        .value = njs_native_function(njs_query_string_unescape, 1),
+        .writable = 1,
+        .configurable = 1,
+    },
+
+    {
+        .type = NJS_PROPERTY,
+        .name = njs_string("decode"),
+        .value = njs_native_function(njs_query_string_parse, 4),
+        .writable = 1,
+        .configurable = 1,
+    },
+
+    {
+        .type = NJS_PROPERTY,
+        .name = njs_string("encode"),
+        .value = njs_native_function(njs_query_string_stringify, 4),
+        .writable = 1,
+        .configurable = 1,
+    },
+};
+
+
+const njs_object_init_t  njs_query_string_object_init = {
+    njs_query_string_object_properties,
+    njs_nitems(njs_query_string_object_properties),
+};
diff --git a/src/njs_query_string.h b/src/njs_query_string.h
new file mode 100644
index 0000000..1cdbcee
--- /dev/null
+++ b/src/njs_query_string.h
@@ -0,0 +1,12 @@
+
+/*
+ * Copyright (C) Alexander Borisov
+ * Copyright (C) NGINX, Inc.
+ */
+
+#ifndef _NJS_QUERY_STRING_H_INCLUDED_
+#define _NJS_QUERY_STRING_H_INCLUDED_
+
+extern const njs_object_init_t  njs_query_string_object_init;
+
+#endif /* _NJS_QUERY_STRING_H_INCLUDED_ */
diff --git a/src/njs_string.c b/src/njs_string.c
index 6207288..de575b8 100644
--- a/src/njs_string.c
+++ b/src/njs_string.c
@@ -4172,40 +4172,6 @@
 };
 
 
-njs_inline njs_bool_t
-njs_need_escape(const uint32_t *escape, uint32_t byte)
-{
-    return ((escape[byte >> 5] & ((uint32_t) 1 << (byte & 0x1f))) != 0);
-}
-
-
-njs_inline u_char *
-njs_string_encode(const uint32_t *escape, size_t size, const u_char *src,
-    u_char *dst)
-{
-    uint8_t              byte;
-    static const u_char  hex[16] = "0123456789ABCDEF";
-
-    do {
-        byte = *src++;
-
-        if (njs_need_escape(escape, byte)) {
-            *dst++ = '%';
-            *dst++ = hex[byte >> 4];
-            *dst++ = hex[byte & 0xf];
-
-        } else {
-            *dst++ = byte;
-        }
-
-        size--;
-
-    } while (size != 0);
-
-    return dst;
-}
-
-
 njs_int_t
 njs_string_encode_uri(njs_vm_t *vm, njs_value_t *args, njs_uint_t nargs,
     njs_index_t component)
diff --git a/src/njs_string.h b/src/njs_string.h
index a1154cb..fbd07d1 100644
--- a/src/njs_string.h
+++ b/src/njs_string.h
@@ -155,6 +155,40 @@
 }
 
 
+njs_inline njs_bool_t
+njs_need_escape(const uint32_t *escape, uint32_t byte)
+{
+    return ((escape[byte >> 5] & ((uint32_t) 1 << (byte & 0x1f))) != 0);
+}
+
+
+njs_inline u_char *
+njs_string_encode(const uint32_t *escape, size_t size, const u_char *src,
+    u_char *dst)
+{
+    uint8_t              byte;
+    static const u_char  hex[16] = "0123456789ABCDEF";
+
+    do {
+        byte = *src++;
+
+        if (njs_need_escape(escape, byte)) {
+            *dst++ = '%';
+            *dst++ = hex[byte >> 4];
+            *dst++ = hex[byte & 0xf];
+
+        } else {
+            *dst++ = byte;
+        }
+
+        size--;
+
+    } while (size != 0);
+
+    return dst;
+}
+
+
 njs_int_t njs_string_set(njs_vm_t *vm, njs_value_t *value, const u_char *start,
     uint32_t size);
 u_char *njs_string_alloc(njs_vm_t *vm, njs_value_t *value, uint64_t size,
diff --git a/src/njs_utf8.h b/src/njs_utf8.h
index 5c870d4..8e019cf 100644
--- a/src/njs_utf8.h
+++ b/src/njs_utf8.h
@@ -22,6 +22,18 @@
     ssize_t *out_size);
 NJS_EXPORT njs_bool_t njs_utf8_is_valid(const u_char *p, size_t len);
 
+
+njs_inline uint32_t
+njs_utf8_consume(njs_unicode_decode_t *ctx, u_char byte)
+{
+    const u_char  *p;
+
+    p = &byte;
+
+    return njs_utf8_decode(ctx, &p, p + 1);
+}
+
+
 /*
  * njs_utf8_next() and njs_utf8_prev() expect a valid UTF-8 string.
  *
diff --git a/src/test/njs_unit_test.c b/src/test/njs_unit_test.c
index df94439..16864ef 100644
--- a/src/test/njs_unit_test.c
+++ b/src/test/njs_unit_test.c
@@ -17266,6 +17266,320 @@
 
     { njs_str("var t = \"123\"; t = parseInt(t); t"),
       njs_str("123") },
+
+    /* Query String */
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('baz=fuz');"
+              "njs.dump(obj)"),
+      njs_str("{baz:'fuz'}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('baz=');"
+              "njs.dump(obj)"),
+      njs_str("{baz:''}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('baz=fuz&muz=tax');"
+              "njs.dump(obj)"),
+      njs_str("{baz:'fuz',muz:'tax'}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('baz=fuz&');"
+              "njs.dump(obj)"),
+      njs_str("{baz:'fuz'}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('&baz=fuz');"
+              "njs.dump(obj)"),
+      njs_str("{baz:'fuz'}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('&&&&&baz=fuz');"
+              "njs.dump(obj)"),
+      njs_str("{baz:'fuz'}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('=fuz');"
+              "njs.dump(obj)"),
+      njs_str("{:'fuz'}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('=fuz=');"
+              "njs.dump(obj)"),
+      njs_str("{:'fuz='}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('===fu=z');"
+              "njs.dump(obj)"),
+      njs_str("{:'==fu=z'}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('baz=fuz&baz=tax');"
+              "njs.dump(obj)"),
+      njs_str("{baz:['fuz','tax']}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('freespace');"
+              "njs.dump(obj)"),
+      njs_str("{freespace:''}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('baz=fuz&muz=tax', 'fuz');"
+              "njs.dump(obj)"),
+      njs_str("{baz:'',&muz:'tax'}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('baz=fuz&muz=tax', '');"
+              "njs.dump(obj)"),
+      njs_str("{baz:'fuz',muz:'tax'}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('baz=fuz&muz=tax', null);"
+              "njs.dump(obj)"),
+      njs_str("{baz:'fuz',muz:'tax'}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('baz=fuz&muz=tax', undefined);"
+              "njs.dump(obj)"),
+      njs_str("{baz:'fuz',muz:'tax'}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('baz=fuz123muz=tax', 123);"
+              "njs.dump(obj)"),
+      njs_str("{baz:'fuz',muz:'tax'}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('baz=fuzαααmuz=tax', 'ααα');"
+              "njs.dump(obj)"),
+      njs_str("{baz:'fuz',muz:'tax'}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('baz=fuz&muz=tax', '=');"
+              "njs.dump(obj)"),
+      njs_str("{baz:'',fuz&muz:'',tax:''}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('baz=fuz&muz=tax', null, 'fuz');"
+              "njs.dump(obj)"),
+      njs_str("{baz=:'',muz=tax:''}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('baz=fuz&muz=tax', null, '&');"
+              "njs.dump(obj)"),
+      njs_str("{baz=fuz:'',muz=tax:''}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('baz123fuz&muz123tax', null, 123);"
+              "njs.dump(obj)"),
+      njs_str("{baz:'fuz',muz:'tax'}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('bazαααfuz&muzαααtax', null, 'ααα');"
+              "njs.dump(obj)"),
+      njs_str("{baz:'fuz',muz:'tax'}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('baz=fuz&muz=tax', null, null, {maxKeys: 1});"
+              "njs.dump(obj)"),
+      njs_str("{baz:'fuz'}") },
+
+    { njs_str("var qs = require('querystring'); var out = [];"
+              "var obj = qs.parse('baz=fuz&muz=tax', null, null, {decodeURIComponent: (key) => {out.push(key)}});"
+              "out.join('; ');"),
+      njs_str("baz; fuz; muz; tax") },
+
+    { njs_str("var qs = require('querystring'); var i = 0;"
+              "var obj = qs.parse('baz=fuz&muz=tax', null, null, {decodeURIComponent: (key) => 'α' + i++});"
+              "njs.dump(obj);"),
+      njs_str("{α0:'α1',α2:'α3'}") },
+
+    { njs_str("var qs = require('querystring');"
+              "qs.parse('baz=fuz&muz=tax', null, null, {decodeURIComponent: 123});"),
+      njs_str("TypeError: option decodeURIComponent is not a function") },
+
+    { njs_str("var qs = require('querystring');"
+              "qs.unescape = 123;"
+              "qs.parse('baz=fuz&muz=tax');"),
+    njs_str("TypeError: QueryString.unescape is not a function") },
+
+    { njs_str("var qs = require('querystring'); var out = [];"
+              "qs.unescape = (key) => {out.push(key)};"
+              "qs.parse('baz=fuz&muz=tax');"
+              "out.join('; ');"),
+      njs_str("baz; fuz; muz; tax") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('ba%32z=f%32uz');"
+              "njs.dump(obj)"),
+      njs_str("{ba2z:'f2uz'}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('ba%32z=f%32uz');"
+              "njs.dump(obj)"),
+      njs_str("{ba2z:'f2uz'}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('ba%F0%9F%92%A9z=f%F0%9F%92%A9uz');"
+              "njs.dump(obj)"),
+      njs_str("{ba💩z:'f💩uz'}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('======');"
+              "njs.dump(obj)"),
+      njs_str("{:'====='}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('baz=%F0%9F%A9');"
+              "njs.dump(obj)"),
+      njs_str("{baz:'�'}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('baz=αααααα%\x00\x01\x02αααα');"
+              "njs.dump(obj)"),
+      njs_str("{baz:'αααααα%\\u0000\\u0001\\u0002αααα'}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('baz=%F6α');"
+              "njs.dump(obj)"),
+      njs_str("{baz:'�α'}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('baz=%F6');"
+              "njs.dump(obj)"),
+      njs_str("{baz:'�'}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('baz=%FG');"
+              "njs.dump(obj)"),
+      njs_str("{baz:'%FG'}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('baz=%F');"
+              "njs.dump(obj)"),
+      njs_str("{baz:'%F'}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('baz=%');"
+              "njs.dump(obj)"),
+      njs_str("{baz:'%'}") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = qs.parse('ba+z=f+uz');"
+              "njs.dump(obj)"),
+      njs_str("{ba z:'f uz'}") },
+
+
+    { njs_str("var qs = require('querystring');"
+              "qs.parse('X='+'α'.repeat(33)).X.length"),
+      njs_str("33") },
+
+    { njs_str("var qs = require('querystring');"
+              "var x = qs.parse('X='+'α1'.repeat(33)).X;"
+              "[x.length, x[33], x[34]]"),
+      njs_str("66,1,α") },
+
+    { njs_str("var qs = require('querystring');"
+              "var s = qs.parse('X='+String.bytesFrom(Array(16).fill(0x9d))).X;"
+              "[s.length, s.toUTF8().length, s[15]]"),
+      njs_str("16,48,�") },
+
+    { njs_str("var qs = require('querystring');"
+              "qs.stringify({'baz': 'fuz'})"),
+      njs_str("baz=fuz") },
+
+    { njs_str("var qs = require('querystring');"
+              "qs.stringify({'baz': 'fuz', 'muz': 'tax'})"),
+      njs_str("baz=fuz&muz=tax") },
+
+    { njs_str("var qs = require('querystring');"
+              "qs.stringify({'baαz': 'fαuz', 'muαz': 'tαax'});"),
+      njs_str("ba%CE%B1z=f%CE%B1uz&mu%CE%B1z=t%CE%B1ax") },
+
+    { njs_str("var qs = require('querystring');"
+              "qs.stringify({'baz': ['fuz', 'tax']})"),
+      njs_str("baz=fuz&baz=tax") },
+
+    { njs_str("var qs = require('querystring');"
+              njs_declare_sparse_array("arr", 2)
+              "arr[0] = 0; arr[1] = 1.5;"
+              "qs.stringify({'baz': arr})"),
+      njs_str("baz=0&baz=1.5") },
+
+    { njs_str("var qs = require('querystring'); var out = [];"
+              "qs.stringify({'baz': 'fuz', 'muz': 'tax'}, null, null, {encodeURIComponent: (key) => {out.push(key)}});"
+              "out.join('; ')"),
+      njs_str("baz; fuz; muz; tax") },
+
+    { njs_str("var qs = require('querystring'); "
+              "qs.stringify({a: 'b'}, null, null, "
+              "             {encodeURIComponent: () => String.bytesFrom([0x9d])})"),
+      njs_str("TypeError: got non-UTF8 string from encoder") },
+
+    { njs_str("var qs = require('querystring');"
+              "qs.stringify({'baz': 'fuz', 'muz': 'tax'}, null, null, {encodeURIComponent: 123});"
+              "out.join('; ')"),
+      njs_str("TypeError: option encodeURIComponent is not a function") },
+
+    { njs_str("var qs = require('querystring');"
+              "qs.escape = 123;"
+              "qs.stringify({'baz': 'fuz', 'muz': 'tax'})"),
+      njs_str("TypeError: QueryString.escape is not a function") },
+
+    { njs_str("var qs = require('querystring'); var out = [];"
+              "qs.escape = (key) => {out.push(key)};"
+              "qs.stringify({'baz': 'fuz', 'muz': 'tax'});"
+              "out.join('; ')"),
+      njs_str("baz; fuz; muz; tax") },
+
+    { njs_str("var qs = require('querystring');"
+              "qs.stringify({'baz': 'fuz', 'muz': 'tax'}, '****')"),
+      njs_str("baz=fuz****muz=tax") },
+
+    { njs_str("var qs = require('querystring');"
+              "qs.stringify({'baz': 'fuz', 'muz': 'tax'}, null, '^^^^')"),
+      njs_str("baz^^^^fuz&muz^^^^tax") },
+
+    { njs_str("var qs = require('querystring');"
+              "var obj = {A:'α'}; obj['δ'] = 'D';"
+              "var s = qs.stringify(obj,'γ=','&β'); [s, s.length]"),
+      njs_str("A&β%CE%B1γ=%CE%B4&βD,20") },
+
+    { njs_str("var qs = require('querystring');"
+              "qs.stringify({'baz': 'fuz', 'muz': 'tax'}, '', '')"),
+      njs_str("baz=fuz&muz=tax") },
+
+    { njs_str("var qs = require('querystring');"
+              "qs.stringify({'baz': 'fuz', 'muz': 'tax'}, undefined, undefined)"),
+      njs_str("baz=fuz&muz=tax") },
+
+    { njs_str("var qs = require('querystring');"
+              "qs.stringify({'baz': 'fuz', 'muz': 'tax'}, '?', '/')"),
+      njs_str("baz/fuz?muz/tax") },
+
+    { njs_str("var qs = require('querystring');"
+              "qs.stringify('123')"),
+      njs_str("") },
+
+    { njs_str("var qs = require('querystring');"
+              "qs.stringify(123)"),
+      njs_str("") },
+
+    { njs_str("var qs = require('querystring');"
+              "qs.stringify({X: String.bytesFrom(Array(4).fill(0x9d))})"),
+      njs_str("X=%9D%9D%9D%9D") },
+
+    { njs_str("var qs = require('querystring');"
+              "qs.stringify({X:{toString(){return 3}}})"),
+      njs_str("X=3") },
+
+    { njs_str("var qs = require('querystring');"
+              "qs.escape('abcααααdef')"),
+      njs_str("abc%CE%B1%CE%B1%CE%B1%CE%B1def") },
+
+    { njs_str("var qs = require('querystring');"
+              "qs.unescape('abc%CE%B1%CE%B1%CE%B1%CE%B1def')"),
+      njs_str("abcααααdef") },
 };