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
142 changes: 128 additions & 14 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -191,17 +191,94 @@ jobs:
npm publish "$TARBALL" --ignore-scripts --provenance --registry=https://registry.npmjs.org
fi

REGISTRY_INTEGRITY="$(npm view "$PACKAGE_NAME@$PACKAGE_VERSION" dist.integrity --registry=https://registry.npmjs.org)"
if [ "$REGISTRY_INTEGRITY" != "$ACTUAL_INTEGRITY" ]; then
echo "Registry integrity does not match the verified tarball after publish" >&2
exit 1
fi
ATTESTATION_URL="$(
PACKAGE_NAME="$PACKAGE_NAME" \
PACKAGE_VERSION="$PACKAGE_VERSION" \
EXPECTED_INTEGRITY="$ACTUAL_INTEGRITY" \
node --input-type=module <<'NODE'
const packageName = process.env.PACKAGE_NAME
const packageVersion = process.env.PACKAGE_VERSION
const expectedIntegrity = process.env.EXPECTED_INTEGRITY
const registryURL = new URL(
`${encodeURIComponent(packageName)}/${encodeURIComponent(packageVersion)}`,
'https://registry.npmjs.org/',
)
const retryDelaysMs = [1_000, 2_000, 4_000, 8_000, 8_000, 8_000, 8_000]
const transientStatuses = new Set([404, 408, 425, 429, 500, 502, 503, 504])
let lastTransientFailure = 'registry metadata was not available'
let attestationURL

class TerminalRegistryError extends Error {}

for (let attempt = 0; attempt <= retryDelaysMs.length; attempt += 1) {
try {
const response = await fetch(registryURL, {
headers: { accept: 'application/json' },
signal: AbortSignal.timeout(5_000),
})

if (!response.ok) {
if (!transientStatuses.has(response.status)) {
throw new TerminalRegistryError(
`npm registry metadata request failed with ${response.status}`,
)
}
lastTransientFailure = `npm registry returned ${response.status}`
} else {
const metadata = await response.json()
const integrity = metadata.dist?.integrity
const visibleAttestationURL = metadata.dist?.attestations?.url

if (integrity !== undefined && integrity !== expectedIntegrity) {
throw new TerminalRegistryError(
'Published integrity does not match the verified tarball',
)
}

if (
integrity === expectedIntegrity &&
typeof visibleAttestationURL === 'string'
) {
attestationURL = visibleAttestationURL
break
}

lastTransientFailure =
integrity === undefined
? 'published integrity is not visible yet'
: 'published attestation URL is not visible yet'
}
} catch (error) {
if (error instanceof TerminalRegistryError) {
throw error
}
lastTransientFailure =
error instanceof Error ? error.message : String(error)
}

if (
attestationURL !== undefined ||
attempt === retryDelaysMs.length
) {
break
}

const delayMs = retryDelaysMs[attempt]
console.error(
`${lastTransientFailure}; retrying npm metadata in ${delayMs}ms`,
)
await new Promise((resolve) => setTimeout(resolve, delayMs))
}

ATTESTATION_URL="$(npm view "$PACKAGE_NAME@$PACKAGE_VERSION" dist.attestations.url --registry=https://registry.npmjs.org)"
if [ -z "$ATTESTATION_URL" ]; then
echo "Published package has no npm attestation URL" >&2
exit 1
fi
if (attestationURL === undefined) {
throw new Error(
`npm metadata did not become ready: ${lastTransientFailure}`,
)
}

console.log(attestationURL)
NODE
)"

