diff --git a/.changeset/tame-otters-relax.md b/.changeset/tame-otters-relax.md new file mode 100644 index 0000000..0db05eb --- /dev/null +++ b/.changeset/tame-otters-relax.md @@ -0,0 +1,5 @@ +--- +"fetchify": minor +--- + +Add `defineOptions` factory for authoring a typed `FetchifyOptions` object independently of `createFetchify`, e.g. `createFetchify(defineOptions({ baseUrl: "https://api.example.com" }))`. **BREAKING**: the `CreateFetchifyOptions` type has been renamed to `FetchifyOptions`. diff --git a/openspec/changes/archive/2026-08-02-define-fetchify-options/design.md b/openspec/changes/archive/2026-08-02-define-fetchify-options/design.md new file mode 100644 index 0000000..f0b3eae --- /dev/null +++ b/openspec/changes/archive/2026-08-02-define-fetchify-options/design.md @@ -0,0 +1,42 @@ +## Context + +`createFetchify` currently accepts a plain object literal typed as `CreateFetchifyOptions` (`src/lib/create-fetchify.ts`). The package is unreleased (`version: 0.0.0`, `private: true`), so there are no external consumers to preserve compatibility for. The request is to support a `defineOptions`-style entry point, mirroring the `defineConfig` pattern used by tools like Vite and Vue, so consumers can build a fetchify options object independently of the call that creates the client: + +```ts +const options = defineOptions({ baseUrl: new URL("https://api.packetify.app") }); +const http = createFetchify(options); +``` + +## Goals / Non-Goals + +**Goals:** +- Provide a `defineOptions` function that returns a typed `FetchifyOptions` object, giving editors full type inference/autocomplete at the point options are authored, independent of `createFetchify`. +- Keep `createFetchify`'s runtime behavior unchanged: it still accepts a `FetchifyOptions`-shaped object, whether produced by `defineOptions` or written as a literal. +- Make `defineOptions` available from both entry points (`fetchify`, `fetchify/native`), consistent with `createFetchify`. + +**Non-Goals:** +- No new option fields beyond the existing `baseUrl`. This change only introduces the `defineOptions` entry point and aligns naming; it does not expand `FetchifyOptions`' shape. +- No runtime validation/parsing beyond what `createFetchify` already does (e.g. no schema validation library). `defineOptions` does not normalize `baseUrl` to a `URL` eagerly — that normalization still happens inside `createFetchify`, as today. +- No support for merging/extending multiple option objects (e.g. `defineOptions(a, b)`). + +## Decisions + +**`defineOptions` is an identity function at runtime.** +`defineOptions(options: FetchifyOptions): FetchifyOptions` simply returns its argument unchanged. Its value is purely at the type level: it gives consumers a named call to hang a typed object literal off, the same way `defineConfig` does in other tools, without requiring them to import and annotate `FetchifyOptions` by hand. Keeping it a pure identity function means there is no behavioral difference between `createFetchify({ baseUrl: ... })` and `createFetchify(defineOptions({ baseUrl: ... }))` — `defineOptions` is opt-in sugar, not a new code path `createFetchify` has to special-case. +- Alternative considered: have `defineOptions` normalize `baseUrl` to a `URL` up front. Rejected for now — it would create two normalization sites (here and in `createFetchify`) and isn't required by the requested usage; can be revisited if `defineOptions` grows validation responsibilities. + +**Rename `CreateFetchifyOptions` to `FetchifyOptions`.** +The options type is shared by both `defineOptions` and `createFetchify` now, so `CreateFetchifyOptions` (named after one specific consumer of the type) is no longer accurate. Renaming to `FetchifyOptions` reflects that it's the package's general options shape. +- Alternative considered: keep `CreateFetchifyOptions` as the canonical name and have `defineOptions` reference it. Rejected — the name would be misleading for a type used outside `createFetchify`, and since the package has no released consumers yet, there's no compatibility cost to renaming now rather than carrying the confusing name forward. + +**Location: new `src/lib/define-options.ts`.** +Mirrors the existing one-file-per-export layout (`src/lib/create-fetchify.ts`). `FetchifyOptions` is defined in `define-options.ts` and re-exported from `create-fetchify.ts` (or vice versa) to avoid a circular/duplicate type definition — `create-fetchify.ts` imports `FetchifyOptions` from `define-options.ts` since the type conceptually belongs to the options module. + +## Risks / Trade-offs + +- [Type rename breaks any in-flight branches/PRs importing `CreateFetchifyOptions`] → Acceptable: package is unreleased (`0.0.0`, `private: true`); communicated via the proposal's **BREAKING** note and a changeset entry. +- [`defineOptions` being a no-op identity function may read as pointless to some consumers] → Mitigated by documenting it as the type-inference entry point (same rationale as `defineConfig` in other tools); leaves room to add validation later without a signature change. + +## Migration Plan + +Not applicable — the package has no published releases depending on `CreateFetchifyOptions`. The rename and new export ship together in the same release, recorded by one changeset. diff --git a/openspec/changes/archive/2026-08-02-define-fetchify-options/proposal.md b/openspec/changes/archive/2026-08-02-define-fetchify-options/proposal.md new file mode 100644 index 0000000..9b4f675 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-define-fetchify-options/proposal.md @@ -0,0 +1,34 @@ +## Why + +Today a consumer configures `createFetchify` by passing an object literal directly, e.g. `createFetchify({ baseUrl: "..." })`. There is no dedicated way to author, name, or reuse a fetchify configuration independently of the call that creates the client, and no explicit type-inference entry point for editors/IDEs the way ecosystem tools like Vite's `defineConfig` provide. Introducing a `defineOptions` helper gives consumers a typed, standalone way to build a `FetchifyOptions` object before handing it to `createFetchify`, matching the requested usage: + +```ts +const options = defineOptions({ baseUrl: new URL("https://api.packetify.app") }); +const http = createFetchify(options); +const response = http.GET("/users"); +``` + +## What Changes + +- Add a `defineOptions(options: FetchifyOptions): FetchifyOptions` function that returns a typed, validated fetchify options object for later use with `createFetchify`. +- Rename the existing `CreateFetchifyOptions` type to `FetchifyOptions`, the shared shape used by both `defineOptions` and `createFetchify`. **BREAKING**: consumers importing the `CreateFetchifyOptions` type name must switch to `FetchifyOptions`. +- `createFetchify` continues to accept an options object matching `FetchifyOptions`, whether constructed via `defineOptions` or passed as a plain object literal (no behavior change to `createFetchify` itself beyond the type rename). +- Export `defineOptions` and `FetchifyOptions` from both the web entry point (`fetchify`) and the React Native entry point (`fetchify/native`), mirroring `createFetchify`'s availability. + +## Capabilities + +### New Capabilities +- `fetchify-options`: The `defineOptions` factory function that produces a typed `FetchifyOptions` object for use with `createFetchify`. + +### Modified Capabilities +- `http-client`: `createFetchify`'s options parameter type is renamed from `CreateFetchifyOptions` to `FetchifyOptions`, and its options object is documented as accepting the result of `defineOptions`. + +## Impact + +- `src/lib/create-fetchify.ts`: rename `CreateFetchifyOptions` to `FetchifyOptions`; `createFetchify` continues to accept `FetchifyOptions`. +- New `src/lib/define-options.ts` (or similar) implementing `defineOptions`. +- `src/index.ts` and `src/native.ts`: export `defineOptions` and `FetchifyOptions`. +- `openspec/specs/http-client/spec.md`: update the requirement referencing `CreateFetchifyOptions`. +- New `openspec/specs/fetchify-options/spec.md`. +- Tests for `defineOptions` and updated tests referencing the renamed type. +- A changeset recording the breaking type rename and the new `defineOptions` export. diff --git a/openspec/changes/archive/2026-08-02-define-fetchify-options/specs/fetchify-options/spec.md b/openspec/changes/archive/2026-08-02-define-fetchify-options/specs/fetchify-options/spec.md new file mode 100644 index 0000000..3f22794 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-define-fetchify-options/specs/fetchify-options/spec.md @@ -0,0 +1,38 @@ +## ADDED Requirements + +### Requirement: defineOptions factory +The package SHALL export a `defineOptions` function that accepts a `FetchifyOptions` object and returns it unchanged, giving consumers a typed entry point for authoring fetchify options independently of `createFetchify`. + +#### Scenario: Defining options with a baseUrl +- **WHEN** a consumer calls `defineOptions({ baseUrl: new URL("https://api.example.com") })` +- **THEN** the call returns a `FetchifyOptions` object equal to the argument passed in + +#### Scenario: Defined options are usable with createFetchify +- **WHEN** a consumer passes the object returned by `defineOptions` directly to `createFetchify` +- **THEN** `createFetchify` behaves exactly as if the same object literal had been passed to it directly + +### Requirement: FetchifyOptions shape +`FetchifyOptions` SHALL describe the same option fields accepted by `createFetchify`, currently an optional `baseUrl` accepting either a `string` or a `URL` instance. + +#### Scenario: baseUrl as a string +- **WHEN** a consumer calls `defineOptions({ baseUrl: "https://api.example.com" })` +- **THEN** the call type-checks and returns the object unchanged + +#### Scenario: baseUrl as a URL instance +- **WHEN** a consumer calls `defineOptions({ baseUrl: new URL("https://api.example.com") })` +- **THEN** the call type-checks and returns the object unchanged + +#### Scenario: No options +- **WHEN** a consumer calls `defineOptions({})` or `defineOptions()` +- **THEN** the call returns an empty (or undefined-defaulted) `FetchifyOptions` object without error + +### Requirement: Available from both the web and native entry points +`defineOptions` and the `FetchifyOptions` type SHALL be available, with identical behavior, from both the package's web entry point (`fetchify`) and its React Native entry point (`fetchify/native`). + +#### Scenario: Importing from the web entry +- **WHEN** a consumer imports `defineOptions` from `"fetchify"` +- **THEN** the imported function behaves as described by this capability's other requirements + +#### Scenario: Importing from the native entry +- **WHEN** a consumer imports `defineOptions` from `"fetchify/native"` +- **THEN** the imported function behaves identically to the one imported from `"fetchify"` diff --git a/openspec/changes/archive/2026-08-02-define-fetchify-options/specs/http-client/spec.md b/openspec/changes/archive/2026-08-02-define-fetchify-options/specs/http-client/spec.md new file mode 100644 index 0000000..6cc679f --- /dev/null +++ b/openspec/changes/archive/2026-08-02-define-fetchify-options/specs/http-client/spec.md @@ -0,0 +1,16 @@ +## MODIFIED Requirements + +### Requirement: createFetchify factory +The package SHALL export a `createFetchify` function that accepts a `FetchifyOptions` object and returns a client object exposing one method per supported HTTP verb. `FetchifyOptions` is the same type produced by the `defineOptions` factory, so `createFetchify` SHALL accept either a plain object literal or the object returned by `defineOptions`. + +#### Scenario: Creating a client +- **WHEN** a consumer calls `createFetchify({ baseUrl: "https://api.example.com" })` +- **THEN** the call returns an object with `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, and `OPTIONS` methods + +#### Scenario: Creating a client with no options +- **WHEN** a consumer calls `createFetchify()` or `createFetchify({})` +- **THEN** the call returns a client object with the same set of HTTP methods, and no `baseUrl` resolution is applied to request paths + +#### Scenario: Creating a client from defineOptions +- **WHEN** a consumer calls `createFetchify(defineOptions({ baseUrl: "https://api.example.com" }))` +- **THEN** the call returns a client behaving identically to one created by passing the equivalent object literal directly to `createFetchify` diff --git a/openspec/changes/archive/2026-08-02-define-fetchify-options/tasks.md b/openspec/changes/archive/2026-08-02-define-fetchify-options/tasks.md new file mode 100644 index 0000000..c364243 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-define-fetchify-options/tasks.md @@ -0,0 +1,22 @@ +## 1. Options module + +- [x] 1.1 Create `src/lib/define-options.ts` exporting the `FetchifyOptions` type (moved/renamed from `CreateFetchifyOptions`) and the `defineOptions(options: FetchifyOptions = {}): FetchifyOptions` identity function +- [x] 1.2 Create `src/lib/define-options.test.ts` covering: returns the input unchanged, works with a `string` `baseUrl`, works with a `URL` `baseUrl`, works with no arguments + +## 2. Update createFetchify to use FetchifyOptions + +- [x] 2.1 In `src/lib/create-fetchify.ts`, remove the local `CreateFetchifyOptions` type and import `FetchifyOptions` from `./define-options` +- [x] 2.2 Update `createFetchify`'s signature to accept `FetchifyOptions` +- [x] 2.3 Update `src/lib/create-fetchify.test.ts` (and any other references) from `CreateFetchifyOptions` to `FetchifyOptions`, and add a test that `createFetchify(defineOptions({ baseUrl: ... }))` behaves the same as passing the object literal directly + +## 3. Entry points + +- [x] 3.1 Update `src/index.ts` to export `defineOptions` and the `FetchifyOptions` type (in place of `CreateFetchifyOptions`) +- [x] 3.2 Update `src/native.ts` to export `defineOptions` and the `FetchifyOptions` type (in place of `CreateFetchifyOptions`) +- [x] 3.3 Update `src/index.test.ts` and `src/native.test.ts` to cover `defineOptions` being exported and usable end-to-end with `createFetchify` + +## 4. Verification and release + +- [x] 4.1 Run `bun test` and confirm all tests pass +- [x] 4.2 Run `bun run build` and confirm `dist/lib/define-options.d.ts` (or equivalent) is generated with no type errors +- [x] 4.3 Run `bun run changeset` to record the new `defineOptions` export and the breaking `CreateFetchifyOptions` → `FetchifyOptions` rename diff --git a/openspec/specs/fetchify-options/spec.md b/openspec/specs/fetchify-options/spec.md new file mode 100644 index 0000000..19b34c5 --- /dev/null +++ b/openspec/specs/fetchify-options/spec.md @@ -0,0 +1,44 @@ +# fetchify-options Specification + +## Purpose + +Defines the `defineOptions` factory and the `FetchifyOptions` type that consumers use to author fetchify configuration independently of `createFetchify`, available from both the web and React Native entry points. + +## Requirements + +### Requirement: defineOptions factory +The package SHALL export a `defineOptions` function that accepts a `FetchifyOptions` object and returns it unchanged, giving consumers a typed entry point for authoring fetchify options independently of `createFetchify`. + +#### Scenario: Defining options with a baseUrl +- **WHEN** a consumer calls `defineOptions({ baseUrl: new URL("https://api.example.com") })` +- **THEN** the call returns a `FetchifyOptions` object equal to the argument passed in + +#### Scenario: Defined options are usable with createFetchify +- **WHEN** a consumer passes the object returned by `defineOptions` directly to `createFetchify` +- **THEN** `createFetchify` behaves exactly as if the same object literal had been passed to it directly + +### Requirement: FetchifyOptions shape +`FetchifyOptions` SHALL describe the same option fields accepted by `createFetchify`, currently an optional `baseUrl` accepting either a `string` or a `URL` instance. + +#### Scenario: baseUrl as a string +- **WHEN** a consumer calls `defineOptions({ baseUrl: "https://api.example.com" })` +- **THEN** the call type-checks and returns the object unchanged + +#### Scenario: baseUrl as a URL instance +- **WHEN** a consumer calls `defineOptions({ baseUrl: new URL("https://api.example.com") })` +- **THEN** the call type-checks and returns the object unchanged + +#### Scenario: No options +- **WHEN** a consumer calls `defineOptions({})` or `defineOptions()` +- **THEN** the call returns an empty (or undefined-defaulted) `FetchifyOptions` object without error + +### Requirement: Available from both the web and native entry points +`defineOptions` and the `FetchifyOptions` type SHALL be available, with identical behavior, from both the package's web entry point (`fetchify`) and its React Native entry point (`fetchify/native`). + +#### Scenario: Importing from the web entry +- **WHEN** a consumer imports `defineOptions` from `"fetchify"` +- **THEN** the imported function behaves as described by this capability's other requirements + +#### Scenario: Importing from the native entry +- **WHEN** a consumer imports `defineOptions` from `"fetchify/native"` +- **THEN** the imported function behaves identically to the one imported from `"fetchify"` diff --git a/openspec/specs/http-client/spec.md b/openspec/specs/http-client/spec.md index 32e29a3..cb08f90 100644 --- a/openspec/specs/http-client/spec.md +++ b/openspec/specs/http-client/spec.md @@ -7,7 +7,7 @@ Defines the `createFetchify` HTTP client factory that consumers use to make requ ## Requirements ### Requirement: createFetchify factory -The package SHALL export a `createFetchify` function that accepts an options object and returns a client object exposing one method per supported HTTP verb. +The package SHALL export a `createFetchify` function that accepts a `FetchifyOptions` object and returns a client object exposing one method per supported HTTP verb. `FetchifyOptions` is the same type produced by the `defineOptions` factory, so `createFetchify` SHALL accept either a plain object literal or the object returned by `defineOptions`. #### Scenario: Creating a client - **WHEN** a consumer calls `createFetchify({ baseUrl: "https://api.example.com" })` @@ -17,6 +17,10 @@ The package SHALL export a `createFetchify` function that accepts an options obj - **WHEN** a consumer calls `createFetchify()` or `createFetchify({})` - **THEN** the call returns a client object with the same set of HTTP methods, and no `baseUrl` resolution is applied to request paths +#### Scenario: Creating a client from defineOptions +- **WHEN** a consumer calls `createFetchify(defineOptions({ baseUrl: "https://api.example.com" }))` +- **THEN** the call returns a client behaving identically to one created by passing the equivalent object literal directly to `createFetchify` + ### Requirement: Available from both the web and native entry points `createFetchify` SHALL be available, with identical behavior, from both the package's web entry point (`fetchify`) and its React Native entry point (`fetchify/native`). diff --git a/src/index.test.ts b/src/index.test.ts index 99ff6d0..e532abf 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { createFetchify, VERSION } from "./index"; +import { createFetchify, defineOptions, VERSION } from "./index"; test("web entry exports VERSION", () => { expect(VERSION).toBeDefined(); @@ -8,3 +8,14 @@ test("web entry exports VERSION", () => { test("web entry exports createFetchify", () => { expect(typeof createFetchify).toBe("function"); }); + +test("web entry exports defineOptions", () => { + expect(typeof defineOptions).toBe("function"); +}); + +test("web entry: defineOptions works end-to-end with createFetchify", () => { + const options = defineOptions({ baseUrl: "https://api.example.com" }); + const client = createFetchify(options); + + expect(typeof client.GET).toBe("function"); +}); diff --git a/src/index.ts b/src/index.ts index 9cb5399..a39e043 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ export const VERSION = "0.0.0"; export { createFetchify } from "./lib/create-fetchify"; -export type { CreateFetchifyOptions, Fetchify } from "./lib/create-fetchify"; +export type { Fetchify } from "./lib/create-fetchify"; +export { defineOptions } from "./lib/define-options"; +export type { FetchifyOptions } from "./lib/define-options"; export type { FetchifyMethod } from "./types/fetchify-method"; diff --git a/src/lib/create-fetchify.test.ts b/src/lib/create-fetchify.test.ts index 73333dc..1396a1d 100644 --- a/src/lib/create-fetchify.test.ts +++ b/src/lib/create-fetchify.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, expect, mock, test } from "bun:test"; import { createFetchify } from "./create-fetchify"; +import { defineOptions } from "./define-options"; const originalFetch = globalThis.fetch; let fetchMock: ReturnType; @@ -94,3 +95,14 @@ test("returns the fetch Response unmodified", async () => { expect(response.status).toBe(204); }); + +test("accepts options built with defineOptions", async () => { + const client = createFetchify( + defineOptions({ baseUrl: "https://api.example.com" }), + ); + + await client.GET("/users"); + + const [url] = fetchMock.mock.calls[0] as [URL, RequestInit]; + expect(url.toString()).toBe("https://api.example.com/users"); +}); diff --git a/src/lib/create-fetchify.ts b/src/lib/create-fetchify.ts index a5519dd..e7e9390 100644 --- a/src/lib/create-fetchify.ts +++ b/src/lib/create-fetchify.ts @@ -1,9 +1,6 @@ +import type { FetchifyOptions } from "./define-options"; import type { FetchifyMethod } from "../types/fetchify-method"; -export interface CreateFetchifyOptions { - baseUrl?: string | URL; -} - export interface Fetchify { GET: FetchifyMethod; POST: FetchifyMethod; @@ -24,7 +21,7 @@ const HTTP_METHODS = [ "OPTIONS", ] as const; -export function createFetchify(options: CreateFetchifyOptions = {}): Fetchify { +export function createFetchify(options: FetchifyOptions = {}): Fetchify { const baseUrl = options.baseUrl === undefined ? undefined : new URL(options.baseUrl); diff --git a/src/lib/define-options.test.ts b/src/lib/define-options.test.ts new file mode 100644 index 0000000..16295cc --- /dev/null +++ b/src/lib/define-options.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from "bun:test"; +import { defineOptions } from "./define-options"; + +test("returns the input unchanged", () => { + const input = { baseUrl: "https://api.example.com" }; + + expect(defineOptions(input)).toBe(input); +}); + +test("works with a string baseUrl", () => { + const options = defineOptions({ baseUrl: "https://api.example.com" }); + + expect(options.baseUrl).toBe("https://api.example.com"); +}); + +test("works with a URL baseUrl", () => { + const baseUrl = new URL("https://api.example.com"); + const options = defineOptions({ baseUrl }); + + expect(options.baseUrl).toBe(baseUrl); +}); + +test("works with no arguments", () => { + const options = defineOptions(); + + expect(options).toEqual({}); +}); diff --git a/src/lib/define-options.ts b/src/lib/define-options.ts new file mode 100644 index 0000000..40ecf0b --- /dev/null +++ b/src/lib/define-options.ts @@ -0,0 +1,9 @@ +export interface FetchifyOptions { + baseUrl?: string | URL; +} + +export function defineOptions( + options: FetchifyOptions = {}, +): FetchifyOptions { + return options; +} diff --git a/src/native.test.ts b/src/native.test.ts index 586a9c3..c335636 100644 --- a/src/native.test.ts +++ b/src/native.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { createFetchify, VERSION } from "./native"; +import { createFetchify, defineOptions, VERSION } from "./native"; test("native entry exports VERSION", () => { expect(VERSION).toBeDefined(); @@ -8,3 +8,14 @@ test("native entry exports VERSION", () => { test("native entry exports createFetchify", () => { expect(typeof createFetchify).toBe("function"); }); + +test("native entry exports defineOptions", () => { + expect(typeof defineOptions).toBe("function"); +}); + +test("native entry: defineOptions works end-to-end with createFetchify", () => { + const options = defineOptions({ baseUrl: "https://api.example.com" }); + const client = createFetchify(options); + + expect(typeof client.GET).toBe("function"); +}); diff --git a/src/native.ts b/src/native.ts index 9cb5399..a39e043 100644 --- a/src/native.ts +++ b/src/native.ts @@ -1,5 +1,7 @@ export const VERSION = "0.0.0"; export { createFetchify } from "./lib/create-fetchify"; -export type { CreateFetchifyOptions, Fetchify } from "./lib/create-fetchify"; +export type { Fetchify } from "./lib/create-fetchify"; +export { defineOptions } from "./lib/define-options"; +export type { FetchifyOptions } from "./lib/define-options"; export type { FetchifyMethod } from "./types/fetchify-method";