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

### Fixed

- **Adding `cache: { ttl }` no longer turns a handled vendor failure into a process exit.**
([#670](https://github.com/rejifald/StitchAPI/issues/670)) A cached stitch whose vendor returned
`503` emitted an **unhandled promise rejection**, which under Node's default
`--unhandled-rejections=throw` terminates the process — on a failure the caller had handled
correctly, with `.safe()` returning an honest `ok: false`. The same failure with no `cache` block
produced none.

The coalescer's leader rejects one shared promise to release its waiters. With no concurrent
caller there are no waiters, so nothing ever attached a handler and the rejection went
unobserved. That made the bug **invisible in the shape a test takes and fatal in the shape
production has**: a test exercises coalescing with a concurrent burst, and a follower's `await`
catches the rejection by accident; a webhook backlog or retry drain arrives staggered, where
every call is its own leader. Measured against a failing vendor, 20 staggered calls produced 20
unhandled rejections; the same 20 as a burst produced none.

The shared promise now carries a terminal no-op handler from the moment it is created, so being
unobserved is never fatal. **A follower still receives the leader's failure unchanged** — same
tick, same error identity — because the handler is attached to a derived promise and discarded;
only the coalescer's own liability is retired. Failure is still not _shared_ (a follower re-runs
independently, as before); [#653](https://github.com/rejifald/StitchAPI/issues/653) tracks
whether it should be, and this leaves that channel intact for it.

- **`@stitchapi/aws-sigv4` stamps `x-amz-date` from the injected clock, so SigV4 is testable on
virtual time.** ([#658](https://github.com/rejifald/StitchAPI/issues/658)) The signer called
`amzDateOf(new Date())`, so 600 **virtual** seconds moved the shipped stamp **0** seconds and a
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
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
29 changes: 28 additions & 1 deletion packages/core/scripts/bundle-size.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,33 @@ 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.
//
// `import { stitch }` raised for the coalescer's unhandled-rejection guard — #670 (21.50→21.55 KB;
// measured 21.51 = 22026 B against a `main` at 22015 B, so the fix is +11 B). A cached stitch whose
// vendor fails, with no concurrent follower, rejected the coalescer's shared promise with nobody
// attached to it: a handled failure (`.safe()` returning `ok: false`) killed the process under
// Node's default `--unhandled-rejections=throw`. The bytes are one terminal `.catch`, attached
// where that promise is created.
//
// It cannot move behind a subpath: the coalescer is reached from `stitch()` whenever `cache` is
// configured, and it is the fix for a crash, not a capability that could be opted into. There is no
// cheaper spelling — resolving a sentinel instead of rejecting would be smaller and would remove
// the hazard outright, but it would throw away the rejection channel #653 wants to hand to
// followers. Dropping the leader claim's unread `promise` field was measured too: 3 B, which pays
// for none of this and is a public type change on `stitchapi/cache`, so it is not taken here.
//
// A MINIMUM step, not the ~0.2 KB this gate usually restores — matching #477/#524/#485: this is a
// fix squeezing past a full ceiling, not a new capability, and `main` had run down to 1 byte.
// Headroom lands at 41 B here and 14 B on the whole entry (unchanged at 24.10), so the next
// core-path byte trips this gate again; sizing that step is the maintainer's call, not a bug fix's.
//
// The ADVERTISED figure moves, and NOT because of this change: 21.5 KB is both the budget and a
// rounding boundary (22016 B), and `main` measured 22015 B — one byte below both. Any core-path
// byte at all takes `import { stitch }` from ~21 → ~22 kB. Nine figures across the six sites under
// the `bundle-advertised-size` tether — both READMEs, the installation and principles pages, the
// home-page metrics component, and the docs' source blurb — propagated by hand and verified with
// the tether. (The core README's "~21 kB brotli" moves with them and is more accurate for it: the
// whole entry's brotli is 21.6 KB, which rounds to 22, not 21.)
// `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 = [
Expand All @@ -285,7 +312,7 @@ const SCENARIOS = [
{
name: 'import { stitch }',
code: `export { stitch } from './index.mjs';`,
budget: 21.5 * KB,
budget: 21.55 * KB,
advertised: true,
},
{
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,12 +193,16 @@ export interface CoalesceJoinOptions {
onCancel?: () => void;
}

/** The WRITE end of a shared run: the leader runs the chain and reports the real error to its own
* caller. `promise` is here for symmetry only — a leader must never await it (doing so before
* `settle`/`fail` deadlocks on itself), which is why rejecting it must be safe with no audience. */
export interface LeaderClaim<T> {
leader: true;
promise: Promise<T>;
settle: (value: T) => void;
fail: (err: unknown) => void;
}
/** The READ end: a follower has nothing to run, only the leader's one result to await. */
export interface FollowerClaim<T> {
leader: false;
promise: Promise<T>;
Expand Down Expand Up @@ -227,6 +231,17 @@ export class InflightCoalescer<T> {
resolve = res;
reject = rej;
});
// The shared promise is an OFFER a follower may take up, not a result anyone is
// obliged to consume: the leader never awaits it (it owns and throws the real
// error itself), so with no follower a `fail()` rejects a promise nobody observes
// — an unhandled rejection that kills the process under Node's default
// `--unhandled-rejections=throw` (#670). Marking it handled in the same breath as
// creating it makes that structural rather than dependent on who happens to join.
// This attaches to a DERIVED promise and discards it; `promise` is untouched, so a
// follower's `await` still sees the same rejection, same tick, same error identity.
promise.catch(() => {
/* an audience of nobody is not an error */
});
entry = { promise, resolve, reject, refs: 0 };
if (opts?.onCancel) entry.onCancel = opts.onCancel;
this.map.set(key, entry);
Expand Down
22 changes: 22 additions & 0 deletions packages/core/test/cache-internals.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,28 @@ describe('InflightCoalescer', () => {
expect(c.size).toBe(0);
});

test('a LONE leader failure is not an unhandled rejection (#670)', async () => {
// The coalescer's own half of the engine-level guard in cache.spec.ts: with no follower,
// nothing awaits the shared promise, so rejecting it would go unobserved and terminate the
// process under Node's default `--unhandled-rejections=throw`. The promise carries a
// terminal handler from construction, so `fail()` stays safe with an audience of nobody.
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown): void => {
unhandled.push(reason);
};
process.on('unhandledRejection', onUnhandled);
try {
const c = new InflightCoalescer<number>();
const a = c.join('k');
if (a.leader) a.fail(new Error('boom'));
expect(c.size).toBe(0);
await new Promise((r) => setTimeout(r, 0));
} finally {
process.off('unhandledRejection', onUnhandled);
}
expect(unhandled).toEqual([]);
});

test('distinct keys do not coalesce', () => {
const c = new InflightCoalescer<number>();
expect(c.join('a').leader).toBe(true);
Expand Down
Loading
Loading