ATTESTATION_DIR="$(mktemp -d)"
trap 'rm -rf "$ATTESTATION_DIR"' EXIT
Expand All @@ -218,12 +295,49 @@ jobs:
PACKAGE_INTEGRITY="$ACTUAL_INTEGRITY" \
TAG_NAME="$TAG_NAME" \
node --input-type=module <<'NODE'
const response = await fetch(process.env.ATTESTATION_URL)
if (!response.ok) {
throw new Error(`npm attestation request failed with ${response.status}`)
const retryDelaysMs = [1_000, 2_000, 4_000, 8_000]
const transientStatuses = new Set([404, 408, 425, 429, 500, 502, 503, 504])
let document

class TerminalAttestationError extends Error {}

for (let attempt = 0; attempt <= retryDelaysMs.length; attempt += 1) {
try {
const response = await fetch(process.env.ATTESTATION_URL, {
signal: AbortSignal.timeout(5_000),
})
if (response.ok) {
document = await response.json()
break
}
if (!transientStatuses.has(response.status)) {
throw new TerminalAttestationError(
`npm attestation request failed with ${response.status}`,
)
}
console.error(`npm attestation returned ${response.status}`)
} catch (error) {
if (error instanceof TerminalAttestationError) {
throw error
}
console.error(
error instanceof Error ? error.message : String(error),
)
}

if (attempt === retryDelaysMs.length) {
break
}

const delayMs = retryDelaysMs[attempt]
console.error(`retrying npm attestation in ${delayMs}ms`)
await new Promise((resolve) => setTimeout(resolve, delayMs))
}

if (document === undefined) {
throw new Error('npm attestation did not become available')
}

const document = await response.json()
const provenance = document.attestations?.find(
(entry) => entry.predicateType === 'https://slsa.dev/provenance/v1',
)
Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,27 @@ the matching `vX.Y.Z` tag to publish to npm and a GitHub Release to exist.
Versions `1.0.0` and `1.0.1` predate the GitHub Release record requirement;
their npm publications and Git tags remain the historical release evidence.

## Unreleased

## 1.0.8

- validate all supplied client defaults during `createClient()` and `extend()`
construction, including malformed string base URLs and invalid scalar default
values
- consolidate reusable-client response-mode overloads so all client methods
preserve explicit `json`, `text`, `blob`, `arrayBuffer`, and `raw` return
types in the declaration surface
- bound non-2xx diagnostic body reads by size and time, preserve valid Unicode
while truncating, and keep stalled error bodies from delaying `HttpError`
classification indefinitely
- classify asynchronous custom JSON parser rejections as `ParseError` and keep
timeout or external-abort signals authoritative while a parser is pending
- reject invalid request abort signals with `ConfigError` while preserving
cross-realm native signal support
- retry post-publish npm integrity and attestation visibility checks during
bounded registry propagation windows
- clean up root tarballs when packed-artifact assertions fail

## 1.0.7

- reject timeout and retry-delay values above the maximum reliable platform timer delay and preserve timeout classification through `afterResponse` hooks
Expand Down
12 changes: 10 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,8 @@ Mutable default inputs are snapshotted when the client is created. This includes

### Validation rules

Validation must occur before the request is executed.
Client defaults are validated while `createClient()` or `extend()` constructs a
client. Request options are validated before the request is executed.

Invalid configurations must fail fast with a configuration error.

Expand All @@ -363,6 +364,7 @@ Examples of invalid configurations include:
- timeout or retry-delay values above the platform timer maximum of `2,147,483,647` milliseconds
- malformed base URL
- unsupported response type
- invalid abort signal
- invalid retry values, including explicit `null` nested fields

Strict validation is desirable. Silent coercion should be avoided unless it is trivial and unsurprising.
Expand Down Expand Up @@ -537,6 +539,9 @@ When parsing JSON:
3. Return parsed value
4. Throw a parse error if parsing fails

The configured parser may return a value or a promise. Synchronous throws and
asynchronous rejections are both classified as `ParseError`.

The package must not silently fall back from JSON to text.

Silent fallback hides data problems and makes failures harder to reason about.
Expand All @@ -563,7 +568,9 @@ Non-2xx responses must throw an `HttpError` before response parsing is returned

This is a deliberate divergence from native fetch behavior and a major part of the package’s value proposition.

`HttpError` may include `bodyText` for diagnostics, but capture should be bounded so large payloads do not cause avoidable memory pressure.
`HttpError` may include `bodyText` for diagnostics, but capture is bounded by
both size and a short diagnostic read budget so large or stalled payloads do not
cause avoidable memory pressure or delay HTTP classification indefinitely.

Diagnostic body capture may consume or cancel the `HttpError.response` body. Consumers that need to inspect a non-2xx response body should use `bodyText` or an `afterResponse` hook instead of assuming the retained `Response` body remains readable.

Expand Down Expand Up @@ -667,6 +674,7 @@ A timeout means:
- the controller aborts once the configured timeout elapses
- timeout expiration produces a `TimeoutError`
- after the timer starts, timeout classification remains authoritative through `afterResponse` hooks and response parsing
- asynchronous custom JSON parsing is raced against the active attempt signal so a pending parser cannot outlive timeout or external-abort classification

### External abort model

Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,14 +365,16 @@ const user = User.parse(data)

- Non-2xx responses throw `HttpError`.
- `HttpError.bodyText` capture is bounded and may be truncated for very large payloads.
- `HttpError.bodyText` capture also has a 250ms diagnostic read budget, so stalled error bodies cannot delay HTTP classification indefinitely; partial captures are marked as truncated.
- `ParseError.bodyText` capture is also bounded and may be truncated for very large invalid JSON payloads.
- `HttpError.response` remains available for status, headers, and metadata, but its body may already be consumed or canceled by diagnostic `bodyText` capture.
- JSON mode returns `undefined` for empty response bodies.
- In JSON mode, successful empty bodies resolve as `T | undefined`.
- No default timeout is applied. Requests run until completion or external abort unless `timeout` is configured.
- Timeout and retry-delay values may not exceed `2,147,483,647` milliseconds, the maximum reliable platform timer delay.
- After the timeout window starts, expiration remains authoritative through `afterResponse` hooks and response parsing.
- Invalid request configuration, including invalid hook lists, fails fast with `ConfigError`.
- Invalid request configuration, including invalid hook lists, fails fast with `ConfigError`. `createClient()` and `extend()` also validate supplied client defaults during construction.
- Invalid request abort signals fail with `ConfigError`; native signals from another browser realm remain supported.
- Hook, request-normalization, retry rebuild, and request-construction failures are not wrapped as `NetworkError`.
- Each `afterResponse` hook receives an independently readable cloned `Response` for safe inspection.
- Relative request inputs require `baseURL`.
Expand All @@ -388,6 +390,7 @@ const user = User.parse(data)
## Advanced behavior notes

- Timeout windows are per attempt when retries are enabled. A configured `timeout` is not a total deadline across all retry attempts.
- Custom `parseJson` functions may return a value or promise. Rejections become `ParseError`, and active timeout or external-abort signals remain authoritative while an asynchronous parser is pending.
- External abort signals surface as `AbortRequestError`, including when the signal was aborted with a custom reason.
- External abort beats timeout if it happens first; timeout beats external abort if the timeout fires first.
- Timeout aborts surface as `TimeoutError`.
Expand Down
5 changes: 5 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ Expected flow:
7. Let the `Release` workflow verify and upload one exact tarball, publish that artifact to npm, and create or verify the matching GitHub Release record.
8. Confirm npm and GitHub Releases show the same current version.

After publication, registry integrity, attestation metadata, and the attestation
document are verified with bounded retry/backoff so normal npm propagation delay
does not leave an otherwise successful release incomplete. Integrity mismatches
and non-transient registry failures remain terminal.

Local `npm publish` should not be used for normal releases.

The tag must match the package version exactly, for example package version
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.7",
"version": "1.0.8",
"description": "A dependency-free, fetch-native HTTP client for modern JavaScript and TypeScript runtimes.",
"type": "module",
"sideEffects": false,
Expand Down
39 changes: 32 additions & 7 deletions scripts/check-pack-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,17 @@ if (unexpectedArguments.length > 0) {
const { stdout } = await execFileAsync('npm', ['pack', '--json', '--ignore-scripts'], {
cwd: rootDir,
})
const [packResult] = JSON.parse(stdout)
const packResult = selectPackResult(JSON.parse(stdout))
const tarballPath = path.join(rootDir, packResult.filename)

assertPackedFiles(packResult.files)
assertPackedSize(packResult)

const packageName = '@gavoryn/clearfetch'
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'clearfetch-pack-'))
let tempDir
let tarballRetained = false

