From 7684d70257708a4dad6aca024e906d9e1ed705dc Mon Sep 17 00:00:00 2001 From: Dmitry Volyntsev Date: Fri, 27 Mar 2026 18:43:56 -0700 Subject: [PATCH] HTTP: added r.readRequestText() and friends. Added async methods - r.readRequestText() as string - r.readRequestArrayBuffer() as ArrayBuffer - r.readRequestJSON() as object. that return Promises resolving with the request body wrapped as a corresponding type. --- nginx/ngx_http_js_module.c | 639 ++++++++++++++++++++++++++++++++----- nginx/ngx_js.h | 4 + nginx/ngx_js_fetch.c | 3 - nginx/ngx_qjs_fetch.c | 31 +- nginx/t/js_access_body.t | 459 ++++++++++++++++++++++++++ test/ts/test.ts | 9 + ts/ngx_http_js_module.d.ts | 41 +++ 7 files changed, 1091 insertions(+), 95 deletions(-) create mode 100644 nginx/t/js_access_body.t diff --git a/nginx/ngx_http_js_module.c b/nginx/ngx_http_js_module.c index 1654b310..a448ce57 100644 --- a/nginx/ngx_http_js_module.c +++ b/nginx/ngx_http_js_module.c @@ -77,6 +77,50 @@ struct ngx_http_js_ctx_s { ngx_js_periodic_t *periodic; unsigned in_progress:1; + + /* + * Body-read ownership state for the js_access phase handler. + * + * readRequest*() cannot call ngx_http_read_client_request_body() + * from the JS native directly. The phase handler must choose between + * NGX_AGAIN ("I still own the request") and NGX_DONE ("I finalized + * it myself"), but which one is correct depends on whether the body + * read completes synchronously or goes async -- and that is only + * known after the call returns. + * + * In the async case the body reader takes over r->read_event_handler + * and may call ngx_http_finalize_request() on I/O errors without + * invoking our post_handler. If the phase handler had returned + * NGX_AGAIN, both the phase engine and the body reader would own + * the request, causing a hang on errors such as chunked 413. + * + * IDLE no body read requested (initial and terminal state). + * DEFERRED JS called readRequest*(); the access handler + * will start the read after engine->call() returns. + * IN_PROGRESS ngx_http_read_client_request_body() returned + * NGX_AGAIN; request ownership transferred to the + * body reader via NGX_DONE. access_body_done + * callback resumes phases on completion. + * + * IDLE -> DEFERRED -> IDLE (sync completion or error) + * IDLE -> DEFERRED -> IN_PROGRESS -> IDLE (async completion) + */ +#define NGX_HTTP_JS_BODY_READ_IDLE 0 +#define NGX_HTTP_JS_BODY_READ_DEFERRED 1 +#define NGX_HTTP_JS_BODY_READ_IN_PROGRESS 2 + unsigned body_read_state:2; + + /* + * Collected request body as a contiguous buffer. + * Shared by both synchronous property getters (requestText, + * requestBuffer) and async readRequest*() methods. + */ + unsigned body_read_nul:1; + u_char *body_read_data; + size_t body_read_len; + + /* Pending promise/event for deferred body reads. */ + void *body_read_event; }; @@ -129,6 +173,25 @@ static ngx_int_t ngx_http_js_variable_var(ngx_http_request_t *r, ngx_http_variable_value_t *v, uintptr_t data); static ngx_int_t ngx_http_js_init_vm(ngx_http_request_t *r, njs_int_t proto_id); static void ngx_http_js_cleanup_ctx(void *data); +static void ngx_http_js_body_read_abort(ngx_http_js_ctx_t *ctx); +static ngx_int_t ngx_http_js_collect_body(ngx_http_request_t *r, + ngx_http_js_ctx_t *ctx); +static void ngx_http_js_access_body_finalize(ngx_http_request_t *r, + ngx_http_js_ctx_t *ctx, ngx_int_t rc); +static void ngx_http_js_access_body_done(ngx_http_request_t *r); +static ngx_int_t ngx_http_js_body_resolve(ngx_http_js_ctx_t *ctx, + void *event); + +static njs_int_t ngx_http_js_ext_read_request_body(njs_vm_t *vm, + njs_value_t *args, njs_uint_t nargs, njs_index_t magic, + njs_value_t *retval); +#if (NJS_HAVE_QUICKJS) +static JSValue ngx_http_qjs_ext_read_request_body(JSContext *cx, + JSValueConst this_val, int argc, JSValueConst *argv, int magic); +static ngx_int_t ngx_http_qjs_body_resolve(ngx_http_js_ctx_t *ctx, + void *event); +static void ngx_http_js_read_body_event_destructor(ngx_qjs_event_t *event); +#endif static njs_int_t ngx_http_js_ext_keys_header(njs_vm_t *vm, njs_value_t *value, njs_value_t *keys, ngx_list_t *headers); @@ -999,6 +1062,42 @@ static njs_external_t ngx_http_js_ext_request[] = { } }, + { + .flags = NJS_EXTERN_METHOD, + .name.string = njs_str("readRequestArrayBuffer"), + .writable = 1, + .configurable = 1, + .enumerable = 1, + .u.method = { + .native = ngx_http_js_ext_read_request_body, + .magic8 = NGX_JS_BODY_ARRAY_BUFFER, + } + }, + + { + .flags = NJS_EXTERN_METHOD, + .name.string = njs_str("readRequestJSON"), + .writable = 1, + .configurable = 1, + .enumerable = 1, + .u.method = { + .native = ngx_http_js_ext_read_request_body, + .magic8 = NGX_JS_BODY_JSON, + } + }, + + { + .flags = NJS_EXTERN_METHOD, + .name.string = njs_str("readRequestText"), + .writable = 1, + .configurable = 1, + .enumerable = 1, + .u.method = { + .native = ngx_http_js_ext_read_request_body, + .magic8 = NGX_JS_BODY_TEXT, + } + }, + { .flags = NJS_EXTERN_METHOD, .name.string = njs_str("subrequest"), @@ -1168,6 +1267,13 @@ static const JSCFunctionListEntry ngx_http_qjs_ext_request[] = { JS_CFUNC_DEF("setReturnValue", 1, ngx_http_qjs_ext_set_return_value), JS_CGETSET_DEF("status", ngx_http_qjs_ext_status_get, ngx_http_qjs_ext_status_set), + JS_CFUNC_MAGIC_DEF("readRequestArrayBuffer", 0, + ngx_http_qjs_ext_read_request_body, + NGX_JS_BODY_ARRAY_BUFFER), + JS_CFUNC_MAGIC_DEF("readRequestJSON", 0, + ngx_http_qjs_ext_read_request_body, NGX_JS_BODY_JSON), + JS_CFUNC_MAGIC_DEF("readRequestText", 0, + ngx_http_qjs_ext_read_request_body, NGX_JS_BODY_TEXT), JS_CFUNC_DEF("subrequest", 3, ngx_http_qjs_ext_subrequest), JS_CGETSET_MAGIC_DEF("uri", ngx_http_qjs_ext_string, NULL, offsetof(ngx_http_request_t, uri)), @@ -1293,6 +1399,38 @@ ngx_http_js_access_handler(ngx_http_request_t *r) return NGX_HTTP_INTERNAL_SERVER_ERROR; } + /* JS called readRequest*(). */ + + if (ctx->body_read_state == NGX_HTTP_JS_BODY_READ_DEFERRED) { + + rc = ngx_http_read_client_request_body(r, ngx_http_js_access_body_done); + + if (rc >= NGX_HTTP_SPECIAL_RESPONSE) { + ngx_http_js_body_read_abort(ctx); + return rc; + } + + r->preserve_body = 1; + + if (rc == NGX_OK) { + /* + * Sync: access_body_done callback already fired, resolved + * or rejected the promise. access_body_finalize() returned + * without running posted requests. Fall through to let + * the pending/status check handle the result. + */ + ctx->body_read_state = NGX_HTTP_JS_BODY_READ_IDLE; + goto done; + } + + ctx->body_read_state = NGX_HTTP_JS_BODY_READ_IN_PROGRESS; + ctx->in_progress = 1; + ngx_http_finalize_request(r, NGX_DONE); + return NGX_DONE; + } + +done: + if (ngx_js_ctx_pending(ctx)) { ctx->in_progress = 1; r->write_event_handler = ngx_http_js_access_write_event_handler; @@ -3129,14 +3267,9 @@ ngx_http_js_ext_get_request_body(njs_vm_t *vm, njs_object_prop_t *prop, uint32_t unused, njs_value_t *value, njs_value_t *setval, njs_value_t *retval) { - u_char *p, *body; - size_t len; - ssize_t n; uint32_t buffer_type; - ngx_buf_t *buf; njs_int_t ret; njs_value_t *request_body; - ngx_chain_t *cl; ngx_http_js_ctx_t *ctx; ngx_http_request_t *r; @@ -3164,6 +3297,43 @@ ngx_http_js_ext_get_request_body(njs_vm_t *vm, njs_object_prop_t *prop, return NJS_DECLINED; } + if (ngx_http_js_collect_body(r, ctx) != NGX_OK) { + njs_vm_internal_error(vm, "failed to read request body"); + return NJS_ERROR; + } + + ret = ngx_js_prop(vm, buffer_type, request_body, ctx->body_read_data, + ctx->body_read_len); + if (ret != NJS_OK) { + return NJS_ERROR; + } + + njs_value_assign(retval, request_body); + + return NJS_OK; +} + + +static ngx_int_t +ngx_http_js_collect_body(ngx_http_request_t *r, ngx_http_js_ctx_t *ctx) +{ + u_char *p, *body; + size_t len; + ssize_t n; + ngx_buf_t *buf; + ngx_chain_t *cl; + + if (ctx->body_read_data != NULL) { + return NGX_OK; + } + + if (r->request_body == NULL || r->request_body->bufs == NULL) { + ctx->body_read_data = (u_char *) ""; + ctx->body_read_len = 0; + ctx->body_read_nul = 1; + return NGX_OK; + } + cl = r->request_body->bufs; buf = cl->buf; @@ -3172,66 +3342,289 @@ ngx_http_js_ext_get_request_body(njs_vm_t *vm, njs_object_prop_t *prop, "http js reading request body from a temporary file"); if (buf == NULL || !buf->in_file) { - njs_vm_internal_error(vm, "cannot find request body"); - return NJS_ERROR; + return NGX_ERROR; } len = buf->file_last - buf->file_pos; - body = ngx_pnalloc(r->pool, len); + body = ngx_pnalloc(r->pool, len + 1); if (body == NULL) { - njs_vm_memory_error(vm); - return NJS_ERROR; + return NGX_ERROR; } n = ngx_read_file(buf->file, body, len, buf->file_pos); if (n != (ssize_t) len) { - njs_vm_internal_error(vm, "failed to read request body"); - return NJS_ERROR; + return NGX_ERROR; } - goto done; - } + body[len] = '\0'; + ctx->body_read_nul = 1; - if (cl->next == NULL) { + } else if (cl->next == NULL) { len = buf->last - buf->pos; body = buf->pos; - goto done; + } else { + len = buf->last - buf->pos; + cl = cl->next; + + for ( /* void */ ; cl; cl = cl->next) { + buf = cl->buf; + len += buf->last - buf->pos; + } + + p = ngx_pnalloc(r->pool, len + 1); + if (p == NULL) { + return NGX_ERROR; + } + + body = p; + cl = r->request_body->bufs; + + for ( /* void */ ; cl; cl = cl->next) { + buf = cl->buf; + p = ngx_cpymem(p, buf->pos, buf->last - buf->pos); + } + + *p = '\0'; + ctx->body_read_nul = 1; } - len = buf->last - buf->pos; - cl = cl->next; + ctx->body_read_data = body; + ctx->body_read_len = len; + + return NGX_OK; +} + - for ( /* void */ ; cl; cl = cl->next) { - buf = cl->buf; - len += buf->last - buf->pos; +static void +ngx_http_js_body_read_abort(ngx_http_js_ctx_t *ctx) +{ + if (ctx->body_read_event != NULL) { +#if (NJS_HAVE_QUICKJS) + if (ctx->engine->type == NGX_ENGINE_QJS) { + ngx_js_del_event(ctx, (ngx_qjs_event_t *) ctx->body_read_event); + } else +#endif + { + ngx_js_del_event(ctx, (ngx_js_event_t *) ctx->body_read_event); + } + + ctx->body_read_event = NULL; } - p = ngx_pnalloc(r->pool, len); - if (p == NULL) { + ctx->body_read_state = NGX_HTTP_JS_BODY_READ_IDLE; +} + + +static njs_int_t +ngx_http_js_body_to_value(njs_vm_t *vm, ngx_http_js_ctx_t *ctx, + ngx_uint_t type, njs_value_t *retval) +{ + njs_int_t ret; + njs_opaque_value_t arg; + + switch (type) { + case NGX_JS_BODY_ARRAY_BUFFER: + return njs_vm_value_array_buffer_set(vm, retval, + ctx->body_read_data, + ctx->body_read_len); + + case NGX_JS_BODY_JSON: + ret = njs_vm_value_string_create(vm, njs_value_arg(&arg), + ctx->body_read_data, + ctx->body_read_len); + if (ret != NJS_OK) { + return NJS_ERROR; + } + + return njs_vm_json_parse(vm, njs_value_arg(&arg), 1, retval); + + case NGX_JS_BODY_TEXT: + default: + return njs_vm_value_string_create(vm, retval, + ctx->body_read_data, + ctx->body_read_len); + } +} + + +static void +ngx_http_js_access_body_finalize(ngx_http_request_t *r, ngx_http_js_ctx_t *ctx, + ngx_int_t rc) +{ + switch (ctx->body_read_state) { + case NGX_HTTP_JS_BODY_READ_IN_PROGRESS: + ctx->body_read_state = NGX_HTTP_JS_BODY_READ_IDLE; + + if (ngx_js_ctx_pending(ctx)) { + r->write_event_handler = ngx_http_js_access_write_event_handler; + return; + } + + r->write_event_handler = ngx_http_core_run_phases; + ngx_http_core_run_phases(r); + break; + + case NGX_HTTP_JS_BODY_READ_DEFERRED: + /* + * Sync body read completion from the access handler. + * The promise is resolved/rejected but the access handler + * is still on the call stack -- do not run posted requests + * or resume phases here; the access handler will do it. + */ + break; + + case NGX_HTTP_JS_BODY_READ_IDLE: + default: + ngx_http_js_event_finalize(r, rc); + } +} + + +static ngx_int_t +ngx_http_js_body_resolve(ngx_http_js_ctx_t *ctx, void *event) +{ + njs_vm_t *vm; + njs_int_t rc; + ngx_js_event_t *ev; + njs_opaque_value_t result; + + ev = event; + vm = ctx->engine->u.njs.vm; + + rc = ngx_http_js_body_to_value(vm, ctx, (uintptr_t) ev->data, + njs_value_arg(&result)); + if (rc != NJS_OK) { + njs_vm_exception_get(vm, njs_value_arg(&result)); + + rc = ngx_js_call(vm, njs_value_function(njs_value_arg(&ev->args[1])), + &result, 1); + + ngx_js_del_event(ctx, ev); + return rc; + } + + rc = ngx_js_call(vm, njs_value_function(njs_value_arg(&ev->function)), + &result, 1); + + ngx_js_del_event(ctx, ev); + + return rc; +} + + +static void +ngx_http_js_access_body_done(ngx_http_request_t *r) +{ + ngx_int_t rc; + ngx_http_js_ctx_t *ctx; + + ctx = ngx_http_get_module_ctx(r, ngx_http_js_module); + + ngx_log_debug0(NGX_LOG_DEBUG_HTTP, r->connection->log, 0, + "http js body read done"); + + /* + * ngx_http_read_client_request_body() incremented count. + * For the IN_PROGRESS (async) path, ngx_http_finalize_request(NGX_DONE) + * already consumed it; for the DEFERRED (sync) path, we consume it here. + */ + if (ctx->body_read_state == NGX_HTTP_JS_BODY_READ_DEFERRED) { + r->main->count--; + } + + if (ctx->body_read_event == NULL) { + return; + } + + if (ngx_http_js_collect_body(r, ctx) != NGX_OK) { + ngx_http_js_body_read_abort(ctx); + ngx_http_js_access_body_finalize(r, ctx, NGX_ERROR); + return; + } + +#if (NJS_HAVE_QUICKJS) + if (ctx->engine->type == NGX_ENGINE_QJS) { + rc = ngx_http_qjs_body_resolve(ctx, ctx->body_read_event); + } else +#endif + { + rc = ngx_http_js_body_resolve(ctx, ctx->body_read_event); + } + + ctx->body_read_event = NULL; + + ngx_http_js_access_body_finalize(r, ctx, rc); +} + + +static njs_int_t +ngx_http_js_ext_read_request_body(njs_vm_t *vm, njs_value_t *args, + njs_uint_t nargs, njs_index_t magic, njs_value_t *retval) +{ + ngx_int_t rc; + ngx_js_event_t *event; + ngx_http_js_ctx_t *ctx; + ngx_http_request_t *r; + + r = njs_vm_external(vm, ngx_http_js_request_proto_id, + njs_argument(args, 0)); + if (r == NULL) { + njs_vm_error(vm, "\"this\" is not an external"); + return NJS_ERROR; + } + + ctx = ngx_http_get_module_ctx(r, ngx_http_js_module); + + event = njs_mp_zalloc(njs_vm_memory_pool(vm), + sizeof(ngx_js_event_t) + + sizeof(njs_opaque_value_t) * 2); + if (njs_slow_path(event == NULL)) { njs_vm_memory_error(vm); return NJS_ERROR; } - body = p; - cl = r->request_body->bufs; + /* + * r->request_body is set by ngx_http_read_client_request_body(). + * JS only runs after body reading completes, so non-NULL means + * the body is available. + */ + if (r->request_body) { + goto resolve; + } - for ( /* void */ ; cl; cl = cl->next) { - buf = cl->buf; - p = ngx_cpymem(p, buf->pos, buf->last - buf->pos); + if (ctx->body_read_event) { + njs_vm_error(vm, "request body is already being read"); + return NJS_ERROR; } -done: + event->fd = ctx->event_id++; + event->args = (njs_opaque_value_t *) &event[1]; + event->data = (void *) (uintptr_t) magic; - ret = ngx_js_prop(vm, buffer_type, request_body, body, len); - if (ret != NJS_OK) { + rc = njs_vm_promise_create(vm, retval, njs_value_arg(event->args)); + if (rc != NJS_OK) { return NJS_ERROR; } - njs_value_assign(retval, request_body); + njs_value_assign(&event->function, njs_value_arg(event->args)); + + ngx_js_add_event(ctx, event); + + ctx->body_read_event = event; + ctx->body_read_state = NGX_HTTP_JS_BODY_READ_DEFERRED; return NJS_OK; + +resolve: + + if (ngx_http_js_collect_body(r, ctx) != NGX_OK) { + njs_vm_memory_error(vm); + return NJS_ERROR; + } + + return ngx_http_js_body_to_value(vm, ctx, (ngx_uint_t) magic, retval); } @@ -5486,14 +5879,10 @@ ngx_http_qjs_ext_response_body(JSContext *cx, JSValueConst this_val, int type) static JSValue ngx_http_qjs_ext_request_body(JSContext *cx, JSValueConst this_val, int type) { - u_char *p, *data; - size_t len; - ssize_t n; JSValue body; uint32_t buffer_type; - ngx_buf_t *buf; - ngx_chain_t *cl; ngx_http_request_t *r; + ngx_http_js_ctx_t *ctx; ngx_http_qjs_request_t *req; req = JS_GetOpaque(this_val, NGX_QJS_CLASS_ID_HTTP_REQUEST); @@ -5517,70 +5906,170 @@ ngx_http_qjs_ext_request_body(JSContext *cx, JSValueConst this_val, int type) return JS_UNDEFINED; } - cl = r->request_body->bufs; - buf = cl->buf; + ctx = ngx_http_get_module_ctx(r, ngx_http_js_module); - if (r->request_body->temp_file) { - ngx_log_error(NGX_LOG_WARN, r->connection->log, 0, - "http js reading request body from a temporary file"); + if (ngx_http_js_collect_body(r, ctx) != NGX_OK) { + return JS_ThrowInternalError(cx, "failed to read request body"); + } - if (buf == NULL || !buf->in_file) { - return JS_ThrowInternalError(cx, "cannot find body file"); - } + body = ngx_qjs_prop(cx, buffer_type, ctx->body_read_data, + ctx->body_read_len); + if (JS_IsException(body)) { + return JS_EXCEPTION; + } - len = buf->file_last - buf->file_pos; + req->request_body = body; - data = ngx_pnalloc(r->pool, len); - if (data == NULL) { - return JS_ThrowOutOfMemory(cx); + return JS_DupValue(cx, req->request_body); +} + + +static JSValue +ngx_http_qjs_body_to_value(JSContext *cx, ngx_http_js_ctx_t *ctx, + ngx_uint_t type) +{ + JSValue str; + const char *cstr; + + switch (type) { + case NGX_JS_BODY_ARRAY_BUFFER: + return JS_NewArrayBuffer(cx, ctx->body_read_data, + ctx->body_read_len, NULL, NULL, 0); + + case NGX_JS_BODY_JSON: + if (ctx->body_read_nul) { + return JS_ParseJSON(cx, (const char *) ctx->body_read_data, + ctx->body_read_len, ""); } - n = ngx_read_file(buf->file, data, len, buf->file_pos); - if (n != (ssize_t) len) { - return JS_ThrowInternalError(cx, "failed to read request body"); + str = qjs_string_create(cx, ctx->body_read_data, + ctx->body_read_len); + if (JS_IsException(str)) { + return str; + } + + cstr = JS_ToCString(cx, str); + JS_FreeValue(cx, str); + + if (cstr == NULL) { + return JS_EXCEPTION; } - goto done; + str = JS_ParseJSON(cx, cstr, ctx->body_read_len, ""); + JS_FreeCString(cx, cstr); + + return str; + + case NGX_JS_BODY_TEXT: + default: + return qjs_string_create(cx, ctx->body_read_data, + ctx->body_read_len); } +} + + +static ngx_int_t +ngx_http_qjs_body_resolve(ngx_http_js_ctx_t *ctx, void *event) +{ + JSValue result; + JSContext *cx; + ngx_int_t rc; + ngx_qjs_event_t *ev; + + ev = event; + cx = ctx->engine->u.qjs.ctx; + + result = ngx_http_qjs_body_to_value(cx, ctx, (uintptr_t) ev->data); + if (JS_IsException(result)) { + result = JS_GetException(cx); + + rc = ngx_qjs_call(cx, ev->args[1], &result, 1); + + JS_FreeValue(cx, result); + ngx_js_del_event(ctx, ev); + return rc; + } + + rc = ngx_qjs_call(cx, ev->function, &result, 1); + + JS_FreeValue(cx, result); + ngx_js_del_event(ctx, ev); + + return rc; +} - if (cl->next == NULL) { - len = buf->last - buf->pos; - data = buf->pos; - goto done; +static JSValue +ngx_http_qjs_ext_read_request_body(JSContext *cx, JSValueConst this_val, + int argc, JSValueConst *argv, int magic) +{ + JSValue retval; + ngx_qjs_event_t *event; + ngx_http_js_ctx_t *ctx; + ngx_http_request_t *r; + ngx_http_qjs_request_t *req; + + req = JS_GetOpaque(this_val, NGX_QJS_CLASS_ID_HTTP_REQUEST); + if (req == NULL) { + return JS_ThrowInternalError(cx, "\"this\" is not a request object"); } - len = buf->last - buf->pos; - cl = cl->next; + r = req->request; + ctx = ngx_http_get_module_ctx(r, ngx_http_js_module); + + if (r->request_body) { + goto resolve; + } - for ( /* void */ ; cl; cl = cl->next) { - buf = cl->buf; - len += buf->last - buf->pos; + if (ctx->body_read_event) { + return JS_ThrowInternalError(cx, "request body is already being read"); } - p = ngx_pnalloc(r->pool, len); - if (p == NULL) { + event = ngx_pcalloc(r->pool, sizeof(ngx_qjs_event_t) + sizeof(JSValue) * 2); + if (event == NULL) { return JS_ThrowOutOfMemory(cx); } - data = p; - cl = r->request_body->bufs; + event->ctx = cx; + event->fd = ctx->event_id++; + event->args = (JSValue *) &event[1]; + event->data = (void *) (uintptr_t) magic; - for ( /* void */ ; cl; cl = cl->next) { - buf = cl->buf; - p = ngx_cpymem(p, buf->pos, buf->last - buf->pos); + retval = JS_NewPromiseCapability(cx, &event->args[0]); + if (JS_IsException(retval)) { + return JS_EXCEPTION; } -done: + event->function = JS_DupValue(cx, event->args[0]); + event->destructor = ngx_http_js_read_body_event_destructor; - body = ngx_qjs_prop(cx, buffer_type, data, len); - if (JS_IsException(body)) { - return JS_EXCEPTION; + ngx_js_add_event(ctx, event); + + ctx->body_read_event = event; + ctx->body_read_state = NGX_HTTP_JS_BODY_READ_DEFERRED; + + return retval; + +resolve: + + if (ngx_http_js_collect_body(r, ctx) != NGX_OK) { + return JS_ThrowOutOfMemory(cx); } - req->request_body = body; + return ngx_http_qjs_body_to_value(cx, ctx, (ngx_uint_t) magic); +} - return JS_DupValue(cx, req->request_body); + +static void +ngx_http_js_read_body_event_destructor(ngx_qjs_event_t *event) +{ + JSContext *cx; + + cx = event->ctx; + + JS_FreeValue(cx, event->function); + JS_FreeValue(cx, event->args[0]); + JS_FreeValue(cx, event->args[1]); } diff --git a/nginx/ngx_js.h b/nginx/ngx_js.h index 6012e7fd..63cbf00a 100644 --- a/nginx/ngx_js.h +++ b/nginx/ngx_js.h @@ -32,6 +32,10 @@ #define NGX_JS_BOOLEAN 8 #define NGX_JS_NUMBER 16 +#define NGX_JS_BODY_ARRAY_BUFFER 0 +#define NGX_JS_BODY_JSON 1 +#define NGX_JS_BODY_TEXT 2 + #define NGX_JS_BOOL_FALSE 0 #define NGX_JS_BOOL_TRUE 1 #define NGX_JS_BOOL_UNSET 2 diff --git a/nginx/ngx_js_fetch.c b/nginx/ngx_js_fetch.c index fd40a5ba..608af25b 100644 --- a/nginx/ngx_js_fetch.c +++ b/nginx/ngx_js_fetch.c @@ -268,9 +268,6 @@ static njs_external_t ngx_js_ext_http_request[] = { .enumerable = 1, .u.method = { .native = ngx_request_js_ext_body, -#define NGX_JS_BODY_ARRAY_BUFFER 0 -#define NGX_JS_BODY_JSON 1 -#define NGX_JS_BODY_TEXT 2 .magic8 = NGX_JS_BODY_ARRAY_BUFFER } }, diff --git a/nginx/ngx_qjs_fetch.c b/nginx/ngx_qjs_fetch.c index 8e010bec..7c0a7a34 100644 --- a/nginx/ngx_qjs_fetch.c +++ b/nginx/ngx_qjs_fetch.c @@ -131,22 +131,19 @@ static const JSCFunctionListEntry ngx_qjs_ext_fetch_headers_proto[] = { 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), + NGX_JS_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), + NGX_JS_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), + NGX_JS_BODY_TEXT), JS_CGETSET_MAGIC_DEF("url", ngx_qjs_ext_fetch_request_field, NULL, offsetof(ngx_js_request_t, url) ), }; @@ -154,17 +151,17 @@ static const JSCFunctionListEntry ngx_qjs_ext_fetch_request_proto[] = { 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), + NGX_JS_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), + NGX_JS_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), + NGX_JS_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) ), @@ -2027,7 +2024,7 @@ ngx_qjs_ext_fetch_request_body(JSContext *cx, JSValueConst this_val, request->body_used = 1; switch (magic) { - case NGX_QJS_BODY_ARRAY_BUFFER: + case NGX_JS_BODY_ARRAY_BUFFER: /* * no free_func for JS_NewArrayBuffer() * because request->body is allocated from e->pool @@ -2041,15 +2038,15 @@ ngx_qjs_ext_fetch_request_body(JSContext *cx, JSValueConst this_val, break; - case NGX_QJS_BODY_JSON: - case NGX_QJS_BODY_TEXT: + case NGX_JS_BODY_JSON: + case NGX_JS_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) { + if (magic == NGX_JS_BODY_JSON) { string = js_malloc(cx, request->body.len + 1); JS_FreeValue(cx, result); @@ -2309,14 +2306,14 @@ ngx_qjs_ext_fetch_response_body(JSContext *cx, JSValueConst this_val, response->body_used = 1; switch (magic) { - case NGX_QJS_BODY_ARRAY_BUFFER: - case NGX_QJS_BODY_TEXT: + case NGX_JS_BODY_ARRAY_BUFFER: + case NGX_JS_BODY_TEXT: ret = njs_chb_join(&response->chain, &string); if (ret != NJS_OK) { return JS_ThrowOutOfMemory(cx); } - if (magic == NGX_QJS_BODY_TEXT) { + if (magic == NGX_JS_BODY_TEXT) { result = qjs_string_create(cx, string.start, string.length); if (JS_IsException(result)) { return JS_ThrowOutOfMemory(cx); @@ -2338,7 +2335,7 @@ ngx_qjs_ext_fetch_response_body(JSContext *cx, JSValueConst this_val, break; - case NGX_QJS_BODY_JSON: + case NGX_JS_BODY_JSON: default: /* 'string.start' must be zero terminated. */ njs_chb_append_literal(&response->chain, "\0"); diff --git a/nginx/t/js_access_body.t b/nginx/t/js_access_body.t new file mode 100644 index 00000000..abe53b39 --- /dev/null +++ b/nginx/t/js_access_body.t @@ -0,0 +1,459 @@ +#!/usr/bin/perl + +# (C) Dmitry Volyntsev +# (C) F5, Inc. + +# Tests for http njs module, js_access body reading methods. + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +use Socket qw/ CRLF /; + +BEGIN { use FindBin; chdir($FindBin::Bin); } + +use lib 'lib'; +use Test::Nginx qw/ :DEFAULT http_end /; + +############################################################################### + +select STDERR; $| = 1; +select STDOUT; $| = 1; + +my $t = Test::Nginx->new()->has(qw/http proxy/) + ->write_file_expand('nginx.conf', <<'EOF'); + +%%TEST_GLOBALS%% + +daemon off; + +events { +} + +http { + %%TEST_GLOBALS_HTTP%% + + js_import test.js; + + js_var $foo; + + server { + listen 127.0.0.1:8080; + server_name localhost; + + location /text { + js_access test.read_text; + js_content test.content; + } + + location /buffer { + js_access test.read_buffer; + js_content test.content; + } + + location /text_twice { + js_access test.read_text_twice; + js_content test.content; + } + + location /buffer_twice { + js_access test.read_buffer_twice; + js_content test.content; + } + + location /concurrent_text_buffer { + js_access test.read_concurrent_text_buffer; + js_content test.content; + } + + location /text_then_buffer { + js_access test.read_text_then_buffer; + js_content test.content; + } + + location /json { + js_access test.read_json; + js_content test.content; + } + + location /json_invalid { + js_access test.read_json_invalid; + js_content test.content; + } + + location /empty { + js_access test.read_text; + js_content test.content; + } + + location /big { + client_body_buffer_size 64k; + js_access test.read_text_length; + js_content test.content; + } + + location /big_4k { + client_body_buffer_size 4k; + js_access test.read_text_length; + js_content test.content; + } + + location /slow { + js_access test.read_text; + js_content test.content; + } + + location /chunked { + js_access test.read_text; + js_content test.content; + } + + location /text_timeout { + js_access test.read_text_timeout; + js_content test.content; + } + + location /access_content_async { + js_access test.read_text_timeout; + js_content test.content_async; + } + + location /content_text { + js_content test.content_text; + } + + location /proxy { + js_access test.read_text; + proxy_pass http://127.0.0.1:%%PORT_8081%%; + } + + location /in_file { + client_body_in_file_only on; + js_access test.read_text; + js_content test.content; + } + + location /too_large { + client_max_body_size 4; + js_access test.read_text; + js_content test.content; + } + + location /too_large_chunked { + client_max_body_size 4; + client_body_timeout 2s; + js_access test.read_text; + js_content test.content; + } + + + } + + server { + listen 127.0.0.1:8081; + + location / { + js_content test.echo_body; + } + } +} + +EOF + +$t->write_file('test.js', < setTimeout(resolve, 5)); + r.return(200, `var:\${r.variables.foo}:content-async`); + } + + async function content_text(r) { + let body = await r.readRequestText(); + r.return(200, `content:\${body}`); + } + + async function read_text(r) { + let body = await r.readRequestText(); + r.variables.foo = body; + } + + async function read_text_timeout(r) { + let body = await r.readRequestText(); + await new Promise(resolve => setTimeout(resolve, 5)); + r.variables.foo = body + ':after-timeout'; + } + + async function read_buffer(r) { + let buf = await r.readRequestArrayBuffer(); + r.variables.foo = String.fromCharCode.apply(null, new Uint8Array(buf)); + } + + async function read_text_twice(r) { + let first = await r.readRequestText(); + let second = await r.readRequestText(); + r.variables.foo = (first === second) ? 'same' : 'different'; + } + + async function read_buffer_twice(r) { + let a = new Uint8Array(await r.readRequestArrayBuffer()); + let b = new Uint8Array(await r.readRequestArrayBuffer()); + let eq = a.length === b.length + && a.every((v, i) => v === b[i]); + r.variables.foo = eq ? 'same' : 'different'; + } + + async function read_concurrent_text_buffer(r) { + try { + await Promise.all([ + r.readRequestText(), + r.readRequestArrayBuffer() + ]); + + r.variables.foo = 'no_error'; + + } catch (e) { + r.variables.foo = e.message; + } + } + + async function read_text_then_buffer(r) { + let text = await r.readRequestText(); + let buf = await r.readRequestArrayBuffer(); + let text2 = String.fromCharCode.apply(null, new Uint8Array(buf)); + r.variables.foo = (text === text2) ? 'same' : 'different'; + } + + async function read_json(r) { + let obj = await r.readRequestJSON(); + r.variables.foo = obj.method + ':' + obj.name; + } + + async function read_json_invalid(r) { + try { + await r.readRequestJSON(); + r.variables.foo = 'no_error'; + } catch (e) { + r.variables.foo = e.constructor.name; + } + } + + async function read_text_length(r) { + let body = await r.readRequestText(); + r.variables.foo = body.length; + } + + function echo_body(r) { + r.return(200, 'echo:' + r.requestText); + } + + export default { content, content_async, content_text, read_text, + read_text_timeout, read_buffer, read_text_twice, + read_buffer_twice, read_concurrent_text_buffer, + read_text_then_buffer, read_json, read_json_invalid, + read_text_length, echo_body }; + +EOF + +$t->try_run('no js_access')->plan(23); + +############################################################################### + +like(http_post('/text'), qr/var:REQ-BODY/, 'readRequestText'); +like(http_post('/buffer'), qr/var:REQ-BODY/, 'readRequestArrayBuffer'); +like(http_post_json('/json', '{"method":"GET","name":"test"}'), + qr/var:GET:test/, 'readRequestJSON'); +like(http_post_json('/json_invalid', 'not-json'), qr/var:SyntaxError/, + 'readRequestJSON invalid rejects with SyntaxError'); +like(http_get('/empty'), qr/var:/, 'readRequestText empty body'); + +like(http_post('/text_twice'), qr/var:same/, + 'readRequestText twice returns same value'); +like(http_post('/buffer_twice'), qr/var:same/, + 'readRequestArrayBuffer twice returns same value'); +like(http_post('/text_then_buffer'), qr/var:same/, + 'readRequestText then readRequestArrayBuffer same content'); +like(http_post('/concurrent_text_buffer'), + qr/var:request body is already being read/, + 'concurrent body read throws error'); + +like(http_post_big('/big'), qr/var:10240/, + 'readRequestText large body'); +like(http_post_big('/big_4k'), qr/var:10240/, + 'readRequestText large body with small buffer'); + +like(http_post('/proxy'), qr/echo:REQ-BODY/, + 'body preserved for proxy_pass'); +like(http_post('/in_file'), qr/var:REQ-BODY/, + 'readRequestText from temp file'); + +like(http_post_slow('/slow'), qr/var:SLOW-BODY/, + 'readRequestText with slow client'); +like(http_post_chunked('/chunked'), qr/var:CHUNKED-BODY/, + 'readRequestText chunked transfer encoding'); +like(http_post('/text_timeout'), qr/var:REQ-BODY:after-timeout/, + 'readRequestText before async action in js_access'); +like(http_post('/access_content_async'), + qr/var:REQ-BODY:after-timeout:content-async/, + 'async js_content after async js_access body read'); +like(http_post('/content_text'), qr/content:REQ-BODY/, + 'readRequestText in js_content'); + +http_post_disconnect('/text'); +like(http_post('/text'), qr/var:REQ-BODY/, + 'readRequestText after client disconnect'); + +like(http_post('/too_large'), qr/413 Request Entity Too Large/, + 'readRequestText client_max_body_size exceeded'); + +like(http_post_slow_chunked('/too_large_chunked'), + qr/413 Request Entity Too Large/, + 'readRequestText chunked body exceeds client_max_body_size'); +like(http_post_chunked_too_large('/too_large_chunked'), + qr/413 Request Entity Too Large/, + 'readRequestText chunked body rejected in preread'); +like(http_post('/text'), qr/var:REQ-BODY/, + 'readRequestText works after chunked 413'); + +############################################################################### + +sub http_post { + my ($url, %extra) = @_; + + my $p = "POST $url HTTP/1.0" . CRLF . + "Host: localhost" . CRLF . + "Content-Length: 8" . CRLF . + CRLF . + "REQ-BODY"; + + return http($p, %extra); +} + +sub http_post_json { + my ($url, $body, %extra) = @_; + + my $p = "POST $url HTTP/1.0" . CRLF . + "Host: localhost" . CRLF . + "Content-Type: application/json" . CRLF . + "Content-Length: " . length($body) . CRLF . + CRLF . + $body; + + return http($p, %extra); +} + +sub http_post_big { + my ($url, %extra) = @_; + + my $p = "POST $url HTTP/1.0" . CRLF . + "Host: localhost" . CRLF . + "Content-Length: 10240" . CRLF . + CRLF . + ("1234567890" x 1024); + + return http($p, %extra); +} + +sub http_post_slow { + my ($url, %extra) = @_; + + my $header = "POST $url HTTP/1.1" . CRLF . + "Host: localhost" . CRLF . + "Content-Length: 9" . CRLF . + "Connection: close" . CRLF . + CRLF; + + my $s = http($header, start => 1); + + select undef, undef, undef, 0.1; + print $s "SLOW"; + + select undef, undef, undef, 0.1; + print $s "-BODY"; + + return http_end($s); +} + +sub http_post_disconnect { + my ($url) = @_; + + my $header = "POST $url HTTP/1.1" . CRLF . + "Host: localhost" . CRLF . + "Content-Length: 1024" . CRLF . + "Connection: close" . CRLF . + CRLF; + + my $s = http($header, start => 1); + + select undef, undef, undef, 0.1; + print $s "PARTIAL"; + + select undef, undef, undef, 0.1; + close($s); + + select undef, undef, undef, 0.3; +} + +sub http_post_slow_chunked { + my ($url, %extra) = @_; + + my $header = "POST $url HTTP/1.1" . CRLF . + "Host: localhost" . CRLF . + "Transfer-Encoding: chunked" . CRLF . + "Connection: close" . CRLF . + CRLF; + + my $s = http($header, start => 1); + + select undef, undef, undef, 0.1; + print $s "8" . CRLF . "TOOLARGE" . CRLF; + + my $resp = http_end($s); + + # wait for nginx to finish lingering close and cleanup + select undef, undef, undef, 0.5; + + return $resp; +} + +sub http_post_chunked { + my ($url, %extra) = @_; + + my $p = "POST $url HTTP/1.1" . CRLF . + "Host: localhost" . CRLF . + "Transfer-Encoding: chunked" . CRLF . + "Connection: close" . CRLF . + CRLF . + "8" . CRLF . + "CHUNKED-" . CRLF . + "4" . CRLF . + "BODY" . CRLF . + "0" . CRLF . + CRLF; + + return http($p, %extra); +} + +sub http_post_chunked_too_large { + my ($url, %extra) = @_; + + my $p = "POST $url HTTP/1.1" . CRLF . + "Host: localhost" . CRLF . + "Transfer-Encoding: chunked" . CRLF . + "Connection: close" . CRLF . + CRLF . + "8" . CRLF . + "TOOLARGE" . CRLF . + "0" . CRLF . + CRLF; + + return http($p, %extra); +} + +############################################################################### diff --git a/test/ts/test.ts b/test/ts/test.ts index a30e02fe..f810bedc 100644 --- a/test/ts/test.ts +++ b/test/ts/test.ts @@ -69,6 +69,15 @@ async function http_module(r: NginxHTTPRequest) { // r.requestBuffer r.requestBuffer?.equals(Buffer.from([1])); + // r.readRequestText + let text: string = await r.readRequestText(); + + // r.readRequestArrayBuffer + let buf: ArrayBuffer = await r.readRequestArrayBuffer(); + + // r.readRequestJSON + let json: any = await r.readRequestJSON(); + // r.responseText r.responseText == 'a'; r.responseText?.startsWith('a'); diff --git a/ts/ngx_http_js_module.d.ts b/ts/ngx_http_js_module.d.ts index b0af0c5a..6f9a07c7 100644 --- a/ts/ngx_http_js_module.d.ts +++ b/ts/ngx_http_js_module.d.ts @@ -381,6 +381,47 @@ interface NginxHTTPRequest { * @deprecated Use `requestText` or `requestBuffer` instead. */ readonly requestBody?: string; + /** + * Reads the client request body and returns a Promise resolving + * with the body as a string. + * + * Available in js_access and js_content directives. The request body + * size is limited by client_max_body_size. + * + * The body is read once and cached on the request: subsequent + * `readRequestText`, `readRequestArrayBuffer`, and `readRequestJSON` + * calls resolve synchronously from the cache and do not re-read the + * wire. This deliberately differs from the WHATWG Fetch Body mixin + * (which makes the body unusable after the first call) and matches + * the server-side caching pattern used by Express, Flask, and similar + * frameworks. + * + * A second call issued while a previous `readRequest*` promise has + * not yet resolved throws `"request body is already being read"`. + * + * @returns A Promise that resolves with the request body as a string. + * @since 0.9.9 + */ + readRequestText(): Promise; + /** + * Reads the client request body and returns a Promise resolving + * with the body as an ArrayBuffer. See {@link readRequestText} for + * caching, concurrency, and availability semantics. + * + * @returns A Promise that resolves with the request body + * as an ArrayBuffer. + * @since 0.9.9 + */ + readRequestArrayBuffer(): Promise; + /** + * Reads the client request body and returns a Promise resolving + * with the body parsed as JSON. See {@link readRequestText} for + * caching, concurrency, and availability semantics. + * + * @returns A Promise that resolves with the parsed JSON value. + * @since 0.9.9 + */ + readRequestJSON(): Promise; /** * Subrequest response body. The size of response body is limited by * the subrequest_output_buffer_size directive. -- 2.47.3