[njs] Fetch: QuickJS support.

noreply at nginx.com noreply at nginx.com
Thu May 8 17:15:03 UTC 2025


details:   https://github.com/nginx/njs/commit/b7f76b71f4a1511cd9d954e6da3b0da4c9de40fb
branches:  master
commit:    b7f76b71f4a1511cd9d954e6da3b0da4c9de40fb
user:      Zhidao HONG <z.hong at f5.com>
date:      Tue, 22 Apr 2025 10:56:27 +0800
description:
Fetch: QuickJS support.


---
 nginx/config                       |    1 +
 nginx/ngx_http_js_module.c         |    3 +
 nginx/ngx_js.c                     |    1 +
 nginx/ngx_js.h                     |    7 +
 nginx/ngx_js_fetch.c               |    6 +-
 nginx/ngx_qjs_fetch.c              | 2520 ++++++++++++++++++++++++++++++++++++
 nginx/ngx_stream_js_module.c       |    1 +
 nginx/t/js_fetch.t                 |    4 +-
 nginx/t/js_fetch_https.t           |    2 -
 nginx/t/js_fetch_objects.t         |   23 +-
 nginx/t/js_fetch_resolver.t        |    2 -
 nginx/t/js_fetch_timeout.t         |    2 -
 nginx/t/js_fetch_verify.t          |    2 -
 nginx/t/js_periodic_fetch.t        |    2 -
 nginx/t/stream_js_fetch.t          |    2 -
 nginx/t/stream_js_fetch_https.t    |    2 -
 nginx/t/stream_js_fetch_init.t     |    2 -
 nginx/t/stream_js_periodic_fetch.t |    1 -
 src/qjs.c                          |   25 +
 src/qjs.h                          |    2 +
 20 files changed, 2585 insertions(+), 25 deletions(-)

diff --git a/nginx/config b/nginx/config
index b994f97f..1c303d9c 100644
--- a/nginx/config
+++ b/nginx/config
@@ -156,6 +156,7 @@ NJS_ENGINE_LIB="$ngx_addon_dir/../build/libnjs.a"
 if [ "$NJS_HAVE_QUICKJS" = "YES" ];  then
     NJS_ENGINE_DEP="$ngx_addon_dir/../build/libqjs.a"
     NJS_ENGINE_LIB="$ngx_addon_dir/../build/libnjs.a $ngx_addon_dir/../build/libqjs.a"
+    QJS_SRCS="$QJS_SRCS $ngx_addon_dir/ngx_qjs_fetch.c"
 fi
 
 if [ $HTTP != NO ]; then
