From 6e388d7d72b0fae1df4f001357bff980120c414d Mon Sep 17 00:00:00 2001 From: "brian.j.murdock@gmail.com" Date: Sat, 13 Jun 2026 18:43:16 -0500 Subject: [PATCH 1/7] Expose retry attempt metadata to hooks --- CHANGELOG.md | 4 ++ DESIGN.md | 3 ++ README.md | 3 +- src/internal/execute-request.ts | 2 +- src/internal/hook-options.ts | 18 +++++++ src/internal/normalize-request.ts | 9 +++- src/types.ts | 3 ++ test/hook-options.test.ts | 57 +++++++++++++++++++-- test/hooks-and-retries.test.ts | 85 +++++++++++++++++++++++++++++++ 9 files changed, 176 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d53987a..6c97e78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- expose retry attempt and serialized query metadata to hooks through `context.options` + ## 1.0.4 Failure-observability, retry-timeout, diagnostic, and package-guardrail hardening. diff --git a/DESIGN.md b/DESIGN.md index f2908d5..e16ad82 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -758,6 +758,7 @@ In particular: - hooks may not mutate normalized execution options directly - `afterResponse` and `onError` are observational-only apart from throwing - hook metadata exposed through `context.options` is read-only and must not act as a hidden mutation surface +- hook metadata includes current attempt counts for application-owned logging and metrics If a `beforeRequest` hook replaces the URL, the replacement must be a fully resolved absolute URL. Relative replacement URLs are invalid and must fail with `ConfigError`. @@ -828,6 +829,8 @@ This is simpler to implement and reason about. If a future version introduces to Retry behavior should be visible to hook contexts where practical so consuming applications can log and understand repeated attempts. +Hooks expose the current attempt through `context.options.attempt` and the configured attempt ceiling through `context.options.maxAttempts`. When the request `query` option serializes to a non-empty string, hooks also receive `context.options.queryString` without a leading `?`; URL search parameters already present in the input remain visible through `context.url`. Applications own any logging, metrics, or tracing behavior built from that metadata. + ### Retry classification Version 1 retry decisions are based only on: diff --git a/README.md b/README.md index 0de539b..95bc845 100644 --- a/README.md +++ b/README.md @@ -305,7 +305,8 @@ If you need end-to-end runtime safety, validate parsed data with a schema librar - Timeout windows start after `beforeRequest` hooks complete. - Retry backoff waits do not consume per-attempt timeout windows. - If `beforeRequest` replaces `context.url`, that replacement is final. Previously resolved `baseURL` and query parameters are not reapplied to the replacement URL. -- Retry attempt metadata is not currently exposed to hooks. Hooks can inspect normalized retry configuration, but not the current attempt number. +- Hook metadata includes `context.options.attempt` and `context.options.maxAttempts`. Non-retried requests report attempt `1` and max attempts `1`. +- When `query` serializes to a non-empty string, hook metadata includes `context.options.queryString` without a leading `?`. Existing search parameters from the input URL remain visible on `context.url`. ## Important limitations by design diff --git a/src/internal/execute-request.ts b/src/internal/execute-request.ts index 196f136..1000596 100644 --- a/src/internal/execute-request.ts +++ b/src/internal/execute-request.ts @@ -56,7 +56,7 @@ export async function executeRequest( const context = attempt === 1 ? initialContext - : createBeforeRequestContext(input, defaults, options) + : createBeforeRequestContext(input, defaults, options, attempt) try { try { diff --git a/src/internal/hook-options.ts b/src/internal/hook-options.ts index 12a7223..f783bda 100644 --- a/src/internal/hook-options.ts +++ b/src/internal/hook-options.ts @@ -7,12 +7,15 @@ import type { export function createHookRequestOptions( options: NormalizedRequestOptions, + metadata: HookLifecycleMetadata, ): HookRequestOptions { // Hooks get a read-only metadata view rather than the internal mutable // execution object. This keeps hook inspection useful without turning // `context.options` into a hidden mutation surface. const snapshot: HookRequestOptions = { method: options.method, + attempt: metadata.attempt, + maxAttempts: metadata.maxAttempts, responseType: options.responseType, retry: options.retry === false @@ -25,6 +28,15 @@ export function createHookRequestOptions( parseJson: options.parseJson, } + if (metadata.queryString !== undefined) { + Object.defineProperty(snapshot, 'queryString', { + configurable: false, + enumerable: true, + value: metadata.queryString, + writable: false, + }) + } + if (options.query !== undefined) { Object.defineProperty(snapshot, 'query', { configurable: false, @@ -55,6 +67,12 @@ export function createHookRequestOptions( return Object.freeze(snapshot) } +export interface HookLifecycleMetadata { + attempt: number + maxAttempts: number + queryString?: string +} + function freezeQueryParams(query: QueryParams): QueryParams { const snapshot: QueryParams = {} diff --git a/src/internal/normalize-request.ts b/src/internal/normalize-request.ts index 3aeef8d..bf737b9 100644 --- a/src/internal/normalize-request.ts +++ b/src/internal/normalize-request.ts @@ -37,12 +37,19 @@ export function createBeforeRequestContext( input: string | URL, defaults: ClientDefaults = {}, options: RequestOptions = {}, + attempt = 1, ): ExecutionBeforeRequestContext { const url = resolveRequestURL(input, defaults.baseURL, options.query) const normalized = normalizeRequestOptions(defaults, options) const body = resolveRequestBody(normalized) validateRetryableBody(body, normalized.retry) - const optionsView = createHookRequestOptions(normalized) + const maxAttempts = normalized.retry === false ? 1 : normalized.retry.attempts + const queryString = serializeQueryParams(normalized.query) + const optionsView = createHookRequestOptions(normalized, { + attempt, + maxAttempts, + ...(queryString === '' ? {} : { queryString }), + }) const context: ExecutionBeforeRequestContext = { input, diff --git a/src/types.ts b/src/types.ts index e4a6269..48cea64 100644 --- a/src/types.ts +++ b/src/types.ts @@ -94,7 +94,10 @@ export interface HookRetryOptions { */ export interface HookRequestOptions { readonly method: RequestMethod + readonly attempt: number + readonly maxAttempts: number readonly query?: QueryParams + readonly queryString?: string readonly timeout?: number readonly signal?: AbortSignal readonly responseType: ResponseType diff --git a/test/hook-options.test.ts b/test/hook-options.test.ts index e3ef797..943f229 100644 --- a/test/hook-options.test.ts +++ b/test/hook-options.test.ts @@ -4,6 +4,11 @@ import test from 'node:test' import { createHookRequestOptions } from '../src/internal/hook-options.js' import type { NormalizedRequestOptions } from '../src/types.js' +const DEFAULT_METADATA = { + attempt: 1, + maxAttempts: 1, +} + function createOptions( overrides: Partial = {}, ): NormalizedRequestOptions { @@ -23,7 +28,7 @@ function createOptions( } test('createHookRequestOptions freezes the top-level options object', () => { - const snapshot = createHookRequestOptions(createOptions()) + const snapshot = createHookRequestOptions(createOptions(), DEFAULT_METADATA) assert.equal(Object.isFrozen(snapshot), true) }) @@ -38,7 +43,10 @@ test('createHookRequestOptions freezes retry metadata and retry arrays when retr retryOnMethods: ['GET'], } - const snapshot = createHookRequestOptions(createOptions({ retry })) + const snapshot = createHookRequestOptions( + createOptions({ retry }), + DEFAULT_METADATA, + ) assert.notEqual(snapshot.retry, retry) if (snapshot.retry === false) { @@ -54,6 +62,38 @@ test('createHookRequestOptions freezes retry metadata and retry arrays when retr assert.deepEqual(snapshot.retry.retryOnMethods, ['GET']) }) +test('createHookRequestOptions exposes retry attempt metadata', () => { + const snapshot = createHookRequestOptions( + createOptions({ + retry: { + attempts: 3, + backoffMs: 10, + maxBackoffMs: 100, + multiplier: 2, + retryOnStatuses: [503], + retryOnMethods: ['GET'], + }, + }), + { + attempt: 2, + maxAttempts: 3, + }, + ) + + assert.equal(snapshot.attempt, 2) + assert.equal(snapshot.maxAttempts, 3) +}) + +test('createHookRequestOptions exposes serialized query metadata', () => { + const snapshot = createHookRequestOptions(createOptions(), { + attempt: 1, + maxAttempts: 1, + queryString: 'tag=a&tag=b&page=1', + }) + + assert.equal(snapshot.queryString, 'tag=a&tag=b&page=1') +}) + test('createHookRequestOptions freezes query metadata and query arrays when query is present', () => { const query = { page: 2, @@ -61,7 +101,10 @@ test('createHookRequestOptions freezes query metadata and query arrays when quer nullable: null, } - const snapshot = createHookRequestOptions(createOptions({ query })) + const snapshot = createHookRequestOptions( + createOptions({ query }), + DEFAULT_METADATA, + ) assert.notEqual(snapshot.query, query) assert.equal(Object.isFrozen(snapshot.query), true) @@ -71,15 +114,19 @@ test('createHookRequestOptions freezes query metadata and query arrays when quer }) test('createHookRequestOptions omits optional metadata keys when absent', () => { - const snapshot = createHookRequestOptions(createOptions()) + const snapshot = createHookRequestOptions(createOptions(), DEFAULT_METADATA) assert.equal(Object.hasOwn(snapshot, 'query'), false) + assert.equal(Object.hasOwn(snapshot, 'queryString'), false) assert.equal(Object.hasOwn(snapshot, 'timeout'), false) assert.equal(Object.hasOwn(snapshot, 'signal'), false) }) test('createHookRequestOptions sets retry to exactly false when retries are disabled', () => { - const snapshot = createHookRequestOptions(createOptions({ retry: false })) + const snapshot = createHookRequestOptions( + createOptions({ retry: false }), + DEFAULT_METADATA, + ) assert.equal(snapshot.retry, false) }) diff --git a/test/hooks-and-retries.test.ts b/test/hooks-and-retries.test.ts index 05ed597..3e6bfbe 100644 --- a/test/hooks-and-retries.test.ts +++ b/test/hooks-and-retries.test.ts @@ -515,6 +515,91 @@ test('retry attempts rebuild hook context after the first attempt', async () => } }) +test('beforeRequest hooks can inspect retry attempt metadata', async () => { + const originalFetch = globalThis.fetch + const attempts: Array<{ attempt: number; maxAttempts: number }> = [] + let calls = 0 + + globalThis.fetch = async () => { + calls += 1 + + if (calls === 1) { + return new Response('retry', { + status: 503, + statusText: 'Service Unavailable', + }) + } + + return new Response(JSON.stringify({ ok: true })) + } + + try { + const result = await request<{ ok: boolean }>('https://api.example.com/retry', { + retry: { + attempts: 2, + backoffMs: 0, + maxBackoffMs: 0, + retryOnStatuses: [503], + retryOnMethods: ['GET'], + }, + hooks: { + beforeRequest: [ + (context) => { + attempts.push({ + attempt: context.options.attempt, + maxAttempts: context.options.maxAttempts, + }) + }, + ], + }, + }) + + assert.deepEqual(result, { ok: true }) + assert.deepEqual(attempts, [ + { attempt: 1, maxAttempts: 2 }, + { attempt: 2, maxAttempts: 2 }, + ]) + assert.equal(calls, 2) + } finally { + globalThis.fetch = originalFetch + } +}) + +test('beforeRequest hooks can inspect serialized query metadata', async () => { + const originalFetch = globalThis.fetch + let queryString: string | undefined + + globalThis.fetch = async () => new Response(JSON.stringify({ ok: true })) + + try { + const result = await request<{ ok: boolean }>( + 'https://api.example.com/users?active=true', + { + query: { + tag: ['a', 'b'], + page: 1, + }, + hooks: { + beforeRequest: [ + (context) => { + queryString = context.options.queryString + assert.equal( + context.url.href, + 'https://api.example.com/users?active=true&tag=a&tag=b&page=1', + ) + }, + ], + }, + }, + ) + + assert.deepEqual(result, { ok: true }) + assert.equal(queryString, 'tag=a&tag=b&page=1') + } finally { + globalThis.fetch = originalFetch + } +}) + test('retry attempts rebuild POST json bodies after the first attempt', async () => { const originalFetch = globalThis.fetch let attempts = 0 From 4252a07d4d4eef23838ee8b836851dbaaebf374b Mon Sep 17 00:00:00 2001 From: "brian.j.murdock@gmail.com" Date: Sat, 13 Jun 2026 18:43:26 -0500 Subject: [PATCH 2/7] Create GitHub Releases during publish --- .github/workflows/release.yml | 49 ++++++++++++++++++++++++++++++++++- CHANGELOG.md | 1 + README.md | 1 + RELEASE.md | 12 ++++++++- 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2d8f0cf..d445e1d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,6 +45,9 @@ jobs: needs: verify-release runs-on: ubuntu-latest environment: npm + permissions: + contents: write + id-token: write steps: - name: Check out repository @@ -70,4 +73,48 @@ jobs: npm publish --dry-run - name: Publish to npm with provenance - run: npm publish + env: + TAG_NAME: ${{ github.ref_name }} + run: | + PACKAGE_VERSION="$(node -p "require('./package.json').version")" + + if [ "$TAG_NAME" != "v$PACKAGE_VERSION" ]; then + echo "Release tag $TAG_NAME does not match package version v$PACKAGE_VERSION" >&2 + exit 1 + fi + + PUBLISHED_VERSION="$(npm view "@gavoryn/clearfetch@$PACKAGE_VERSION" version --registry=https://registry.npmjs.org 2>/dev/null || true)" + + if [ "$PUBLISHED_VERSION" = "$PACKAGE_VERSION" ]; then + PUBLISHED_GIT_HEAD="$(npm view "@gavoryn/clearfetch@$PACKAGE_VERSION" gitHead --registry=https://registry.npmjs.org 2>/dev/null || true)" + CURRENT_GIT_HEAD="$(git rev-parse HEAD)" + + if [ "$PUBLISHED_GIT_HEAD" != "$CURRENT_GIT_HEAD" ]; then + echo "Published gitHead $PUBLISHED_GIT_HEAD does not match current tag commit $CURRENT_GIT_HEAD" >&2 + exit 1 + fi + + echo "Version $PACKAGE_VERSION is already published; skipping npm publish." + else + npm publish + fi + + - name: Create or verify GitHub Release + env: + GH_TOKEN: ${{ github.token }} + TAG_NAME: ${{ github.ref_name }} + run: | + if release_json="$(gh release view "$TAG_NAME" --json tagName,name,isDraft,isPrerelease,isLatest,url 2>/dev/null)"; then + RELEASE_JSON="$release_json" node <<'NODE' + const release = JSON.parse(process.env.RELEASE_JSON) + + if (release.isDraft || release.isPrerelease) { + console.error(`Release ${release.tagName} must be a published, non-prerelease GitHub Release`) + process.exit(1) + } + + console.log(JSON.stringify(release, null, 2)) + NODE + else + gh release create "$TAG_NAME" --title "$TAG_NAME" --generate-notes --verify-tag + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c97e78..0647c77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- have the release workflow create or verify GitHub Release records after npm publish - expose retry attempt and serialized query metadata to hooks through `context.options` ## 1.0.4 diff --git a/README.md b/README.md index 95bc845..c925f13 100644 --- a/README.md +++ b/README.md @@ -339,6 +339,7 @@ The package is ESM-only and does not target legacy runtimes or polyfill-driven e - Dependency review is enforced for pull requests and supports manual base/head validation. - The release workflow supports a non-publishing dry-run path via manual dispatch. - npm publishing now uses npm trusted publishing from GitHub Actions instead of a long-lived publish token. +- The release workflow publishes to npm with provenance and creates or verifies the matching GitHub Release record. - Normal releases are expected to publish from GitHub Actions, not from local machines. - Release and repository protection policy is documented in [RELEASE.md](./RELEASE.md). diff --git a/RELEASE.md b/RELEASE.md index 134058d..3cddd80 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -11,10 +11,20 @@ Expected flow: 3. Optionally run the `Release` workflow manually to exercise the non-publishing dry-run path. 4. Create an annotated release tag in the form `vX.Y.Z`. 5. Push the tag to GitHub. -6. Let the `Release` GitHub Actions workflow publish the package. +6. Let the `Release` GitHub Actions workflow publish the package and create or verify the matching GitHub Release record. +7. Confirm npm and GitHub Releases show the same current version. Local `npm publish` should not be used for normal releases. +The tag must match the package version exactly, for example package version `1.2.3` must be released from tag `v1.2.3`. If a workflow rerun finds that exact package version already published on npm and the published `gitHead` matches the checked-out tag commit, it skips publishing and still creates or verifies the GitHub Release record. + +Post-release verification: + +```bash +npm view @gavoryn/clearfetch version --registry=https://registry.npmjs.org +gh release list --limit 5 --json tagName,name,isDraft,isPrerelease,isLatest,createdAt,publishedAt +``` + ## Release dry-run The `Release` workflow supports a manual, non-publishing validation path through From bcc65624443394393a8b32c88cb4d3964a97061c Mon Sep 17 00:00:00 2001 From: "brian.j.murdock@gmail.com" Date: Sat, 13 Jun 2026 18:56:16 -0500 Subject: [PATCH 3/7] Tighten request option types --- CHANGELOG.md | 1 + DESIGN.md | 3 +- README.md | 1 + scripts/check-pack-smoke.mjs | 12 +++ src/internal/execute-request.ts | 6 +- src/types.ts | 157 ++++++++++++++++++++++++-------- test/normalize-request.test.ts | 7 +- test/type-signatures.ts | 32 +++++++ 8 files changed, 175 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0647c77..3751384 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - have the release workflow create or verify GitHub Release records after npm publish - expose retry attempt and serialized query metadata to hooks through `context.options` +- tighten public TypeScript request option shapes for body/json and GET/HEAD misuse ## 1.0.4 diff --git a/DESIGN.md b/DESIGN.md index e16ad82..ff17828 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -930,10 +930,11 @@ Where possible, incompatible option combinations should be discouraged or preven Examples: * discourage simultaneous `body` and `json` +* reject body shapes on `GET` and `HEAD` * constrain response-type values * strongly type hook contexts and retry configuration -Runtime validation still remains necessary. +Runtime validation still remains necessary, especially for JavaScript callers and intentionally invalid test inputs. Invalid body combinations should be guarded both by public TypeScript types and runtime validation. --- diff --git a/README.md b/README.md index c925f13..833a012 100644 --- a/README.md +++ b/README.md @@ -292,6 +292,7 @@ If you need end-to-end runtime safety, validate parsed data with a schema librar - Retry support does not allow streaming request bodies. - The `json` helper serializes request bodies and sets `Content-Type: application/json` when absent. - `body` and `json` cannot be used together. +- TypeScript rejects common invalid option combinations such as `body` plus `json`, and request bodies on `GET`/`HEAD` request shapes. Runtime validation still protects JavaScript callers. - The package performs no telemetry or hidden network activity beyond the caller's request. ## Advanced behavior notes diff --git a/scripts/check-pack-smoke.mjs b/scripts/check-pack-smoke.mjs index 256f4c0..6caf153 100644 --- a/scripts/check-pack-smoke.mjs +++ b/scripts/check-pack-smoke.mjs @@ -103,6 +103,18 @@ try { 'const jsonPromise: Promise<{ ok: boolean } | undefined> = client.get<{ ok: boolean }>(\'/users\')', 'void jsonPromise', '', + 'async function smokeRequestBodies() {', + " await request('https://api.example.com/create', {", + " method: 'POST',", + ' json: { ok: true },', + ' })', + '', + " await client.post('/create', {", + ' json: { ok: true },', + ' })', + '}', + 'void smokeRequestBodies', + '', 'const publicErrors = [', " new AbortRequestError('aborted'),", " new ConfigError('bad config'),", diff --git a/src/internal/execute-request.ts b/src/internal/execute-request.ts index 1000596..8495012 100644 --- a/src/internal/execute-request.ts +++ b/src/internal/execute-request.ts @@ -173,7 +173,11 @@ function createMethodCaller( return ( input: string | URL, options: RequestOptions = {}, - ) => executeRequest(input, defaults, { ...options, method }) + ) => executeRequest( + input, + defaults, + { ...options, method } as RequestOptions, + ) } async function runBeforeRequestHooks( diff --git a/src/types.ts b/src/types.ts index 48cea64..dba3a22 100644 --- a/src/types.ts +++ b/src/types.ts @@ -48,12 +48,11 @@ export interface RetryOptions { /** * Per-request configuration for `request()` and client method calls. */ -export interface RequestOptions { - method?: RequestMethod +export type BodyCapableRequestMethod = Exclude + +export interface RequestOptionsBase { headers?: HeadersInit query?: QueryParams - body?: BodyInit | null - json?: unknown timeout?: number signal?: AbortSignal responseType?: ResponseType @@ -62,6 +61,41 @@ export interface RequestOptions { parseJson?: (text: string) => unknown } +export type BodylessRequestOptions = RequestOptionsBase & { + method?: 'GET' | 'HEAD' + body?: never + json?: never +} + +export type BodyCapableRequestWithoutBody = RequestOptionsBase & { + method?: BodyCapableRequestMethod + body?: never + json?: never +} + +export type RawBodyRequestOptions = RequestOptionsBase & { + method: BodyCapableRequestMethod + body: BodyInit | null + json?: never +} + +export type JsonBodyRequestOptions = RequestOptionsBase & { + method: BodyCapableRequestMethod + body?: never + json: unknown +} + +export type RequestOptions = + | BodylessRequestOptions + | BodyCapableRequestWithoutBody + | RawBodyRequestOptions + | JsonBodyRequestOptions + +export type ClientMethodOptions = + | (RequestOptionsBase & { body?: never; json?: never }) + | (RequestOptionsBase & { body: BodyInit | null; json?: never }) + | (RequestOptionsBase & { body?: never; json: unknown }) + /** * Shared defaults captured by a client created with `createClient()`. */ @@ -214,177 +248,177 @@ export interface HttpClient { get( input: string | URL, - options?: Omit, + options?: JsonBodylessClientMethodOptions, ): Promise get( input: string | URL, - options: Omit, + options: TextBodylessClientMethodOptions, ): Promise get( input: string | URL, - options: Omit, + options: BlobBodylessClientMethodOptions, ): Promise get( input: string | URL, - options: Omit, + options: ArrayBufferBodylessClientMethodOptions, ): Promise get( input: string | URL, - options: Omit, + options: RawBodylessClientMethodOptions, ): Promise post( input: string | URL, - options?: Omit, + options?: JsonClientMethodOptions, ): Promise post( input: string | URL, - options: Omit, + options: TextClientMethodOptions, ): Promise post( input: string | URL, - options: Omit, + options: BlobClientMethodOptions, ): Promise post( input: string | URL, - options: Omit, + options: ArrayBufferClientMethodOptions, ): Promise post( input: string | URL, - options: Omit, + options: RawClientMethodOptions, ): Promise put( input: string | URL, - options?: Omit, + options?: JsonClientMethodOptions, ): Promise put( input: string | URL, - options: Omit, + options: TextClientMethodOptions, ): Promise put( input: string | URL, - options: Omit, + options: BlobClientMethodOptions, ): Promise put( input: string | URL, - options: Omit, + options: ArrayBufferClientMethodOptions, ): Promise put( input: string | URL, - options: Omit, + options: RawClientMethodOptions, ): Promise patch( input: string | URL, - options?: Omit, + options?: JsonClientMethodOptions, ): Promise patch( input: string | URL, - options: Omit, + options: TextClientMethodOptions, ): Promise patch( input: string | URL, - options: Omit, + options: BlobClientMethodOptions, ): Promise patch( input: string | URL, - options: Omit, + options: ArrayBufferClientMethodOptions, ): Promise patch( input: string | URL, - options: Omit, + options: RawClientMethodOptions, ): Promise delete( input: string | URL, - options?: Omit, + options?: JsonClientMethodOptions, ): Promise delete( input: string | URL, - options: Omit, + options: TextClientMethodOptions, ): Promise delete( input: string | URL, - options: Omit, + options: BlobClientMethodOptions, ): Promise delete( input: string | URL, - options: Omit, + options: ArrayBufferClientMethodOptions, ): Promise delete( input: string | URL, - options: Omit, + options: RawClientMethodOptions, ): Promise head( input: string | URL, - options?: Omit, + options?: JsonBodylessClientMethodOptions, ): Promise head( input: string | URL, - options: Omit, + options: TextBodylessClientMethodOptions, ): Promise head( input: string | URL, - options: Omit, + options: BlobBodylessClientMethodOptions, ): Promise head( input: string | URL, - options: Omit, + options: ArrayBufferBodylessClientMethodOptions, ): Promise head( input: string | URL, - options: Omit, + options: RawBodylessClientMethodOptions, ): Promise options( input: string | URL, - options?: Omit, + options?: JsonClientMethodOptions, ): Promise options( input: string | URL, - options: Omit, + options: TextClientMethodOptions, ): Promise options( input: string | URL, - options: Omit, + options: BlobClientMethodOptions, ): Promise options( input: string | URL, - options: Omit, + options: ArrayBufferClientMethodOptions, ): Promise options( input: string | URL, - options: Omit, + options: RawClientMethodOptions, ): Promise extend(defaults: ClientDefaults): HttpClient @@ -394,18 +428,63 @@ type JsonRequestOptions = RequestOptions & { responseType?: 'json' } +type JsonClientMethodOptions = ClientMethodOptions & { + responseType?: 'json' +} + +type BodylessClientMethodOptions = RequestOptionsBase & { + body?: never + json?: never +} + +type JsonBodylessClientMethodOptions = BodylessClientMethodOptions & { + responseType?: 'json' +} + type TextRequestOptions = RequestOptions & { responseType: 'text' } +type TextClientMethodOptions = ClientMethodOptions & { + responseType: 'text' +} + +type TextBodylessClientMethodOptions = BodylessClientMethodOptions & { + responseType: 'text' +} + type BlobRequestOptions = RequestOptions & { responseType: 'blob' } +type BlobClientMethodOptions = ClientMethodOptions & { + responseType: 'blob' +} + +type BlobBodylessClientMethodOptions = BodylessClientMethodOptions & { + responseType: 'blob' +} + type ArrayBufferRequestOptions = RequestOptions & { responseType: 'arrayBuffer' } +type ArrayBufferClientMethodOptions = ClientMethodOptions & { + responseType: 'arrayBuffer' +} + +type ArrayBufferBodylessClientMethodOptions = BodylessClientMethodOptions & { + responseType: 'arrayBuffer' +} + type RawRequestOptions = RequestOptions & { responseType: 'raw' } + +type RawClientMethodOptions = ClientMethodOptions & { + responseType: 'raw' +} + +type RawBodylessClientMethodOptions = BodylessClientMethodOptions & { + responseType: 'raw' +} diff --git a/test/normalize-request.test.ts b/test/normalize-request.test.ts index e1d1aa6..4df9707 100644 --- a/test/normalize-request.test.ts +++ b/test/normalize-request.test.ts @@ -9,6 +9,7 @@ import { resolveRequestURL, serializeQueryParams, } from '../src/internal/normalize-request.js' +import type { RequestOptions } from '../src/types.js' test('serializeQueryParams repeats array keys and skips undefined', () => { const query = serializeQueryParams({ @@ -73,7 +74,7 @@ test('normalizeRequestOptions rejects body plus json', () => { json: { hello: 'world', }, - }), + } as RequestOptions), (error) => error instanceof ConfigError && error.message === '`body` and `json` cannot both be provided', @@ -86,7 +87,7 @@ test('normalizeRequestOptions rejects request bodies for GET and HEAD', () => { normalizeRequestOptions({}, { method: 'GET', body: 'payload', - }), + } as RequestOptions), (error) => error instanceof ConfigError && error.message === '`GET` requests cannot include a request body', @@ -99,7 +100,7 @@ test('normalizeRequestOptions rejects request bodies for GET and HEAD', () => { json: { ping: true, }, - }), + } as RequestOptions), (error) => error instanceof ConfigError && error.message === '`HEAD` requests cannot include a request body', diff --git a/test/type-signatures.ts b/test/type-signatures.ts index 82a65f2..e65da14 100644 --- a/test/type-signatures.ts +++ b/test/type-signatures.ts @@ -40,6 +40,38 @@ const clientJsonPromise: Promise<{ ok: boolean } | undefined> = client.get<{ }>('https://api.example.com/users') void clientJsonPromise +request('https://api.example.com/create', { + method: 'POST', + json: { ok: true }, +}) + +client.post('https://api.example.com/create', { + json: { ok: true }, +}) + +// @ts-expect-error body and json are mutually exclusive +request('https://api.example.com/create', { + method: 'POST', + body: 'raw', + json: { ok: true }, +}) + +// @ts-expect-error one-off JSON bodies require a body-capable method +request('https://api.example.com/create', { + json: { ok: true }, +}) + +// @ts-expect-error GET requests cannot include JSON request bodies +request('https://api.example.com/users', { + method: 'GET', + json: { invalid: true }, +}) + +client.get('https://api.example.com/users', { + // @ts-expect-error GET helper options cannot include request bodies + body: 'invalid', +}) + const typedTextPromise = request('https://api.example.com/text', { responseType: 'text', }) From ea91fd187e19dfda99eb14018df72af0498882fa Mon Sep 17 00:00:00 2001 From: "brian.j.murdock@gmail.com" Date: Sat, 13 Jun 2026 19:05:37 -0500 Subject: [PATCH 4/7] Support URLSearchParams query input --- CHANGELOG.md | 1 + DESIGN.md | 14 +++++++------- README.md | 23 +++++++++++++++++++++++ scripts/check-pack-smoke.mjs | 4 ++++ src/index.ts | 1 + src/internal/hook-options.ts | 6 +++++- src/internal/normalize-request.ts | 19 +++++++++++++++---- src/types.ts | 6 ++++-- test/hook-options.test.ts | 15 +++++++++++++++ test/normalize-request.test.ts | 23 +++++++++++++++++++++++ test/type-signatures.ts | 8 ++++++++ 11 files changed, 106 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3751384..fa93d4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - have the release workflow create or verify GitHub Release records after npm publish - expose retry attempt and serialized query metadata to hooks through `context.options` +- allow native `URLSearchParams` as `query` input while preserving duplicate-key ordering - tighten public TypeScript request option shapes for body/json and GET/HEAD misuse ## 1.0.4 diff --git a/DESIGN.md b/DESIGN.md index ff17828..19ec94f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -431,18 +431,16 @@ Query serialization should be conservative and easy to understand. Supported values: -- string -- number -- boolean -- null -- arrays of the above -- `undefined` as “omit the key” +- object-record query inputs with string, number, boolean, null, arrays of those values, and `undefined` as “omit the key” +- native `URLSearchParams` Unsupported structures, such as deeply nested objects, are intentionally out of scope for v1. +`URLSearchParams` is accepted because it is a native web platform primitive. It preserves duplicate-key ordering for callers that need that behavior without requiring the package to invent custom complex-object serialization rules. + ### Serialization rules -Default rules: +Default object-record rules: - `undefined` values are omitted - scalar values produce a single key-value pair @@ -456,6 +454,8 @@ Recommended default for arrays: This is widely understood and avoids introducing custom query conventions by default. +For `URLSearchParams`, the package uses the platform serializer directly. + ### Non-goal: deep object flattening The package should not automatically flatten complex object graphs into query strings. diff --git a/README.md b/README.md index 833a012..03a8fa4 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,29 @@ const api = createClient({ const user = await api.get<{ id: string; name: string }>('/users/123') ``` +### Query parameters + +```ts +import { createClient } from '@gavoryn/clearfetch' + +const api = createClient({ + baseURL: 'https://api.example.com', +}) + +const users = await api.get('/users', { + query: { + active: true, + tag: ['admin', 'editor'], + }, +}) + +const ordered = await api.get('/users', { + query: new URLSearchParams('tag=admin&page=1&tag=editor'), +}) +``` + +Use an object for ordinary query parameters. Use native `URLSearchParams` when duplicate-key ordering matters. + ### JSON request bodies ```ts diff --git a/scripts/check-pack-smoke.mjs b/scripts/check-pack-smoke.mjs index 6caf153..5950920 100644 --- a/scripts/check-pack-smoke.mjs +++ b/scripts/check-pack-smoke.mjs @@ -90,6 +90,7 @@ try { ' isHttpError,', ' request,', ' type HttpClient,', + ' type QueryInput,', ' type RequestOptions,', `} from '${packageName}'`, '', @@ -99,6 +100,9 @@ try { "const requestOptions: RequestOptions = { headers: { Accept: 'application/json' } }", 'void requestOptions', '', + "const queryInput: QueryInput = new URLSearchParams('tag=a&tag=b')", + 'void queryInput', + '', "const client: HttpClient = createClient({ baseURL: 'https://api.example.com' })", 'const jsonPromise: Promise<{ ok: boolean } | undefined> = client.get<{ ok: boolean }>(\'/users\')', 'void jsonPromise', diff --git a/src/index.ts b/src/index.ts index eae31e1..ebe71c6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,7 @@ export type { NormalizedRequestOptions, OnErrorHook, PrimitiveQueryValue, + QueryInput, QueryParams, QueryValue, RequestOptions, diff --git a/src/internal/hook-options.ts b/src/internal/hook-options.ts index f783bda..339686a 100644 --- a/src/internal/hook-options.ts +++ b/src/internal/hook-options.ts @@ -37,7 +37,7 @@ export function createHookRequestOptions( }) } - if (options.query !== undefined) { + if (options.query !== undefined && !isURLSearchParams(options.query)) { Object.defineProperty(snapshot, 'query', { configurable: false, enumerable: true, @@ -73,6 +73,10 @@ export interface HookLifecycleMetadata { queryString?: string } +function isURLSearchParams(value: unknown): value is URLSearchParams { + return value instanceof URLSearchParams +} + function freezeQueryParams(query: QueryParams): QueryParams { const snapshot: QueryParams = {} diff --git a/src/internal/normalize-request.ts b/src/internal/normalize-request.ts index bf737b9..94fd8c6 100644 --- a/src/internal/normalize-request.ts +++ b/src/internal/normalize-request.ts @@ -5,6 +5,7 @@ import type { Hooks, NormalizedRequestOptions, PrimitiveQueryValue, + QueryInput, QueryParams, RequestMethod, RequestOptions, @@ -144,7 +145,9 @@ export function normalizeRequestOptions( } if (options.query !== undefined) { - validateQueryParams(options.query) + if (!isURLSearchParams(options.query)) { + validateQueryParams(options.query) + } normalized.query = options.query } @@ -170,7 +173,7 @@ export function normalizeRequestOptions( export function resolveRequestURL( input: string | URL, baseURL?: string | URL, - query?: QueryParams, + query?: QueryInput, ): URL { const base = baseURL === undefined ? undefined : toAbsoluteURL(baseURL, 'Invalid base URL') const url = input instanceof URL ? new URL(input) : resolveInputURL(input, base) @@ -179,11 +182,15 @@ export function resolveRequestURL( return url } -export function serializeQueryParams(query?: QueryParams): string { +export function serializeQueryParams(query?: QueryInput): string { if (query === undefined) { return '' } + if (isURLSearchParams(query)) { + return query.toString() + } + const params = new URLSearchParams() for (const [key, value] of Object.entries(query)) { @@ -204,7 +211,7 @@ export function serializeQueryParams(query?: QueryParams): string { return params.toString() } -function applyQueryParams(url: URL, query?: QueryParams): void { +function applyQueryParams(url: URL, query?: QueryInput): void { const serialized = serializeQueryParams(query) if (serialized === '') { @@ -215,6 +222,10 @@ function applyQueryParams(url: URL, query?: QueryParams): void { url.search += suffix } +function isURLSearchParams(value: unknown): value is URLSearchParams { + return value instanceof URLSearchParams +} + function mergeHeaders( defaultHeaders?: HeadersInit, requestHeaders?: HeadersInit, diff --git a/src/types.ts b/src/types.ts index dba3a22..d5c0af3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,6 +31,8 @@ export type QueryValue = export type QueryParams = Record +export type QueryInput = QueryParams | URLSearchParams + /** * Conservative retry configuration. * @@ -52,7 +54,7 @@ export type BodyCapableRequestMethod = Exclude export interface RequestOptionsBase { headers?: HeadersInit - query?: QueryParams + query?: QueryInput timeout?: number signal?: AbortSignal responseType?: ResponseType @@ -206,7 +208,7 @@ export interface Hooks { export interface NormalizedRequestOptions { method: RequestMethod headers: Headers - query?: QueryParams + query?: QueryInput body?: BodyInit | null json?: unknown timeout?: number diff --git a/test/hook-options.test.ts b/test/hook-options.test.ts index 943f229..98c7ba9 100644 --- a/test/hook-options.test.ts +++ b/test/hook-options.test.ts @@ -94,6 +94,21 @@ test('createHookRequestOptions exposes serialized query metadata', () => { assert.equal(snapshot.queryString, 'tag=a&tag=b&page=1') }) +test('createHookRequestOptions exposes serialized URLSearchParams query metadata', () => { + const query = new URLSearchParams('tag=a&page=1&tag=b') + const snapshot = createHookRequestOptions( + createOptions({ query }), + { + attempt: 1, + maxAttempts: 1, + queryString: 'tag=a&page=1&tag=b', + }, + ) + + assert.equal(Object.hasOwn(snapshot, 'query'), false) + assert.equal(snapshot.queryString, 'tag=a&page=1&tag=b') +}) + test('createHookRequestOptions freezes query metadata and query arrays when query is present', () => { const query = { page: 2, diff --git a/test/normalize-request.test.ts b/test/normalize-request.test.ts index 4df9707..130c4b0 100644 --- a/test/normalize-request.test.ts +++ b/test/normalize-request.test.ts @@ -22,6 +22,29 @@ test('serializeQueryParams repeats array keys and skips undefined', () => { assert.equal(query, 'page=1&tags=a&tags=b&nullable=null') }) +test('serializeQueryParams preserves URLSearchParams ordering and duplicate keys', () => { + const query = new URLSearchParams() + query.append('tag', 'a') + query.append('page', '1') + query.append('tag', 'b') + + assert.equal(serializeQueryParams(query), 'tag=a&page=1&tag=b') +}) + +test('resolveRequestURL appends URLSearchParams query input', () => { + const query = new URLSearchParams('tag=a&page=1&tag=b') + const url = resolveRequestURL( + '/users?active=true', + 'https://api.example.com', + query, + ) + + assert.equal( + url.href, + 'https://api.example.com/users?active=true&tag=a&page=1&tag=b', + ) +}) + test('resolveRequestURL requires baseURL for relative inputs', () => { assert.throws( () => resolveRequestURL('/users'), diff --git a/test/type-signatures.ts b/test/type-signatures.ts index e65da14..3e827b2 100644 --- a/test/type-signatures.ts +++ b/test/type-signatures.ts @@ -49,6 +49,14 @@ client.post('https://api.example.com/create', { json: { ok: true }, }) +request('https://api.example.com/users', { + query: new URLSearchParams('tag=a&tag=b'), +}) + +client.get('https://api.example.com/users', { + query: new URLSearchParams('tag=a&tag=b'), +}) + // @ts-expect-error body and json are mutually exclusive request('https://api.example.com/create', { method: 'POST', From aeb941dfb4d990b2efa59fb901d086c0563f9030 Mon Sep 17 00:00:00 2001 From: "brian.j.murdock@gmail.com" Date: Sat, 13 Jun 2026 19:16:03 -0500 Subject: [PATCH 5/7] Add header redaction helper --- CHANGELOG.md | 1 + DESIGN.md | 16 +++++++-------- README.md | 23 +++++++++++++++++++++ scripts/check-pack-smoke.mjs | 13 ++++++++++++ src/diagnostics.ts | 33 +++++++++++++++++++++++++++++ src/index.ts | 2 ++ src/types.ts | 5 +++++ test/diagnostics.test.ts | 40 ++++++++++++++++++++++++++++++++++++ test/index.test.ts | 1 + 9 files changed, 126 insertions(+), 8 deletions(-) create mode 100644 src/diagnostics.ts create mode 100644 test/diagnostics.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fa93d4e..82099bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- add `redactHeaders()` for safe application-owned diagnostics without built-in logging - have the release workflow create or verify GitHub Release records after npm publish - expose retry attempt and serialized query metadata to hooks through `context.options` - allow native `URLSearchParams` as `query` input while preserving duplicate-key ordering diff --git a/DESIGN.md b/DESIGN.md index 19ec94f..6b7eaec 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -859,16 +859,16 @@ The package’s runtime security posture is grounded in the following choices: ### Sensitive data handling -The package must avoid logging or exposing sensitive headers automatically. +The package must avoid logging or exposing sensitive headers automatically. The core package itself should avoid built-in logging. -If helper utilities exist for diagnostics, they should support redaction of commonly sensitive header names such as: +Applications that own diagnostics may use the public `redactHeaders()` helper to copy headers and redact exactly matched sensitive header names. By default, the helper redacts: -- `Authorization` -- `Cookie` -- `Set-Cookie` -- API-key style headers - -The core package itself should avoid built-in logging. +- `authorization` +- `cookie` +- `set-cookie` +- `proxy-authorization` +- `x-api-key` +- `api-key` ### Redirect behavior diff --git a/README.md b/README.md index 03a8fa4..307958b 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,29 @@ Hook scope is intentionally narrow: Cloned `afterResponse` inspection is intended for ordinary API payloads, not large streaming or heavy binary workflows. +#### Safe diagnostic header logging + +clearfetch has no built-in logging or telemetry. Applications that log request +diagnostics can use `redactHeaders()` to copy headers and replace common +sensitive values before writing application-owned diagnostics. +By default, it redacts exact case-insensitive matches for `authorization`, +`cookie`, `set-cookie`, `proxy-authorization`, `x-api-key`, and `api-key`. + +```ts +import { createClient, redactHeaders } from '@gavoryn/clearfetch' + +const api = createClient({ + hooks: { + beforeRequest: [ + (context) => { + const safeHeaders = redactHeaders(context.headers) + console.log(Object.fromEntries(safeHeaders)) + }, + ], + }, +}) +``` + ### Error handling ```ts diff --git a/scripts/check-pack-smoke.mjs b/scripts/check-pack-smoke.mjs index 5950920..2b6dd35 100644 --- a/scripts/check-pack-smoke.mjs +++ b/scripts/check-pack-smoke.mjs @@ -38,6 +38,7 @@ try { " 'createClient',", " 'isHttpClientError',", " 'isHttpError',", + " 'redactHeaders',", " 'request',", ']', '', @@ -88,9 +89,11 @@ try { ' createClient,', ' isHttpClientError,', ' isHttpError,', + ' redactHeaders,', ' request,', ' type HttpClient,', ' type QueryInput,', + ' type RedactHeadersOptions,', ' type RequestOptions,', `} from '${packageName}'`, '', @@ -103,6 +106,16 @@ try { "const queryInput: QueryInput = new URLSearchParams('tag=a&tag=b')", 'void queryInput', '', + 'const redactionOptions: RedactHeadersOptions = {', + " headerNames: ['authorization'],", + '}', + 'void redactionOptions', + '', + "const safeHeaders = redactHeaders({ Authorization: 'secret' })", + "if (safeHeaders.get('authorization') !== '[redacted]') {", + " throw new Error('redactHeaders did not redact Authorization')", + '}', + '', "const client: HttpClient = createClient({ baseURL: 'https://api.example.com' })", 'const jsonPromise: Promise<{ ok: boolean } | undefined> = client.get<{ ok: boolean }>(\'/users\')', 'void jsonPromise', diff --git a/src/diagnostics.ts b/src/diagnostics.ts new file mode 100644 index 0000000..9f224da --- /dev/null +++ b/src/diagnostics.ts @@ -0,0 +1,33 @@ +import type { RedactHeadersOptions } from './types.js' + +const DEFAULT_REDACTED_HEADER_NAMES = [ + 'authorization', + 'cookie', + 'set-cookie', + 'proxy-authorization', + 'x-api-key', + 'api-key', +] + +const DEFAULT_REPLACEMENT = '[redacted]' + +export function redactHeaders( + headers: HeadersInit, + options: RedactHeadersOptions = {}, +): Headers { + const redacted = new Headers(headers) + const replacement = options.replacement ?? DEFAULT_REPLACEMENT + const sensitiveNames = new Set( + (options.headerNames ?? DEFAULT_REDACTED_HEADER_NAMES).map((name) => + name.toLowerCase(), + ), + ) + + for (const [name] of redacted.entries()) { + if (sensitiveNames.has(name.toLowerCase())) { + redacted.set(name, replacement) + } + } + + return redacted +} diff --git a/src/index.ts b/src/index.ts index ebe71c6..6c38a06 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export { createClient } from './client.js' +export { redactHeaders } from './diagnostics.js' export { request } from './request.js' export { @@ -30,6 +31,7 @@ export type { QueryInput, QueryParams, QueryValue, + RedactHeadersOptions, RequestOptions, RequestMethod, ResponseType, diff --git a/src/types.ts b/src/types.ts index d5c0af3..5d1c98c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -33,6 +33,11 @@ export type QueryParams = Record export type QueryInput = QueryParams | URLSearchParams +export interface RedactHeadersOptions { + headerNames?: readonly string[] + replacement?: string +} + /** * Conservative retry configuration. * diff --git a/test/diagnostics.test.ts b/test/diagnostics.test.ts new file mode 100644 index 0000000..91cb068 --- /dev/null +++ b/test/diagnostics.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { redactHeaders } from '../src/index.js' + +test('redactHeaders redacts common sensitive headers case-insensitively', () => { + const redacted = redactHeaders({ + Authorization: 'Bearer secret', + Cookie: 'session=secret', + 'Set-Cookie': 'session=secret', + 'Proxy-Authorization': 'Basic secret', + 'X-API-Key': 'secret', + 'Api-Key': 'secret', + Accept: 'application/json', + }) + + assert.equal(redacted.get('authorization'), '[redacted]') + assert.equal(redacted.get('cookie'), '[redacted]') + assert.equal(redacted.get('set-cookie'), '[redacted]') + assert.equal(redacted.get('proxy-authorization'), '[redacted]') + assert.equal(redacted.get('x-api-key'), '[redacted]') + assert.equal(redacted.get('api-key'), '[redacted]') + assert.equal(redacted.get('accept'), 'application/json') +}) + +test('redactHeaders returns a copy and supports custom redaction options', () => { + const source = new Headers({ + 'x-secret-token': 'secret', + accept: 'application/json', + }) + + const redacted = redactHeaders(source, { + headerNames: ['x-secret-token'], + replacement: '', + }) + + assert.equal(source.get('x-secret-token'), 'secret') + assert.equal(redacted.get('x-secret-token'), '') + assert.equal(redacted.get('accept'), 'application/json') +}) diff --git a/test/index.test.ts b/test/index.test.ts index dbc10c3..6e359ad 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -16,6 +16,7 @@ test('package entrypoint loads', () => { 'createClient', 'isHttpClientError', 'isHttpError', + 'redactHeaders', 'request', ]) }) From fc1e6a90aeeade2c762b26b5bdbb3619342b56be Mon Sep 17 00:00:00 2001 From: "brian.j.murdock@gmail.com" Date: Sat, 13 Jun 2026 23:14:47 -0500 Subject: [PATCH 6/7] Fix GitHub Release verification fields --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d445e1d..58652a6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -104,7 +104,7 @@ jobs: GH_TOKEN: ${{ github.token }} TAG_NAME: ${{ github.ref_name }} run: | - if release_json="$(gh release view "$TAG_NAME" --json tagName,name,isDraft,isPrerelease,isLatest,url 2>/dev/null)"; then + if release_json="$(gh release view "$TAG_NAME" --json tagName,name,isDraft,isPrerelease,url 2>/dev/null)"; then RELEASE_JSON="$release_json" node <<'NODE' const release = JSON.parse(process.env.RELEASE_JSON) From 5300877127547ea9f972b9cd71800aa5cf2ebce5 Mon Sep 17 00:00:00 2001 From: "brian.j.murdock@gmail.com" Date: Sat, 13 Jun 2026 23:32:17 -0500 Subject: [PATCH 7/7] Prepare v1.0.5 release --- CHANGELOG.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82099bf..88f0233 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 1.0.5 - add `redactHeaders()` for safe application-owned diagnostics without built-in logging - have the release workflow create or verify GitHub Release records after npm publish diff --git a/package-lock.json b/package-lock.json index 3e6bb72..b8343da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@gavoryn/clearfetch", - "version": "1.0.4", + "version": "1.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@gavoryn/clearfetch", - "version": "1.0.4", + "version": "1.0.5", "license": "MIT", "devDependencies": { "@types/node": "^24.5.2", diff --git a/package.json b/package.json index b096b43..d4ba82b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@gavoryn/clearfetch", - "version": "1.0.4", + "version": "1.0.5", "description": "A dependency-free, fetch-native HTTP client for modern JavaScript and TypeScript runtimes.", "type": "module", "sideEffects": false,