diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c277f4..c578f69 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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', ) diff --git a/CHANGELOG.md b/CHANGELOG.md index caba2ef..73fbd83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/DESIGN.md b/DESIGN.md index b14d0ba..9264243 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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. @@ -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. @@ -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. @@ -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. @@ -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 diff --git a/README.md b/README.md index 5a3bd35..75ea3e5 100644 --- a/README.md +++ b/README.md @@ -365,6 +365,7 @@ 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. @@ -372,7 +373,8 @@ const user = User.parse(data) - 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`. @@ -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`. diff --git a/RELEASE.md b/RELEASE.md index 5e005b5..23e09fe 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 878c7e2..a1b0489 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@gavoryn/clearfetch", - "version": "1.0.7", + "version": "1.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@gavoryn/clearfetch", - "version": "1.0.7", + "version": "1.0.8", "license": "MIT", "devDependencies": { "@types/node": "^24.5.2", diff --git a/package.json b/package.json index 9c4ff97..03df89e 100644 --- a/package.json +++ b/package.json @@ -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, diff --git a/scripts/check-pack-smoke.mjs b/scripts/check-pack-smoke.mjs index 3299a88..5bc6037 100644 --- a/scripts/check-pack-smoke.mjs +++ b/scripts/check-pack-smoke.mjs @@ -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, @@ -236,7 +236,9 @@ 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 }) } @@ -244,6 +246,29 @@ try { 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) diff --git a/scripts/check-publish-dry-run.mjs b/scripts/check-publish-dry-run.mjs index 4528f83..5edb080 100644 --- a/scripts/check-publish-dry-run.mjs +++ b/scripts/check-publish-dry-run.mjs @@ -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 @@ -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')}` diff --git a/src/internal/client-defaults.ts b/src/internal/client-defaults.ts index b513939..51e2e8f 100644 --- a/src/internal/client-defaults.ts +++ b/src/internal/client-defaults.ts @@ -10,6 +10,12 @@ import { normalizeBeforeRequestHooks, normalizeOnErrorHooks, } from './hooks.js' +import { + normalizeParseJson, + normalizeResponseType, + normalizeTimeout, + toAbsoluteURL, +} from './normalize-request.js' import { normalizeRetry } from './retry-policy.js' export function mergeClientDefaults( @@ -141,17 +147,15 @@ function snapshotBaseURL( } if (typeof defaults.baseURL === 'string') { + toAbsoluteURL(defaults.baseURL, '`baseURL` must be a string or URL') snapshot.baseURL = defaults.baseURL return } - try { - snapshot.baseURL = new URL( - URL.prototype.toString.call(defaults.baseURL), - ) - } catch (cause) { - throw new ConfigError('`baseURL` must be a string or URL', cause) - } + snapshot.baseURL = toAbsoluteURL( + defaults.baseURL, + '`baseURL` must be a string or URL', + ) } function snapshotHeaders( @@ -168,11 +172,14 @@ function snapshotScalarDefaults( defaults: ClientDefaults, ): void { if (defaults.timeout !== undefined) { - snapshot.timeout = defaults.timeout + const timeout = normalizeTimeout(defaults.timeout) + if (timeout !== undefined) { + snapshot.timeout = timeout + } } if (defaults.responseType !== undefined) { - snapshot.responseType = defaults.responseType + snapshot.responseType = normalizeResponseType(defaults.responseType) } } @@ -199,7 +206,7 @@ function snapshotParseJsonDefault( defaults: ClientDefaults, ): void { if (defaults.parseJson !== undefined) { - snapshot.parseJson = defaults.parseJson + snapshot.parseJson = normalizeParseJson(defaults.parseJson) } } diff --git a/src/internal/execute-request.ts b/src/internal/execute-request.ts index cbca617..6f81bb3 100644 --- a/src/internal/execute-request.ts +++ b/src/internal/execute-request.ts @@ -535,12 +535,15 @@ async function parseWithHandling(params: { } try { - return (await parseResponse({ - request, - response, - responseType: context.normalizedOptions.responseType, - parseJson: context.normalizedOptions.parseJson, - })) as T | Response | string | Blob | ArrayBuffer | undefined + return (await waitForResultOrAbort( + parseResponse({ + request, + response, + responseType: context.normalizedOptions.responseType, + parseJson: context.normalizedOptions.parseJson, + }), + request.signal, + )) as T | Response | string | Blob | ArrayBuffer | undefined } catch (error) { const normalized = normalizeExecutionError( context.normalizedOptions.timeout !== undefined && timeout.didTimeout() @@ -589,6 +592,50 @@ async function parseWithHandling(params: { } } +function waitForResultOrAbort( + promise: Promise, + signal: AbortSignal, +): Promise { + if (signal.aborted) { + return Promise.reject( + signal.reason ?? new DOMException('Request was aborted', 'AbortError'), + ) + } + + return new Promise((resolve, reject) => { + let settled = false + const onAbort = () => { + if (settled) { + return + } + settled = true + reject( + signal.reason ?? new DOMException('Request was aborted', 'AbortError'), + ) + } + + signal.addEventListener('abort', onAbort, { once: true }) + void promise.then( + (value) => { + if (settled) { + return + } + settled = true + signal.removeEventListener('abort', onAbort) + resolve(value) + }, + (error: unknown) => { + if (settled) { + return + } + settled = true + signal.removeEventListener('abort', onAbort) + reject(error) + }, + ) + }) +} + function cancelResponseBody(response: Response): void { if (response.body === null) { return diff --git a/src/internal/hook-options.ts b/src/internal/hook-options.ts index 71e3a3c..1cf680a 100644 --- a/src/internal/hook-options.ts +++ b/src/internal/hook-options.ts @@ -4,6 +4,7 @@ import type { PrimitiveQueryValue, QueryParams, } from '../types.js' +import { isFrozenQueryInput } from './query-params.js' export function createHookRequestOptions( options: NormalizedRequestOptions, @@ -41,7 +42,9 @@ export function createHookRequestOptions( Object.defineProperty(snapshot, 'query', { configurable: false, enumerable: true, - value: freezeQueryParams(options.query), + value: isFrozenQueryInput(options.query) + ? options.query + : freezeQueryParams(options.query), writable: false, }) } diff --git a/src/internal/normalize-request.ts b/src/internal/normalize-request.ts index a81137f..8fe8bf8 100644 --- a/src/internal/normalize-request.ts +++ b/src/internal/normalize-request.ts @@ -16,6 +16,8 @@ import { } from './platform-values.js' import { applyQueryString, + freezeQueryInput, + isFrozenQueryInput, serializeQueryParams, serializeValidatedQueryParams, snapshotQueryInput, @@ -208,7 +210,9 @@ function cloneNormalizedRequestOptions( } if (options.query !== undefined) { - snapshot.query = snapshotQueryInput(options.query) + snapshot.query = isFrozenQueryInput(options.query) + ? options.query + : snapshotQueryInput(options.query) } if (options.body !== undefined) { @@ -293,6 +297,7 @@ export function normalizeRequestOptions( : DEFAULT_PARSE_JSON, ) const headers = mergeHeaders(defaults.headers, options.headers) + const signal = normalizeAbortSignal(options.signal) const hasJson = Object.hasOwn(options, 'json') @@ -321,7 +326,7 @@ export function normalizeRequestOptions( } if (options.query !== undefined) { - normalized.query = snapshotQueryInput(options.query) + normalized.query = freezeQueryInput(snapshotQueryInput(options.query)) } if (options.body !== undefined) { @@ -336,13 +341,55 @@ export function normalizeRequestOptions( normalized.timeout = timeout } - if (options.signal !== undefined) { - normalized.signal = options.signal + if (signal !== undefined) { + normalized.signal = signal } return normalized } +function normalizeAbortSignal(signal: unknown): AbortSignal | undefined { + if (signal === undefined) { + return undefined + } + + const message = '`signal` must be an AbortSignal' + if ( + typeof AbortSignal === 'undefined' || + typeof signal !== 'object' || + signal === null + ) { + throw new ConfigError(message) + } + + try { + const abortedGetter = Object.getOwnPropertyDescriptor( + AbortSignal.prototype, + 'aborted', + )?.get + + if (abortedGetter === undefined) { + if (!(signal instanceof AbortSignal)) { + throw new TypeError(message) + } + } else { + abortedGetter.call(signal) + } + + const candidate = signal as AbortSignal + if ( + typeof candidate.addEventListener !== 'function' || + typeof candidate.removeEventListener !== 'function' + ) { + throw new TypeError(message) + } + + return candidate + } catch (cause) { + throw new ConfigError(message, cause) + } +} + export function resolveRequestURL( input: string | URL, baseURL?: string | URL, @@ -420,7 +467,7 @@ function normalizeMethod(method: unknown): RequestMethod { return method.toUpperCase() as RequestMethod } -function normalizeTimeout(timeout?: number): number | undefined { +export function normalizeTimeout(timeout?: number): number | undefined { if (timeout === undefined) { return undefined } @@ -449,7 +496,7 @@ function normalizeBeforeRequestURL(value: unknown): URL { } } -function normalizeResponseType(responseType: unknown): NormalizedRequestOptions['responseType'] { +export function normalizeResponseType(responseType: unknown): NormalizedRequestOptions['responseType'] { if ( typeof responseType !== 'string' || !RESPONSE_TYPES.has(responseType as ResponseType) @@ -460,7 +507,7 @@ function normalizeResponseType(responseType: unknown): NormalizedRequestOptions[ return responseType as NormalizedRequestOptions['responseType'] } -function normalizeParseJson( +export function normalizeParseJson( parseJson: unknown, ): NormalizedRequestOptions['parseJson'] { if (typeof parseJson !== 'function') { @@ -486,7 +533,7 @@ function resolveInputURL(input: string, base?: URL): URL { } } -function toAbsoluteURL(value: string | URL, message: string): URL { +export function toAbsoluteURL(value: string | URL, message: string): URL { try { return value instanceof URL ? new URL(value) : new URL(value) } catch (cause) { diff --git a/src/internal/parse-response.ts b/src/internal/parse-response.ts index faeb39a..c80c04f 100644 --- a/src/internal/parse-response.ts +++ b/src/internal/parse-response.ts @@ -2,12 +2,14 @@ import { HttpError, ParseError } from '../errors.js' import type { ResponseType } from '../types.js' const MAX_ERROR_BODY_TEXT_CHARS = 16_384 +const MAX_ERROR_BODY_READ_MS = 250 +const MAX_ERROR_BODY_DECODE_CHUNK_BYTES = 4_096 const TRUNCATED_BODY_SUFFIX = '...[truncated]' export async function parseResponse(params: { response: Response responseType: ResponseType - parseJson: (text: string) => unknown + parseJson: (text: string) => unknown | PromiseLike request?: Request }): Promise { const { parseJson, request, response, responseType } = params @@ -55,7 +57,7 @@ export async function createHttpError( async function parseJsonResponse( response: Response, - parseJson: (text: string) => unknown, + parseJson: (text: string) => unknown | PromiseLike, ): Promise { const bodyText = await response.text() @@ -64,7 +66,7 @@ async function parseJsonResponse( } try { - return parseJson(bodyText) as T + return await parseJson(bodyText) as T } catch (cause) { throw new ParseError({ response, @@ -100,46 +102,119 @@ async function readBodyTextWithLimit( const decoder = new TextDecoder() let bodyText = '' let truncated = false + let stoppedEarly = false + const deadline = Date.now() + MAX_ERROR_BODY_READ_MS try { + readLoop: while (true) { - const { done, value } = await reader.read() - if (done) { + const result = await readWithDeadline(reader, deadline) + if (result === undefined) { + stoppedEarly = true + truncated = bodyText !== '' + void reader.cancel().catch(() => undefined) break } - const remainingChars = maxChars - bodyText.length - const chunk = - value.byteLength > remainingChars + 1 - ? value.subarray(0, remainingChars + 1) - : value + const { done, value } = result + if (done) { + break + } - bodyText += decoder.decode(chunk, { stream: true }) - if ( - value.byteLength > chunk.byteLength || - bodyText.length >= maxChars + for ( + let offset = 0; + offset < value.byteLength; + offset += MAX_ERROR_BODY_DECODE_CHUNK_BYTES ) { - truncated = true - bodyText = bodyText.slice(0, maxChars) - void reader.cancel().catch(() => undefined) - break + const chunk = value.subarray( + offset, + Math.min( + offset + MAX_ERROR_BODY_DECODE_CHUNK_BYTES, + value.byteLength, + ), + ) + bodyText += decoder.decode(chunk, { stream: true }) + + if (bodyText.length >= maxChars) { + stoppedEarly = true + truncated = true + bodyText = sliceAtCodePointBoundary(bodyText, maxChars) + void reader.cancel().catch(() => undefined) + break readLoop + } } } } finally { reader.releaseLock() } - bodyText += decoder.decode() + if (!stoppedEarly) { + bodyText += decoder.decode() + } return truncated ? `${bodyText}${TRUNCATED_BODY_SUFFIX}` : bodyText } +async function readWithDeadline( + reader: ReadableStreamDefaultReader, + deadline: number, +): Promise | undefined> { + const remainingMs = deadline - Date.now() + if (remainingMs <= 0) { + return undefined + } + + return new Promise((resolve, reject) => { + let settled = false + const timeoutId = setTimeout(() => { + settled = true + resolve(undefined) + }, remainingMs) + + void reader.read().then( + (result) => { + if (settled) { + return + } + settled = true + clearTimeout(timeoutId) + resolve(result) + }, + (error: unknown) => { + if (settled) { + return + } + settled = true + clearTimeout(timeoutId) + reject(error) + }, + ) + }) +} + function truncateBodyText(text: string, maxChars: number): string { if (text.length <= maxChars) { return text } - return `${text.slice(0, maxChars)}${TRUNCATED_BODY_SUFFIX}` + return `${sliceAtCodePointBoundary(text, maxChars)}${TRUNCATED_BODY_SUFFIX}` +} + +function sliceAtCodePointBoundary(text: string, maxChars: number): string { + let end = maxChars + const lastRetainedCodeUnit = text.charCodeAt(end - 1) + const firstOmittedCodeUnit = text.charCodeAt(end) + + if ( + lastRetainedCodeUnit >= 0xd800 && + lastRetainedCodeUnit <= 0xdbff && + firstOmittedCodeUnit >= 0xdc00 && + firstOmittedCodeUnit <= 0xdfff + ) { + end -= 1 + } + + return text.slice(0, end) } diff --git a/src/internal/query-params.ts b/src/internal/query-params.ts index 3e9a204..581fd06 100644 --- a/src/internal/query-params.ts +++ b/src/internal/query-params.ts @@ -27,6 +27,30 @@ export function snapshotQueryInput(query: unknown): QueryInput { return snapshot } +export function freezeQueryInput(query: QueryInput): QueryInput { + if (isURLSearchParams(query)) { + return query + } + + for (const value of Object.values(query)) { + if (Array.isArray(value)) { + Object.freeze(value) + } + } + + return Object.freeze(query) +} + +export function isFrozenQueryInput(query: QueryInput): boolean { + if (isURLSearchParams(query) || !Object.isFrozen(query)) { + return false + } + + return Object.values(query).every( + (value) => !Array.isArray(value) || Object.isFrozen(value), + ) +} + export function serializeQueryParams(query?: QueryInput): string { if (query === undefined) { return '' diff --git a/src/types.ts b/src/types.ts index 944b363..49f5866 100644 --- a/src/types.ts +++ b/src/types.ts @@ -65,7 +65,7 @@ export interface RequestOptionsBase { responseType?: ResponseType retry?: false | RetryOptions hooks?: Hooks - parseJson?: (text: string) => unknown + parseJson?: (text: string) => unknown | PromiseLike } export type BodylessRequestOptions = RequestOptionsBase & { @@ -113,7 +113,7 @@ export interface ClientDefaults { responseType?: ResponseType retry?: false | RetryOptions hooks?: Hooks - parseJson?: (text: string) => unknown + parseJson?: (text: string) => unknown | PromiseLike } /** @@ -143,7 +143,7 @@ export interface HookRequestOptions { readonly signal?: AbortSignal readonly responseType: ResponseType readonly retry: false | HookRetryOptions - readonly parseJson: (text: string) => unknown + readonly parseJson: (text: string) => unknown | PromiseLike } /** @@ -225,252 +225,28 @@ export interface NormalizedRequestOptions { responseType: ResponseType retry: false | Required hooks: Required - parseJson: (text: string) => unknown + parseJson: (text: string) => unknown | PromiseLike } /** * Reusable client API produced by `createClient()`. */ export interface HttpClient { - request( - input: string | URL, - options?: DefaultRequestOptions, - ): Promise> + request: ClientResponseMethod - request( - input: string | URL, - options: JsonRequestOptions, - ): Promise + get: ClientResponseMethod - request( - input: string | URL, - options: TextRequestOptions, - ): Promise + post: ClientResponseMethod - request( - input: string | URL, - options: BlobRequestOptions, - ): Promise + put: ClientResponseMethod - request( - input: string | URL, - options: ArrayBufferRequestOptions, - ): Promise + patch: ClientResponseMethod - request( - input: string | URL, - options: RawRequestOptions, - ): Promise + delete: ClientResponseMethod - get( - input: string | URL, - options?: DefaultBodylessClientMethodOptions, - ): Promise> + head: ClientResponseMethod - get( - input: string | URL, - options: JsonBodylessClientMethodOptions, - ): Promise - - get( - input: string | URL, - options: TextBodylessClientMethodOptions, - ): Promise - - get( - input: string | URL, - options: BlobBodylessClientMethodOptions, - ): Promise - - get( - input: string | URL, - options: ArrayBufferBodylessClientMethodOptions, - ): Promise - - get( - input: string | URL, - options: RawBodylessClientMethodOptions, - ): Promise - - post( - input: string | URL, - options?: DefaultClientMethodOptions, - ): Promise> - - post( - input: string | URL, - options: JsonClientMethodOptions, - ): Promise - - post( - input: string | URL, - options: TextClientMethodOptions, - ): Promise - - post( - input: string | URL, - options: BlobClientMethodOptions, - ): Promise - - post( - input: string | URL, - options: ArrayBufferClientMethodOptions, - ): Promise - - post( - input: string | URL, - options: RawClientMethodOptions, - ): Promise - - put( - input: string | URL, - options?: DefaultClientMethodOptions, - ): Promise> - - put( - input: string | URL, - options: JsonClientMethodOptions, - ): Promise - - put( - input: string | URL, - options: TextClientMethodOptions, - ): Promise - - put( - input: string | URL, - options: BlobClientMethodOptions, - ): Promise - - put( - input: string | URL, - options: ArrayBufferClientMethodOptions, - ): Promise - - put( - input: string | URL, - options: RawClientMethodOptions, - ): Promise - - patch( - input: string | URL, - options?: DefaultClientMethodOptions, - ): Promise> - - patch( - input: string | URL, - options: JsonClientMethodOptions, - ): Promise - - patch( - input: string | URL, - options: TextClientMethodOptions, - ): Promise - - patch( - input: string | URL, - options: BlobClientMethodOptions, - ): Promise - - patch( - input: string | URL, - options: ArrayBufferClientMethodOptions, - ): Promise - - patch( - input: string | URL, - options: RawClientMethodOptions, - ): Promise - - delete( - input: string | URL, - options?: DefaultClientMethodOptions, - ): Promise> - - delete( - input: string | URL, - options: JsonClientMethodOptions, - ): Promise - - delete( - input: string | URL, - options: TextClientMethodOptions, - ): Promise - - delete( - input: string | URL, - options: BlobClientMethodOptions, - ): Promise - - delete( - input: string | URL, - options: ArrayBufferClientMethodOptions, - ): Promise - - delete( - input: string | URL, - options: RawClientMethodOptions, - ): Promise - - head( - input: string | URL, - options?: DefaultBodylessClientMethodOptions, - ): Promise> - - head( - input: string | URL, - options: JsonBodylessClientMethodOptions, - ): Promise - - head( - input: string | URL, - options: TextBodylessClientMethodOptions, - ): Promise - - head( - input: string | URL, - options: BlobBodylessClientMethodOptions, - ): Promise - - head( - input: string | URL, - options: ArrayBufferBodylessClientMethodOptions, - ): Promise - - head( - input: string | URL, - options: RawBodylessClientMethodOptions, - ): Promise - - options( - input: string | URL, - options?: DefaultClientMethodOptions, - ): Promise> - - options( - input: string | URL, - options: JsonClientMethodOptions, - ): Promise - - options( - input: string | URL, - options: TextClientMethodOptions, - ): Promise - - options( - input: string | URL, - options: BlobClientMethodOptions, - ): Promise - - options( - input: string | URL, - options: ArrayBufferClientMethodOptions, - ): Promise - - options( - input: string | URL, - options: RawClientMethodOptions, - ): Promise + options: ClientResponseMethod extend( defaults: Omit & { @@ -498,79 +274,42 @@ type ResponseResult = ? ArrayBuffer : Response -type DefaultRequestOptions = RequestOptions & { - responseType?: never -} - -type JsonRequestOptions = RequestOptions & { - responseType: 'json' -} - -type DefaultClientMethodOptions = ClientMethodOptions & { - responseType?: never -} - -type JsonClientMethodOptions = ClientMethodOptions & { - responseType: 'json' -} - type BodylessClientMethodOptions = RequestOptionsBase & { body?: never json?: never } -type DefaultBodylessClientMethodOptions = BodylessClientMethodOptions & { - responseType?: 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 ClientResponseMethod< + Options, + DefaultResponseType extends ResponseType, +> = { + ( + input: string | URL, + options?: Options & { responseType?: never }, + ): Promise> -type ArrayBufferClientMethodOptions = ClientMethodOptions & { - responseType: 'arrayBuffer' -} + ( + input: string | URL, + options: Options & { responseType: 'json' }, + ): Promise -type ArrayBufferBodylessClientMethodOptions = BodylessClientMethodOptions & { - responseType: 'arrayBuffer' -} + ( + input: string | URL, + options: Options & { responseType: 'text' }, + ): Promise -type RawRequestOptions = RequestOptions & { - responseType: 'raw' -} + ( + input: string | URL, + options: Options & { responseType: 'blob' }, + ): Promise -type RawClientMethodOptions = ClientMethodOptions & { - responseType: 'raw' -} + ( + input: string | URL, + options: Options & { responseType: 'arrayBuffer' }, + ): Promise -type RawBodylessClientMethodOptions = BodylessClientMethodOptions & { - responseType: 'raw' + ( + input: string | URL, + options: Options & { responseType: 'raw' }, + ): Promise } diff --git a/test/browser-real.browser.ts b/test/browser-real.browser.ts index f825fc9..5cf8aa6 100644 --- a/test/browser-real.browser.ts +++ b/test/browser-real.browser.ts @@ -54,6 +54,7 @@ test('real browser handles native values created in another realm', { let result: { arrayBufferResult: { attempts: number; bodies: number[][] } crossRealmBaseURLResult: { pathname: string } + crossRealmSignalResult: { search: string } crossRealmURLResult: { search: string } formDataResult: { attempts: number; bodies: string[]; contentTypes: string[] } queryResult: { search: string } @@ -112,6 +113,12 @@ test('real browser handles native values created in another realm', { foreignBaseURL.pathname = '/base-mutated/' const crossRealmBaseURLResult = await crossRealmBaseURLClient.get('query') + const foreignAbortController = new foreign.AbortController() + const crossRealmSignalResult = await client.get('/query', { + query: { source: 'foreign-signal' }, + signal: foreignAbortController.signal, + }) + const bytes = new foreign.ArrayBuffer(4) new foreign.Uint8Array(bytes).set([1, 2, 3, 4]) const arrayBufferResult = await client.post('/array-buffer', { @@ -149,6 +156,7 @@ test('real browser handles native values created in another realm', { return { arrayBufferResult, crossRealmBaseURLResult, + crossRealmSignalResult, crossRealmURLResult, formDataResult, queryResult, @@ -171,6 +179,9 @@ test('real browser handles native values created in another realm', { assert.deepEqual(result.crossRealmBaseURLResult, { pathname: '/base-original/query', }) + assert.deepEqual(result.crossRealmSignalResult, { + search: '?source=foreign-signal', + }) assert.deepEqual(result.arrayBufferResult, { attempts: 2, bodies: [[1, 2, 3, 4], [1, 2, 3, 4]], diff --git a/test/check-pack-smoke.test.ts b/test/check-pack-smoke.test.ts new file mode 100644 index 0000000..bd1aa7c --- /dev/null +++ b/test/check-pack-smoke.test.ts @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' +import { + mkdir, + mkdtemp, + readdir, + rm, + writeFile, +} from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' + +const execFileAsync = promisify(execFile) +const checkPackSmokePath = fileURLToPath( + new URL('../scripts/check-pack-smoke.mjs', import.meta.url), +) + +test('check-pack-smoke removes the tarball when package assertions fail', async () => { + const fixtureDir = await mkdtemp( + path.join(os.tmpdir(), 'clearfetch-pack-failure-'), + ) + + try { + await mkdir(path.join(fixtureDir, 'dist')) + await writeFile( + path.join(fixtureDir, 'package.json'), + JSON.stringify({ + name: 'clearfetch-pack-failure-fixture', + version: '1.0.0', + files: ['dist', 'unexpected.txt'], + }), + 'utf8', + ) + await writeFile( + path.join(fixtureDir, 'dist', 'index.js'), + 'export {}\n', + 'utf8', + ) + await writeFile( + path.join(fixtureDir, 'unexpected.txt'), + 'unexpected package content\n', + 'utf8', + ) + + await assert.rejects( + () => + execFileAsync(process.execPath, [checkPackSmokePath], { + cwd: fixtureDir, + }), + (error) => + typeof error === 'object' && + error !== null && + 'stderr' in error && + String(error.stderr).includes('unexpected files in packed artifact'), + ) + + const fixtureFiles = await readdir(fixtureDir) + assert.deepEqual( + fixtureFiles.filter((fileName) => fileName.endsWith('.tgz')), + [], + ) + } finally { + await rm(fixtureDir, { recursive: true, force: true }) + } +}) diff --git a/test/client.test.ts b/test/client.test.ts index b4dd6f2..d3f02a6 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -45,6 +45,29 @@ test('public request surfaces reject invalid option containers', async () => { ) }) +test('createClient rejects invalid defaults during construction', () => { + assert.throws( + () => createClient({ timeout: -1 }), + (error) => + error instanceof ConfigError && + error.message === '`timeout` must be a non-negative finite number', + ) + + assert.throws( + () => createClient({ responseType: 'xml' as never }), + (error) => + error instanceof ConfigError && + error.message === 'Unsupported responseType: xml', + ) + + assert.throws( + () => createClient({ baseURL: 'not a URL' }), + (error) => + error instanceof ConfigError && + error.message === '`baseURL` must be a string or URL', + ) +}) + test('createClient resolves baseURL and extend merges headers', async () => { const originalFetch = globalThis.fetch const requests: Request[] = [] diff --git a/test/hooks-and-retries.test.ts b/test/hooks-and-retries.test.ts index 24ad810..1c20c91 100644 --- a/test/hooks-and-retries.test.ts +++ b/test/hooks-and-retries.test.ts @@ -148,6 +148,25 @@ test('request timeout surfaces TimeoutError', async () => { } }) +test('request timeout remains authoritative during asynchronous JSON parsing', async () => { + await withMockedFetch( + async () => new Response('{"ok":true}'), + async () => { + await assert.rejects( + () => + request('https://api.example.com/users', { + timeout: 5, + parseJson: async (text) => { + await new Promise((resolve) => setTimeout(resolve, 25)) + return JSON.parse(text) + }, + }), + (error) => error instanceof TimeoutError && error.timeout === 5, + ) + }, + ) +}) + test('timeout starts after beforeRequest hooks complete', async () => { const originalFetch = globalThis.fetch let fetchCalls = 0 @@ -1944,6 +1963,30 @@ test('onError observes request normalization failures before rethrow', async () assert.ok(observedErrors[0] instanceof ConfigError) }) +test('invalid abort signals fail as ConfigError and run onError', async () => { + const observedErrors: unknown[] = [] + + await assert.rejects( + () => + request('https://api.example.com/users', { + signal: { aborted: false } as never, + hooks: { + onError: [ + (context) => { + observedErrors.push(context.error) + }, + ], + }, + }), + (error) => + error instanceof ConfigError && + error.message === '`signal` must be an AbortSignal', + ) + + assert.equal(observedErrors.length, 1) + assert.ok(observedErrors[0] instanceof ConfigError) +}) + test('invalid onError hooks do not mask request normalization failures', async () => { await assert.rejects( () => diff --git a/test/normalize-request.test.ts b/test/normalize-request.test.ts index bb6096f..279f57b 100644 --- a/test/normalize-request.test.ts +++ b/test/normalize-request.test.ts @@ -79,6 +79,8 @@ test('createBeforeRequestContext reads accessor-backed query values once', () => 'https://api.example.com/users?token=stable', ) assert.deepEqual(context.hookContext.options.query, { token: 'stable' }) + assert.equal(context.normalizedOptions.query, context.hookContext.options.query) + assert.equal(Object.isFrozen(context.normalizedOptions.query), true) }) test('createBeforeRequestContext reads accessor-backed query array values once', () => { @@ -105,6 +107,7 @@ test('createBeforeRequestContext reads accessor-backed query array values once', 'https://api.example.com/users?token=stable', ) assert.deepEqual(context.hookContext.options.query, { token: ['stable'] }) + assert.equal(Object.isFrozen(context.hookContext.options.query?.token), true) }) test('resolveRequestURL appends URLSearchParams query input', () => { diff --git a/test/parse-response.test.ts b/test/parse-response.test.ts index 3f8dc8c..66d3568 100644 --- a/test/parse-response.test.ts +++ b/test/parse-response.test.ts @@ -44,6 +44,29 @@ test('parseResponse throws ParseError for invalid non-empty json', async () => { ) }) +test('parseResponse wraps asynchronous JSON parser rejections', async () => { + const response = new Response('{"ok":true}', { + headers: { + 'Content-Type': 'application/json', + }, + }) + + await assert.rejects( + () => + parseResponse({ + response, + responseType: 'json', + parseJson: async () => { + throw new SyntaxError('asynchronous parser failure') + }, + }), + (error) => + error instanceof ParseError && + error.cause instanceof SyntaxError && + error.cause.message === 'asynchronous parser failure', + ) +}) + test('parseResponse truncates large ParseError body text', async () => { const response = new Response(`{${'x'.repeat(20_000)}`, { headers: { @@ -109,6 +132,64 @@ test('parseResponse truncates large HttpError body text', async () => { ) }) +test('parseResponse preserves valid Unicode below the diagnostic character cap', async () => { + const bodyText = '😀'.repeat(5_000) + const response = new Response(bodyText, { + status: 500, + statusText: 'Internal Server Error', + }) + + await assert.rejects( + () => + parseResponse({ + response, + responseType: 'text', + parseJson: JSON.parse, + }), + (error) => + error instanceof HttpError && + error.bodyText === bodyText && + !error.bodyText.includes('�'), + ) +}) + +test('parseResponse does not split Unicode at the HttpError truncation boundary', async () => { + const response = new Response(`a${'😀'.repeat(9_000)}`, { + status: 500, + statusText: 'Internal Server Error', + }) + + await assert.rejects( + () => + parseResponse({ + response, + responseType: 'text', + parseJson: JSON.parse, + }), + (error) => + error instanceof HttpError && + error.bodyText?.endsWith('😀...[truncated]') === true, + ) +}) + +test('parseResponse does not split Unicode at the ParseError truncation boundary', async () => { + const response = new Response(`a${'😀'.repeat(9_000)}`) + + await assert.rejects( + () => + parseResponse({ + response, + responseType: 'json', + parseJson: () => { + throw new SyntaxError('invalid JSON') + }, + }), + (error) => + error instanceof ParseError && + error.bodyText?.endsWith('😀...[truncated]') === true, + ) +}) + test('parseResponse caps decoded HttpError body chunks before retaining text', async () => { const originalTextDecoder = globalThis.TextDecoder let maxDecodedBytes = 0 @@ -267,6 +348,52 @@ test('parseResponse truncates and cancels an error body that stalls at the diagn assert.equal(cancelCalls, 1) }) +test('parseResponse stops diagnostic capture when an error body stalls below the cap', async () => { + let cancelCalls = 0 + let timeoutId: ReturnType | undefined + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('partial')) + }, + cancel() { + cancelCalls += 1 + }, + }), + { + status: 500, + statusText: 'Internal Server Error', + }, + ) + + try { + await assert.rejects( + () => + Promise.race([ + parseResponse({ + response, + responseType: 'text', + parseJson: JSON.parse, + }), + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error('diagnostic capture remained pending')) + }, 1_000) + }), + ]), + (error) => + error instanceof HttpError && + error.bodyText === 'partial...[truncated]', + ) + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId) + } + } + + assert.equal(cancelCalls, 1) +}) + test('normalizeExecutionError maps aborts to TimeoutError when timeout is present', () => { const error = normalizeExecutionError({ error: new DOMException('Aborted', 'AbortError'), diff --git a/test/release-workflow.test.ts b/test/release-workflow.test.ts new file mode 100644 index 0000000..840cd6e --- /dev/null +++ b/test/release-workflow.test.ts @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' +import { readFile } from 'node:fs/promises' +import test from 'node:test' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' + +const execFileAsync = promisify(execFile) +const releaseWorkflowPath = fileURLToPath( + new URL('../.github/workflows/release.yml', import.meta.url), +) + +interface MockRegistryResponse { + status: number + body?: unknown +} + +test('release metadata verification retries transient propagation gaps', async () => { + const result = await runRegistryMetadataVerification([ + { status: 404 }, + { + status: 200, + body: { dist: { integrity: 'sha512-expected' } }, + }, + { + status: 200, + body: { + dist: { + integrity: 'sha512-expected', + attestations: { url: 'https://registry.example/attestation' }, + }, + }, + }, + ]) + + assert.equal(result.stdout.trim(), 'https://registry.example/attestation') + assert.match(result.stderr, /__fetches__=3/) +}) + +test('release metadata verification treats integrity mismatches as terminal', async () => { + await assert.rejects( + () => + runRegistryMetadataVerification([ + { + status: 200, + body: { + dist: { + integrity: 'sha512-wrong', + attestations: { url: 'https://registry.example/attestation' }, + }, + }, + }, + ]), + (error) => + typeof error === 'object' && + error !== null && + 'stderr' in error && + String(error.stderr).includes( + 'Published integrity does not match the verified tarball', + ) && + String(error.stderr).includes('__fetches__=1'), + ) +}) + +test('release polling fetches have per-request deadlines', async () => { + const workflow = await readFile(releaseWorkflowPath, 'utf8') + + for (const marker of [ + ' const packageName = process.env.PACKAGE_NAME', + ' const retryDelaysMs = [1_000, 2_000, 4_000, 8_000]', + ]) { + const start = workflow.indexOf(marker) + assert.notEqual(start, -1, `release polling block is missing: ${marker}`) + + const end = workflow.indexOf('\n NODE', start) + assert.notEqual(end, -1, `release polling block is unterminated: ${marker}`) + + assert.match( + workflow.slice(start, end), + /signal:\s*AbortSignal\.timeout\([\d_]+\)/, + ) + } +}) + +async function runRegistryMetadataVerification( + responses: MockRegistryResponse[], +) { + const workflow = await readFile(releaseWorkflowPath, 'utf8') + const startMarker = ' const packageName = process.env.PACKAGE_NAME' + const start = workflow.indexOf(startMarker) + assert.notEqual(start, -1, 'release metadata verification block is missing') + + const end = workflow.indexOf('\n NODE', start) + assert.notEqual(end, -1, 'release metadata verification block is unterminated') + + const source = workflow + .slice(start, end) + .replace(/^ {10}/gm, '') + const harness = [ + `const mockResponses = ${JSON.stringify(responses)}`, + 'let fetchCalls = 0', + "process.on('exit', () => console.error(`__fetches__=${fetchCalls}`))", + 'globalThis.fetch = async () => {', + ' const mock = mockResponses[fetchCalls]', + ' fetchCalls += 1', + " if (mock === undefined) throw new Error('unexpected registry request')", + ' return new Response(JSON.stringify(mock.body), { status: mock.status })', + '}', + 'globalThis.setTimeout = (callback) => {', + ' queueMicrotask(callback)', + ' return 0', + '}', + source, + ].join('\n') + + return execFileAsync( + process.execPath, + ['--input-type=module', '--eval', harness], + { + env: { + ...process.env, + EXPECTED_INTEGRITY: 'sha512-expected', + PACKAGE_NAME: '@gavoryn/clearfetch', + PACKAGE_VERSION: '1.0.7', + }, + }, + ) +} diff --git a/test/type-signatures.ts b/test/type-signatures.ts index 652a594..9ed3727 100644 --- a/test/type-signatures.ts +++ b/test/type-signatures.ts @@ -179,6 +179,30 @@ const typedRawPromise = client.get('https://api.example.com/raw', { const typedClientRawPromise: Promise = typedRawPromise void typedClientRawPromise +const typedPutTextPromise: Promise = client.put( + 'https://api.example.com/put', + { responseType: 'text' }, +) +const typedPatchBlobPromise: Promise = client.patch( + 'https://api.example.com/patch', + { responseType: 'blob' }, +) +const typedDeleteArrayBufferPromise: Promise = client.delete( + 'https://api.example.com/delete', + { responseType: 'arrayBuffer' }, +) +const typedHeadRawPromise: Promise = client.head( + 'https://api.example.com/head', + { responseType: 'raw' }, +) +const typedOptionsJsonPromise: Promise<{ ok: boolean } | undefined> = + client.options<{ ok: boolean }>('https://api.example.com/options') +void typedPutTextPromise +void typedPatchBlobPromise +void typedDeleteArrayBufferPromise +void typedHeadRawPromise +void typedOptionsJsonPromise + // @ts-expect-error raw mode resolves to Promise const invalidRawPromise: Promise<{ statusCode: number } | undefined> = rawPromise void invalidRawPromise