From 313a00d873a097b02a73e93b7742e8006512ab7b Mon Sep 17 00:00:00 2001 From: theMackabu Date: Tue, 14 Apr 2026 23:22:59 -0700 Subject: [PATCH] add fetch redirect support --- include/modules/response.h | 1 + src/modules/fetch.c | 207 ++++++++++++++++++++++++++++++- src/modules/response.c | 3 +- tests/class_vs_proto.js | 68 ++++++++++ tests/test_fetch_redirect.cjs | 92 ++++++++++++++ tests/test_url_bracket_query.cjs | 24 ++++ 6 files changed, 392 insertions(+), 3 deletions(-) create mode 100644 tests/class_vs_proto.js create mode 100644 tests/test_fetch_redirect.cjs create mode 100644 tests/test_url_bracket_query.cjs diff --git a/include/modules/response.h b/include/modules/response.h index 4743133..af0d754 100644 --- a/include/modules/response.h +++ b/include/modules/response.h @@ -46,6 +46,7 @@ ant_value_t response_create_fetched( int status, const char *status_text, const char *url, + int url_list_size, ant_value_t headers_obj, const uint8_t *body, size_t body_len, diff --git a/src/modules/fetch.c b/src/modules/fetch.c index 555ad08..dafa778 100644 --- a/src/modules/fetch.c +++ b/src/modules/fetch.c @@ -38,12 +38,16 @@ typedef struct fetch_request_s { ant_http_request_t *http_req; int refs; + int redirect_count; bool settled; bool aborted; + bool restart_pending; bool response_started; } fetch_request_t; static UT_array *pending_requests = NULL; +static const int k_fetch_max_redirects = 20; +static void fetch_start_http(fetch_request_t *req); static void fetch_request_retain(fetch_request_t *req) { if (req) req->refs++; @@ -94,6 +98,15 @@ static ant_value_t fetch_rejection_reason(ant_t *js, ant_value_t value) { return value; } +static bool fetch_is_redirect_status(int status) { + return + status == 301 || + status == 302 || + status == 303 || + status == 307 || + status == 308; +} + static void fetch_cancel_request_body(fetch_request_t *req, ant_value_t reason) { request_data_t *data = request_get_data(req->request_obj); ant_value_t stream = js_get_slot(req->request_obj, SLOT_REQUEST_BODY_STREAM); @@ -136,6 +149,154 @@ static char *fetch_build_request_url(request_data_t *request) { return build_href(&request->url); } +static const char *fetch_find_header_value(const ant_http_header_t *headers, const char *name) { + for (const ant_http_header_t *entry = headers; entry; entry = entry->next) { + if (entry->name && strcasecmp(entry->name, name) == 0) return entry->value; + } + return NULL; +} + +static bool fetch_redirect_rewrites_to_get(int status, const char *method) { + if (!method) return false; + if (status == 303) return strcasecmp(method, "HEAD") != 0; + return (status == 301 || status == 302) && strcasecmp(method, "POST") == 0; +} + +typedef struct { + ant_t *js; + ant_value_t headers; + bool drop_body_headers; + bool failed; +} fetch_redirect_headers_ctx_t; + +static void fetch_copy_redirect_header(const char *name, const char *value, void *ctx) { + fetch_redirect_headers_ctx_t *copy = (fetch_redirect_headers_ctx_t *)ctx; + ant_value_t step = 0; + + if (!copy || copy->failed) return; + if (copy->drop_body_headers && name && strcasecmp(name, "content-type") == 0) return; + + step = headers_append_literal(copy->js, copy->headers, name, value); + if (is_err(step)) copy->failed = true; +} + +static ant_value_t fetch_replace_request_headers(fetch_request_t *req, bool drop_body_headers) { + ant_t *js = req->js; + + request_data_t *request = request_get_data(req->request_obj); + ant_value_t current = request_get_headers(req->request_obj); + ant_value_t headers = headers_create_empty(js); + + fetch_redirect_headers_ctx_t ctx = { + .js = js, + .headers = headers, + .drop_body_headers = drop_body_headers, + .failed = false, + }; + + if (is_err(headers)) return headers; + headers_for_each(current, fetch_copy_redirect_header, &ctx); + if (ctx.failed) return js_mkerr(js, "out of memory"); + + headers_set_guard(headers, + strcmp(request->mode, "no-cors") == 0 + ? HEADERS_GUARD_REQUEST_NO_CORS + : HEADERS_GUARD_REQUEST + ); + + headers_apply_guard(headers); + js_set_slot_wb(js, req->request_obj, SLOT_REQUEST_HEADERS, headers); + + return js_mkundef(); +} + +static ant_value_t fetch_clear_redirect_request_body(fetch_request_t *req) { + request_data_t *request = request_get_data(req->request_obj); + ant_value_t headers_step = 0; + + if (!request) + return fetch_type_error(req->js, "Invalid Request object"); + + free(request->body_data); + free(request->body_type); + request->body_data = NULL; + request->body_size = 0; + request->body_type = NULL; + request->body_is_stream = false; + request->has_body = false; + request->body_used = false; + js_set_slot_wb(req->js, req->request_obj, SLOT_REQUEST_BODY_STREAM, js_mkundef()); + + headers_step = fetch_replace_request_headers(req, true); + if (is_err(headers_step)) return headers_step; + + return js_mkundef(); +} + +static ant_value_t fetch_set_redirect_method(fetch_request_t *req, const char *method) { + request_data_t *request = request_get_data(req->request_obj); + char *dup = NULL; + + if (!request) return fetch_type_error(req->js, "Invalid Request object"); + dup = strdup(method); + if (!dup) return js_mkerr(req->js, "out of memory"); + free(request->method); + request->method = dup; + return js_mkundef(); +} + +static ant_value_t fetch_update_request_url(fetch_request_t *req, const char *location) { + request_data_t *request = request_get_data(req->request_obj); + url_state_t next = {0}; + char *base = NULL; + + if (!request || !location) return fetch_type_error(req->js, "Invalid redirect URL"); + base = fetch_build_request_url(request); + if (!base) return fetch_type_error(req->js, "Invalid request URL"); + + if (parse_url_to_state(location, base, &next) != 0) { + free(base); + url_state_clear(&next); + return fetch_type_error(req->js, "Invalid redirect URL"); + } + + free(base); + url_state_clear(&request->url); + request->url = next; + return js_mkundef(); +} + +static ant_value_t fetch_prepare_redirect(fetch_request_t *req, const ant_http_response_t *resp) { + request_data_t *request = request_get_data(req->request_obj); + const char *location = fetch_find_header_value(resp->headers, "location"); + ant_value_t step = 0; + bool rewrite_to_get = false; + + if (!request || !location || location[0] == '\0') return js_mkundef(); + if (req->redirect_count >= k_fetch_max_redirects) { + return fetch_type_error(req->js, "fetch failed: too many redirects"); + } + + rewrite_to_get = fetch_redirect_rewrites_to_get(resp->status, request->method); + if (!rewrite_to_get && request->body_is_stream) { + return fetch_type_error(req->js, "fetch failed: cannot follow redirect with a streamed request body"); + } + + if (rewrite_to_get) { + step = fetch_set_redirect_method(req, strcasecmp(request->method, "HEAD") == 0 ? "HEAD" : "GET"); + if (is_err(step)) return step; + step = fetch_clear_redirect_request_body(req); + if (is_err(step)) return step; + } + + step = fetch_update_request_url(req, location); + if (is_err(step)) return step; + + req->redirect_count++; + req->restart_pending = true; + return js_mkundef(); +} + typedef struct { ant_http_header_t *head; ant_http_header_t **tail; @@ -283,12 +444,45 @@ static void fetch_http_on_response(ant_http_request_t *http_req, const ant_http_ fetch_request_t *req = (fetch_request_t *)user_data; ant_t *js = req->js; + request_data_t *request = request_get_data(req->request_obj); + ant_value_t headers = 0; + ant_value_t step = 0; ant_value_t stream = 0; ant_value_t response = 0; char *url = NULL; if (req->aborted) return; + if (!request) { + fetch_reject(req, fetch_type_error(js, "Invalid Request object")); + ant_http_request_cancel(http_req); + return; + } + + if (fetch_is_redirect_status(resp->status)) { + const char *location = fetch_find_header_value(resp->headers, "location"); + const char *redirect_mode = request->redirect ? request->redirect : "follow"; + + if (location && location[0] != '\0' && strcmp(redirect_mode, "error") == 0) { + fetch_reject(req, fetch_type_error(js, "fetch failed: redirect mode is set to error")); + ant_http_request_cancel(http_req); + return; + } + + if (strcmp(redirect_mode, "follow") == 0) { + step = fetch_prepare_redirect(req, resp); + + if (is_err(step)) { + fetch_reject(req, fetch_rejection_reason(js, step)); + ant_http_request_cancel(http_req); + return; + } + + if (req->restart_pending) { + ant_http_request_cancel(http_req); + return; + }} + } headers = fetch_headers_from_http(js, resp->headers); if (is_err(headers)) { @@ -306,8 +500,10 @@ static void fetch_http_on_response(ant_http_request_t *http_req, const ant_http_ url = fetch_build_request_url(request_get_data(req->request_obj)); response = response_create_fetched( - js, resp->status, resp->status_text, url, headers, NULL, 0, stream, NULL + js, resp->status, resp->status_text, url, + req->redirect_count + 1, headers, NULL, 0, stream, NULL ); + free(url); if (is_err(response)) { @@ -370,6 +566,12 @@ static void fetch_http_on_complete( ant_value_t reason = 0; req->http_req = NULL; + if (req->restart_pending) { + req->restart_pending = false; + fetch_start_http(req); + return; + } + if (result != ANT_HTTP_RESULT_OK || error_code != 0) { reason = fetch_transport_reason(req, result, error_message); if (is_object_type(req->response_obj)) fetch_error_response_body(req, reason); @@ -435,7 +637,8 @@ static bool fetch_handle_data_url(fetch_request_t *req) { headers_set_literal(js, headers, "content-type", content_type); response = response_create_fetched( - js, 200, "OK", url, headers, (const uint8_t *)body, len, js_mkundef(), content_type + js, 200, "OK", url, 1, headers, + (const uint8_t *)body, len, js_mkundef(), content_type ); free(url); diff --git a/src/modules/response.c b/src/modules/response.c index dd1a35e..ae62d97 100644 --- a/src/modules/response.c +++ b/src/modules/response.c @@ -1142,6 +1142,7 @@ ant_value_t response_create_fetched( int status, const char *status_text, const char *url, + int url_list_size, ant_value_t headers_obj, const uint8_t *body, size_t body_len, @@ -1168,7 +1169,7 @@ ant_value_t response_create_fetched( url_state_clear(&resp->url); resp->url = parsed; resp->has_url = true; - resp->url_list_size = 1; + resp->url_list_size = url_list_size > 0 ? url_list_size : 1; } else url_state_clear(&parsed); if (rs_is_stream(body_stream)) { diff --git a/tests/class_vs_proto.js b/tests/class_vs_proto.js new file mode 100644 index 0000000..025cfc4 --- /dev/null +++ b/tests/class_vs_proto.js @@ -0,0 +1,68 @@ +var SequenceProto = function (start) { + this.item = start; + return this; +}; + +SequenceProto.prototype.next = function () { + var temp = this.item; + this.item = temp + 2; + return temp; +}; + +class SequenceClass { + constructor(start) { + this.item = start; + } + next() { + const temp = this.item; + this.item = temp + 2; + return temp; + } +} + +const ITERATIONS = 1_000_000; +const WARMUP = 100_000; + +function runProto() { + const seq = new SequenceProto(1); + let num = 0; + for (let i = 0; i < ITERATIONS; i++) { + num = seq.next(); + } + return num; +} + +function runClass() { + const seq = new SequenceClass(1); + let num = 0; + for (let i = 0; i < ITERATIONS; i++) { + num = seq.next(); + } + return num; +} + +for (let i = 0; i < WARMUP; i++) { + new SequenceProto(1).next(); + new SequenceClass(1).next(); +} + +const runs = 10; +const protoTimes = []; +const classTimes = []; + +for (let i = 0; i < runs; i++) { + const t1 = performance.now(); + runProto(); + protoTimes.push(performance.now() - t1); + + const t2 = performance.now(); + runClass(); + classTimes.push(performance.now() - t2); +} + +const avg = arr => arr.reduce((a, b) => a + b, 0) / arr.length; + +console.log(`Prototype avg: ${avg(protoTimes).toFixed(3)}ms`); +console.log(`Class avg: ${avg(classTimes).toFixed(3)}ms`); +console.log(`Difference: ${Math.abs(avg(protoTimes) - avg(classTimes)).toFixed(3)}ms`); +console.log(`Winner: ${avg(protoTimes) < avg(classTimes) ? 'Prototype' : 'Class'}`); diff --git a/tests/test_fetch_redirect.cjs b/tests/test_fetch_redirect.cjs new file mode 100644 index 0000000..f7f5667 --- /dev/null +++ b/tests/test_fetch_redirect.cjs @@ -0,0 +1,92 @@ +const assert = require('node:assert'); +const http = require('node:http'); + +const server = http.createServer(async (req, res) => { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + + const body = Buffer.concat(chunks).toString('utf8'); + + if (req.url === '/redirect') { + res.writeHead(302, { location: '/final' }); + res.end('redirecting'); + return; + } + + if (req.url === '/redirect-post') { + res.writeHead(302, { location: '/final-post' }); + res.end('redirecting post'); + return; + } + + if (req.url === '/redirect-307') { + res.writeHead(307, { location: '/final-307' }); + res.end('redirecting preserve'); + return; + } + + if (req.url === '/final') { + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end('redirect-ok'); + return; + } + + if (req.url === '/final-post') { + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end(`${req.method}:${body}`); + return; + } + + if (req.url === '/final-307') { + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end(`${req.method}:${body}`); + return; + } + + res.writeHead(404); + res.end('missing'); +}); + +server.listen(0, async () => { + const { port } = server.address(); + const base = `http://127.0.0.1:${port}`; + + try { + const followed = await fetch(`${base}/redirect`); + assert.equal(followed.status, 200); + assert.equal(followed.redirected, true); + assert.equal(followed.url, `${base}/final`); + assert.equal(await followed.text(), 'redirect-ok'); + + const rewritten = await fetch(`${base}/redirect-post`, { + method: 'POST', + body: 'hello-body', + }); + assert.equal(rewritten.status, 200); + assert.equal(await rewritten.text(), 'GET:'); + + const preserved = await fetch(`${base}/redirect-307`, { + method: 'POST', + body: 'hello-again', + }); + assert.equal(preserved.status, 200); + assert.equal(await preserved.text(), 'POST:hello-again'); + + const manual = await fetch(`${base}/redirect`, { redirect: 'manual' }); + assert.equal(manual.status, 302); + assert.equal(manual.redirected, false); + assert.equal(manual.url, `${base}/redirect`); + + let sawRedirectError = false; + try { + await fetch(`${base}/redirect`, { redirect: 'error' }); + } catch (error) { + sawRedirectError = /redirect mode is set to error/.test(String(error)); + } + assert.equal(sawRedirectError, true); + + console.log('ok'); + } finally { + server.close(); + } +}); diff --git a/tests/test_url_bracket_query.cjs b/tests/test_url_bracket_query.cjs new file mode 100644 index 0000000..284ce0a --- /dev/null +++ b/tests/test_url_bracket_query.cjs @@ -0,0 +1,24 @@ +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +const input = 'https://e621.net/pools.json?search[id]=14032,20025,26727'; + +const url = new URL(input); +assert(url.href === input, `expected href to preserve bracketed query, got ${url.href}`); +assert(url.search === '?search[id]=14032,20025,26727', `unexpected search: ${url.search}`); +assert( + url.searchParams.get('search[id]') === '14032,20025,26727', + `unexpected search param value: ${url.searchParams.get('search[id]')}` +); + +assert(URL.canParse(input) === true, 'expected URL.canParse to accept bracketed query URL'); + +const parsed = URL.parse(input); +assert(parsed !== null, 'expected URL.parse to return a URL object'); +assert(parsed.href === input, `expected URL.parse href to preserve bracketed query, got ${parsed.href}`); + +const request = new Request(input); +assert(request.url === input, `expected Request.url to preserve bracketed query, got ${request.url}`); + +console.log('url bracket query test passed'); -- 2.51.2