diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0c9be86..3f91c76 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,6 +2,12 @@ name: ๐Ÿ—๏ธ Build Native Modules on: pull_request: + paths: + - '.github/workflows/build.yml' + - 'package.json' + - 'package-lock.json' + - 'rust/**' + - 'scripts/**' release: types: [published] diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 85711c3..7bcc36f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,7 +2,23 @@ name: ๐Ÿงช Test on: push: + paths: + - '.github/workflows/test.yml' + - 'package.json' + - 'package-lock.json' + - 'tsconfig.json' + - 'src/**' + - 'rust/**' + - 'scripts/**' pull_request: + paths: + - '.github/workflows/test.yml' + - 'package.json' + - 'package-lock.json' + - 'tsconfig.json' + - 'src/**' + - 'rust/**' + - 'scripts/**' jobs: test: diff --git a/.skills/release-notes/SKILL.md b/.skills/release-notes/SKILL.md index 97d4b25..fcca817 100644 --- a/.skills/release-notes/SKILL.md +++ b/.skills/release-notes/SKILL.md @@ -72,12 +72,10 @@ Generate accurate release notes from the git history since the previous release. - `Dependencies` - `Breaking Changes` -7. Write release notes in this shape unless the user requests another format: +7. Write release notes in this shape: ```markdown - ## - - Compared with ``. + ## Release Notes ### Added - ... @@ -98,5 +96,7 @@ Generate accurate release notes from the git history since the previous release. - Mention internal-only test, lint, formatting, and CI work only under `Tests` or `Build & CI`. - Keep bullets concrete and scoped: name the API, feature, platform, or workflow affected. - Include the compared range and base tag in the final answer. +- Always return the final release notes inside one outer fenced Markdown code block using four backticks and `md` as the language: ````md ... ````. This is mandatory even when the user does not explicitly ask for a code block. +- If the release notes contain code examples, keep normal triple-backtick fences inside the outer four-backtick block so the response does not terminate early. - If the range is empty, say no changes were found after the selected release tag. - If tags are missing or ambiguous, stop and ask for the intended base release instead of guessing. diff --git a/rust/src/napi/convert.rs b/rust/src/napi/convert.rs index 6e6cf7b..279bdeb 100644 --- a/rust/src/napi/convert.rs +++ b/rust/src/napi/convert.rs @@ -705,12 +705,17 @@ pub(crate) fn response_to_js_object<'a, C: Context<'a>>( let url = cx.string(&response.url); obj.set(cx, "url", url)?; - let headers_obj = cx.empty_object(); - for (key, value) in response.headers { + let headers_array = JsArray::new(cx, response.headers.len()); + for (index, (key, value)) in response.headers.into_iter().enumerate() { + let tuple = JsArray::new(cx, 2); + let key_str = cx.string(&key); let value_str = cx.string(&value); - headers_obj.set(cx, key.as_str(), value_str)?; + + tuple.set(cx, 0, key_str)?; + tuple.set(cx, 1, value_str)?; + headers_array.set(cx, index as u32, tuple)?; } - obj.set(cx, "headers", headers_obj)?; + obj.set(cx, "headers", headers_array)?; let cookies_obj = cx.empty_object(); for (key, value) in response.cookies { diff --git a/rust/src/transport/request.rs b/rust/src/transport/request.rs index 545c91d..de5b5cf 100644 --- a/rust/src/transport/request.rs +++ b/rust/src/transport/request.rs @@ -5,7 +5,6 @@ use crate::transport::headers::build_orig_header_map; use crate::transport::tls::configure_client_builder; use crate::transport::types::{RequestOptions, Response, ResponseTlsInfo}; use anyhow::{Context, Result}; -use std::collections::HashMap; use std::time::Duration; use wreq::{redirect, tls::TlsInfo, Method}; @@ -165,14 +164,14 @@ pub async fn make_request(options: RequestOptions) -> Result { let status = response.status().as_u16(); let final_url = response.uri().to_string(); - let mut response_headers = HashMap::new(); + let mut response_headers = Vec::new(); for (key, value) in response.headers() { if let Ok(value_str) = value.to_str() { - response_headers.insert(key.to_string(), value_str.to_string()); + response_headers.push((key.to_string(), value_str.to_string())); } } - let mut cookies = HashMap::new(); + let mut cookies = std::collections::HashMap::new(); let mut set_cookies = Vec::new(); for cookie_header in response.headers().get_all("set-cookie") { if let Ok(cookie_str) = cookie_header.to_str() { diff --git a/rust/src/transport/types.rs b/rust/src/transport/types.rs index 9678a3d..601e659 100644 --- a/rust/src/transport/types.rs +++ b/rust/src/transport/types.rs @@ -89,7 +89,7 @@ pub struct RequestOptions { #[derive(Debug, Clone)] pub struct Response { pub status: u16, - pub headers: HashMap, + pub headers: Vec<(String, String)>, pub body_handle: u64, pub cookies: HashMap, pub set_cookies: Vec, diff --git a/src/headers/index.ts b/src/headers/index.ts index b5a519e..ff3f196 100644 --- a/src/headers/index.ts +++ b/src/headers/index.ts @@ -115,6 +115,11 @@ export class Headers implements Iterable { return entry ? entry.values.join(', ') : null; } + /** Returns all `Set-Cookie` header values without comma-joining them. */ + getSetCookie(): string[] { + return [...(this.store.get('set-cookie')?.values ?? [])]; + } + /** Returns `true` when the header collection contains `name`. */ has(name: string): boolean { const normalized = this.normalizeName(name); diff --git a/src/http/response.ts b/src/http/response.ts index 8316f43..64db1cf 100644 --- a/src/http/response.ts +++ b/src/http/response.ts @@ -229,7 +229,7 @@ export class Response { const cloned = new Response(null, { status: this.status, statusText: this.statusText, - headers: this.headers.toObject(), + headers: this.headers.toTuples(), url: this.url, }); diff --git a/src/test/helpers/local-server.ts b/src/test/helpers/local-server.ts index b5f4a72..04c841e 100644 --- a/src/test/helpers/local-server.ts +++ b/src/test/helpers/local-server.ts @@ -218,6 +218,20 @@ export function setupLocalTestServer() { return; } + if (url.pathname === '/headers/duplicates') { + sendJson( + response, + 200, + { ok: true }, + { + 'x-dupe': ['one', 'two'], + 'set-cookie': ['session=abc123; Path=/', 'csrf=token123; Path=/'], + } + ); + + return; + } + if (url.pathname === '/cookies/echo') { sendJson(response, 200, { cookie: readCookieHeader(request) }); diff --git a/src/test/transport-features.spec.ts b/src/test/transport-features.spec.ts index 0d57222..6e6eb39 100644 --- a/src/test/transport-features.spec.ts +++ b/src/test/transport-features.spec.ts @@ -141,6 +141,28 @@ describe('transport features', () => { assert.strictEqual(body.headers.host, `example.test:${target.port}`); }); + test('should preserve duplicate response headers like fetch', async () => { + const response = await fetch(`${getBaseUrl()}/headers/duplicates`); + + assert.strictEqual(response.headers.get('x-dupe'), 'one, two'); + assert.deepStrictEqual(response.headers.getSetCookie(), [ + 'session=abc123; Path=/', + 'csrf=token123; Path=/', + ]); + assert.strictEqual( + response.headers.get('set-cookie'), + 'session=abc123; Path=/, csrf=token123; Path=/' + ); + + const cloned = response.clone(); + + assert.strictEqual(cloned.headers.get('x-dupe'), 'one, two'); + assert.deepStrictEqual(cloned.headers.getSetCookie(), [ + 'session=abc123; Path=/', + 'csrf=token123; Path=/', + ]); + }); + test('should reject non-HTTPS DoH endpoints', async () => { await assert.rejects( () => diff --git a/src/types/native.ts b/src/types/native.ts index 0b46ccb..65cc8df 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -133,8 +133,8 @@ export interface NativeResponse { status: number; /** HTTP reason phrase when provided by the transport. */ statusText?: string; - /** Response headers normalized to a plain object. */ - headers: Record; + /** Response headers as ordered `[name, value]` tuples. */ + headers: HeaderTuple[]; /** Eagerly loaded UTF-8 response body, when available. */ body?: string; /** Native body handle used for streaming large responses. */