Skip to content
Open
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
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,37 @@ npm release are grouped under the in-development version that introduced them.

### Added

- **`QUERY` is a first-class method — a read that carries a request body.**
([draft-ietf-httpbis-safe-method-w-body](https://datatracker.ietf.org/doc/draft-ietf-httpbis-safe-method-w-body/))
`method: 'QUERY'` already sent its body and already cached correctly under
`cache: { methods: 'QUERY' }`, because the cache key folds the body in. What it did **not** have
was the engine's agreement that it is a read: "safe method" was spelled `=== 'GET' || === 'HEAD'`
inline, so everything else was a write by default.

One `isSafeMethod` predicate now answers that question in the two places that ask it — RFC 9110
§9.2.1's safe set (`GET`, `HEAD`, `OPTIONS`, `TRACE`) plus `QUERY`:

- **No `Idempotency-Key` on a safe method.** A stitch with `idempotency` configured no longer
stamps a dedupe token on a `QUERY` — there is no side effect to collapse, and the header would
have varied the cache key on every send. `OPTIONS`/`TRACE` stop being stamped too; they were
only ever getting a key because they were not `GET` or `HEAD`.
- **The construction nudge follows.** Declaring `idempotency` on a `QUERY` now logs the same
"the key is sent on writes only" hint a `GET` gets, so the drop is never silent. Silenced by
`idempotency.warn = false` as before.

**A 301/302 no longer downgrades a `QUERY` to a bodyless `GET`** (default `fetch` transport).
That downgrade is a historical exception granted to `POST`, and the draft rules it out by name
for `QUERY`; applying it dropped the body, which silently turned a filtered read into an
unfiltered one. `303` still redirects to a `GET` — for a `QUERY` that is what it means. `POST`,
`PUT`, `PATCH` and `DELETE` redirect exactly as before.

**Three method tests, not one.** `encodeRequestBody` still drops a body on `GET`/`HEAD` **only**
and is deliberately not routed through the safety predicate: that branch enforces a transport
constraint (`fetch` throws on a `GET` with a body), and widening it to "safe" would have deleted
the payload of every `QUERY` — the one thing that already worked. The cacheable-method default
is likewise untouched at `['GET','HEAD']`; `QUERY` is now documented as a valid `cache.methods`
entry, opt-in like a GraphQL `POST`.

- **`throttle.concurrency` goes fleet-wide too, by lease.** ([ADR 0025](docs/adr/0025-fleet-wide-concurrency-by-lease.md))
[ADR 0024](docs/adr/0024-the-fleet-wide-pacing-cell.md) made the rate budget fleet-wide and left
the concurrency cap per-process, so `concurrency: 10` across eight workers was really a fleet cap
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
<!-- /yakir:readme-badges -->

<p align="center">
<strong>Zero runtime dependencies · ~24&nbsp;kB min+gzip</strong> — a typical <code>import { stitch }</code> tree-shakes to ~21&nbsp;kB, and with no transitive tree there is nothing else to install or audit. The size is an <a href="packages/core/scripts/bundle-size.mjs">enforced budget in CI</a>, not an aspiration.
<strong>Zero runtime dependencies · ~24&nbsp;kB min+gzip</strong> — a typical <code>import { stitch }</code> tree-shakes to ~22&nbsp;kB, and with no transitive tree there is nothing else to install or audit. The size is an <a href="packages/core/scripts/bundle-size.mjs">enforced budget in CI</a>, not an aspiration.
</p>

<p align="center">
Expand Down Expand Up @@ -164,7 +164,7 @@ No server, no codegen, no config files, no implicit inheritance — **only expli
- **Pluggable state store** — throttle counters and sessions behind a 3-method store; swap in Redis/Postgres to go distributed.
- **Zero-infra observability** — tracing is **off by default**; opt in per stitch or via `STITCH_TRACE_*` env vars. No collector, no dashboard.
- **Four front doors, one definition** — in-process function, CLI (`stitch run`), HTTP (`stitch serve`), and MCP (`stitch mcp`).
- **Zero runtime dependencies** — `"dependencies": {}`, built on global `fetch`, tree-shakeable; **~24 kB min+gzip** for the whole entry, **~21 kB** for a typical `import { stitch }`.
- **Zero runtime dependencies** — `"dependencies": {}`, built on global `fetch`, tree-shakeable; **~24 kB min+gzip** for the whole entry, **~22 kB** for a typical `import { stitch }`.

## Install

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/app/(home)/components/metrics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const metrics = [
body: 'The whole stitchapi entry, tree-shaken — and it is an enforced budget in CI, not an aspiration.',
},
{
value: '~21 kB',
value: '~22 kB',
unit: 'import { stitch }',
body: 'Pay only for what you import: every surface beyond http lives behind its own subpath, so the core trims down.',
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ export const PLAYGROUND_COMPLETIONS: Record<string, Completion[]> = {
{
label: "method",
type: "property",
detail: "string",
info: "HTTP method; defaults to `GET`.",
detail: "KnownMethod | (string & {})",
info: "HTTP method; defaults to `GET`. Any verb the transport accepts, including **`QUERY`** — the safe, idempotent, cacheable method that carries a **request body** (`draft-ietf-httpbis-safe-method-w-body`), for a read whose filter is too large or too structured for a URL. The engine classifies `QUERY` as a read: no `Idempotency-Key` is stamped on it, and a 301/302 re-sends it as a `QUERY` rather than downgrading it to a bodyless `GET`. It keeps its body (unlike `GET`/`HEAD`, where the transport forbids one), and it is a valid CacheOptions.methods entry — opt in, since caching a body-carrying request is never a default.",
},
{
label: "wire",
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/concepts/principles.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ package practices with your bundle.

Concretely, the whole `stitch` entry is **~24 kB minified + gzipped**
(≈61 kB raw), and because every surface beyond `http` is a separate subpath
import, a typical `import { stitch }` tree-shakes to **~21 kB**. With zero
import, a typical `import { stitch }` tree-shakes to **~22 kB**. With zero
runtime dependencies, that figure is the entire cost — not the tip of a
transitive tree.

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/getting-started/installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Install the package, import `stitch`, and turn your first endpoint into a typed,
callable function. `stitchapi` has zero dependencies and runs anywhere `fetch`
does — Node, the browser, and edge runtimes. The whole entry is **~24 kB
minified + gzipped** — and with no dependencies, there is no transitive tree
behind it (a typical `import { stitch }` tree-shakes to ~21 kB).
behind it (a typical `import { stitch }` tree-shakes to ~22 kB).

<Callout type="info">
**Validators are bring-your-own.** Because `stitchapi` ships with zero
Expand Down
49 changes: 48 additions & 1 deletion apps/docs/content/docs/guides/authoring/stitch.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ The handful of fields that shape every stitch:

- **`baseUrl`** + **`path`** — the request target. `path` may contain `{param}`
slots and a `?query` string; in string form the whole string becomes `path`.
- **`method`** — the HTTP verb. Defaults to `GET`.
- **`method`** — the HTTP verb. Defaults to `GET`. Any verb your transport
accepts, including [`QUERY`](#the-query-method).
- **The input object** — `params`, `query`, `headers`, and `body` all travel in
one `StitchInput` you pass at call time: `await createUser({ params: { id: 1 } })`.
- **The generic** — `stitch<T>(...)` types the awaited value `T`.
Expand All @@ -49,6 +50,52 @@ The handful of fields that shape every stitch:
per feature.
</Callout>

## The QUERY method

Some reads don't fit in a URL. A faceted search, a big list of ids, a nested
filter — encode it as a query string and you hit length limits, proxies that
truncate, and logs that now hold your filter. The usual workaround is to `POST`
the filter and give up everything a read gets: no caching, and a client that
can't tell the call apart from a write.

`QUERY` ([`draft-ietf-httpbis-safe-method-w-body`](https://datatracker.ietf.org/doc/draft-ietf-httpbis-safe-method-w-body/))
is the method for exactly that — **safe**, **idempotent**, and **cacheable**,
with a request body. Set it like any other verb:

```ts
const search = stitch({
method: 'QUERY',
baseUrl: 'https://api.example.com',
path: '/orders',
// Responses are spec-cacheable, but opt in — the key folds in the body, so two
// different filters get two entries and cannot collide.
cache: { ttl: '1m', methods: 'QUERY' },
});

await search({ body: { status: ['open', 'held'], region: 'eu', limit: 200 } });
```

StitchAPI treats it as the read it is:

- **The body is sent.** `GET`/`HEAD` have theirs dropped — the transport forbids
one — but `QUERY` keeps it, JSON-encoded like any other body.
- **No idempotency key.** With `idempotency` configured, a `QUERY` is not
stamped with an `Idempotency-Key`; there is no side effect to dedupe. Setting
`idempotency` on one logs the same construction nudge a `GET` gets.
- **A 301/302 stays a `QUERY`.** The downgrade-to-`GET` on a permanent or
temporary redirect is a historical exception granted to `POST`, and the draft
says it does not apply here — downgrading would drop the body, turning a
filtered read into an unfiltered one. A `303` still means "GET the result at
the `Location`", for a `QUERY` as for anything else.
- **`cache.methods` accepts it.** Not in the default `['GET','HEAD']`: like a
GraphQL `POST`, caching a body-carrying request is explicit.

<Callout type="warn">
`QUERY` is a young method. Check that your server — **and every proxy, CDN,
and WAF between you and it** — actually routes it before reaching for it;
intermediaries have been known to reject or mangle an unfamiliar verb.
</Callout>

For every field and its default, see
[Reference → stitch()](/docs/reference/stitch) and
[Reference → Config types](/docs/reference/config-types).
Expand Down
11 changes: 11 additions & 0 deletions apps/docs/content/docs/guides/resilience/idempotency.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,17 @@ to silence it when the keyless-retry case (a proxy dedupe, say) is deliberate.
See [Reference → Config types](/docs/reference/config-types) for the full
`IdempotencyOptions` shape.

## Reads never get a key

The key rides on **writes only**. Declare `idempotency` on a read and the engine
drops it and logs the same kind of nudge — almost always a missing
`method: 'POST'`. "A read" means every method
[RFC 9110 §9.2.1](https://www.rfc-editor.org/rfc/rfc9110#section-9.2.1) calls
_safe_ — `GET`, `HEAD`, `OPTIONS`, `TRACE` — plus
[`QUERY`](/docs/guides/authoring/stitch#the-query-method), which is safe **and
carries a request body**. Carrying a body is not what makes a call a write, so a
`QUERY` is not stamped: there is no side effect for the server to collapse.

## See also

<Cards>
Expand Down
8 changes: 8 additions & 0 deletions apps/docs/content/docs/reference/config-types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,14 @@ request, so it cannot drift from what it names.

<AutoTypeTable path="../../packages/core/src/types.ts" name="CacheOptions" />

`methods` defaults to `['GET','HEAD']`. Two body-carrying reads opt in by naming
their method: a GraphQL **query** (`methods: 'POST'`) and
[`QUERY`](/docs/guides/authoring/stitch#the-query-method)
(`methods: 'QUERY'`), whose responses are cacheable by spec. Neither is a default —
a `POST`'s read-vs-mutate intent cannot be inferred, and caching a body-carrying
request is a decision worth writing down. The key already folds the request body in,
so two different `QUERY` bodies to the same URL get two entries and cannot collide.

#### CacheFingerprintOptions

The shape behind `cache.fingerprint` — how a stored value is detected as stale against
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/lib/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Search these docs instead of loading the whole file: this site is also a hosted
- Capability, not credential: an agent invokes a stitch and gets structured, validated, traceable data; the secret stays behind the boundary.
- One context-frugal **code-mode** tool (run_stitch + list_stitches + describe_stitch), not one tool per endpoint — adding APIs never floods the context window.
- No server, no codegen, no config files — a URL and one example response is enough; only explicit composition (no ambient/global config a stitch silently inherits).
- Zero-dependency core, ~24 kB min+gzip for the whole entry (~21 kB for a tree-shaken import { stitch }), validator-agnostic (bring your own Standard Schema / Zod), and it runs in the browser.
- Zero-dependency core, ~24 kB min+gzip for the whole entry (~22 kB for a tree-shaken import { stitch }), validator-agnostic (bring your own Standard Schema / Zod), and it runs in the browser.
- Composes with your data layer: a stitch is the queryFn for TanStack Query / SWR — it owns the call's resilience; your query layer owns view state.

## Quickstart
Expand Down
4 changes: 2 additions & 2 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ No server, no codegen, no config files, no implicit inheritance — **only expli
- **CLI, HTTP & MCP surfaces** - the definition your code imports is also runnable from the shell (`stitch run <name>` streams JSONL events), served over HTTP (`stitch serve`), or exposed to agents over MCP (`stitch mcp`) — the same stitch behind every front door.
- **Typed URLs** - full [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) URI templates (`{id}`, `{+path}`, `{?q,sort}`, explode `*`, prefix `:n`), and a `qs`-style query builder that serializes nested objects (`a[b]=c`) and arrays — both dependency-free.
- **Pluggable transport** - `fetch` by default; drop in the shipped `axiosAdapter`, or any `Adapter` function, to route requests through axios or another HTTP client.
- **Zero runtime dependencies** - `"dependencies": {}`; built on the platform's global `fetch`; tree-shakeable. The whole entry is **~24 kB min+gzip**; a typical `import { stitch }` trims to **~21 kB** — and with no transitive tree, that is the entire cost.
- **Zero runtime dependencies** - `"dependencies": {}`; built on the platform's global `fetch`; tree-shakeable. The whole entry is **~24 kB min+gzip**; a typical `import { stitch }` trims to **~22 kB** — and with no transitive tree, that is the entire cost.

## Documentation

Expand Down Expand Up @@ -162,7 +162,7 @@ const { stitch } = require("stitchapi");

The runtime ships with zero dependencies. Schema validation is bring-your-own — pass a [Zod](https://zod.dev) schema or any [Standard Schema](https://standardschema.dev) validator ([Valibot](https://valibot.dev), [ArkType](https://arktype.io), …); none of them is bundled. The examples below use Zod for familiarity.

**Bundle size.** The whole `stitchapi` entry is **~24 kB minified + gzipped** (66 kB raw, ~21 kB brotli); because the package is side-effect-free and every surface beyond `http` lives behind its own subpath import, a typical `import { stitch }` tree-shakes to **~21 kB min+gzip**. With zero dependencies, that is the _whole_ cost — there is no transitive tree to install or audit.
**Bundle size.** The whole `stitchapi` entry is **~24 kB minified + gzipped** (66 kB raw, ~22 kB brotli); because the package is side-effect-free and every surface beyond `http` lives behind its own subpath import, a typical `import { stitch }` tree-shakes to **~22 kB min+gzip**. With zero dependencies, that is the _whole_ cost — there is no transitive tree to install or audit.

## Quick start

Expand Down
30 changes: 28 additions & 2 deletions packages/core/scripts/bundle-size.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -273,19 +273,45 @@ const KB = 1024;
// are UNCHANGED at 24 / 21 this time — 23.91 still rounds to 24 — so no README or docs figure
// moves; verified against the `bundle-advertised-size` tether rather than assumed, which is the
// mistake the ADR 0024 raise made.
// Budgets raised for the first-class QUERY method — issue #462 part 1 (24.10→24.20 /
// 21.50→21.65 KB; measured 24.12 / 21.56 against a `main` at 24.08 / 21.50, so the whole change
// is +42 / +58 BYTES). `main` had 0.02 KB left on the entry and ONE byte on `import { stitch }`
// (22015 of a 22016 B ceiling), so the entry was full in the literal sense and the next core-path
// change of any size was going to pay for the raise. This is that change.
//
// What the bytes buy: one named `isSafeMethod` predicate (RFC 9110 §9.2.1's safe set plus QUERY)
// replacing the inline `=== 'GET' || === 'HEAD'` in `applyIdempotency` and in the construction
// nudge that mirrors it, so a QUERY — a READ that carries a request body — stops being stamped
// with an `Idempotency-Key`; plus the QUERY exemption from the 301/302 downgrade-to-GET, which
// draft-ietf-httpbis-safe-method-w-body requires by name. Attributed exactly: +33 B is the helper
// and its two rerouted call sites, +21 B is carrying OPTIONS/TRACE in the safe set so the
// predicate means what RFC 9110 says it means, +4 B is the redirect exemption. None of it can
// move behind a subpath — `applyIdempotency` is in `buildRequest`, the nudge is in `makeStitch`,
// and `fetchAdapter` is the default transport; all three run for every stitch.
//
// A MINIMUM step (0.08 / 0.09 KB headroom), not the ~0.2 KB this gate usually restores. The
// change is 58 bytes and the entry is full: keeping the ceiling tight keeps that signal, exactly
// as #477/#524/#485 did.
//
// The ADVERTISED figure moves for `import { stitch }`: 22015 B is 21.499 KB and rounds to 21,
// 22073 B is 21.556 and rounds to 22, so every site quoting it goes ~21 → ~22 kB (the whole entry
// stays ~24). Eight sites, propagated under the `bundle-advertised-size` drift tether — both
// READMEs, the installation and principles pages, the home-page metrics component, and the docs'
// own source blurb. Recorded here because it is the number the project advertises, and a raise
// that left the docs claiming the old one is the exact drift that tether exists to catch.
// `advertised: true` means the READMEs/docs quote this scenario's rounded gzip kB — see the
// `--json` note below for why that flag, not the row's presence, drives the drift tether.
const SCENARIOS = [
{
name: 'stitchapi — whole entry',
code: `export * from './index.mjs';`,
budget: 24.1 * KB,
budget: 24.2 * KB,
advertised: true,
},
{
name: 'import { stitch }',
code: `export { stitch } from './index.mjs';`,
budget: 21.5 * KB,
budget: 21.65 * KB,
advertised: true,
},
{
Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
buildQuery,
expandPath,
getPath,
isSafeMethod,
newRunContext,
now,
parseDuration,
Expand Down Expand Up @@ -156,15 +157,18 @@ function joinUrl(base: string, path: string): string {

// Inject a stable Idempotency-Key on writes. The key is computed once per logical call (here,
// in buildRequest) and the attempt loop reuses the same request, so it stays constant across
// retries. GET/HEAD are skipped, and a caller-provided header (case-insensitive) wins.
// retries. SAFE methods are skipped, and a caller-provided header (case-insensitive) wins.
// "Safe" rather than "GET/HEAD" because QUERY is a read that carries a body: stamping a
// dedupe token on a request that changes nothing is a category error, and the header would
// vary the cache key on every send.
function applyIdempotency(
cfg: ResolvedStitchConfig,
input: StitchInput,
method: string,
headers: Record<string, string>,
): void {
if (!cfg.idempotency) return;
if (method === 'GET' || method === 'HEAD') return; // writes only
if (isSafeMethod(method)) return; // writes only
const header = cfg.idempotency.header ?? 'Idempotency-Key';
if (
Object.keys(headers).some(
Expand Down
Loading
Loading