Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 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
- 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

Failure-observability, retry-timeout, diagnostic, and package-guardrail hardening.
Expand Down
36 changes: 20 additions & 16 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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`.

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -856,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.

If helper utilities exist for diagnostics, they should support redaction of commonly sensitive header names such as:
The package must avoid logging or exposing sensitive headers automatically. The core package itself should avoid built-in logging.

- `Authorization`
- `Cookie`
- `Set-Cookie`
- API-key style headers
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:

The core package itself should avoid built-in logging.
- `authorization`
- `cookie`
- `set-cookie`
- `proxy-authorization`
- `x-api-key`
- `api-key`

### Redirect behavior

Expand Down Expand Up @@ -927,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.

---

Expand Down
51 changes: 50 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -204,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
Expand Down Expand Up @@ -292,6 +338,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
Expand All @@ -305,7 +352,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

Expand Down Expand Up @@ -338,6 +386,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).

Expand Down
12 changes: 11 additions & 1 deletion RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
29 changes: 29 additions & 0 deletions scripts/check-pack-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ try {
" 'createClient',",
" 'isHttpClientError',",
" 'isHttpError',",
" 'redactHeaders',",
" 'request',",
']',
'',
Expand Down Expand Up @@ -88,8 +89,11 @@ try {
' createClient,',
' isHttpClientError,',
' isHttpError,',
' redactHeaders,',
' request,',
' type HttpClient,',
' type QueryInput,',
' type RedactHeadersOptions,',
' type RequestOptions,',
`} from '${packageName}'`,
'',
Expand All @@ -99,10 +103,35 @@ try {
"const requestOptions: RequestOptions = { headers: { Accept: 'application/json' } }",
'void requestOptions',
'',
"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',
'',
'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'),",
Expand Down
33 changes: 33 additions & 0 deletions src/diagnostics.ts
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { createClient } from './client.js'
export { redactHeaders } from './diagnostics.js'
export { request } from './request.js'

export {
Expand Down Expand Up @@ -27,8 +28,10 @@ export type {
NormalizedRequestOptions,
OnErrorHook,
PrimitiveQueryValue,
QueryInput,
QueryParams,
QueryValue,
RedactHeadersOptions,
RequestOptions,
RequestMethod,
ResponseType,
Expand Down
Loading