try {
assertPackedFiles(packResult.files)
assertPackedSize(packResult)

tempDir = await mkdtemp(path.join(os.tmpdir(), 'clearfetch-pack-'))
const importSmokeFile = path.join(tempDir, 'smoke-import.mjs')
await writeFile(
importSmokeFile,
Expand Down Expand Up @@ -236,14 +236,39 @@ try {
console.log(`retained verified tarball at ${artifactPath}`)
}
} finally {
await rm(tempDir, { recursive: true, force: true })
if (tempDir !== undefined) {
await rm(tempDir, { recursive: true, force: true })
}
if (!tarballRetained) {
await rm(tarballPath, { force: true })
}
}

console.log('packed artifact smoke checks passed')

function selectPackResult(output) {
const candidates = Array.isArray(output)
? output
: [output, ...(isRecord(output) ? Object.values(output) : [])]
const packResult = candidates.find((candidate) => {
return (
isRecord(candidate) &&
typeof candidate.filename === 'string' &&
Array.isArray(candidate.files)
)
})

if (packResult === undefined) {
throw new Error('npm pack did not return a usable package result')
}

return packResult
}

function isRecord(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}

function assertPackedFiles(files) {
const unexpectedFiles = files
.map((entry) => entry.path)
Expand Down
17 changes: 16 additions & 1 deletion scripts/check-publish-dry-run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ async function getPublishedMetadata(packageSpec) {
],
{ maxBuffer: 1024 * 1024 },
)
return JSON.parse(stdout)
return normalizeNpmViewResult(JSON.parse(stdout))
} catch (error) {
if (isNotFoundError(error)) {
return undefined
Expand All @@ -88,6 +88,21 @@ async function getPublishedMetadata(packageSpec) {
}
}

function normalizeNpmViewResult(value) {
if (Array.isArray(value)) {
if (value.length !== 1) {
throw new Error('npm view returned an unexpected number of package records')
}
return value[0]
}

if (value === null || typeof value !== 'object') {
throw new Error('npm view did not return a package record')
}

return value
}

async function calculateIntegrity(tarballPath) {
const tarball = await readFile(path.resolve(tarballPath))
return `sha512-${createHash('sha512').update(tarball).digest('base64')}`
Expand Down
Loading