diff --git a/nginx/ngx_http_js_module.c b/nginx/ngx_http_js_module.c
index 3ac95478..40bb83a5 100644
--- a/nginx/ngx_http_js_module.c
+++ b/nginx/ngx_http_js_module.c
@@ -1134,6 +1134,9 @@ static JSClassDef ngx_http_qjs_headers_out_class = {
 qjs_module_t *njs_http_qjs_addon_modules[] = {
     &ngx_qjs_ngx_module,
     &ngx_qjs_ngx_shared_dict_module,
+#ifdef NJS_HAVE_QUICKJS
+    &ngx_qjs_ngx_fetch_module,
+#endif
     /*
      * Shared addons should be in the same order and the same positions
      * in all nginx modules.
diff --git a/nginx/ngx_js.c b/nginx/ngx_js.c
index 34221d28..e4bae32a 100644
--- a/nginx/ngx_js.c
+++ b/nginx/ngx_js.c
@@ -441,6 +441,7 @@ static const JSCFunctionListEntry ngx_qjs_ext_ngx[] = {
     JS_CGETSET_MAGIC_DEF("ERR", ngx_qjs_ext_constant_integer, NULL,
                          NGX_LOG_ERR),
     JS_CGETSET_DEF("error_log_path", ngx_qjs_ext_error_log_path, NULL),
+    JS_CFUNC_DEF("fetch", 2, ngx_qjs_ext_fetch),
     JS_CGETSET_MAGIC_DEF("INFO", ngx_qjs_ext_constant_integer, NULL,
                          NGX_LOG_INFO),
     JS_CFUNC_MAGIC_DEF("log", 1, ngx_qjs_ext_log, 0),
diff --git a/nginx/ngx_js.h b/nginx/ngx_js.h
index e13efc41..bb7c1d26 100644
--- a/nginx/ngx_js.h
+++ b/nginx/ngx_js.h
@@ -63,6 +63,9 @@
 #define NGX_QJS_CLASS_ID_SHARED (NGX_QJS_CLASS_ID_OFFSET + 11)
 #define NGX_QJS_CLASS_ID_SHARED_DICT (NGX_QJS_CLASS_ID_OFFSET + 12)
 #define NGX_QJS_CLASS_ID_SHARED_DICT_ERROR (NGX_QJS_CLASS_ID_OFFSET + 13)
+#define NGX_QJS_CLASS_ID_FETCH_HEADERS (NGX_QJS_CLASS_ID_OFFSET + 14)
+#define NGX_QJS_CLASS_ID_FETCH_REQUEST (NGX_QJS_CLASS_ID_OFFSET + 15)
+#define NGX_QJS_CLASS_ID_FETCH_RESPONSE (NGX_QJS_CLASS_ID_OFFSET + 16)
 
 
 typedef struct ngx_js_loc_conf_s ngx_js_loc_conf_t;
@@ -346,6 +349,9 @@ ngx_int_t ngx_qjs_exception(ngx_engine_t *e, ngx_str_t *s);
 ngx_int_t ngx_qjs_integer(JSContext *cx, JSValueConst val, ngx_int_t *n);
 ngx_int_t ngx_qjs_string(JSContext *cx, JSValueConst val, ngx_str_t *str);
 
+JSValue ngx_qjs_ext_fetch(JSContext *cx, JSValueConst this_val, int argc,
+     JSValueConst *argv);
+
 #define ngx_qjs_prop(cx, type, start, len)                                   \
     ((type == NGX_JS_STRING) ? qjs_string_create(cx, start, len)             \
                              : qjs_buffer_create(cx, (u_char *) start, len))
@@ -382,6 +388,7 @@ extern qjs_module_t  qjs_xml_module;
 extern qjs_module_t  qjs_zlib_module;
 extern qjs_module_t  ngx_qjs_ngx_module;
 extern qjs_module_t  ngx_qjs_ngx_shared_dict_module;
+extern qjs_module_t  ngx_qjs_ngx_fetch_module;
 
 #endif
 
diff --git a/nginx/ngx_js_fetch.c b/nginx/ngx_js_fetch.c
index 05d220b9..45f2dc10 100644
--- a/nginx/ngx_js_fetch.c
+++ b/nginx/ngx_js_fetch.c
@@ -1171,7 +1171,7 @@ ngx_js_fetch_alloc(njs_vm_t *vm, ngx_pool_t *pool, ngx_log_t *log)
     fetch->vm = vm;
     fetch->event = event;
 
-    ngx_log_debug1(NGX_LOG_DEBUG_EVENT, log, 0, "js fetch alloc:%p", fetch);
+    ngx_log_debug1(NGX_LOG_DEBUG_EVENT, log, 0, "js http alloc:%p", fetch);
 
     return fetch;
 
@@ -1207,7 +1207,7 @@ ngx_js_fetch_destructor(ngx_js_event_t *event)
     fetch = event->data;
     http = &fetch->http;
 
-    ngx_log_debug1(NGX_LOG_DEBUG_EVENT, http->log, 0, "js fetch destructor:%p",
+    ngx_log_debug1(NGX_LOG_DEBUG_EVENT, http->log, 0, "js http destructor:%p",
                    fetch);
 
     ngx_js_http_resolve_done(http);
@@ -1273,7 +1273,7 @@ ngx_js_fetch_done(ngx_js_fetch_t *fetch, njs_opaque_value_t *retval,
     http = &fetch->http;
 
     ngx_log_debug2(NGX_LOG_DEBUG_EVENT, http->log, 0,
-                   "js fetch done fetch:%p rc:%i", fetch, (ngx_int_t) rc);
+                   "js http done fetch:%p rc:%i", fetch, (ngx_int_t) rc);
 
     ngx_js_http_close_peer(http);
 
diff --git a/nginx/ngx_qjs_fetch.c b/nginx/ngx_qjs_fetch.c
new file mode 100644
index 00000000..084162ba
--- /dev/null
+++ b/nginx/ngx_qjs_fetch.c
@@ -0,0 +1,2520 @@
+
+/*
+ * Copyright (C) hongzhidao
+ * Copyright (C) F5, Inc.
+ */
+
+
+#include <ngx_config.h>
+#include <ngx_core.h>
+#include <ngx_event.h>
+#include <ngx_event_connect.h>
+#include "ngx_js.h"
+#include "ngx_js_http.h"
+
+
+typedef struct {
+    ngx_str_t        name;
+    ngx_int_t        value;
+} ngx_qjs_entry_t;
+
+
+typedef struct {
+    ngx_js_http_t    http;
+
+    JSContext        *cx;
+    ngx_qjs_event_t  *event;
+
+    JSValue           response_value;
+
+    JSValue           promise;
+    JSValue           promise_callbacks[2];
+} ngx_qjs_fetch_t;
+
+
+static ngx_int_t ngx_qjs_method_process(JSContext *cx,
+    ngx_js_request_t *request);
+static ngx_int_t ngx_qjs_headers_inherit(JSContext *cx,
+    ngx_js_headers_t *headers, ngx_js_headers_t *orig);
+static ngx_int_t ngx_qjs_headers_fill(JSContext *cx, ngx_js_headers_t *headers,
+    JSValue init);
+static ngx_qjs_fetch_t *ngx_qjs_fetch_alloc(JSContext *cx, ngx_pool_t *pool,
+    ngx_log_t *log);
+static void ngx_qjs_fetch_error(ngx_js_http_t *http, const char *err);
+static void ngx_qjs_fetch_destructor(ngx_qjs_event_t *event);
+static void ngx_qjs_fetch_done(ngx_qjs_fetch_t *fetch, JSValue retval,
+    ngx_int_t rc);
+
+static ngx_int_t ngx_qjs_request_ctor(JSContext *cx, ngx_js_request_t *request,
+    ngx_url_t *u, int argc, JSValueConst *argv);
+
+static ngx_int_t ngx_qjs_fetch_append_headers(ngx_js_http_t *http,
+    ngx_js_headers_t *headers, u_char *name, size_t len, u_char *value,
+    size_t vlen);
+static void ngx_qjs_fetch_process_done(ngx_js_http_t *http);
+static ngx_int_t ngx_qjs_headers_append(JSContext *cx,
+    ngx_js_headers_t *headers, u_char *name, size_t len, u_char *value,
+    size_t vlen);
+
+static JSValue ngx_qjs_fetch_headers_ctor(JSContext *cx,
+    JSValueConst new_target, int argc, JSValueConst *argv);
+static int ngx_qjs_fetch_headers_own_property(JSContext *cx,
+    JSPropertyDescriptor *desc, JSValueConst obj, JSAtom prop);
+static int ngx_qjs_fetch_headers_own_property_names(JSContext *cx,
+    JSPropertyEnum **ptab, uint32_t *plen, JSValueConst obj);
+static JSValue ngx_qjs_ext_fetch_headers_append(JSContext *cx,
+    JSValueConst this_val, int argc, JSValueConst *argv);
+static JSValue ngx_qjs_ext_fetch_headers_delete(JSContext *cx,
+    JSValueConst this_val, int argc, JSValueConst *argv);
+static JSValue ngx_qjs_ext_fetch_headers_foreach(JSContext *cx,
+    JSValueConst this_val, int argc, JSValueConst *argv);
+static JSValue ngx_qjs_ext_fetch_headers_get(JSContext *cx,
+    JSValueConst this_val, int argc, JSValueConst *argv, int magic);
+static JSValue ngx_qjs_ext_fetch_headers_has(JSContext *cx,
+    JSValueConst this_val, int argc, JSValueConst *argv);
+static JSValue ngx_qjs_ext_fetch_headers_set(JSContext *cx,
+    JSValueConst this_val, int argc, JSValueConst *argv);
+
+static JSValue ngx_qjs_fetch_request_ctor(JSContext *cx,
+    JSValueConst new_target, int argc, JSValueConst *argv);
+static JSValue ngx_qjs_ext_fetch_request_body(JSContext *cx,
+    JSValueConst this_val, int argc, JSValueConst *argv, int magic);
+static JSValue ngx_qjs_ext_fetch_request_body_used(JSContext *cx,
+    JSValueConst this_val);
+static JSValue ngx_qjs_ext_fetch_request_cache(JSContext *cx,
+    JSValueConst this_val);
+static JSValue ngx_qjs_ext_fetch_request_credentials(JSContext *cx,
+    JSValueConst this_val);
+static JSValue ngx_qjs_ext_fetch_request_headers(JSContext *cx,
+    JSValueConst this_val);
+static JSValue ngx_qjs_ext_fetch_request_field(JSContext *cx,
+    JSValueConst this_val, int magic);
+static JSValue ngx_qjs_ext_fetch_request_mode(JSContext *cx,
+    JSValueConst this_val);
+static void ngx_qjs_fetch_request_finalizer(JSRuntime *rt, JSValue val);
+
+static JSValue ngx_qjs_fetch_response_ctor(JSContext *cx,
+    JSValueConst new_target, int argc, JSValueConst *argv);
+static JSValue ngx_qjs_ext_fetch_response_status(JSContext *cx,
+    JSValueConst this_val);
+static JSValue ngx_qjs_ext_fetch_response_status_text(JSContext *cx,
+    JSValueConst this_val);
+static JSValue ngx_qjs_ext_fetch_response_ok(JSContext *cx,
+    JSValueConst this_val);
+static JSValue ngx_qjs_ext_fetch_response_body_used(JSContext *cx,
+    JSValueConst this_val);
+static JSValue ngx_qjs_ext_fetch_response_headers(JSContext *cx,
+    JSValueConst this_val);
+static JSValue ngx_qjs_ext_fetch_response_type(JSContext *cx,
+    JSValueConst this_val);
+static JSValue ngx_qjs_ext_fetch_response_body(JSContext *cx,
+    JSValueConst this_val, int argc, JSValueConst *argv, int magic);
+static JSValue ngx_qjs_ext_fetch_response_redirected(JSContext *cx,
+    JSValueConst this_val);
+static JSValue ngx_qjs_ext_fetch_response_field(JSContext *cx,
+    JSValueConst this_val, int magic);
+static void ngx_qjs_fetch_response_finalizer(JSRuntime *rt, JSValue val);
+
+static JSValue ngx_qjs_fetch_flag(JSContext *cx, const ngx_qjs_entry_t *entries,
+    ngx_int_t value);
+static ngx_int_t ngx_qjs_fetch_flag_set(JSContext *cx,
+    const ngx_qjs_entry_t *entries, JSValue object, const char *prop);
+
+static JSModuleDef *ngx_qjs_fetch_init(JSContext *cx, const char *name);
+
+
+static const JSCFunctionListEntry  ngx_qjs_ext_fetch_headers_proto[] = {
+    JS_CFUNC_DEF("append", 2, ngx_qjs_ext_fetch_headers_append),
+    JS_CFUNC_DEF("delete", 1, ngx_qjs_ext_fetch_headers_delete),
+    JS_CFUNC_DEF("forEach", 1, ngx_qjs_ext_fetch_headers_foreach),
+    JS_CFUNC_MAGIC_DEF("get", 1, ngx_qjs_ext_fetch_headers_get, 0),
+    JS_CFUNC_MAGIC_DEF("getAll", 1, ngx_qjs_ext_fetch_headers_get, 1),
+    JS_CFUNC_DEF("has", 1, ngx_qjs_ext_fetch_headers_has),
+    JS_CFUNC_DEF("set", 2, ngx_qjs_ext_fetch_headers_set),
+};
+
+
+static const JSCFunctionListEntry  ngx_qjs_ext_fetch_request_proto[] = {
+#define NGX_QJS_BODY_ARRAY_BUFFER   0
+#define NGX_QJS_BODY_JSON           1
+#define NGX_QJS_BODY_TEXT           2
+    JS_CFUNC_MAGIC_DEF("arrayBuffer", 0, ngx_qjs_ext_fetch_request_body,
+                       NGX_QJS_BODY_ARRAY_BUFFER),
+    JS_CGETSET_DEF("bodyUsed", ngx_qjs_ext_fetch_request_body_used, NULL),
+    JS_CGETSET_DEF("cache", ngx_qjs_ext_fetch_request_cache, NULL),
+    JS_CGETSET_DEF("credentials", ngx_qjs_ext_fetch_request_credentials, NULL),
+    JS_CFUNC_MAGIC_DEF("json", 0, ngx_qjs_ext_fetch_request_body,
+                       NGX_QJS_BODY_JSON),
+    JS_CGETSET_DEF("headers", ngx_qjs_ext_fetch_request_headers, NULL ),
+    JS_CGETSET_MAGIC_DEF("method", ngx_qjs_ext_fetch_request_field, NULL,
+                         offsetof(ngx_js_request_t, method) ),
+    JS_CGETSET_DEF("mode", ngx_qjs_ext_fetch_request_mode, NULL),
+    JS_CFUNC_MAGIC_DEF("text", 0, ngx_qjs_ext_fetch_request_body,
+                       NGX_QJS_BODY_TEXT),
+    JS_CGETSET_MAGIC_DEF("url", ngx_qjs_ext_fetch_request_field, NULL,
+                         offsetof(ngx_js_request_t, url) ),
+};
+
+
+static const JSCFunctionListEntry  ngx_qjs_ext_fetch_response_proto[] = {
+    JS_CFUNC_MAGIC_DEF("arrayBuffer", 0, ngx_qjs_ext_fetch_response_body,
+                       NGX_QJS_BODY_ARRAY_BUFFER),
+    JS_CGETSET_DEF("bodyUsed", ngx_qjs_ext_fetch_response_body_used, NULL),
+    JS_CGETSET_DEF("headers", ngx_qjs_ext_fetch_response_headers, NULL ),
+    JS_CFUNC_MAGIC_DEF("json", 0, ngx_qjs_ext_fetch_response_body,
+                       NGX_QJS_BODY_JSON),
+    JS_CGETSET_DEF("ok", ngx_qjs_ext_fetch_response_ok, NULL),
+    JS_CGETSET_DEF("redirected", ngx_qjs_ext_fetch_response_redirected, NULL),
+    JS_CGETSET_DEF("status", ngx_qjs_ext_fetch_response_status, NULL),
+    JS_CGETSET_DEF("statusText", ngx_qjs_ext_fetch_response_status_text, NULL),
+    JS_CFUNC_MAGIC_DEF("text", 0, ngx_qjs_ext_fetch_response_body,
+                       NGX_QJS_BODY_TEXT),
+    JS_CGETSET_DEF("type", ngx_qjs_ext_fetch_response_type, NULL),
+    JS_CGETSET_MAGIC_DEF("url", ngx_qjs_ext_fetch_response_field, NULL,
+                         offsetof(ngx_js_response_t, url) ),
+};
+
+
+static const JSClassDef  ngx_qjs_fetch_headers_class = {
+    "Headers",
+    .finalizer = NULL,
+    .exotic = & (JSClassExoticMethods) {
+        .get_own_property = ngx_qjs_fetch_headers_own_property,
+        .get_own_property_names = ngx_qjs_fetch_headers_own_property_names,
+    },
+};
+
+
+static const JSClassDef  ngx_qjs_fetch_request_class = {
+    "Request",
+    .finalizer = ngx_qjs_fetch_request_finalizer,
+};
+
+
+static const JSClassDef  ngx_qjs_fetch_response_class = {
+    "Response",
+    .finalizer = ngx_qjs_fetch_response_finalizer,
+};
+
+
+static const ngx_qjs_entry_t  ngx_qjs_fetch_cache_modes[] = {
+    { ngx_string("default"), CACHE_MODE_DEFAULT },
+    { ngx_string("no-store"), CACHE_MODE_NO_STORE },
+    { ngx_string("reload"), CACHE_MODE_RELOAD },
+    { ngx_string("no-cache"), CACHE_MODE_NO_CACHE },
+    { ngx_string("force-cache"), CACHE_MODE_FORCE_CACHE },
+    { ngx_string("only-if-cached"), CACHE_MODE_ONLY_IF_CACHED },
+    { ngx_null_string, 0 },
+};
+
+
+static const ngx_qjs_entry_t  ngx_qjs_fetch_credentials[] = {
+    { ngx_string("same-origin"), CREDENTIALS_SAME_ORIGIN },
+    { ngx_string("omit"), CREDENTIALS_OMIT },
+    { ngx_string("include"), CREDENTIALS_INCLUDE },
+    { ngx_null_string, 0 },
+};
+
+
+static const ngx_qjs_entry_t  ngx_qjs_fetch_modes[] = {
+    { ngx_string("no-cors"), MODE_NO_CORS },
+    { ngx_string("cors"), MODE_CORS },
+    { ngx_string("same-origin"), MODE_SAME_ORIGIN },
+    { ngx_string("navigate"), MODE_NAVIGATE },
+    { ngx_string("websocket"), MODE_WEBSOCKET },
+    { ngx_null_string, 0 },
+};
+
+
+qjs_module_t  ngx_qjs_ngx_fetch_module = {
+    .name = "fetch",
+    .init = ngx_qjs_fetch_init,
+};
+
+
+JSValue
+ngx_qjs_ext_fetch(JSContext *cx, JSValueConst this_val, int argc,
+    JSValueConst *argv)
+{
+    int                  has_host;
+    void                *external;
+    JSValue              init, value, promise;
+    ngx_int_t            rc;
+    ngx_url_t            u;
+    ngx_uint_t           i;
+    ngx_pool_t          *pool;
+    ngx_js_ctx_t        *ctx;
+    ngx_js_http_t       *http;
+    ngx_qjs_fetch_t     *fetch;
+    ngx_list_part_t     *part;
+    ngx_js_tb_elt_t     *h;
+    ngx_connection_t    *c;
+    ngx_js_request_t     request;
+    ngx_resolver_ctx_t  *rs;
+
+    external = JS_GetContextOpaque(cx);
+    c = ngx_qjs_external_connection(cx, external);
+    pool = ngx_qjs_external_pool(cx, external);
+
+    fetch = ngx_qjs_fetch_alloc(cx, pool, c->log);
+    if (fetch == NULL) {
+        return JS_ThrowOutOfMemory(cx);
+    }
+
+    promise = JS_DupValue(cx, fetch->promise);
+
+    rc = ngx_qjs_request_ctor(cx, &request, &u, argc, argv);
+    if (rc != NGX_OK) {
+        goto fail;
+    }
+
+    http = &fetch->http;
+    http->response.url = request.url;
+    http->timeout = ngx_qjs_external_fetch_timeout(cx, external);
+    http->buffer_size = ngx_qjs_external_buffer_size(cx, external);
+    http->max_response_body_size =
+                        ngx_qjs_external_max_response_buffer_size(cx, external);
+
+#if (NGX_SSL)
+    if (u.default_port == 443) {
+        http->ssl = ngx_qjs_external_ssl(cx, external);
+        http->ssl_verify = ngx_qjs_external_ssl_verify(cx, external);
+    }
+#endif
+
+    if (JS_IsObject(argv[1])) {
+        init = argv[1];
+        value = JS_GetPropertyStr(cx, init, "buffer_size");
+        if (JS_IsException(value)) {
+            goto fail;
+        }
+
+        if (!JS_IsUndefined(value)) {
+            if (JS_ToInt64(cx, (int64_t *) &http->buffer_size, value) < 0) {
+                JS_FreeValue(cx, value);
+                goto fail;
+            }
+        }
+
+        value = JS_GetPropertyStr(cx, init, "max_response_body_size");
+        if (JS_IsException(value)) {
+            goto fail;
+        }
+
+        if (!JS_IsUndefined(value)) {
+            if (JS_ToInt64(cx, (int64_t *) &http->max_response_body_size,
+                           value) < 0)
+            {
+                JS_FreeValue(cx, value);
+                goto fail;
+            }
+        }
+
+#if (NGX_SSL)
+        value = JS_GetPropertyStr(cx, init, "verify");
+        if (JS_IsException(value)) {
+            goto fail;
+        }
+
+        if (!JS_IsUndefined(value)) {
+            http->ssl_verify = JS_ToBool(cx, value);
+        }
+#endif
+    }
+
+    if (request.method.len == 4
+        && ngx_strncasecmp(request.method.data, (u_char *) "HEAD", 4) == 0)
+    {
+        http->header_only = 1;
+    }
+
+    ctx = ngx_qjs_external_ctx(cx, JS_GetContextOpaque(cx));
+
+    NJS_CHB_MP_INIT(&http->chain, ctx->engine->pool);
+    NJS_CHB_MP_INIT(&http->response.chain, ctx->engine->pool);
+
+    njs_chb_append(&http->chain, request.method.data, request.method.len);
+    njs_chb_append_literal(&http->chain, " ");
+
+    if (u.uri.len == 0 || u.uri.data[0] != '/') {
+        njs_chb_append_literal(&http->chain, "/");
+    }
+
+    njs_chb_append(&http->chain, u.uri.data, u.uri.len);
+    njs_chb_append_literal(&http->chain, " HTTP/1.1" CRLF);
+
+    has_host = 0;
+    part = &request.headers.header_list.part;
+    h = part->elts;
+
+    for (i = 0; /* void */; i++) {
+
+        if (i >= part->nelts) {
+            if (part->next == NULL) {
+                break;
+            }
+
+            part = part->next;
+            h = part->elts;
+            i = 0;
+        }
+
+        if (h[i].hash == 0) {
+            continue;
+        }
+
+        if (h[i].key.len == 4
+            && ngx_strncasecmp(h[i].key.data, (u_char *) "Host", 4) == 0)
+        {
+            has_host = 1;
+            njs_chb_append_literal(&http->chain, "Host: ");
+            njs_chb_append(&http->chain, h[i].value.data, h[i].value.len);
+            njs_chb_append_literal(&http->chain, CRLF);
+            break;
+        }
+    }
+
+    if (!has_host) {
+        njs_chb_append_literal(&http->chain, "Host: ");
+        njs_chb_append(&http->chain, u.host.data, u.host.len);
+
+        if (!u.no_port) {
+            njs_chb_sprintf(&http->chain, 32, ":%d", u.port);
+        }
+
+        njs_chb_append_literal(&http->chain, CRLF);
+    }
+
+    part = &request.headers.header_list.part;
+    h = part->elts;
+
+    for (i = 0; /* void */; i++) {
+
+        if (i >= part->nelts) {
+            if (part->next == NULL) {
+                break;
+            }
+
+            part = part->next;
+            h = part->elts;
+            i = 0;
+        }
+
+        if (h[i].hash == 0) {
+            continue;
+        }
+
+        if (h[i].key.len == 4
+            && ngx_strncasecmp(h[i].key.data, (u_char *) "Host", 4) == 0)
+        {
+            continue;
+        }
+
+        njs_chb_append(&http->chain, h[i].key.data, h[i].key.len);
+        njs_chb_append_literal(&http->chain, ": ");
+        njs_chb_append(&http->chain, h[i].value.data, h[i].value.len);
+        njs_chb_append_literal(&http->chain, CRLF);
+    }
+
+    njs_chb_append_literal(&http->chain, "Connection: close" CRLF);
+
+#if (NGX_SSL)
+    http->tls_name.data = u.host.data;
+    http->tls_name.len = u.host.len;
+#endif
+
+    if (request.body.len != 0) {
+        njs_chb_sprintf(&http->chain, 32, "Content-Length: %uz" CRLF CRLF,
+                        request.body.len);
+        njs_chb_append(&http->chain, request.body.data, request.body.len);
+
+    } else {
+        njs_chb_append_literal(&http->chain, CRLF);
+    }
+
+    if (u.addrs == NULL) {
+        rs = ngx_js_http_resolve(http, ngx_qjs_external_resolver(cx, external),
+                                 &u.host, u.port,
+                               ngx_qjs_external_resolver_timeout(cx, external));
+        if (rs == NULL) {
+            JS_FreeValue(cx, promise);
+            return JS_ThrowOutOfMemory(cx);
+        }
+
+        if (rs == NGX_NO_RESOLVER) {
+            JS_ThrowInternalError(cx, "no resolver defined");
+            goto fail;
+        }
+
+        return promise;
+    }
+
+    http->naddrs = 1;
+    ngx_memcpy(&http->addr, &u.addrs[0], sizeof(ngx_addr_t));
+    http->addrs = &http->addr;
+
+    ngx_js_http_connect(http);
+
+    return promise;
+
+fail:
+
+    fetch->response_value = JS_GetException(cx);
+
+    ngx_qjs_fetch_done(fetch, fetch->response_value, NGX_ERROR);
+
+    return promise;
+}
+
+
+static JSValue
+ngx_qjs_fetch_headers_ctor(JSContext *cx, JSValueConst new_target, int argc,
+    JSValueConst *argv)
+{
+    JSValue            init, proto, obj;
+    ngx_int_t          rc;
+    ngx_pool_t        *pool;
+    ngx_js_headers_t  *headers;
+
+    pool = ngx_qjs_external_pool(cx, JS_GetContextOpaque(cx));
+
+    headers = ngx_pcalloc(pool, sizeof(ngx_js_headers_t));
+    if (headers == NULL) {
+        return JS_ThrowOutOfMemory(cx);
+    }
+
+    headers->guard = GUARD_NONE;
+
+    rc = ngx_list_init(&headers->header_list, pool, 4,
+                       sizeof(ngx_js_tb_elt_t));
+    if (rc != NGX_OK) {
+        return JS_ThrowOutOfMemory(cx);
+    }
+
+    init = argv[0];
+
+    if (JS_IsObject(init)) {
+        rc = ngx_qjs_headers_fill(cx, headers, init);
+        if (rc != NGX_OK) {
+            return JS_EXCEPTION;
+        }
+    }
+
+    proto = JS_GetPropertyStr(cx, new_target, "prototype");
+    if (JS_IsException(proto)) {
+        return JS_EXCEPTION;
+    }
+
+    obj = JS_NewObjectProtoClass(cx, proto, NGX_QJS_CLASS_ID_FETCH_HEADERS);
+    JS_FreeValue(cx, proto);
+
+    if (JS_IsException(obj)) {
+        return JS_EXCEPTION;
+    }
+
+    JS_SetOpaque(obj, headers);
+
+    return obj;
+}
+
+
+static JSValue
+ngx_qjs_fetch_request_ctor(JSContext *cx, JSValueConst new_target, int argc,
+    JSValueConst *argv)
+{
+    JSValue            proto, obj;
+    ngx_int_t          rc;
+    ngx_url_t          u;
+    ngx_pool_t        *pool;
+    ngx_js_request_t  *request;
+
+    pool = ngx_qjs_external_pool(cx, JS_GetContextOpaque(cx));
+
+    request = ngx_pcalloc(pool, sizeof(ngx_js_request_t));
+    if (request == NULL) {
+        return JS_ThrowOutOfMemory(cx);
+    }
+
+    rc = ngx_qjs_request_ctor(cx, request, &u, argc, argv);
+    if (rc != NGX_OK) {
+        return JS_EXCEPTION;
+    }
+
+    proto = JS_GetPropertyStr(cx, new_target, "prototype");
+    if (JS_IsException(proto)) {
+        return JS_EXCEPTION;
+    }
+
+    obj = JS_NewObjectProtoClass(cx, proto, NGX_QJS_CLASS_ID_FETCH_REQUEST);
+    JS_FreeValue(cx, proto);
+
+    if (JS_IsException(obj)) {
+        return JS_EXCEPTION;
+    }
+
+    JS_SetOpaque(obj, request);
+
+    return obj;
+}
+
+
+static ngx_int_t
+ngx_qjs_request_ctor(JSContext *cx, ngx_js_request_t *request,
+    ngx_url_t *u, int argc, JSValueConst *argv)
+{
+    JSValue            input, init, value;
+    ngx_int_t          rc;
+    ngx_pool_t        *pool;
+    ngx_js_request_t  *orig;
+
+    input = argv[0];
+    if (JS_IsUndefined(input)) {
+        JS_ThrowInternalError(cx, "1st argument is required");
+        return NGX_ERROR;
+    }
+
+    /*
+     * set by ngx_memzero():
+     *
+     *  request->url.len = 0;
+     *  request->body.length = 0;
+     *  request->cache_mode = CACHE_MODE_DEFAULT;
+     *  request->credentials = CREDENTIALS_SAME_ORIGIN;
+     *  request->mode = MODE_NO_CORS;
+     *  request->headers.content_type = NULL;
+     */
+
+    ngx_memzero(request, sizeof(ngx_js_request_t));
+
+    request->method.data = (u_char *) "GET";
+    request->method.len = 3;
+    request->body.data = NULL;
+    request->body.len = 0;
+    request->headers.guard = GUARD_REQUEST;
+    ngx_qjs_arg(request->header_value) = JS_UNDEFINED;
+
+    pool = ngx_qjs_external_pool(cx, JS_GetContextOpaque(cx));
+
+    rc = ngx_list_init(&request->headers.header_list, pool, 4,
+                       sizeof(ngx_js_tb_elt_t));
+    if (rc != NGX_OK) {
+        JS_ThrowOutOfMemory(cx);
+        return NGX_ERROR;
+    }
+
+    if (JS_IsString(input)) {
+        rc = ngx_qjs_string(cx, input, &request->url);
+        if (rc != NGX_OK) {
+            JS_ThrowInternalError(cx, "failed to convert url arg");
+            return NGX_ERROR;
+        }
+
+    } else {
+        orig = JS_GetOpaque2(cx, input, NGX_QJS_CLASS_ID_FETCH_REQUEST);
+        if (orig == NULL) {
+            JS_ThrowInternalError(cx,
+                                  "input is not string or a Request object");
+            return NGX_ERROR;
+        }
+
+        request->url = orig->url;
+        request->method = orig->method;
+        request->body = orig->body;
+        request->body_used = orig->body_used;
+        request->cache_mode = orig->cache_mode;
+        request->credentials = orig->credentials;
+        request->mode = orig->mode;
+
+        rc = ngx_qjs_headers_inherit(cx, &request->headers, &orig->headers);
+        if (rc != NGX_OK) {
+            return NGX_ERROR;
+        }
+    }
+
+    ngx_js_http_trim(&request->url.data, &request->url.len, 1);
+
+    ngx_memzero(u, sizeof(ngx_url_t));
+
+    u->url = request->url;
+    u->default_port = 80;
+    u->uri_part = 1;
+    u->no_resolve = 1;
+
+    if (u->url.len > 7
+        && ngx_strncasecmp(u->url.data, (u_char *) "http://", 7) == 0)
+    {
+        u->url.len -= 7;
+        u->url.data += 7;
+
+#if (NGX_SSL)
+    } else if (u->url.len > 8
+        && ngx_strncasecmp(u->url.data, (u_char *) "https://", 8) == 0)
+    {
+        u->url.len -= 8;
+        u->url.data += 8;
+        u->default_port = 443;
+#endif
+
+    } else {
+        JS_ThrowInternalError(cx, "unsupported URL schema (only http or https"
+                                  " are supported)");
+        return NGX_ERROR;
+    }
+
+    if (ngx_parse_url(pool, u) != NGX_OK) {
+        JS_ThrowInternalError(cx, "invalid url");
+        return NGX_ERROR;
+    }
+
+    if (JS_IsObject(argv[1])) {
+        init = argv[1];
+        value = JS_GetPropertyStr(cx, init, "method");
+        if (JS_IsException(value)) {
+            JS_ThrowInternalError(cx, "invalid Request method");
+            return NGX_ERROR;
+        }
+
+        if (!JS_IsUndefined(value)) {
+            rc = ngx_qjs_string(cx, value, &request->method);
+            JS_FreeValue(cx, value);
+
+            if (rc != NGX_OK) {
+                JS_ThrowInternalError(cx, "invalid Request method");
+                return NGX_ERROR;
+            }
+        }
+
+        rc = ngx_qjs_method_process(cx, request);
+        if (rc != NGX_OK) {
+            return NGX_ERROR;
+        }
+
+        rc = ngx_qjs_fetch_flag_set(cx, ngx_qjs_fetch_cache_modes, init,
+                                    "cache");
+        if (rc == NGX_ERROR) {
+            return NGX_ERROR;
+        }
+
+        request->cache_mode = rc;
+
+        rc = ngx_qjs_fetch_flag_set(cx, ngx_qjs_fetch_credentials, init,
+                                    "credentials");
+        if (rc == NGX_ERROR) {
+            return NGX_ERROR;
+        }
+
+        request->credentials = rc;
+
+        rc = ngx_qjs_fetch_flag_set(cx, ngx_qjs_fetch_modes, init, "mode");
+        if (rc == NGX_ERROR) {
+            return NGX_ERROR;
+        }
+
+        request->mode = rc;
+
+        value = JS_GetPropertyStr(cx, init, "headers");
+        if (JS_IsException(value)) {
+            JS_ThrowInternalError(cx, "invalid Request headers");
+            return NGX_ERROR;
+        }
+
+        if (!JS_IsUndefined(value)) {
+            if (!JS_IsObject(value)) {
+                JS_ThrowInternalError(cx, "Headers is not an object");
+                return NGX_ERROR;
+            }
+
+            /*
+             * There are no API to reset or destroy ngx_list,
+             * just allocating a new one.
+             */
+
+            ngx_memset(&request->headers, 0, sizeof(ngx_js_headers_t));
+            request->headers.guard = GUARD_REQUEST;
+
+            rc = ngx_list_init(&request->headers.header_list, pool, 4,
+                               sizeof(ngx_js_tb_elt_t));
+            if (rc != NGX_OK) {
+                JS_FreeValue(cx, value);
+                JS_ThrowOutOfMemory(cx);
+                return NGX_ERROR;
+            }
+
+            rc = ngx_qjs_headers_fill(cx, &request->headers, value);
+            JS_FreeValue(cx, value);
+
+            if (rc != NGX_OK) {
+                return NGX_ERROR;
+            }
+        }
+
+        value = JS_GetPropertyStr(cx, init, "body");
+        if (JS_IsException(value)) {
+            JS_ThrowInternalError(cx, "invalid Request body");
+            return NGX_ERROR;
+        }
+
+        if (!JS_IsUndefined(value)) {
+            if (ngx_qjs_string(cx, value, &request->body) != NGX_OK) {
+                JS_FreeValue(cx, value);
+                JS_ThrowInternalError(cx, "invalid Request body");
+                return NGX_ERROR;
+            }
+
+            if (request->headers.content_type == NULL && JS_IsString(value)) {
+                rc = ngx_qjs_headers_append(cx, &request->headers,
+                                        (u_char *) "Content-Type",
+                                        sizeof("Content-Type") - 1,
+                                        (u_char *) "text/plain;charset=UTF-8",
+                                        sizeof("text/plain;charset=UTF-8") - 1);
+                if (rc != NGX_OK) {
+                    JS_FreeValue(cx, value);
+                    return NGX_ERROR;
+                }
+            }
+
+            JS_FreeValue(cx, value);
+        }
+    }
+
+    return NGX_OK;
+}
+
+
+static JSValue
+ngx_qjs_fetch_response_ctor(JSContext *cx, JSValueConst new_target, int argc,
+    JSValueConst *argv)
+{
+    int                 ret;
+    u_char             *p, *end;
+    JSValue             init, value, body, proto, obj;
+    ngx_str_t           bd;
+    ngx_int_t           rc;
+    ngx_pool_t         *pool;
+    ngx_js_ctx_t       *ctx;
+    ngx_js_response_t  *response;
+
+    pool = ngx_qjs_external_pool(cx, JS_GetContextOpaque(cx));
+
+    response = ngx_pcalloc(pool, sizeof(ngx_js_response_t));
+    if (response == NULL) {
+        return JS_ThrowOutOfMemory(cx);
+    }
+
+    /*
+     * set by ngx_pcalloc():
+     *
+     *  response->url.length = 0;
+     *  response->status_text.length = 0;
+     */
+
+    response->code = 200;
+    response->headers.guard = GUARD_RESPONSE;
+    ngx_qjs_arg(response->header_value) = JS_UNDEFINED;
+
+    ret = ngx_list_init(&response->headers.header_list, pool, 4,
+                        sizeof(ngx_js_tb_elt_t));
+    if (ret != NGX_OK) {
+        JS_ThrowOutOfMemory(cx);
+    }
+
+    init = argv[1];
+
+    if (JS_IsObject(init)) {
+        value = JS_GetPropertyStr(cx, init, "status");
+        if (JS_IsException(value)) {
+            return JS_ThrowInternalError(cx, "invalid Response status");
+        }
+
+        if (!JS_IsUndefined(value)) {
+            ret = JS_ToInt64(cx, (int64_t *) &response->code, value);
+            JS_FreeValue(cx, value);
+
+            if (ret < 0) {
+                return JS_EXCEPTION;
+            }
+
+            if (response->code < 200 || response->code > 599) {
+                return JS_ThrowInternalError(cx, "status provided (%d) is "
+                                                 "outside of [200, 599] range",
+                                             (int) response->code);
+            }
+        }
+
+        value = JS_GetPropertyStr(cx, init, "statusText");
+        if (JS_IsException(value)) {
+            return JS_ThrowInternalError(cx, "invalid Response statusText");
+        }
+
+        if (!JS_IsUndefined(value)) {
+            ret = ngx_qjs_string(cx, value, &response->status_text);
+            JS_FreeValue(cx, value);
+
+            if (ret < 0) {
+                return JS_EXCEPTION;
+            }
+
+            p = response->status_text.data;
+            end = p + response->status_text.len;
+
+            while (p < end) {
+                if (*p != '\t' && *p < ' ') {
+                    return JS_ThrowInternalError(cx,
+                                                 "invalid Response statusText");
+                }
+
+                p++;
+            }
+        }
+
+        value = JS_GetPropertyStr(cx, init, "headers");
+        if (JS_IsException(value)) {
+            return JS_ThrowInternalError(cx, "invalid Response headers");
+        }
+
+        if (!JS_IsUndefined(value)) {
+            if (!JS_IsObject(value)) {
+                JS_FreeValue(cx, value);
+                return JS_ThrowInternalError(cx, "Headers is not an object");
+            }
+
+            rc = ngx_qjs_headers_fill(cx, &response->headers, value);
+            JS_FreeValue(cx, value);
+
+            if (ret != NGX_OK) {
+                return JS_EXCEPTION;
+            }
+        }
+    }
+
+    ctx = ngx_qjs_external_ctx(cx, JS_GetContextOpaque(cx));
+
+    NJS_CHB_MP_INIT(&response->chain, ctx->engine->pool);
+
+    body = argv[0];
+
+    if (!JS_IsNullOrUndefined(body)) {
+        if (ngx_qjs_string(cx, body, &bd) != NGX_OK) {
+            return JS_ThrowInternalError(cx, "invalid Response body");
+        }
+
+        njs_chb_append(&response->chain, bd.data, bd.len);
+
+        if (JS_IsString(body)) {
+            rc = ngx_qjs_headers_append(cx, &response->headers,
+                                      (u_char *) "Content-Type",
+                                      sizeof("Content-Type") - 1,
+                                      (u_char *) "text/plain;charset=UTF-8",
+                                      sizeof("text/plain;charset=UTF-8") - 1);
+            if (rc != NGX_OK) {
+                return JS_EXCEPTION;
+            }
+        }
+    }
+
+    proto = JS_GetPropertyStr(cx, new_target, "prototype");
+    if (JS_IsException(proto)) {
+        return JS_EXCEPTION;
+    }
+
+    obj = JS_NewObjectProtoClass(cx, proto, NGX_QJS_CLASS_ID_FETCH_RESPONSE);
+    JS_FreeValue(cx, proto);
+
+    if (JS_IsException(obj)) {
+        return JS_EXCEPTION;
+    }
+
+    JS_SetOpaque(obj, response);
+
+    return obj;
+}
+
+
+static u_char
+ngx_js_upper_case(u_char c)
+{
+    return (u_char) ((c >= 'a' && c <= 'z') ? c & 0xDF : c);
+}
+
+
+static ngx_int_t
+ngx_qjs_method_process(JSContext *cx, ngx_js_request_t *request)
+{
+    u_char           *s;
+    const u_char     *p;
+    const ngx_str_t  *m;
+
+    static const ngx_str_t forbidden[] = {
+        ngx_string("CONNECT"),
+        ngx_string("TRACE"),
+        ngx_string("TRACK"),
+        ngx_null_string,
+    };
+
+    static const ngx_str_t to_normalize[] = {
+        ngx_string("DELETE"),
+        ngx_string("GET"),
+        ngx_string("HEAD"),
+        ngx_string("OPTIONS"),
+        ngx_string("POST"),
+        ngx_string("PUT"),
+        ngx_null_string,
+    };
+
+    for (m = &forbidden[0]; m->len != 0; m++) {
+        if (request->method.len == m->len
+            && ngx_strncasecmp(request->method.data, m->data, m->len) == 0)
+        {
+            JS_ThrowInternalError(cx, "forbidden method: %.*s",
+                                  (int) m->len, m->data);
+            return NGX_ERROR;
+        }
+    }
+
+    for (m = &to_normalize[0]; m->len != 0; m++) {
+        if (request->method.len == m->len
+            && ngx_strncasecmp(request->method.data, m->data, m->len) == 0)
+        {
+            s = &request->m[0];
+            p = m->data;
+
+            while (*p != '\0') {
+                *s++ = ngx_js_upper_case(*p++);
+            }
+
+            request->method.data = &request->m[0];
+            request->method.len = m->len;
+            break;
+        }
+    }
+
+    return NGX_OK;
+}
+
+
+static ngx_int_t
+ngx_qjs_headers_inherit(JSContext *cx, ngx_js_headers_t *headers,
+    ngx_js_headers_t *orig)
+{
+    ngx_int_t         rc;
+    ngx_uint_t        i;
+    ngx_list_part_t  *part;
+    ngx_js_tb_elt_t  *h;
+
+    part = &orig->header_list.part;
+    h = part->elts;
+
+    for (i = 0; /* void */; i++) {
+
+        if (i >= part->nelts) {
+            if (part->next == NULL) {
+                break;
+            }
+
+            part = part->next;
+            h = part->elts;
+            i = 0;
+        }
+
+        if (h[i].hash == 0) {
+            continue;
+        }
+
+        rc = ngx_qjs_headers_append(cx, headers, h[i].key.data, h[i].key.len,
+                                    h[i].value.data, h[i].value.len);
+        if (rc != NGX_OK) {
+            return NGX_ERROR;
+        }
+    }
+
+    return NGX_OK;
+}
+
+
+static ngx_int_t
+ngx_qjs_headers_fill_header_free(JSContext *cx, ngx_js_headers_t *headers,
+    JSValue prop_name, JSValue prop_value)
+{
+    ngx_int_t  rc;
+    ngx_str_t  name, value;
+
+    if (ngx_qjs_string(cx, prop_name, &name) != NGX_OK) {
+        JS_FreeValue(cx, prop_name);
+        JS_FreeValue(cx, prop_value);
+        return NGX_ERROR;
+    }
+
+    if (ngx_qjs_string(cx, prop_value, &value) != NGX_OK) {
+        JS_FreeValue(cx, prop_name);
+        JS_FreeValue(cx, prop_value);
+        return NGX_ERROR;
+    }
+
+    rc = ngx_qjs_headers_append(cx, headers, name.data, name.len,
+                                value.data, value.len);
+
+    JS_FreeValue(cx, prop_name);
+    JS_FreeValue(cx, prop_value);
+
+    return rc;
+}
+
+
+static ngx_int_t
+ngx_qjs_headers_fill(JSContext *cx, ngx_js_headers_t *headers, JSValue init)
+{
+    JSValue            header, prop_name, prop_value;
+    uint32_t           i, len, length;
+    ngx_int_t          rc;
+    JSPropertyEnum    *tab;
+    ngx_js_headers_t  *hh;
+
+    hh = JS_GetOpaque2(cx, init, NGX_QJS_CLASS_ID_FETCH_HEADERS);
+    if (hh != NULL) {
+        return ngx_qjs_headers_inherit(cx, headers, hh);
+    }
+
+    if (JS_GetOwnPropertyNames(cx, &tab, &len, init,
+                               JS_GPN_STRING_MASK | JS_GPN_ENUM_ONLY) < 0) {
+        return NGX_ERROR;
+    }
+
+    if (qjs_is_array(cx, init)) {
+        for (i = 0; i < len; i++) {
+            header = JS_GetPropertyUint32(cx, init, i);
+            if (JS_IsException(header)) {
+                goto fail;
+            }
+
+            if (qjs_array_length(cx, header, &length)) {
+                JS_FreeValue(cx, header);
+                goto fail;
+            }
+
+            if (length != 2) {
+                JS_FreeValue(cx, header);
+                JS_ThrowInternalError(cx,
+                                   "header does not contain exactly two items");
+                goto fail;
+            }
+
+            prop_name = JS_GetPropertyUint32(cx, header, 0);
+            prop_value = JS_GetPropertyUint32(cx, header, 1);
+
+            JS_FreeValue(cx, header);
+
+            rc = ngx_qjs_headers_fill_header_free(cx, headers, prop_name,
+                                                   prop_value);
+            if (rc != NGX_OK) {
+                goto fail;
+            }
+        }
+
+    } else {
+
+        for (i = 0; i < len; i++) {
+            prop_name = JS_AtomToString(cx, tab[i].atom);
+
+            prop_value = JS_GetProperty(cx, init, tab[i].atom);
+            if (JS_IsException(prop_value)) {
+                JS_FreeValue(cx, prop_name);
+                goto fail;
+            }
+
+            rc = ngx_qjs_headers_fill_header_free(cx, headers, prop_name,
+                                                   prop_value);
+            if (rc != NGX_OK) {
+                goto fail;
+            }
+        }
+    }
+
+    qjs_free_prop_enum(cx, tab, len);
+
+    return NGX_OK;
+
+fail:
+
+    qjs_free_prop_enum(cx, tab, len);
+
+    return NGX_ERROR;
+}
+
+
+static ngx_qjs_fetch_t *
+ngx_qjs_fetch_alloc(JSContext *cx, ngx_pool_t *pool, ngx_log_t *log)
+{
+    ngx_js_ctx_t     *ctx;
+    ngx_js_http_t    *http;
+    ngx_qjs_fetch_t  *fetch;
+    ngx_qjs_event_t  *event;
+
+    fetch = ngx_pcalloc(pool, sizeof(ngx_qjs_fetch_t));
+    if (fetch == NULL) {
+        return NULL;
+    }
+
+    http = &fetch->http;
+
+    http->pool = pool;
+    http->log = log;
+
+    http->timeout = 10000;
+
+    http->http_parse.content_length_n = -1;
+
+    ngx_qjs_arg(http->response.header_value) = JS_UNDEFINED;
+
+    http->append_headers = ngx_qjs_fetch_append_headers;
+    http->ready_handler = ngx_qjs_fetch_process_done;
+    http->error_handler = ngx_qjs_fetch_error;
+
+    fetch->promise = JS_NewPromiseCapability(cx, fetch->promise_callbacks);
+    if (JS_IsException(fetch->promise)) {
+        return NULL;
+    }
+
+    event = ngx_palloc(pool, sizeof(ngx_qjs_event_t));
+    if (event == NULL) {
+        goto fail;
+    }
+
+    ctx = ngx_qjs_external_ctx(cx, JS_GetContextOpaque(cx));
+
+    event->ctx = cx;
+    event->destructor = ngx_qjs_fetch_destructor;
+    event->fd = ctx->event_id++;
+    event->data = fetch;
+
+    ngx_js_add_event(ctx, event);
+
+    fetch->cx = cx;
+    fetch->event = event;
+
+    ngx_log_debug1(NGX_LOG_DEBUG_EVENT, log, 0, "js http alloc:%p", fetch);
+
+    return fetch;
+
+fail:
+
+    JS_FreeValue(cx, fetch->promise);
+    JS_FreeValue(cx, fetch->promise_callbacks[0]);
+    JS_FreeValue(cx, fetch->promise_callbacks[1]);
+
+    JS_ThrowInternalError(cx, "internal error");
+
+    return NULL;
+}
+
+
+static void
+ngx_qjs_fetch_error(ngx_js_http_t *http, const char *err)
+{
+    ngx_qjs_fetch_t  *fetch;
+
+    fetch = (ngx_qjs_fetch_t *) http;
+
+    JS_ThrowInternalError(fetch->cx, "%s", err);
+
+    fetch->response_value = JS_GetException(fetch->cx);
+
+    ngx_qjs_fetch_done(fetch, fetch->response_value, NGX_ERROR);
+}
+
+
+static void
+ngx_qjs_fetch_destructor(ngx_qjs_event_t *event)
+{
+    JSContext        *cx;
+    ngx_js_http_t    *http;
+    ngx_qjs_fetch_t  *fetch;
+
+    cx = event->ctx;
+    fetch = event->data;
+    http = &fetch->http;
+
+    ngx_log_debug1(NGX_LOG_DEBUG_EVENT, http->log, 0, "js http destructor:%p",
+                   fetch);
+
+    ngx_js_http_resolve_done(http);
+    ngx_js_http_close_peer(http);
+
+    JS_FreeValue(cx, fetch->promise_callbacks[0]);
+    JS_FreeValue(cx, fetch->promise_callbacks[1]);
+    JS_FreeValue(cx, fetch->promise);
+    JS_FreeValue(cx, fetch->response_value);
+}
+
+
+static void
+ngx_qjs_fetch_done(ngx_qjs_fetch_t *fetch, JSValue retval, ngx_int_t rc)
+{
+    void             *external;
+    JSValue           action;
+    JSContext        *cx;
+    ngx_js_ctx_t     *ctx;
+    ngx_js_http_t    *http;
+    ngx_qjs_event_t  *event;
+
+    http = &fetch->http;
+
+    ngx_log_debug2(NGX_LOG_DEBUG_EVENT, http->log, 0,
+                   "js http done fetch:%p rc:%i", fetch, rc);
+
+    ngx_js_http_close_peer(http);
+
+    if (fetch->event != NULL) {
+        action = fetch->promise_callbacks[(rc != NGX_OK)];
+
+        cx = fetch->cx;
+        event = fetch->event;
+
+        rc = ngx_qjs_call(cx, action, &retval, 1);
+
+        external = JS_GetContextOpaque(cx);
+        ctx = ngx_qjs_external_ctx(cx, external);
+        ngx_js_del_event(ctx, event);
+
+        ngx_qjs_external_event_finalize(cx)(external, rc);
+    }
+}
+
+
+static ngx_int_t
+ngx_qjs_fetch_append_headers(ngx_js_http_t *http, ngx_js_headers_t *headers,
+    u_char *name, size_t len, u_char *value, size_t vlen)
+{
+    ngx_qjs_fetch_t  *fetch;
+
+    fetch = (ngx_qjs_fetch_t *) http;
+
+    return ngx_qjs_headers_append(fetch->cx, &http->response.headers,
+                                  name, len, value, vlen);
+}
+
+
+static void
+ngx_qjs_fetch_process_done(ngx_js_http_t *http)
+{
+    ngx_qjs_fetch_t  *fetch;
+
+    fetch = (ngx_qjs_fetch_t *) http;
+
+    fetch->response_value = JS_NewObjectClass(fetch->cx,
+                                              NGX_QJS_CLASS_ID_FETCH_RESPONSE);
+    if (JS_IsException(fetch->response_value)) {
+        ngx_qjs_fetch_error(http, "fetch response creation failed");
+        return;
+    }
+
+    JS_SetOpaque(fetch->response_value, &http->response);
+
+    ngx_qjs_fetch_done(fetch, fetch->response_value, NGX_OK);
+}
+
+
+static ngx_int_t
+ngx_qjs_headers_append(JSContext *cx, ngx_js_headers_t *headers,
+    u_char *name, size_t len, u_char *value, size_t vlen)
+{
+    u_char           *p, *end;
+    ngx_int_t         ret;
+    ngx_uint_t        i;
+    ngx_list_part_t  *part;
+    ngx_js_tb_elt_t  *h, **ph;
+
+    ngx_js_http_trim(&value, &vlen, 0);
+
+    ret = ngx_js_check_header_name(name, len);
+    if (ret != NGX_OK) {
+        JS_ThrowInternalError(cx, "invalid header name");
+        return NGX_ERROR;
+    }
+
+    p = value;
+    end = p + vlen;
+
+    while (p < end) {
+        if (*p == '\0') {
+            JS_ThrowInternalError(cx, "invalid header value");
+            return NGX_ERROR;
+        }
+
+        p++;
+    }
+
+    if (headers->guard == GUARD_IMMUTABLE) {
+        JS_ThrowInternalError(cx, "cannot append to immutable object");
+        return NGX_ERROR;
+    }
+
+    ph = NULL;
+    part = &headers->header_list.part;
+    h = part->elts;
+
+    for (i = 0; /* void */; i++) {
+
+        if (i >= part->nelts) {
+            if (part->next == NULL) {
+                break;
+            }
+
+            part = part->next;
+            h = part->elts;
+            i = 0;
+        }
+
+        if (h[i].hash == 0) {
+            continue;
+        }
+
+        if (len == h[i].key.len
+            && (ngx_strncasecmp(name, h[i].key.data, len) == 0))
+        {
+            ph = &h[i].next;
+            while (*ph) { ph = &(*ph)->next; }
+            break;
+        }
+    }
+
+    h = ngx_list_push(&headers->header_list);
+    if (h == NULL) {
+        JS_ThrowOutOfMemory(cx);
+        return NGX_ERROR;
+    }
+
+    if (ph != NULL) {
+        *ph = h;
+    }
+
+    h->hash = 1;
+    h->key.data = name;
+    h->key.len = len;
+    h->value.data = value;
+    h->value.len = vlen;
+    h->next = NULL;
+
+    if (len == (sizeof("Content-Type") - 1)
+        && ngx_strncasecmp(name, (u_char *) "Content-Type", len) == 0)
+    {
+        headers->content_type = h;
+    }
+
+    return NGX_OK;
+}
+
+
+static JSValue
+ngx_qjs_headers_ext_keys(JSContext *cx, JSValue value)
+{
+    int                ret, found;
+    JSValue            keys, key, item, func, retval;
+    uint32_t           length;
+    ngx_str_t          hdr;
+    ngx_uint_t         i, k, n;
+    ngx_list_part_t   *part;
+    ngx_js_tb_elt_t   *h;
+    ngx_js_headers_t  *headers;
+
+    headers = JS_GetOpaque2(cx, value, NGX_QJS_CLASS_ID_FETCH_HEADERS);
+    if (headers == NULL) {
+        return JS_NULL;
+    }
+
+    keys = JS_NewArray(cx);
+    if (JS_IsException(keys)) {
+        return JS_EXCEPTION;
+    }
+
+    n = 0;
+
+    part = &headers->header_list.part;
+    h = part->elts;
+
+    for (i = 0; /* void */; i++) {
+
+        if (i >= part->nelts) {
+            if (part->next == NULL) {
+                break;
+            }
+
+            part = part->next;
+            h = part->elts;
+            i = 0;
+        }
+
+        if (h[i].hash == 0) {
+            continue;
+        }
+
+        if (qjs_array_length(cx, keys, &length)) {
+            goto fail;
+        }
+
+        for (k = 0; k < length; k++) {
+            key = JS_GetPropertyUint32(cx, keys, k);
+            if (JS_IsException(key)) {
+                goto fail;
+            }
+
+            hdr.data = (u_char *) JS_ToCStringLen(cx, &hdr.len, key);
+            JS_FreeValue(cx, key);
+
+            found = h[i].key.len == hdr.len
+                    && ngx_strncasecmp(h[i].key.data,
+                                       hdr.data, hdr.len) == 0;
+
+            JS_FreeCString(cx, (const char *) hdr.data);
+
+            if (found) {
+                break;
+            }
+        }
+
+        if (k == n) {
+            item = JS_NewStringLen(cx, (const char *) h[i].key.data,
+                                    h[i].key.len);
+            if (JS_IsException(value)) {
+                goto fail;
+            }
+
+            ret = JS_DefinePropertyValueUint32(cx, keys, n, item,
+                                               JS_PROP_C_W_E);
+            if (ret < 0) {
+                JS_FreeValue(cx, item);
+                goto fail;
+            }
+
+            n++;
+        }
+    }
+
+    func = JS_GetPropertyStr(cx, keys, "sort");
+    if (JS_IsException(func)) {
+        JS_ThrowInternalError(cx, "sort function not found");
+        goto fail;
+    }
+
+    retval = JS_Call(cx, func, keys, 0, NULL);
+
+    JS_FreeValue(cx, func);
+    JS_FreeValue(cx, keys);
+
+    return retval;
+
+fail:
+
+    JS_FreeValue(cx, keys);
+
+    return JS_EXCEPTION;
+}
+
+
+static JSValue
+ngx_qjs_headers_get(JSContext *cx, JSValue this_val, ngx_str_t *name,
+    int as_array)
+{
+    int                ret;
+    JSValue            retval, value;
+    njs_chb_t          chain;
+    ngx_uint_t         i;
+    ngx_list_part_t   *part;
+    ngx_js_tb_elt_t   *h, *ph;
+    ngx_js_headers_t  *headers;
+
+    headers = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_HEADERS);
+    if (headers == NULL) {
+        return JS_NULL;
+    }
+
+    part = &headers->header_list.part;
+    h = part->elts;
+    ph = NULL;
+
+    for (i = 0; /* void */; i++) {
+
+        if (i >= part->nelts) {
+            if (part->next == NULL) {
+                break;
+            }
+
+            part = part->next;
+            h = part->elts;
+            i = 0;
+        }
+
+        if (h[i].hash == 0) {
+            continue;
+        }
+
+        if (h[i].key.len == name->len
+            && ngx_strncasecmp(h[i].key.data, name->data, name->len) == 0)
+        {
+            ph = &h[i];
+            break;
+        }
+    }
+
+    if (as_array) {
+        retval = JS_NewArray(cx);
+        if (JS_IsException(retval)) {
+            return JS_EXCEPTION;
+        }
+
+        i = 0;
+        while (ph != NULL) {
+            value = JS_NewStringLen(cx, (const char *) ph->value.data,
+                                    ph->value.len);
+            if (JS_IsException(value)) {
+                JS_FreeValue(cx, retval);
+                return JS_EXCEPTION;
+            }
+
+            ret = JS_DefinePropertyValueUint32(cx, retval, i, value,
+                                               JS_PROP_C_W_E);
+            if (ret < 0) {
+                JS_FreeValue(cx, retval);
+                JS_FreeValue(cx, value);
+                return JS_EXCEPTION;
+            }
+
+            i++;
+            ph = ph->next;
+        }
+
+        return retval;
+    }
+
+    if (ph == NULL) {
+        return JS_NULL;
+    }
+
+    NJS_CHB_CTX_INIT(&chain, cx);
+
+    h = ph;
+
+    for ( ;; ) {
+        njs_chb_append(&chain, h->value.data, h->value.len);
+
+        if (h->next == NULL) {
+            break;
+        }
+
+        njs_chb_append_literal(&chain, ", ");
+        h = h->next;
+    }
+
+    retval = qjs_string_create_chb(cx, &chain);
+
+    return retval;
+}
+
+
+static int
+ngx_qjs_fetch_headers_own_property(JSContext *cx, JSPropertyDescriptor *desc,
+    JSValueConst obj, JSAtom prop)
+{
+    JSValue    value;
+    ngx_str_t  name;
+
+    name.data = (u_char *) JS_AtomToCString(cx, prop);
+    if (name.data == NULL) {
+        return -1;
+    }
+
+    name.len = ngx_strlen(name.data);
+
+    value = ngx_qjs_headers_get(cx, obj, &name, 0);
+    JS_FreeCString(cx, (char *) name.data);
+
+    if (JS_IsException(value)) {
+        return -1;
+    }
+
+    if (JS_IsNull(value)) {
+        return 0;
+    }
+
+    if (desc == NULL) {
+        JS_FreeValue(cx, value);
+
+    } else {
+        desc->flags = JS_PROP_ENUMERABLE;
+        desc->getter = JS_UNDEFINED;
+        desc->setter = JS_UNDEFINED;
+        desc->value = value;
+    }
+
+    return 1;
+}
+
+
+static int
+ngx_qjs_fetch_headers_own_property_names(JSContext *cx, JSPropertyEnum **ptab,
+    uint32_t *plen, JSValueConst obj)
+{
+    int                ret;
+    JSAtom             key;
+    JSValue            keys;
+    ngx_uint_t         i;
+    ngx_list_part_t   *part;
+    ngx_js_tb_elt_t   *h;
+    ngx_js_headers_t  *headers;
+
+    headers = JS_GetOpaque2(cx, obj, NGX_QJS_CLASS_ID_FETCH_HEADERS);
+    if (headers == NULL) {
+        (void) JS_ThrowInternalError(cx, "\"this\" is not a Headers object");
+        return -1;
+    }
+
+    keys = JS_NewObject(cx);
+    if (JS_IsException(keys)) {
+        return -1;
+    }
+
+    part = &headers->header_list.part;
+    h = part->elts;
+
+    for (i = 0; /* void */; i++) {
+
+        if (i >= part->nelts) {
+            if (part->next == NULL) {
+                break;
+            }
+
+            part = part->next;
+            h = part->elts;
+            i = 0;
+        }
+
+        if (h[i].hash == 0) {
+            continue;
+        }
+
+        key = JS_NewAtomLen(cx, (const char *) h[i].key.data, h[i].key.len);
+        if (key == JS_ATOM_NULL) {
+            goto fail;
+        }
+
+        if (JS_DefinePropertyValue(cx, keys, key, JS_UNDEFINED,
+                                   JS_PROP_ENUMERABLE) < 0)
+        {
+            JS_FreeAtom(cx, key);
+            goto fail;
+        }
+
+        JS_FreeAtom(cx, key);
+    }
+
+    ret = JS_GetOwnPropertyNames(cx, ptab, plen, keys,
+                                 JS_GPN_STRING_MASK | JS_GPN_ENUM_ONLY);
+
+    JS_FreeValue(cx, keys);
+
+    return ret;
+
+fail:
+
+    JS_FreeValue(cx, keys);
+
+    return -1;
+}
+
+
+static JSValue
+ngx_qjs_ext_fetch_headers_append(JSContext *cx, JSValueConst this_val,
+    int argc, JSValueConst *argv)
+{
+    ngx_int_t          rc;
+    ngx_js_headers_t  *headers;
+
+    headers = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_HEADERS);
+    if (headers == NULL) {
+        return JS_ThrowInternalError(cx,
+                                     "\"this\" is not fetch headers object");
+    }
+
+    rc = ngx_qjs_headers_fill_header_free(cx, headers,
+                                          JS_DupValue(cx, argv[0]),
+                                          JS_DupValue(cx, argv[1]));
+    if (rc != NGX_OK) {
+        return JS_EXCEPTION;
+    }
+
+    return JS_UNDEFINED;
+}
+
+
+static JSValue
+ngx_qjs_ext_fetch_headers_delete(JSContext *cx, JSValueConst this_val,
+    int argc, JSValueConst *argv)
+{
+    ngx_int_t          rc;
+    ngx_str_t          name;
+    ngx_uint_t         i;
+    ngx_list_part_t   *part;
+    ngx_js_tb_elt_t   *h;
+    ngx_js_headers_t  *headers;
+
+    headers = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_HEADERS);
+    if (headers == NULL) {
+        return JS_ThrowInternalError(cx,
+                                     "\"this\" is not fetch headers object");
+    }
+
+    rc = ngx_qjs_string(cx, argv[0], &name);
+    if (rc != NGX_OK) {
+        return JS_EXCEPTION;
+    }
+
+    part = &headers->header_list.part;
+    h = part->elts;
+
+    for (i = 0; /* void */; i++) {
+
+        if (i >= part->nelts) {
+            if (part->next == NULL) {
+                break;
+            }
+
+            part = part->next;
+            h = part->elts;
+            i = 0;
+        }
+
+        if (h[i].hash == 0) {
+            continue;
+        }
+
+        if (name.len == h[i].key.len
+            && (ngx_strncasecmp(name.data, h[i].key.data, name.len) == 0))
+        {
+            h[i].hash = 0;
+        }
+    }
+
+    if (name.len == (sizeof("Content-Type") - 1)
+        && ngx_strncasecmp(name.data, (u_char *) "Content-Type", name.len)
+           == 0)
+    {
+        headers->content_type = NULL;
+    }
+
+    return JS_UNDEFINED;
+}
+
+
+static JSValue
+ngx_qjs_ext_fetch_headers_foreach(JSContext *cx, JSValueConst this_val,
+    int argc, JSValueConst *argv)
+{
+    int                ret;
+    JSValue            callback, keys, key;
+    JSValue            header, retval, arguments[2];
+    uint32_t           length;;
+    ngx_str_t          name;
+    ngx_uint_t         i;
+    ngx_js_headers_t  *headers;
+
+    headers = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_HEADERS);
+    if (headers == NULL) {
+        return JS_ThrowInternalError(cx,
+                                     "\"this\" is not fetch headers object");
+    }
+
+    callback = argv[0];
+
+    if (!JS_IsFunction(cx, callback)) {
+        return JS_ThrowInternalError(cx, "\"callback\" is not a function");
+    }
+
+    keys = ngx_qjs_headers_ext_keys(cx, this_val);
+    if (JS_IsException(keys)) {
+        return JS_EXCEPTION;
+    }
+
+    if (qjs_array_length(cx, keys, &length)) {
+        goto fail;
+    }
+
+    for (i = 0; i < length; i++) {
+        key = JS_GetPropertyUint32(cx, keys, i);
+        if (JS_IsException(key)) {
+            goto fail;
+        }
+
+        ret = ngx_qjs_string(cx, key, &name);
+        if (ret != NGX_OK) {
+            JS_FreeValue(cx, key);
+            goto fail;
+        }
+
+        header = ngx_qjs_headers_get(cx, this_val, &name, 0);
+        if (JS_IsException(header)) {
+            JS_FreeValue(cx, key);
+            goto fail;
+        }
+
+        arguments[0] = key;
+        arguments[1] = header;
+
+        retval = JS_Call(cx, callback, JS_UNDEFINED, 2, arguments);
+
+        JS_FreeValue(cx, key);
+        JS_FreeValue(cx, header);
+        JS_FreeValue(cx, retval);
+    }
+
+    JS_FreeValue(cx, keys);
+
+    return JS_UNDEFINED;
+
+fail:
+
+    JS_FreeValue(cx, keys);
+
+    return JS_EXCEPTION;
+}
+
+
+static JSValue
+ngx_qjs_ext_fetch_headers_get(JSContext *cx, JSValueConst this_val,
+    int argc, JSValueConst *argv, int magic)
+{
+    ngx_int_t  rc;
+    ngx_str_t  name;
+
+    rc = ngx_qjs_string(cx, argv[0], &name);
+    if (rc != NGX_OK) {
+        return JS_EXCEPTION;
+    }
+
+    return ngx_qjs_headers_get(cx, this_val, &name, magic);
+}
+
+
+static JSValue
+ngx_qjs_ext_fetch_headers_has(JSContext *cx, JSValueConst this_val,
+    int argc, JSValueConst *argv)
+{
+    JSValue    retval;
+    ngx_int_t  rc;
+    ngx_str_t  name;
+
+    rc = ngx_qjs_string(cx, argv[0], &name);
+    if (rc != NGX_OK) {
+        return JS_EXCEPTION;
+    }
+
+    retval = ngx_qjs_headers_get(cx, this_val, &name, 0);
+    if (JS_IsException(retval)) {
+        return JS_EXCEPTION;
+    }
+
+    rc = !JS_IsNull(retval);
+    JS_FreeValue(cx, retval);
+
+    return JS_NewBool(cx, rc);
+}
+
+
+static JSValue
+ngx_qjs_ext_fetch_headers_set(JSContext *cx, JSValueConst this_val,
+    int argc, JSValueConst *argv)
+{
+    ngx_int_t          rc;
+    ngx_str_t          name, value;
+    ngx_uint_t         i;
+    ngx_list_part_t   *part;
+    ngx_js_tb_elt_t   *h, **ph, **pp;
+    ngx_js_headers_t  *headers;
+
+    headers = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_HEADERS);
+    if (headers == NULL) {
+        return JS_ThrowInternalError(cx,
+                                     "\"this\" is not fetch headers object");
+    }
+
+    rc = ngx_qjs_string(cx, argv[0], &name);
+    if (rc != NGX_OK) {
+        return JS_EXCEPTION;
+    }
+
+    rc = ngx_qjs_string(cx, argv[1], &value);
+    if (rc != NGX_OK) {
+        return JS_EXCEPTION;
+    }
+
+    part = &headers->header_list.part;
+    h = part->elts;
+
+    for (i = 0; /* void */; i++) {
+
+        if (i >= part->nelts) {
+            if (part->next == NULL) {
+                break;
+            }
+
+            part = part->next;
+            h = part->elts;
+            i = 0;
+        }
+
+        if (h[i].hash == 0) {
+            continue;
+        }
+
+        if (name.len == h[i].key.len
+            && (ngx_strncasecmp(name.data, h[i].key.data, name.len) == 0))
+        {
+            h[i].value.len = value.len;
+            h[i].value.data = value.data;
+
+            ph = &h[i].next;
+
+            while (*ph) {
+                pp = ph;
+                ph = &(*ph)->next;
+                *pp = NULL;
+            }
+
+            return JS_UNDEFINED;
+        }
+    }
+
+    rc = ngx_qjs_headers_append(cx, headers, name.data, name.len,
+                                 value.data, value.len);
+    if (rc != NGX_OK) {
+        return JS_EXCEPTION;
+    }
+
+    return JS_UNDEFINED;
+}
+
+
+static JSValue
+ngx_qjs_ext_fetch_request_body(JSContext *cx, JSValueConst this_val,
+    int argc, JSValueConst *argv, int magic)
+{
+    char *             string;
+    JSValue            result;
+    ngx_js_request_t  *request;
+
+    request = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_REQUEST);
+    if (request == NULL) {
+        return JS_UNDEFINED;
+    }
+
+    if (request->body_used) {
+        return JS_ThrowInternalError(cx, "body stream already read");
+    }
+
+    request->body_used = 1;
+
+    switch (magic) {
+    case NGX_QJS_BODY_ARRAY_BUFFER:
+        /*
+         * no free_func for JS_NewArrayBuffer()
+         * because request->body is allocated from e->pool
+         * and will be freed when context is freed.
+         */
+        result = JS_NewArrayBuffer(cx, request->body.data, request->body.len,
+                                   NULL, NULL, 0);
+        if (JS_IsException(result)) {
+            return JS_ThrowOutOfMemory(cx);
+        }
+
+        break;
+
+    case NGX_QJS_BODY_JSON:
+    case NGX_QJS_BODY_TEXT:
+    default:
+        result = qjs_string_create(cx, request->body.data, request->body.len);
+        if (JS_IsException(result)) {
+            return JS_ThrowOutOfMemory(cx);
+        }
+
+        if (magic == NGX_QJS_BODY_JSON) {
+            string = js_malloc(cx, request->body.len + 1);
+
+            JS_FreeValue(cx, result);
+            result = JS_UNDEFINED;
+
+            if (string == NULL) {
+                return JS_ThrowOutOfMemory(cx);
+            }
+
+            ngx_memcpy(string, request->body.data, request->body.len);
+            string[request->body.len] = '\0';
+
+            /* 'string' must be zero terminated. */
+            result = JS_ParseJSON(cx, string, request->body.len, "<input>");
+            js_free(cx, string);
+            if (JS_IsException(result)) {
+                break;
+            }
+        }
+    }
+
+    return qjs_promise_result(cx, result);
+}
+
+
+static JSValue
+ngx_qjs_ext_fetch_request_body_used(JSContext *cx, JSValueConst this_val)
+{
+    ngx_js_request_t  *request;
+
+    request = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_REQUEST);
+    if (request == NULL) {
+        return JS_UNDEFINED;
+    }
+
+    return JS_NewBool(cx, request->body_used);
+}
+
+
+static JSValue
+ngx_qjs_ext_fetch_request_cache(JSContext *cx, JSValueConst this_val)
+{
+    ngx_js_request_t  *request;
+
+    request = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_REQUEST);
+    if (request == NULL) {
+        return JS_UNDEFINED;
+    }
+
+    return ngx_qjs_fetch_flag(cx, ngx_qjs_fetch_cache_modes,
+                              request->cache_mode);
+}
+
+
+static JSValue
+ngx_qjs_ext_fetch_request_credentials(JSContext *cx, JSValueConst this_val)
+{
+    ngx_js_request_t  *request;
+
+    request = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_REQUEST);
+    if (request == NULL) {
+        return JS_UNDEFINED;
+    }
+
+    return ngx_qjs_fetch_flag(cx, ngx_qjs_fetch_credentials,
+                              request->credentials);
+}
+
+
+static JSValue
+ngx_qjs_ext_fetch_request_headers(JSContext *cx, JSValueConst this_val)
+{
+    JSValue           header;
+    ngx_js_request_t  *request;
+
+    request = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_REQUEST);
+    if (request == NULL) {
+        return JS_UNDEFINED;
+    }
+
+    header = ngx_qjs_arg(request->header_value);
+
+    if (JS_IsUndefined(header)) {
+        header = JS_NewObjectClass(cx, NGX_QJS_CLASS_ID_FETCH_HEADERS);
+        if (JS_IsException(header)) {
+            return JS_ThrowInternalError(cx, "fetch header creation failed");
+        }
+
+        JS_SetOpaque(header, &request->headers);
+
+        ngx_qjs_arg(request->header_value) = header;
+    }
+
+    return JS_DupValue(cx, header);
+}
+
+
+static JSValue
+ngx_qjs_ext_fetch_request_field(JSContext *cx, JSValueConst this_val, int magic)
+{
+    ngx_str_t         *field;
+    ngx_js_request_t  *request;
+
+    request = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_REQUEST);
+    if (request == NULL) {
+        return JS_UNDEFINED;
+    }
+
+    field = (ngx_str_t *) ((u_char *) request + magic);
+
+    return qjs_string_create(cx, field->data, field->len);
+}
+
+
+static JSValue
+ngx_qjs_ext_fetch_request_mode(JSContext *cx, JSValueConst this_val)
+{
+    ngx_js_request_t  *request;
+
+    request = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_REQUEST);
+    if (request == NULL) {
+        return JS_UNDEFINED;
+    }
+
+    return ngx_qjs_fetch_flag(cx, ngx_qjs_fetch_modes, request->mode);
+}
+
+
+static void
+ngx_qjs_fetch_request_finalizer(JSRuntime *rt, JSValue val)
+{
+    ngx_js_request_t  *request;
+
+    request = JS_GetOpaque(val, NGX_QJS_CLASS_ID_FETCH_REQUEST);
+
+    JS_FreeValueRT(rt, ngx_qjs_arg(request->header_value));
+}
+
+
+static JSValue
+ngx_qjs_ext_fetch_response_status(JSContext *cx, JSValueConst this_val)
+{
+    ngx_js_response_t  *response;
+
+    response = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_RESPONSE);
+    if (response == NULL) {
+        return JS_UNDEFINED;
+    }
+
+    return JS_NewUint32(cx, response->code);
+}
+
+
+static JSValue
+ngx_qjs_ext_fetch_response_status_text(JSContext *cx, JSValueConst this_val)
+{
+    ngx_js_response_t  *response;
+
+    response = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_RESPONSE);
+    if (response == NULL) {
+        return JS_UNDEFINED;
+    }
+
+    return qjs_string_create(cx, response->status_text.data,
+                             response->status_text.len);
+}
+
+
+static JSValue
+ngx_qjs_ext_fetch_response_ok(JSContext *cx, JSValueConst this_val)
+{
+    ngx_js_response_t  *response;
+
+    response = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_RESPONSE);
+    if (response == NULL) {
+        return JS_UNDEFINED;
+    }
+
+    return JS_NewBool(cx, response->code >= 200 && response->code < 300);
+}
+
+
+static JSValue
+ngx_qjs_ext_fetch_response_body_used(JSContext *cx, JSValueConst this_val)
+{
+    ngx_js_response_t  *response;
+
+    response = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_RESPONSE);
+    if (response == NULL) {
+        return JS_UNDEFINED;
+    }
+
+    return JS_NewBool(cx, response->body_used);
+}
+
+
+static JSValue
+ngx_qjs_ext_fetch_response_headers(JSContext *cx, JSValueConst this_val)
+{
+    JSValue            header;
+    ngx_js_response_t  *response;
+
+    response = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_RESPONSE);
+    if (response == NULL) {
+        return JS_UNDEFINED;
+    }
+
+    header = ngx_qjs_arg(response->header_value);
+
+    if (JS_IsUndefined(header)) {
+        header = JS_NewObjectClass(cx, NGX_QJS_CLASS_ID_FETCH_HEADERS);
+        if (JS_IsException(header)) {
+            return JS_ThrowInternalError(cx, "fetch header creation failed");
+        }
+
+        JS_SetOpaque(header, &response->headers);
+
+        ngx_qjs_arg(response->header_value) = header;
+    }
+
+    return JS_DupValue(cx, header);
+}
+
+
+static JSValue
+ngx_qjs_ext_fetch_response_type(JSContext *cx, JSValueConst this_val)
+{
+    ngx_js_response_t  *response;
+
+    response = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_RESPONSE);
+    if (response == NULL) {
+        return JS_UNDEFINED;
+    }
+
+    return JS_NewString(cx, "basic");
+}
+
+
+static JSValue
+ngx_qjs_ext_fetch_response_body(JSContext *cx, JSValueConst this_val,
+    int argc, JSValueConst *argv, int magic)
+{
+    JSValue             result;
+    njs_int_t           ret;
+    njs_str_t           string;
+    ngx_js_response_t  *response;
+
+    response = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_RESPONSE);
+    if (response == NULL) {
+        return JS_UNDEFINED;
+    }
+
+    if (response->body_used) {
+        return JS_ThrowInternalError(cx, "body stream already read");
+    }
+
+    response->body_used = 1;
+
+    switch (magic) {
+    case NGX_QJS_BODY_ARRAY_BUFFER:
+    case NGX_QJS_BODY_TEXT:
+        ret = njs_chb_join(&response->chain, &string);
+        if (ret != NJS_OK) {
+            return JS_ThrowOutOfMemory(cx);
+        }
+
+        if (magic == NGX_QJS_BODY_TEXT) {
+            result = qjs_string_create(cx, string.start, string.length);
+            if (JS_IsException(result)) {
+                return JS_ThrowOutOfMemory(cx);
+            }
+
+            break;
+        }
+
+        /*
+         * no free_func for JS_NewArrayBuffer()
+         * because string.start is allocated from e->pool
+         * and will be freed when context is freed.
+         */
+        result = JS_NewArrayBuffer(cx, string.start, string.length, NULL, NULL,
+                                   0);
+        if (JS_IsException(result)) {
+            return JS_ThrowOutOfMemory(cx);
+        }
+
+        break;
+
+    case NGX_QJS_BODY_JSON:
+    default:
+        /* 'string.start' must be zero terminated. */
+        njs_chb_append_literal(&response->chain, "\0");
+        ret = njs_chb_join(&response->chain, &string);
+        if (ret != NJS_OK) {
+            return JS_ThrowOutOfMemory(cx);
+        }
+
+        result = JS_ParseJSON(cx, (char *) string.start, string.length - 1,
+                              "<input>");
+        if (JS_IsException(result)) {
+            break;
+        }
+    }
+
+    return qjs_promise_result(cx, result);
+}
+
+
+static JSValue
+ngx_qjs_ext_fetch_response_redirected(JSContext *cx, JSValueConst this_val)
+{
+    ngx_js_response_t  *response;
+
+    response = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_RESPONSE);
+    if (response == NULL) {
+        return JS_UNDEFINED;
+    }
+
+    return JS_NewBool(cx, 0);
+}
+
+
+static JSValue
+ngx_qjs_ext_fetch_response_field(JSContext *cx, JSValueConst this_val, int magic)
+{
+    ngx_str_t          *field;
+    ngx_js_response_t  *response;
+
+    response = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_RESPONSE);
+    if (response == NULL) {
+        return JS_UNDEFINED;
+    }
+
+    field = (ngx_str_t *) ((u_char *) response + magic);
+
+    return qjs_string_create(cx, field->data, field->len);
+}
+
+
+static void
+ngx_qjs_fetch_response_finalizer(JSRuntime *rt, JSValue val)
+{
+    ngx_js_response_t  *response;
+
+    response = JS_GetOpaque(val, NGX_QJS_CLASS_ID_FETCH_RESPONSE);
+
+    JS_FreeValueRT(rt, ngx_qjs_arg(response->header_value));
+    njs_chb_destroy(&response->chain);
+}
+
+
+static JSValue
+ngx_qjs_fetch_flag(JSContext *cx, const ngx_qjs_entry_t *entries,
+    ngx_int_t value)
+{
+    const ngx_qjs_entry_t  *e;
+
+    for (e = entries; e->name.len != 0; e++) {
+        if (e->value == value) {
+            return qjs_string_create(cx, e->name.data, e->name.len);
+        }
+    }
+
+    return JS_ThrowInternalError(cx, "unknown fetch flag: %i", (int) value);
+}
+
+
+static ngx_int_t
+ngx_qjs_fetch_flag_set(JSContext *cx, const ngx_qjs_entry_t *entries,
+     JSValue object, const char *prop)
+{
+    JSValue                value;
+    ngx_int_t              rc;
+    ngx_str_t              flag;
+    const ngx_qjs_entry_t  *e;
+
+    value = JS_GetPropertyStr(cx, object, prop);
+    if (JS_IsException(value)) {
+        JS_ThrowInternalError(cx, "failed to get %s property", prop);
+        return NGX_ERROR;
+    }
+
+    if (JS_IsUndefined(value)) {
+        return entries[0].value;
+    }
+
+    rc = ngx_qjs_string(cx, value, &flag);
+    JS_FreeValue(cx, value);
+    if (rc != NGX_OK) {
+        return NGX_ERROR;
+    }
+
+    for (e = entries; e->name.len != 0; e++) {
+        if (flag.len == e->name.len
+            && ngx_strncasecmp(e->name.data, flag.data, flag.len) == 0)
+        {
+            return e->value;
+        }
+    }
+
+    JS_ThrowInternalError(cx, "unknown %s type: %.*s", prop,
+                          (int) flag.len, flag.data);
+
+    return NGX_ERROR;
+}
+
+
+static JSModuleDef *
+ngx_qjs_fetch_init(JSContext *cx, const char *name)
+{
+    int      i, class_id;
+    JSValue  global_obj, proto, class;
+
+    static const JSClassDef  *const classes[] = {
+        &ngx_qjs_fetch_headers_class,
+        &ngx_qjs_fetch_request_class,
+        &ngx_qjs_fetch_response_class,
+        NULL
+    };
+
+    static JSCFunction  *ctors[] = {
+        ngx_qjs_fetch_headers_ctor,
+        ngx_qjs_fetch_request_ctor,
+        ngx_qjs_fetch_response_ctor,
+        NULL
+    };
+
+    static const JSCFunctionListEntry *const  protos[] = {
+        ngx_qjs_ext_fetch_headers_proto,
+        ngx_qjs_ext_fetch_request_proto,
+        ngx_qjs_ext_fetch_response_proto,
+        NULL
+    };
+
+    static const uint8_t  proto_sizes[] = {
+        njs_nitems(ngx_qjs_ext_fetch_headers_proto),
+        njs_nitems(ngx_qjs_ext_fetch_request_proto),
+        njs_nitems(ngx_qjs_ext_fetch_response_proto),
+        0
+    };
+
+    global_obj = JS_GetGlobalObject(cx);
+
+    for (i = 0; classes[i] != NULL; i++) {
+        class_id = NGX_QJS_CLASS_ID_FETCH_HEADERS + i;
+
+        if (JS_NewClass(JS_GetRuntime(cx), class_id, classes[i]) < 0) {
+            return NULL;
+        }
+
+        proto = JS_NewObject(cx);
+        if (JS_IsException(proto)) {
+            JS_FreeValue(cx, global_obj);
+            return NULL;
+        }
+
+        JS_SetPropertyFunctionList(cx, proto, protos[i], proto_sizes[i]);
+
+        class = JS_NewCFunction2(cx, ctors[i], classes[i]->class_name, 2,
+                                 JS_CFUNC_constructor, 0);
+        if (JS_IsException(class)) {
+            JS_FreeValue(cx, proto);
+            JS_FreeValue(cx, global_obj);
+            return NULL;
+        }
+
+        JS_SetConstructor(cx, class, proto);
+        JS_SetClassProto(cx, class_id, proto);
+
+        if (JS_SetPropertyStr(cx, global_obj, classes[i]->class_name, class)
+            < 0)
+        {
+            JS_FreeValue(cx, class);
+            JS_FreeValue(cx, proto);
+            JS_FreeValue(cx, global_obj);
+            return NULL;
+        }
+    }
+
+    JS_FreeValue(cx, global_obj);
+
+    return JS_NewCModule(cx, name, NULL);
+}
diff --git a/nginx/ngx_stream_js_module.c b/nginx/ngx_stream_js_module.c
index 5c837494..0e022eb0 100644
--- a/nginx/ngx_stream_js_module.c
+++ b/nginx/ngx_stream_js_module.c
@@ -834,6 +834,7 @@ static JSClassDef ngx_stream_qjs_variables_class = {
 qjs_module_t *njs_stream_qjs_addon_modules[] = {
     &ngx_qjs_ngx_module,
     &ngx_qjs_ngx_shared_dict_module,
+    &ngx_qjs_ngx_fetch_module,
     /*
      * Shared addons should be in the same order and the same positions
      * in all nginx modules.
diff --git a/nginx/t/js_fetch.t b/nginx/t/js_fetch.t
index ae9d1f61..7ee1a602 100644
--- a/nginx/t/js_fetch.t
+++ b/nginx/t/js_fetch.t
@@ -342,6 +342,8 @@ $t->write_file('test.js', <<EOF);
             }
         }
 
+        out.sort();
+
         r.return(200, JSON.stringify(out));
     }
 
@@ -411,8 +413,6 @@ EOF
 
 $t->try_run('no njs.fetch');
 
-plan(skip_all => 'not yet') if http_get('/engine') =~ /QuickJS$/m;
-
 $t->plan(37);
 
 $t->run_daemon(\&http_daemon, port(8082));
diff --git a/nginx/t/js_fetch_https.t b/nginx/t/js_fetch_https.t
index 9a44a339..8ede1048 100644
--- a/nginx/t/js_fetch_https.t
+++ b/nginx/t/js_fetch_https.t
@@ -196,8 +196,6 @@ foreach my $name ('default.example.com', '1.example.com') {
 
 $t->try_run('no njs.fetch');
 
-plan(skip_all => 'not yet') if http_get('/engine') =~ /QuickJS$/m;
-
 $t->plan(7);
 
 $t->run_daemon(\&dns_daemon, port(8981), $t);
diff --git a/nginx/t/js_fetch_objects.t b/nginx/t/js_fetch_objects.t
index 67cabdfc..bc5cc7ed 100644
--- a/nginx/t/js_fetch_objects.t
+++ b/nginx/t/js_fetch_objects.t
@@ -271,7 +271,9 @@ $t->write_file('test.js', <<EOF);
                     })
 
                 } catch (e) {
-                    if (!e.message.startsWith('Cannot assign to read-only p')) {
+                    if (!e.message.startsWith('Cannot assign to read-only p')
+                         && !e.message.startsWith('no setter for property'))
+                    {
                         throw e;
                     }
                 }
@@ -386,6 +388,17 @@ $t->write_file('test.js', <<EOF);
                 var body = await r.text();
                 return `\${body}: \${r.headers.get('Content-Type')}`;
              }, 'ABC: text/plain;charset=UTF-8'],
+            ['arrayBuffer()', async () => {
+                var r = new Request("http://nginx.org", {body: 'ABC'});
+                var body = await r.arrayBuffer();
+                body = new Uint8Array(body);
+                return new TextDecoder().decode(body);
+             }, 'ABC'],
+            ['json()', async () => {
+                var r = new Request("http://nginx.org", {body: '{"a": 42}'});
+                var body = await r.json();
+                return body.a;
+             }, 42],
             ['user content type', async () => {
                 var r = new Request("http://nginx.org",
                                     {body: 'ABC',
@@ -443,6 +456,12 @@ $t->write_file('test.js', <<EOF);
                 var body = await r.text();
                 return `\${r.url}: \${body} \${r.headers.get('b')}`;
              }, ':  Y'],
+            ['arrayBuffer', async () => {
+                var r = new Response('foo');
+                var body = await r.arrayBuffer();
+                body = new Uint8Array(body);
+                return new TextDecoder().decode(body);
+             }, 'foo'],
             ['json', async () => {
                 var r = new Response('{"a": {"b": 42}}');
                 var json = await r.json();
@@ -515,8 +534,6 @@ EOF
 
 $t->try_run('no njs');
 
-plan(skip_all => 'not yet') if http_get('/engine') =~ /QuickJS$/m;
-
 $t->plan(5);
 
 ###############################################################################
diff --git a/nginx/t/js_fetch_resolver.t b/nginx/t/js_fetch_resolver.t
index 7cea3386..031ff43c 100644
--- a/nginx/t/js_fetch_resolver.t
+++ b/nginx/t/js_fetch_resolver.t
@@ -146,8 +146,6 @@ EOF
 
 $t->try_run('no njs.fetch');
 
-plan(skip_all => 'not yet') if http_get('/engine') =~ /QuickJS$/m;
-
 $t->plan(5);
 
 $t->run_daemon(\&dns_daemon, port(8981), $t);
diff --git a/nginx/t/js_fetch_timeout.t b/nginx/t/js_fetch_timeout.t
index 5b207b90..ab1ba24a 100644
--- a/nginx/t/js_fetch_timeout.t
+++ b/nginx/t/js_fetch_timeout.t
@@ -116,8 +116,6 @@ EOF
 
 $t->try_run('no js_fetch_timeout');
 
-plan(skip_all => 'not yet') if http_get('/engine') =~ /QuickJS$/m;
-
 $t->plan(2);
 
 ###############################################################################
diff --git a/nginx/t/js_fetch_verify.t b/nginx/t/js_fetch_verify.t
index 4c97e04d..f98b4d8c 100644
--- a/nginx/t/js_fetch_verify.t
+++ b/nginx/t/js_fetch_verify.t
@@ -114,8 +114,6 @@ foreach my $name ('localhost') {
 
 $t->try_run('no js_fetch_verify');
 
-plan(skip_all => 'not yet') if http_get('/engine') =~ /QuickJS$/m;
-
 $t->plan(2);
 
 $t->run_daemon(\&dns_daemon, port(8981), $t);
diff --git a/nginx/t/js_periodic_fetch.t b/nginx/t/js_periodic_fetch.t
index d7bcfb76..0231b662 100644
--- a/nginx/t/js_periodic_fetch.t
+++ b/nginx/t/js_periodic_fetch.t
@@ -121,8 +121,6 @@ EOF
 
 $t->try_run('no js_periodic with fetch');
 
-plan(skip_all => 'not yet') if http_get('/engine') =~ /QuickJS$/m;
-
 $t->plan(3);
 
 ###############################################################################
diff --git a/nginx/t/stream_js_fetch.t b/nginx/t/stream_js_fetch.t
index c57128a8..9a42ae29 100644
--- a/nginx/t/stream_js_fetch.t
+++ b/nginx/t/stream_js_fetch.t
@@ -171,8 +171,6 @@ EOF
 
 $t->try_run('no stream njs available');
 
-plan(skip_all => 'not yet') if http_get('/engine') =~ /QuickJS$/m;
-
 $t->plan(9);
 
 $t->run_daemon(\&stream_daemon, port(8090), port(8091));
diff --git a/nginx/t/stream_js_fetch_https.t b/nginx/t/stream_js_fetch_https.t
index 5d7c5c20..987a896a 100644
--- a/nginx/t/stream_js_fetch_https.t
+++ b/nginx/t/stream_js_fetch_https.t
@@ -273,8 +273,6 @@ foreach my $name ('default.example.com', '1.example.com') {
 
 $t->try_run('no njs.fetch');
 
-plan(skip_all => 'not yet') if http_get('/engine') =~ /QuickJS$/m;
-
 $t->plan(6);
 
 $t->run_daemon(\&dns_daemon, port(8981), $t);
diff --git a/nginx/t/stream_js_fetch_init.t b/nginx/t/stream_js_fetch_init.t
index 3f6d7262..f48b9d5e 100644
--- a/nginx/t/stream_js_fetch_init.t
+++ b/nginx/t/stream_js_fetch_init.t
@@ -92,8 +92,6 @@ EOF
 
 $t->try_run('no stream njs available');
 
-plan(skip_all => 'not yet') if http_get('/engine') =~ /QuickJS$/m;
-
 $t->plan(1);
 
 $t->run_daemon(\&stream_daemon, port(8090));
diff --git a/nginx/t/stream_js_periodic_fetch.t b/nginx/t/stream_js_periodic_fetch.t
index e88d69d5..60599423 100644
--- a/nginx/t/stream_js_periodic_fetch.t
+++ b/nginx/t/stream_js_periodic_fetch.t
@@ -147,7 +147,6 @@ EOF
 
 $t->run_daemon(\&stream_daemon, port(8090));
 $t->try_run('no js_periodic with fetch');
-plan(skip_all => 'not yet') if http_get('/engine') =~ /QuickJS$/m;
 $t->plan(3);
 $t->waitforsocket('127.0.0.1:' . port(8090));
 
diff --git a/src/qjs.c b/src/qjs.c
index a941ba71..7993de1b 100644
--- a/src/qjs.c
+++ b/src/qjs.c
@@ -1147,6 +1147,31 @@ qjs_string_base64url(JSContext *cx, const njs_str_t *src)
 }
 
 
+int
+qjs_array_length(JSContext *cx, JSValueConst arr, uint32_t *plen)
+{
+    int       ret;
+    JSValue   value;
+    uint32_t  len;
+
+    value = JS_GetPropertyStr(cx, arr, "length");
+    if (JS_IsException(value)) {
+        return -1;
+    }
+
+    ret = JS_ToUint32(cx, &len, value);
+    JS_FreeValue(cx, value);
+
+    if (ret) {
+        return -1;
+    }
+
+    *plen = len;
+
+    return 0;
+}
+
+
 static JSValue
 qjs_promise_fill_trampoline(JSContext *cx, int argc, JSValueConst *argv)
 {
diff --git a/src/qjs.h b/src/qjs.h
index d3bbc0e8..e920453e 100644
--- a/src/qjs.h
+++ b/src/qjs.h
@@ -136,6 +136,8 @@ JSValue qjs_string_create_chb(JSContext *cx, njs_chb_t *chain);
 
 void qjs_free_prop_enum(JSContext *ctx, JSPropertyEnum *tab, uint32_t len);
 
+int qjs_array_length(JSContext *cx, JSValueConst arr, uint32_t *plen);
+
 JSValue qjs_promise_result(JSContext *cx, JSValue result);
 
 JSValue qjs_string_hex(JSContext *cx, const njs_str_t *src);


More information about the nginx-devel mailing list