Repository navigation
feat(core)!: v2 — safer defaults, file transfer, and pagination (#37–#46) - #47
Open
developerEhsan wants to merge 3 commits into
Open
developerEhsan wants to merge 3 commits into
developerEhsan wants to merge 3 commits into
Conversation
Ten issues reported by a production consumer. Three change existing defaults, each because the old default was the unsafe one. BREAKING CHANGES * Response cache now defaults to off (#40). It was on with a 60s cache-first TTL, serving stale reads underneath an app's own query layer after a write. Pass `cache: { enabled: true }` to keep it. * Unsafe HTTP methods are no longer retried (#42). A POST that fails with a 5xx or a dropped connection may already have committed, so a blind retry double-creates. Opt back in with `retryUnsafeMethods`. * Next.js server-context helpers moved to the `/next` subpath (#41). The root entry no longer references `next/headers`, which was breaking Vite `optimizeDeps` in projects without Next installed. Added * `http.idempotency` — one key per logical call, replayed across its retries, so an opted-in write retry is collapsible server-side (#42). * `errorEnvelope` — teach the classifier a nested error shape, so `error.message` is the server's message and not a JSON blob (#39). * `paginate` / `paginateItems` / `collectPages` for cursor pagination, with same-cursor and maxPages guards (#43). * `createIndexedDbStore({ crossTab: true })` — cross-tab invalidation over BroadcastChannel, matching the Redis store (#46). * FormData bodies on the direct client and over the RPC bridge, via `createRpcRouteHandler({ multipart: true })` (#38). * Binary responses through the RPC bridge via a server-declared `binary` allowlist (#37). Client-supplied responseType still cannot force it — sanitizePerCall (S4) is unchanged. * `openapi` is optional on GlobalConfig (#45). Fixed * A configured `Content-Type: application/json` no longer corrupts a FormData body by suppressing the multipart boundary (#38). * Request bodies are now size-capped against the bytes actually read rather than the client-supplied Content-Length, which can lie or be absent under chunked encoding. Pre-existing on the JSON path. * Multipart envelope revival uses defineProperty, so a `__proto__` key stays an own property and is still caught by the handler's FORBIDDEN_KEYS guard instead of silently moving to the prototype. * Removed a stale "stubbed with TODOs" note from createClient's shipped JSDoc that contradicted the README (#44). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub. 2 Skipped Deployments
|
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cancelling the request-body stream closes it while the runtime may still
be enqueueing already-received chunks, which surfaces as
ERR_INVALID_STATE ('ReadableStream is already closed') thrown inside
undici. That is an unhandled rejection no caller can catch, and an
unhandled rejection can take down a server process.
Caught by CI on Node 24 (all 387 tests passed, but the run reported one
unhandled error); it is a timing-dependent race that did not reproduce
locally across repeated runs on the same version.
Stopping the read and releasing the lock is sufficient: the caller
responds 413 immediately and the runtime tears the request down. The
size cap itself is unchanged and still covered by its test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This branch was previously deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #37, #38, #39, #40, #41, #42, #43, #44, #45, #46.
All ten issues came from one production consumer. Three change existing defaults — in each case because the current default is the unsafe one — so this is a major release.
Breaking
cache: { enabled: true }to keep itretry: { retryUnsafeMethods: true }to keep it/nextimport … from '@developerehsan/api-client/next'Full notes:
docs/migrating-v2.md.#40 — the cache was on with a 60s
cache-firstTTL and no mention in the README. A write would commit, the app's own query layer would refetch, and this client's cache would still serve the stale value underneath it. Apps pairing it with TanStack Query were silently double-cached.#42 — a
POSTthat fails with a 5xx or a dropped connection may already have committed server-side; retrying it creates the resource twice. Now onlyGET/HEAD/OPTIONS/TRACE/PUT/DELETEretry.http.idempotencygenerates one key per logical call and replays it across that call's retries, so an opted-in write retry is collapsible server-side.#41 — the root entry carried a dynamic
import('next/headers'), and Vite'soptimizeDepsscan doesn't honor@vite-ignoreinside a pre-bundled dependency. The specifier is now assembled at runtime and the helpers live behind/next, so no non-Next consumer pulls it in. Verified:dist/index.js,index.cjsandedge.jscontain zero references to it.Added
errorEnvelope— the classifier only knew flat{ code, message }, so a nested envelope fell through torawBodyanderror.messagecame out as stringified JSON (this rendered raw JSON into user-facing toasts in production). A parser that throws is caught and falls back; it can never mask the original failure.paginate/paginateItems/collectPages— cursor pagination as anAsyncIterable. A server echoing the same cursor terminates the loop rather than spinning;maxPages(10,000) backstops the rest.createIndexedDbStore({ crossTab: true })—BroadcastChannelinvalidation, bringing the browser L2 store to parity with RediscrossInstance. Degrades to a plain store where unavailable.FormDatabodies. On the direct client they already passed through, but a configuredContent-Type: application/jsonsuppressed the multipart boundary and silently corrupted the body — fixed in both adapters. Over the bridge,httpTransportdetectsFile/Blobargs and switches that call to multipart; opt the route in withmultipart: true.binaryallowlist oncreateRpcHandler. The route streams the result with its ownContent-Type/Content-Dispositionand the transport surfaces aBlob.openapiis optional. Every field inside it already was, so requiring the block bought no type safety.createClient's shipped JSDoc.Security notes
The RPC trust boundary is unchanged in shape — everything new is deny-by-default and server-declared:
binaryis a separate allowlist; a method must be in bothexposeandbinary. A client-suppliedresponseTypeis still stripped bysanitizePerCall(S4), so the browser cannot turn a JSON method into a byte stream. There's a test for exactly that.multipartis opt-in and documented as leaning entirely on the Origin check, since a multipart body is a CORS "simple request" and so isn't covered by the JSON-content-type half of the CSRF defence. Keepcsrf: truewith it.Content-Dispositionheader injection.Two issues found while reviewing this diff and fixed in it:
Content-Length, which can lie or be absent under chunked encoding. Now bounded against the bytes actually read, aborting the stream past the cap. This bug pre-existed on the JSON path; raising the limit to 10 MB for uploads would have widened it considerably.out[k] = v; with the key__proto__that invokes the prototype setter, so the key disappears fromObject.keysand slips past the handler's existingFORBIDDEN_KEYSguard. Now usesdefineProperty, keeping it an own property so that guard still fires. Covered by a test verified to fail without the fix.Verification
packages/core/src/v2-consumer-issues.test.ts), plus tanstack-query, vite-plugin and the examples.tsc --noEmitand Biome clean across the workspace.dist/browser.jscontains noaxios,openapi, or backend detail.🤖 Generated with Claude Code