diff --git a/README.md b/README.md index 59702cf4..84d710d9 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@

- Zero runtime dependencies · ~24 kB min+gzip — a typical import { stitch } tree-shakes to ~21 kB, and with no transitive tree there is nothing else to install or audit. The size is an enforced budget in CI, not an aspiration. + Zero runtime dependencies · ~24 kB min+gzip — a typical import { stitch } tree-shakes to ~22 kB, and with no transitive tree there is nothing else to install or audit. The size is an enforced budget in CI, not an aspiration.

@@ -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 diff --git a/apps/docs/app/(home)/components/metrics.tsx b/apps/docs/app/(home)/components/metrics.tsx index 88db573c..b01ea559 100644 --- a/apps/docs/app/(home)/components/metrics.tsx +++ b/apps/docs/app/(home)/components/metrics.tsx @@ -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.', }, diff --git a/apps/docs/content/docs/concepts/principles.mdx b/apps/docs/content/docs/concepts/principles.mdx index ec2ff9ef..5f36fc9f 100644 --- a/apps/docs/content/docs/concepts/principles.mdx +++ b/apps/docs/content/docs/concepts/principles.mdx @@ -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. diff --git a/apps/docs/content/docs/getting-started/installation.mdx b/apps/docs/content/docs/getting-started/installation.mdx index 04ed85aa..3937b7eb 100644 --- a/apps/docs/content/docs/getting-started/installation.mdx +++ b/apps/docs/content/docs/getting-started/installation.mdx @@ -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). **Validators are bring-your-own.** Because `stitchapi` ships with zero diff --git a/apps/docs/lib/source.ts b/apps/docs/lib/source.ts index e8ff2e49..f9a4b195 100644 --- a/apps/docs/lib/source.ts +++ b/apps/docs/lib/source.ts @@ -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 diff --git a/packages/core/README.md b/packages/core/README.md index 00fd280b..b8c586b2 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -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 ` 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 @@ -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 diff --git a/packages/core/scripts/bundle-size.mjs b/packages/core/scripts/bundle-size.mjs index 55a338af..ac0da9c1 100644 --- a/packages/core/scripts/bundle-size.mjs +++ b/packages/core/scripts/bundle-size.mjs @@ -273,19 +273,42 @@ 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 abort-reason fix (24.10→24.30 / 21.50→21.70; measured 24.08 / 21.51 +// against a `main` at 24.08 / 21.50, so the fix is ±0.00 / +0.01). A caller's `abort(reason)` +// came back as a minted `Error('aborted')` whenever the abort landed in a retry-backoff sleep, +// and a mid-flight abort still emitted a `retry` progress event (and fired onRetry) before dying +// there — a phantom retry for a deliberately cancelled call. The bytes buy the attempt-loop +// guard (a cancelled call rethrows instead of entering the retry path). The reason-preserving +// rejection itself came out MINIFIED-SMALLER (−0.09 KB: `sleep`, the engine's abort paths and +// `withTimeout`'s link now share one `abortReason` where each had a private copy), but gzip +// charges +0.01 for it — the deleted copies were near-free backreferences, and keeping them +// duplicated MEASURED no better (21.51 either way, with +0.19 KB minified), so the dedup stays. +// +// It cannot move behind a subpath: this is `sleep` and the attempt loop — the resilience chain +// `stitch()` IS — on the core path by construction. +// +// The conventional ~0.2 KB step rather than a minimum one: `main` had run down to 0.02 / 0.00 +// headroom, which is why a +0.01 KB fix tripped the gate at all — the same shape #620 records. +// Sized to the headroom the gate is meant to hold, so the next small core-path fix is not gated +// on a budget PR of its own; headroom lands at 0.22 / 0.19. +// +// The ADVERTISED figure moves with it: `import { stitch }` crosses its rounding boundary at +// 21.505 KB measured, so every site quoting it goes ~21 → ~22 kB under the +// `bundle-advertised-size` tether (the whole entry stays ~24) — both READMEs, the installation +// and principles pages, the home-page metrics component, and the docs' source blurb. // `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.3 * KB, advertised: true, }, { name: 'import { stitch }', code: `export { stitch } from './index.mjs';`, - budget: 21.5 * KB, + budget: 21.7 * KB, advertised: true, }, { diff --git a/packages/core/src/engine.ts b/packages/core/src/engine.ts index 481fd349..cd1739a4 100644 --- a/packages/core/src/engine.ts +++ b/packages/core/src/engine.ts @@ -40,6 +40,7 @@ import type { } from './types'; import { StitchError } from './types'; import { + abortReason, appendQueryString, buildQuery, expandPath, @@ -565,15 +566,6 @@ async function acquireWithin( } } -// The Error to reject with when a caller's signal is already/just aborted — its own `reason` when -// that is an Error (the default AbortError, or a caller-supplied one), else a generic abort Error. -function abortReason(signal: AbortSignal): Error { - const reason: unknown = signal.reason; - return reason instanceof Error - ? reason - : new Error('the operation was aborted'); -} - // Materialize a streaming-path error body for StitchError.body. A streaming adapter hands back the // live `ReadableStream` unparsed (so it can be decoded into deltas); on the error branch the stream // is never decoded, so read it to text and best-effort JSON-parse it — the same shape the buffered @@ -701,6 +693,9 @@ async function* attemptLoop( attempt, error: err, }); + // A caller's abort is a deliberate cancel, not a failed attempt to try again: no + // `retry` event, no onRetry, no backoff — the run ends here with the abort error. + if (baseReq.signal?.aborted) throw err; if (attempt < max) { yield { type: 'progress', diff --git a/packages/core/src/resilience.ts b/packages/core/src/resilience.ts index efb92d87..133855c7 100644 --- a/packages/core/src/resilience.ts +++ b/packages/core/src/resilience.ts @@ -16,7 +16,7 @@ import type { // extends `StitchError` per CONTRACT.md P10. `types.ts` imports nothing at runtime — its own // imports are all type-only — so this edge adds no cycle. import { StitchError } from './types'; -import { parseDuration, parseRate, systemClock } from './util'; +import { abortReason, parseDuration, parseRate, systemClock } from './util'; export class TimeoutError extends Error {} @@ -193,15 +193,6 @@ export function createThrottle( /** Internal: keys the non-enumerable per-key state Map probe used by the resource-leak suite. */ export const THROTTLE_STATES = Symbol('stitch.throttle.states'); -// The Error to reject with when a linked signal is already aborted — its own `reason` when that is -// an Error (the default AbortError, or a caller-supplied one), else a generic abort Error. -function abortError(signal: AbortSignal): Error { - const reason: unknown = signal.reason; - return reason instanceof Error - ? reason - : new Error('the operation was aborted'); -} - /** * Run `fn` with an AbortSignal that aborts after `ms`. On timeout, reject with TimeoutError and * ensure the signal is aborted. If `ms` is undefined, just run `fn` with a non-aborting signal. @@ -218,7 +209,7 @@ export function withTimeout( const controller = new AbortController(); let unlink: (() => void) | undefined; if (linkSignal) { - if (linkSignal.aborted) return Promise.reject(abortError(linkSignal)); + if (linkSignal.aborted) return Promise.reject(abortReason(linkSignal)); const onAbort = () => { controller.abort(linkSignal.reason); }; diff --git a/packages/core/src/test-clock.ts b/packages/core/src/test-clock.ts index 5a12de9c..34dd844b 100644 --- a/packages/core/src/test-clock.ts +++ b/packages/core/src/test-clock.ts @@ -3,6 +3,7 @@ // resolve with zero real waiting — drive them with `advance(ms)`. (Per ADR 0010, `timeout.total` // and event `at`/`ms` timestamps stay on wall-clock.) Browser-safe: no `node:*`. import type { Clock, TimerHandle } from './types'; +import { abortReason } from './util'; /** A {@link Clock} whose time only moves when you call {@link ManualClock.advance}. */ export interface ManualClock extends Clock { @@ -63,19 +64,25 @@ export function manualClock(start = 0): ManualClock { clearTimer: cancel, sleep: (ms, signal) => new Promise((resolve, reject) => { + // Reject with the signal's reason, exactly as `systemClock.sleep` does — a test + // that aborts with a custom reason must see it through the injected clock too. if (signal?.aborted) { - reject(new Error('aborted')); + reject(abortReason(signal)); + return; + } + if (!signal) { + schedule(resolve, ms); return; } const onAbort = () => { cancel(handle); - reject(new Error('aborted')); + reject(abortReason(signal)); }; const handle = schedule(() => { - signal?.removeEventListener('abort', onAbort); + signal.removeEventListener('abort', onAbort); resolve(); }, ms); - signal?.addEventListener('abort', onAbort, { once: true }); + signal.addEventListener('abort', onAbort, { once: true }); }), async advance(ms: number): Promise { const target = current + Math.max(0, ms); diff --git a/packages/core/src/test-mock.ts b/packages/core/src/test-mock.ts index df767adf..fd613e5b 100644 --- a/packages/core/src/test-mock.ts +++ b/packages/core/src/test-mock.ts @@ -11,7 +11,7 @@ import type { AdapterResponse, AtLeastOne, } from './types'; -import { parseDuration } from './util'; +import { abortReason, parseDuration } from './util'; /** One canned response. Omitted fields default sensibly (`status` 200, empty headers). */ export interface MockResponse { @@ -105,11 +105,12 @@ const matches = (req: AdapterRequest, m: MockMatch | undefined): boolean => { const at = (arr: T[], i: number): T => arr[Math.min(i, arr.length - 1)] as T; // An abortable delay: rejects the moment `signal` aborts, so a stitch `timeout` (which aborts the -// per-attempt signal) cancels a slow mock response exactly as it would a real socket. +// per-attempt signal) cancels a slow mock response exactly as it would a real socket. Rejects with +// the signal's reason — real `fetch` surfaces `signal.reason`, so a caller's custom abort must too. const sleep = (ms: number, signal?: AbortSignal): Promise => new Promise((resolve, reject) => { if (signal?.aborted) { - reject(new Error('aborted')); + reject(abortReason(signal)); return; } const t = setTimeout(resolve, ms); @@ -117,7 +118,7 @@ const sleep = (ms: number, signal?: AbortSignal): Promise => 'abort', () => { clearTimeout(t); - reject(new Error('aborted')); + reject(abortReason(signal)); }, { once: true }, ); @@ -166,7 +167,7 @@ export function mockAdapter( // request — every real transport refuses it, and a cancellation test written against a // mock that answered was asserting the opposite of production. Nothing was sent, so the // spy records no call and the route's response sequence keeps its place. - if (req.signal?.aborted) throw new Error('aborted'); + if (req.signal?.aborted) throw abortReason(req.signal); log.push(req); const idx = list.findIndex( (r) => diff --git a/packages/core/src/util.ts b/packages/core/src/util.ts index ca65c69c..268f56a2 100644 --- a/packages/core/src/util.ts +++ b/packages/core/src/util.ts @@ -39,7 +39,7 @@ export function newRunContext(parent?: { export function sleep(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) { - reject(new Error('aborted')); + reject(abortReason(signal)); return; } const t = setTimeout(resolve, ms); @@ -47,13 +47,27 @@ export function sleep(ms: number, signal?: AbortSignal): Promise { 'abort', () => { clearTimeout(t); - reject(new Error('aborted')); + reject(abortReason(signal)); }, { once: true }, ); }); } +/** + * The Error an aborted wait rejects with: the signal's own `reason` when that is an Error (the + * default AbortError, or a caller-supplied `abort(reason)`), else a generic abort Error. The one + * spelling of "which error does an abort surface?" — shared by {@link sleep}, the engine's abort + * paths, and `withTimeout`'s signal link, so a caller's custom reason survives no matter where in + * the resilience chain the abort lands. + */ +export function abortReason(signal: AbortSignal): Error { + const reason: unknown = signal.reason; + return reason instanceof Error + ? reason + : new Error('the operation was aborted'); +} + /** * The default {@link Clock}: wall-clock time and the platform's global timers. The behaviour the * engine has always had — injecting a different `Clock` (e.g. `manualClock()`) is opt-in. diff --git a/packages/core/test/abort-reason.spec.ts b/packages/core/test/abort-reason.spec.ts new file mode 100644 index 00000000..cc0b5fa2 --- /dev/null +++ b/packages/core/test/abort-reason.spec.ts @@ -0,0 +1,120 @@ +// A caller's abort must surface the caller's OWN error, and a deliberate cancel must never read +// as a retry. Two engine guarantees pinned here, plus the clock primitive they rest on: +// • an abort DURING a retry backoff rejects with the signal's `reason` — `sleep` (systemClock +// and manualClock alike) rejects with the reason when it is an Error, so a custom +// `abort(reason)` (or the default AbortError) rides out of the engine instead of a generic +// `Error('aborted')` minted by the sleep; +// • an abort MID-FLIGHT ends the run at the attempt-loop catch: no `retry` progress event, no +// `onRetry` hook, no backoff — before the guard, a cancelled call emitted a phantom retry and +// only then died in the backoff sleep. +import { stitch, systemClock } from '../src'; +import { manualClock, mockAdapter } from '../src/testing'; +import type { StitchEvent } from '../src/types'; + +describe('abort during a retry backoff preserves the reason', () => { + test('a custom abort(reason) surfaces as the call error, not a generic abort', async () => { + const clock = manualClock(); + const api = mockAdapter({ + match: '/flaky', + respond: [{ status: 503 }, { body: { ok: true } }], + }); + const ac = new AbortController(); + const call = stitch({ + baseUrl: 'https://api.test', + path: '/flaky', + adapter: api, + retry: { + attempts: 2, + on: [503], + backoff: { curve: 'fixed', base: 10_000 }, + }, + clock, + }); + + const p = call({ signal: ac.signal }).safe(); + await clock.advance(0); // first attempt → 503 → the backoff sleep is armed + expect(api.callCount()).toBe(1); + expect(clock.pending()).toBe(1); // parked on the backoff + + ac.abort(new Error('user closed the panel')); // mid-backoff, with a custom reason + + const res = await p; // settles on the abort alone — no advance needed + expect(res.ok).toBe(false); + expect(res.error?.message).toBe('user closed the panel'); + expect(api.callCount()).toBe(1); // the second attempt never ran + expect(clock.pending()).toBe(0); // the backoff timer was dropped, not leaked + }); +}); + +describe('abort mid-flight is a cancel, not a retry', () => { + test('no retry event, no onRetry hook, and the reason still surfaces', async () => { + const api = mockAdapter({ + match: '/slow', + // Long enough that only the abort can end the attempt; the delay is abortable, so the + // cancel cuts it short like a real socket. + respond: { body: { ok: true }, delay: 5_000 }, + }); + const ac = new AbortController(); + const retried: unknown[] = []; + const call = stitch({ + baseUrl: 'https://api.test', + path: '/slow', + adapter: api, + retry: { attempts: 3, on: [503], backoff: { base: 5 } }, + hooks: { + onRetry: (ctx) => { + retried.push(ctx); + }, + }, + }); + + const events: StitchEvent[] = []; + const drained = (async () => { + for await (const ev of call({ signal: ac.signal }).stream()) + events.push(ev); + })(); + + // Let the attempt reach the transport, then cancel deliberately. + await new Promise((r) => setTimeout(r, 10)); + expect(api.callCount()).toBe(1); // in flight + ac.abort(new Error('nevermind, user cancelled')); + await drained; + + expect( + events.filter((e) => e.type === 'progress' && e.phase === 'retry'), + ).toHaveLength(0); + expect(retried).toHaveLength(0); + expect(events.find((e) => e.type === 'error')).toMatchObject({ + type: 'error', + message: 'nevermind, user cancelled', + }); + expect(api.callCount()).toBe(1); // no second attempt after the cancel + }); +}); + +// The primitive the backoff guarantee rests on: `systemClock.sleep` rejects with the signal's +// reason — the SAME instance, so `instanceof`/`cause` chains the caller built stay intact. +describe('systemClock.sleep rejects with the abort reason', () => { + test('an abort mid-sleep rejects with the caller-supplied Error instance', async () => { + const ac = new AbortController(); + const reason = new Error('caller reason'); + const p = systemClock.sleep(60_000, ac.signal); + ac.abort(reason); + await expect(p).rejects.toBe(reason); + }); + + test('a pre-aborted signal rejects with its existing reason', async () => { + const ac = new AbortController(); + const reason = new RangeError('already gone'); + ac.abort(reason); + await expect(systemClock.sleep(60_000, ac.signal)).rejects.toBe(reason); + }); + + test('a non-Error reason falls back to a generic abort Error', async () => { + const ac = new AbortController(); + ac.abort('just a string'); + await expect(systemClock.sleep(0, ac.signal)).rejects.toThrow( + 'the operation was aborted', + ); + }); +}); diff --git a/packages/core/test/manual-clock.spec.ts b/packages/core/test/manual-clock.spec.ts index a0104093..fe37b169 100644 --- a/packages/core/test/manual-clock.spec.ts +++ b/packages/core/test/manual-clock.spec.ts @@ -106,4 +106,14 @@ describe('manualClock: sleep', () => { await expect(p).rejects.toThrow('aborted'); expect(c.pending()).toBe(0); }); + + test('sleep rejects with the caller-supplied abort reason (mirrors systemClock)', async () => { + const c = manualClock(); + const ac = new AbortController(); + const reason = new Error('deliberate cancel'); + const p = c.sleep(100, ac.signal); + ac.abort(reason); + await expect(p).rejects.toBe(reason); // the same instance, not a re-minted Error + expect(c.pending()).toBe(0); + }); });