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
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
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
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
27 changes: 25 additions & 2 deletions packages/core/scripts/bundle-size.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
{
Expand Down
13 changes: 4 additions & 9 deletions packages/core/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import type {
} from './types';
import { StitchError } from './types';
import {
abortReason,
appendQueryString,
buildQuery,
expandPath,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down
13 changes: 2 additions & 11 deletions packages/core/src/resilience.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}

Expand Down Expand Up @@ -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.
Expand All @@ -218,7 +209,7 @@ export function withTimeout<T>(
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);
};
Expand Down
15 changes: 11 additions & 4 deletions packages/core/src/test-clock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -63,19 +64,25 @@ export function manualClock(start = 0): ManualClock {
clearTimer: cancel,
sleep: (ms, signal) =>
new Promise<void>((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<void> {
const target = current + Math.max(0, ms);
Expand Down
11 changes: 6 additions & 5 deletions packages/core/src/test-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -105,19 +105,20 @@ const matches = (req: AdapterRequest, m: MockMatch | undefined): boolean => {
const at = <T>(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<void> =>
new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error('aborted'));
reject(abortReason(signal));
return;
}
const t = setTimeout(resolve, ms);
signal?.addEventListener(
'abort',
() => {
clearTimeout(t);
reject(new Error('aborted'));
reject(abortReason(signal));
},
{ once: true },
);
Expand Down Expand Up @@ -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) =>
Expand Down
18 changes: 16 additions & 2 deletions packages/core/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,21 +39,35 @@ export function newRunContext(parent?: {
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error('aborted'));
reject(abortReason(signal));
return;
}
const t = setTimeout(resolve, ms);
signal?.addEventListener(
'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.
Expand Down
Loading
Loading