diff --git a/.agent/rules/comments.md b/.agent/rules/comments.md index 419f730c..437bc0e5 100644 --- a/.agent/rules/comments.md +++ b/.agent/rules/comments.md @@ -1,13 +1,12 @@ # Comment Rules -Add comments when they make the code easier to understand or maintain. Omit them when the code speaks for itself. +Add comments when they make the code easier to understand or maintain. Omit them when the code speaks for itself. For prose style within comments, see `writing.md`. ## Enforce - Add regular comments to explain **why**, a constraint, domain behavior, or to orient the reader at the start of a non-obvious block - Place the comment directly above the code it explains - Omit the trailing period in a short single-line regular comment -- Use plain punctuation only - Add doc comments to public and exported functions, methods, and types only when the name and signature alone do not make the behavior or contract clear; describe what it does first, then note constraints, side effects, or non-obvious return or error behavior when needed - Start doc comments with a single sentence in active voice and end it with a period - Use `// MARK: -` sparingly as a file-level or large-structure navigation aid, mainly in long files @@ -16,7 +15,6 @@ Add comments when they make the code easier to understand or maintain. Omit them ## Avoid -- Do not use em dashes (—) in comments - Do not restate what the code already says in regular comments - Do not use comments as a substitute for good naming - Do not add comments above a block when the intent is already obvious from the code @@ -87,7 +85,7 @@ const sortedItems = items.slice().sort(compareItems); ```ts /** Returns whether the user is authenticated. */ -export function isAuthenticated(): boolean +export function isAuthenticated(): boolean; ``` ```ts diff --git a/.agent/rules/feature-fetch.md b/.agent/rules/feature-fetch.md index 3ab97a83..c2142f62 100644 --- a/.agent/rules/feature-fetch.md +++ b/.agent/rules/feature-fetch.md @@ -9,7 +9,7 @@ Use `feature-fetch` as the typed API layer. Prefer consistent client setup and e - Compose only the features you need, in a clear order - Handle `tuple-result` values explicitly; prefer the tuple pattern from `.agent/rules/tuple-result.md` - Distinguish network failures from request failures when behavior differs -- Keep request configuration explicit: base URL, headers, timeout, auth +- Keep request configuration explicit: base URL, headers, and auth; use `signal` for cancellation - Keep GraphQL operations in dedicated files and use `gql` ## Avoid @@ -22,12 +22,10 @@ Use `feature-fetch` as the typed API layer. Prefer consistent client setup and e ## Example ```ts -const itemId = 'item-123'; -const itemResult = await apiClient.get('/items/{itemId}', { +const [isItemOk, itemErr, item] = await api.get('/items/{itemId}', { pathParams: { itemId } }); -const [isItemOk, itemErr, item] = itemResult; if (!isItemOk) { if (itemErr instanceof NetworkError) { throw new AppError('Failed to connect', { cause: itemErr }); @@ -36,5 +34,5 @@ if (!isItemOk) { throw itemErr; } -return item.data; +return item; ``` diff --git a/.agent/rules/feature-state.md b/.agent/rules/feature-state.md index ea224036..770eaa26 100644 --- a/.agent/rules/feature-state.md +++ b/.agent/rules/feature-state.md @@ -8,7 +8,7 @@ Use `feature-state` and `feature-react/state` as the default state pattern in pr - Name states with a `$` prefix like `$count`, `$status`, or `$selectedId` - Use `useFeatureState(state)` when the component needs the raw value and should re-render on changes - Use `useCompute(state, compute)` when the component needs a derived value from one state -- Use `useCombinedCompute(states, compute)` when the view depends on multiple states together +- Use `useCompute(states, compute)` when the view depends on multiple states together - Use `useListener(state, callback)` for side effects driven by state changes - Keep state creation and state mutation helpers outside components unless the state is truly local to one component @@ -16,7 +16,7 @@ Use `feature-state` and `feature-react/state` as the default state pattern in pr - Do not put many unrelated fields into one state object when separate atoms would stay clearer - Do not read `state._v` directly in app code when a hook or public API fits -- Do not use `useFeatureState` and then derive large computed values inline on every render when `useCompute` or `useCombinedCompute` would express the intent better +- Do not use `useFeatureState` and then derive large computed values inline on every render when `useCompute` would express the intent better - Do not use `useListener` for simple rendering; keep it for effects and subscriptions - Do not create ad-hoc React state when the feature already has a matching `feature-state` source of truth @@ -25,10 +25,10 @@ Use `feature-state` and `feature-react/state` as the default state pattern in pr ### Good ```tsx -const label = useCompute($seconds, ({ value = 0 }) => formatSeconds(value)); -const isDisabled = useCombinedCompute( +const label = useCompute($seconds, (seconds = 0) => formatSeconds(seconds)); +const isDisabled = useCompute( [$status, $selectedId] as const, - ([{ value: status = 'idle' }, { value: selectedId = null }]) => { + ([status = 'idle', selectedId = null]) => { return status === 'loading' || selectedId == null; } ); diff --git a/.agent/rules/tuple-result.md b/.agent/rules/tuple-result.md index 0113ed78..dab13b3d 100644 --- a/.agent/rules/tuple-result.md +++ b/.agent/rules/tuple-result.md @@ -11,7 +11,7 @@ Use `tuple-result` as the default Result pattern when working with repo code tha - Treat the first slot as the primary branch condition - Handle the error branch explicitly before using the value - Use `Ok(...)` and `Err(...)` to construct results -- Use helpers like `mapOk`, `mapErr`, `match`, `unwrapOr`, and `tAsync` when they make the code simpler +- Use helpers like `t`, `tAsync`, `mapOk`, `mapErr`, `match`, and `unwrapOr` when they make the code simpler ## Avoid diff --git a/.agent/rules/vitest.md b/.agent/rules/vitest.md index 9312a259..8164ff6a 100644 --- a/.agent/rules/vitest.md +++ b/.agent/rules/vitest.md @@ -5,10 +5,15 @@ Write tests so they read like small specifications. ## Enforce - Place tests next to the source file they cover -- Use `.test.ts` or `.test.tsx` +- Use `.test.ts`, `.test.tsx`, or `.test-d.ts` for type tests - Group tests with `describe` +- Use one top-level `describe` for the unit under test +- Add nested `describe` blocks only for public members or meaningful behavior categories +- Include the subject kind in concrete `describe` names, such as `createForm function`, `FlatQueue class`, `listen method`, or `value property` +- Omit the subject kind from category `describe` names, such as `types`, `validation`, or `dirty tracking` - Start `it(...)` descriptions with `should` - Keep each test focused on one behavior +- Prefer 80/20 coverage by default: cover the core contract, important edge cases, and regressions without exhaustive case matrices unless explicitly requested - Use Prepare / Act / Assert structure when the test has more than a couple of steps - Use `async` / `await` for async tests - Cover both success and error paths where behavior matters @@ -19,20 +24,53 @@ Write tests so they read like small specifications. - Do not rely on implicit promises or `.then(...)` chains in tests - Do not hide setup inside vague helper names - Do not use unclear test names like `works` or `test x` +- Do not add a kind suffix to category describes that are not testing a concrete package, function, class, method, or property -## Example +## Examples + +### Good ```ts -describe('parseInput', () => { - it('should parse valid JSON', () => { - // Prepare - const input = '{"key":"value"}'; +describe('createStore function', () => { + describe('types', () => { + it('should infer value types', () => { + const store = createStore('value'); + + expectTypeOf(store.get()).toEqualTypeOf(); + }); + }); + + describe('set method', () => { + it('should notify listeners when the value changes', () => { + // Prepare + const store = createStore(0); + const listener = vi.fn(); + store.listen(listener); + + // Act + store.set(1); - // Act - const result = parseInput(input); + // Assert + expect(listener).toHaveBeenCalledWith({ value: 1 }); + }); + }); +}); +``` - // Assert - expect(result).toEqual({ key: 'value' }); +### Avoid + +```ts +describe('store', () => { + describe('set method edge cases and notification behavior', () => { + it('works', () => { + const store = createStore(0); + const listener = vi.fn(); + store.listen(listener); + store.set(1); + store.set(1); + expect(store.get()).toBe(1); + expect(listener).toHaveBeenCalledTimes(1); + }); }); }); ``` diff --git a/.agent/rules/writing.md b/.agent/rules/writing.md new file mode 100644 index 00000000..b91b1eb9 --- /dev/null +++ b/.agent/rules/writing.md @@ -0,0 +1,52 @@ +# Writing Rules + +Apply these rules to all prose: comments, READMEs, doc strings, PR descriptions, and commit messages. + +## Enforce + +- Use a colon to introduce an explanation, elaboration, or list that follows naturally from the preceding clause +- Use a comma or split into two sentences when an em dash would otherwise bridge two independent thoughts +- Use parentheses for brief asides that do not need to interrupt the sentence rhythm +- Write in active voice +- Keep sentences short and direct; one idea per sentence is the default +- End full sentences with a period; omit the period on short fragments used as labels or list items + +## Avoid + +- Do not use em dashes (—); replace with a colon, comma, parentheses, or a restructured sentence depending on context +- Do not use filler phrases ("it is worth noting that", "in order to", "as mentioned above") +- Do not pad sentences with hedges that add no information ("basically", "essentially", "simply") + +## Examples + +### Good + +``` +This function has two phases: validation and persistence. +``` + +``` +Mutable values (arrays, objects) are copied before storing. +``` + +``` +Eager validation can feel intrusive before the user has finished filling in the form. +``` + +``` +The library works directly without an adapter. +``` + +### Avoid + +``` +This function has two phases — validation and persistence. +``` + +``` +Mutable values — arrays, objects — are copied before storing. +``` + +``` +Eager validation can feel intrusive — the user hasn't finished yet. +``` diff --git a/.github/assets/banner.svg b/.github/assets/banner.svg index c5ad628d..685010bd 100644 --- a/.github/assets/banner.svg +++ b/.github/assets/banner.svg @@ -1,6 +1,6 @@ - - - + + + diff --git a/.github/assets/logo.png b/.github/assets/logo.png deleted file mode 100644 index fc9f904a..00000000 Binary files a/.github/assets/logo.png and /dev/null differ diff --git a/.github/assets/logo_v1.png b/.github/assets/logo_v1.png deleted file mode 100644 index 86eda625..00000000 Binary files a/.github/assets/logo_v1.png and /dev/null differ diff --git a/.gitignore b/.gitignore index 0b7e1ff8..fe49fb08 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ yarn-debug.log* yarn-error.log* .todo .DS_Store +.local \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index f000bbc5..69ab3d71 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -35,7 +35,7 @@ // File nesting in explorer "explorer.fileNesting.enabled": true, "explorer.fileNesting.patterns": { - "*.ts": "${capture}.js, ${capture}.test.ts, ${capture}.md", + "*.ts": "${capture}.js, ${capture}.test.ts, ${capture}.test-d.ts, ${capture}.md", "*.js": "${capture}.js.map, ${capture}.min.js, ${capture}.d.ts, ${capture}.test.js, ${capture}.md", "*.jsx": "${capture}.js, ${capture}.md", "*.tsx": "${capture}.ts, ${capture}.stories.tsx, ${capture}.md", diff --git a/AGENTS.md b/AGENTS.md index 6947714b..dd79bee9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,7 @@ This is the shared source of truth for agent guidance in this repository. - `*Cx.ts` feature context pattern: `.agent/rules/cx-pattern.md` - General code style: `.agent/rules/style-guide.md` - Comments: `.agent/rules/comments.md` +- Writing style (prose, READMEs, commit messages): `.agent/rules/writing.md` - `tuple-result`: `.agent/rules/tuple-result.md` - Vitest tests: `.agent/rules/vitest.md` - `feature-fetch`: `.agent/rules/feature-fetch.md` diff --git a/README.md b/README.md index 7d17cb98..94891151 100644 --- a/README.md +++ b/README.md @@ -11,50 +11,61 @@

-A collection of open source libraries maintained by [builder.group](https://builder.group). Let's build together. - -## 📦 Packages - -| Package | Description | NPM Package | -| ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- | -| [config](https://github.com/builder-group/community/blob/develop/packages/config) | Collection of ESLint, Vite, and Typescript configurations | [`@blgc/config`](https://www.npmjs.com/package/@blgc/config) | -| [ecsify](https://github.com/builder-group/community/blob/develop/packages/ecsify) | A flexible, typesafe, and performance-focused Entity Component System (ECS) library for TypeScript | [`ecsify`](https://www.npmjs.com/package/ecsify) | -| [eprel-client](https://github.com/builder-group/community/blob/develop/packages/eprel-client) | Typesafe and straightforward fetch client for interacting with the European Product Registry for Energy Labelling (EPREL) API using feature-fetch | [`eprel-client`](https://www.npmjs.com/package/eprel-client) | -| [feature-fetch](https://github.com/builder-group/community/blob/develop/packages/feature-fetch) | Straightforward, typesafe, and feature-based fetch wrapper supporting OpenAPI types | [`feature-fetch`](https://www.npmjs.com/package/feature-fetch) | -| [feature-form](https://github.com/builder-group/community/blob/develop/packages/feature-form) | Straightforward, typesafe, and feature-based form library | [`feature-form`](https://www.npmjs.com/package/feature-form) | -| [feature-logger](https://github.com/builder-group/community/blob/develop/packages/feature-logger) | Straightforward, typesafe, and feature-based logging library | [`feature-logger`](https://www.npmjs.com/package/feature-logger) | -| [feature-react](https://github.com/builder-group/community/blob/develop/packages/feature-react) | ReactJs extension for the feature-state and feature-form library, providing hooks and features for ReactJs | [`feature-react`](https://www.npmjs.com/package/feature-react) | -| [feature-state](https://github.com/builder-group/community/blob/develop/packages/feature-state) | Straightforward, typesafe, and feature-based state management library for ReactJs | [`feature-state`](https://www.npmjs.com/package/feature-state) | -| [head-metadata](https://github.com/builder-group/community/blob/develop/packages/head-metadata) | Typesafe and straightforward utility for extracting structured metadata (like ``, ``, and `<link>`) from the `<head>` of an HTML document. | [`head-metadata`](https://www.npmjs.com/package/head-metadata) | -| [openapi-ts-router](https://github.com/builder-group/community/blob/develop/packages/openapi-ts-router) | Thin wrapper around the router of web frameworks like Express and Hono, offering OpenAPI typesafety and seamless integration with validation libraries such as Valibot and Zod | [`openapi-ts-router`](https://www.npmjs.com/package/openapi-ts-router) | -| [rollup-presets](https://github.com/builder-group/community/blob/develop/packages/rollup-presets) | A collection of opinionated, production-ready Rollup presets | [`rollup-presets`](https://www.npmjs.com/package/rollup-presets) | -| [split-flap-board](https://github.com/builder-group/community/blob/develop/packages/split-flap-board) | Web component that simulates a split-flap display, the mechanical boards found in airports and train stations | [`split-flap-board`](https://www.npmjs.com/package/split-flap-board) | -| [tuple-result](https://github.com/builder-group/community/blob/develop/packages/tuple-result) | A minimal, functional, and tree-shakable Result library for TypeScript that prioritizes simplicity and serialization | [`tuple-result`](https://www.npmjs.com/package/tuple-result) | -| [types](https://github.com/builder-group/community/blob/develop/packages/types) | Shared TypeScript type definitions used across builder.group community packages | [`@blgc/types`](https://www.npmjs.com/package/@blgc/types) | -| [utils](https://github.com/builder-group/community/blob/develop/packages/utils) | Straightforward, typesafe, and tree-shakable collection of utility functions | [`@blgc/utils`](https://www.npmjs.com/package/@blgc/utils) | -| [validatenv](https://github.com/builder-group/community/blob/develop/packages/validatenv) | Type-safe, straightforward, and lightweight library for validating environment variables using existing validation libraries like Zod, Valibot, and Yup. | [`validatenv`](https://www.npmjs.com/package/validatenv) | -| [validation-adapter](https://github.com/builder-group/community/blob/develop/packages/validation-adapter) | Universal validation adapter that integrates various validation libraries like Zod, Valibot, and Yup | [`validation-adapter`](https://www.npmjs.com/package/validation-adapter) | -| [validation-adapters](https://github.com/builder-group/community/blob/develop/packages/validation-adapters) | Pre-made validation adapters for the validation-adapter library, including adapters for Zod and Valibot | [`validation-adapters`](https://www.npmjs.com/package/validation-adapters) | -| [xml-tokenizer](https://github.com/builder-group/community/blob/develop/packages/xml-tokenizer) | Straightforward and typesafe XML tokenizer that streams tokens through a callback mechanism | [`xml-tokenizer`](https://www.npmjs.com/package/xml-tokenizer) | - -### 🚧 Deprecated - -> **Note:** These packages are deprecated. I'm no longer using them, but if you need them maintained or have questions, please [open an issue](https://github.com/builder-group/community/issues). I'm open to keep maintaining them if there's community interest. - -| Package | Description | NPM Package | Deprecated Since | -| ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ---------------- | -| [elevenlabs-client](https://github.com/builder-group/community/blob/develop/packages/_deprecated/elevenlabs-client) | Typesafe and straightforward fetch client for interacting with the ElevenLabs API using feature-fetch | [`elevenlabs-client`](https://www.npmjs.com/package/elevenlabs-client) | November 6, 2025 | -| [figma-connect](https://github.com/builder-group/community/blob/develop/packages/_deprecated/figma-connect) | Straightforward and typesafe wrapper around the communication between the app/ui (iframe) and plugin (sandbox) part of a Figma Plugin | [`figma-connect`](https://www.npmjs.com/package/figma-connect) | November 6, 2025 | -| [google-webfonts-client](https://github.com/builder-group/community/blob/develop/packages/_deprecated/google-webfonts-client) | Typesafe and straightforward fetch client for interacting with the Google Web Fonts API using feature-fetch | [`google-webfonts-client`](https://www.npmjs.com/package/google-webfonts-client) | November 6, 2025 | -| [kleinanzeigen-client](https://github.com/builder-group/community/blob/develop/packages/_deprecated/kleinanzeigen-client) | Typesafe and straightforward fetch client for interacting with the Kleinanzeigen API using feature-fetch | [`kleinanzeigen-client`](https://www.npmjs.com/package/kleinanzeigen-client) | November 6, 2025 | - -### 📚 Examples +Open source packages from [builder.group](https://builder.group) projects, shared in case they're useful. + +## Packages + +| Package | Description | NPM Package | +| ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| [config](https://github.com/builder-group/community/blob/develop/packages/config) | Shared ESLint, Prettier, Vite, and TypeScript configuration presets | [`@blgc/config`](https://www.npmjs.com/package/@blgc/config) | +| [ecsify](https://github.com/builder-group/community/blob/develop/packages/ecsify) | Typesafe ECS library for building entity-component apps in TypeScript | [`ecsify`](https://www.npmjs.com/package/ecsify) | +| [feature-core](https://github.com/builder-group/community/blob/develop/packages/feature-core) | Typesafe `.with()` composition primitives for building extensible TypeScript libraries | [`feature-core`](https://www.npmjs.com/package/feature-core) | +| [feature-fetch](https://github.com/builder-group/community/blob/develop/packages/feature-fetch) | Typesafe fetch wrapper with composable middleware and OpenAPI type support | [`feature-fetch`](https://www.npmjs.com/package/feature-fetch) | +| [feature-form](https://github.com/builder-group/community/blob/develop/packages/feature-form) | Lightweight typesafe form library with reactive fields and Standard Schema validation | [`feature-form`](https://www.npmjs.com/package/feature-form) | +| [feature-logger](https://github.com/builder-group/community/blob/develop/packages/feature-logger) | Typesafe logger with composable prefixes, labels, timestamps, and output styles | [`feature-logger`](https://www.npmjs.com/package/feature-logger) | +| [feature-react](https://github.com/builder-group/community/blob/develop/packages/feature-react) | React hooks for reactive state and form subscriptions | [`feature-react`](https://www.npmjs.com/package/feature-react) | +| [feature-state](https://github.com/builder-group/community/blob/develop/packages/feature-state) | Lightweight reactive state container with composable feature extensions | [`feature-state`](https://www.npmjs.com/package/feature-state) | +| [head-metadata](https://github.com/builder-group/community/blob/develop/packages/head-metadata) | HTML head metadata extractor for title, meta, and link tags | [`head-metadata`](https://www.npmjs.com/package/head-metadata) | +| [openapi-ts-router](https://github.com/builder-group/community/blob/develop/packages/openapi-ts-router) | OpenAPI-typed router helpers for Express and Hono with runtime validation | [`openapi-ts-router`](https://www.npmjs.com/package/openapi-ts-router) | +| [rollup-presets](https://github.com/builder-group/community/blob/develop/packages/rollup-presets) | Rollup presets and plugins for building TypeScript libraries | [`rollup-presets`](https://www.npmjs.com/package/rollup-presets) | +| [tuple-result](https://github.com/builder-group/community/blob/develop/packages/tuple-result) | Minimal, tree-shakable Result type using plain arrays for easy serialization | [`tuple-result`](https://www.npmjs.com/package/tuple-result) | +| [types](https://github.com/builder-group/community/blob/develop/packages/types) | Shared utility, API, and OpenAPI TypeScript types for builder.group packages | [`@blgc/types`](https://www.npmjs.com/package/@blgc/types) | +| [utils](https://github.com/builder-group/community/blob/develop/packages/utils) | Tree-shakable TypeScript utilities for colors, IDs, objects, URLs, and math | [`@blgc/utils`](https://www.npmjs.com/package/@blgc/utils) | +| [validatenv](https://github.com/builder-group/community/blob/develop/packages/validatenv) | Environment variable validation with Zod, Valibot, or Yup | [`validatenv`](https://www.npmjs.com/package/validatenv) | +| [xml-tokenizer](https://github.com/builder-group/community/blob/develop/packages/xml-tokenizer) | Streaming XML tokenizer with callback-based parsing and object helpers | [`xml-tokenizer`](https://www.npmjs.com/package/xml-tokenizer) | + +### Discontinued + +> **Note:** These packages are no longer maintained. If you need support or have questions, please [open an issue](https://github.com/builder-group/community/issues). Happy to revisit if there's community interest. + +| Package | Description | NPM Package | Discontinued Since | +| ----------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ------------------ | +| [@blgc/cli](https://github.com/builder-group/community/blob/develop/packages/_deprecated/cli) | Rollup and Esbuild-powered CLI for bundling TypeScript libraries | [`@blgc/cli`](https://www.npmjs.com/package/@blgc/cli) | March 9, 2026 | +| [elevenlabs-client](https://github.com/builder-group/community/blob/develop/packages/_deprecated/elevenlabs-client) | Typesafe API client for the ElevenLabs text-to-speech API | [`elevenlabs-client`](https://www.npmjs.com/package/elevenlabs-client) | November 6, 2025 | +| [eprel-client](https://github.com/builder-group/community/blob/develop/packages/eprel-client) | Typesafe API client for the EU EPREL energy label registry | [`eprel-client`](https://www.npmjs.com/package/eprel-client) | May 21, 2026 | +| [figma-connect](https://github.com/builder-group/community/blob/develop/packages/_deprecated/figma-connect) | Typed message bridge between Figma plugin UI iframes and sandbox code | [`figma-connect`](https://www.npmjs.com/package/figma-connect) | November 6, 2025 | +| [google-webfonts-client](https://github.com/builder-group/community/blob/develop/packages/_deprecated/google-webfonts-client) | Typesafe API client for Google Web Fonts metadata and font downloads | [`google-webfonts-client`](https://www.npmjs.com/package/google-webfonts-client) | November 6, 2025 | +| [kleinanzeigen-client](https://github.com/builder-group/community/blob/develop/packages/_deprecated/kleinanzeigen-client) | Typesafe API client for scraping and extracting Kleinanzeigen listings | [`kleinanzeigen-client`](https://www.npmjs.com/package/kleinanzeigen-client) | November 6, 2025 | +| [openapi-express](https://github.com/builder-group/community/blob/develop/packages/_deprecated/openapi-express) | OpenAPI-typed Express router wrapper with Zod request validation | [`openapi-express`](https://www.npmjs.com/package/openapi-express) | January 1, 2025 | +| [split-flap-board](https://github.com/builder-group/community/blob/develop/packages/split-flap-board) | Web Components for animated split-flap boards with configurable spools and grids | [`split-flap-board`](https://www.npmjs.com/package/split-flap-board) | May 21, 2026 | +| [validation-adapter](https://github.com/builder-group/community/blob/develop/packages/validation-adapter) | Universal validation abstraction for validators like Zod, Valibot, and Yup | [`validation-adapter`](https://www.npmjs.com/package/validation-adapter) | Soon | +| [validation-adapters](https://github.com/builder-group/community/blob/develop/packages/validation-adapters) | Ready-made validation-adapter implementations for Zod, Valibot, Yup, and Standard Schema | [`validation-adapters`](https://www.npmjs.com/package/validation-adapters) | Soon | +| [webito](https://github.com/builder-group/community/blob/develop/packages/_deprecated/webito) | ECS-powered web editor experiment for Tailwind-style component editing | [`webito`](https://www.npmjs.com/package/webito) | May 17, 2026 | + +## Templates + +| Template | Description | +| ------------------------------------------------------------------------------------------------ | --------------------------------------------------- | +| [desktop-tauri](https://github.com/builder-group/community/tree/develop/templates/desktop-tauri) | Tauri desktop app template with React and Specta | +| [web-tanstack](https://github.com/builder-group/community/tree/develop/templates/web-tanstack) | TanStack Start web app template with React and Vite | + +## Examples > See [`/examples`](https://github.com/builder-group/community/tree/develop/examples) ### `feature-fetch` -- [`feature-fetch/vanilla/open-meteo`](https://github.com/builder-group/community/tree/develop/examples/feature-fetch/vanilla/open-meteo) +- [`feature-fetch/vanilla/basic`](https://github.com/builder-group/community/tree/develop/examples/feature-fetch/vanilla/basic) ### `feature-form` @@ -62,7 +73,7 @@ A collection of open source libraries maintained by [builder.group](https://buil ### `feature-state` -- [`feature-state/react/counter`](https://github.com/builder-group/community/tree/develop/examples/feature-state/react/counter) +- [`feature-state/react/basic`](https://github.com/builder-group/community/tree/develop/examples/feature-state/react/basic) ### `openapi-ts-router` @@ -73,14 +84,7 @@ A collection of open source libraries maintained by [builder.group](https://buil - [`xml-tokenizer/vanilla/playground`](https://github.com/builder-group/community/tree/develop/examples/xml-tokenizer/vanilla/playground) -## 🧩 Templates - -| Template | Description | -| ------------------------------------------------------------------------------------------------ | --------------------------------------------------- | -| [desktop-tauri](https://github.com/builder-group/community/tree/develop/templates/desktop-tauri) | Tauri desktop app template with React and Specta | -| [web-tanstack](https://github.com/builder-group/community/tree/develop/templates/web-tanstack) | TanStack Start web app template with React and Vite | - -## ❓ FAQ +## FAQ ### What does `blgc` stand for? @@ -88,54 +92,39 @@ A collection of open source libraries maintained by [builder.group](https://buil ### Why a Monorepo? -Maintaining all libraries in a single repository keeps things simple and efficient. A monorepo allows for shared tooling, consistent versioning, and streamlined CI/CD workflows, while making cross-library changes easier. This approach simplifies collaboration and reduces overhead, ensuring all libraries remain in sync. +Keeping all packages in one repository means shared tooling, consistent versioning, and streamlined CI/CD with no extra overhead. Cross-package changes are easier to make and stay in sync automatically. -The only disadvantage is that it's harder to discover individual libraries via SEO since they're all part of one repo. However, the benefits far outweigh this limitation. +The only trade-off is that individual packages are harder to discover via SEO since they all live under one repo, but that's an acceptable cost. ### Why two package build modes (`build` vs `build:prod`)? -Development builds (`pnpm build`): - -- Includes TypeScript declaration maps (IDE navigation goes directly to source files instead of compiled definitions) -- Easier debugging (no code minification and optimizations) +`pnpm build` (development): includes TypeScript declaration maps so IDE navigation goes to source files instead of compiled definitions, and skips minification for easier debugging. -Production builds (`pnpm build:prod`): +`pnpm build:prod` (production): smaller output, minification enabled, declaration maps excluded. Declaration maps cause npm publish errors (e.g. `EINVALIDTAGNAME` in GitHub CLI) so they must be stripped from published packages. -- Smaller package size -- No development artifacts in published packages -- Code minification and optimizations enabled -- Prevents npm errors with declaration maps (e.g. `EINVALIDTAGNAME` in GitHub CLI) - -To switch between modes: +### Why is `@blgc/types` listed as a dependency instead of a devDependency? -- Development: `pnpm build` (includes declaration maps) -- Production: `pnpm build:prod` (excludes declaration maps) +The `@blgc/types` package provides TypeScript type definitions shared across packages in this repo. When listed as a `devDependency`, these types are excluded from the final npm package, causing broken type checks and missing autocompletions for consumers. Listing it as a `dependency` ensures the types are accessible downstream. -### Why is `@blgc/types` listed as a dependency instead of a devDependency? +### What are features? -The `@blgc/types` package provides crucial TypeScript type definitions to ensure full type safety for feature-based libraries. When listed as a `devDependency`, these types are excluded from the final NPM package, resulting in broken type checks and missing autocompletions in projects consuming these libraries. By adding it as a `dependency`, we ensure that the type definitions are bundled and accessible to downstream projects, maintaining a seamless developer experience. +A feature is a self-contained extension that adds typed methods or behaviour to a host object via `.with()`. The host starts with a base API, and each `.with(feature())` call extends it by adding methods, validating dependencies, and narrowing the TypeScript type. See [`feature-core`](https://www.npmjs.com/package/feature-core) for the underlying primitives. -### Why do we use the "wrapper pattern" (`withLogger(withStorage(withUndo(createState(0))))`) instead of a declarative API? +### Why do libraries use a `.with()` chain instead of a declarative feature array? -While declarative APIs like the following offer [better developer experience (DX)](https://www.reddit.com/r/reactjs/comments/1huxvci/i_built_a_bloated_state_manager_then_i_fixed_it/): +The `.with()` chain (powered by [`feature-core`](https://www.npmjs.com/package/feature-core)) gives full TypeScript inference per step. Each call narrows the type based on what was installed before it, including dependency validation: ```ts -createState({ - defaultValue: 0, - features: [withUndo(), withStorage(), withLogger()] -}); -``` +const $count = createState(0).with(undoFeature()).with(storageFeature()); -We currently use the "wrapper pattern" because it ensures better TypeScript type inference. Each wrapper function modifies the state's type in a specific sequence, which is harder to achieve reliably with a feature array. +$count.undo(); // typed +$count.missing(); // type error +``` -We're [actively exploring solutions](https://github.com/builder-group/community/blob/develop/packages/feature-state/src/_experimental) to support both patterns, combining the type safety of the wrapper pattern with the simplicity of declarative APIs. Contributions and ideas are always welcome :) +A declarative feature array like `features: [undoFeature(), storageFeature()]` can't validate dependencies or accumulate types left-to-right reliably without losing inference. The chain solves both. -### Why do feature-based libraries use Objects instead of Classes? +### Why objects instead of classes? This [Medium post](https://medium.com/@markmiro/thoughts-on-choosing-between-plain-js-objects-and-classes-6422af8aaad5) explains the key differences well. -In short, we use objects because they are more flexible and allow for the kind of extensibility we need. Achieving this level of extensibility with classes isn't feasible for our use case, so using objects was the better choice. - -### What Features? - -Think of features like components in an Entity Component System (ECS). Every feature based object (e.g., feature-state) has base functionality, and additional components (features) can be added to extend it. Unlike traditional ECS, we only adopt the concept of Components, without Systems or Entities. Each component contains the necessary functions to interact with the feature based object. +Objects are more flexible and allow for the kind of extensibility the `.with()` model needs. Achieving the same with classes isn't feasible, so objects were the better choice. diff --git a/apps/web/package.json b/apps/web/package.json index 984a8330..5c7a6025 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -26,28 +26,28 @@ "update:latest": "pnpm update --latest" }, "dependencies": { - "@tanstack/react-router": "^1.169.1", - "@tanstack/react-router-devtools": "^1.166.13", - "@tanstack/react-start": "^1.167.62", + "@tanstack/react-router": "^1.170.7", + "@tanstack/react-router-devtools": "^1.167.0", + "@tanstack/react-start": "^1.168.10", "@vercel/analytics": "^2.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "feature-fetch": "workspace:*", - "react": "^19.2.5", - "react-dom": "^19.2.5", - "tailwind-merge": "^3.5.0", - "zod": "^4.4.2" + "react": "^19.2.6", + "react-dom": "^19.2.6", + "tailwind-merge": "^3.6.0", + "zod": "^4.4.3" }, "devDependencies": { "@mdx-js/rollup": "^3.1.1", "@tailwindcss/typography": "^0.5.19", - "@tailwindcss/vite": "^4.2.4", + "@tailwindcss/vite": "^4.3.0", "@types/mdx": "^2.0.13", - "@types/node": "^25.6.0", - "@types/react": "^19.2.14", + "@types/node": "^25.9.1", + "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", + "@vitejs/plugin-react": "^6.0.2", "nitro": "3.0.260429-beta", - "tailwindcss": "^4.2.4" + "tailwindcss": "^4.3.0" } } diff --git a/apps/web/src/components/display/CanvasButton.tsx b/apps/web/src/components/display/CanvasButton.tsx index 43311269..812da244 100644 --- a/apps/web/src/components/display/CanvasButton.tsx +++ b/apps/web/src/components/display/CanvasButton.tsx @@ -18,11 +18,11 @@ export interface TCanvasButtonProps VariantProps<typeof canvasButtonVariants> {} export const canvasButtonVariants = cva( - 'rounded-lg px-5 py-2.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-500 focus-visible:ring-offset-2', + 'focus-visible:ring-base-500 rounded-lg px-5 py-2.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none', { variants: { variant: { - outline: 'text-base-700 ring-1 ring-base-300 hover:bg-base-200 hover:ring-base-400', + outline: 'text-base-700 ring-base-300 hover:bg-base-200 hover:ring-base-400 ring-1', primary: 'bg-base-950 text-base-0 hover:bg-base-800' } }, diff --git a/apps/web/src/components/display/StatusBadge.tsx b/apps/web/src/components/display/StatusBadge.tsx index 099aa5e1..cfbfa0fc 100644 --- a/apps/web/src/components/display/StatusBadge.tsx +++ b/apps/web/src/components/display/StatusBadge.tsx @@ -32,7 +32,7 @@ export interface TStatusBadgeProps extends VariantProps<typeof statusBadgeVarian } export const statusBadgeVariants = cva( - 'rounded-full px-2 py-1 text-[11px] font-medium leading-none', + 'rounded-full px-2 py-1 text-[11px] leading-none font-medium', { variants: { status: { diff --git a/apps/web/src/components/display/TagList.tsx b/apps/web/src/components/display/TagList.tsx index aaecc35d..ec13e59c 100644 --- a/apps/web/src/components/display/TagList.tsx +++ b/apps/web/src/components/display/TagList.tsx @@ -65,7 +65,7 @@ export interface TTagLinkProps extends VariantProps<typeof tagLinkVariants> { } export const tagLinkVariants = cva( - 'rounded px-2 py-0.5 text-xs transition-colors hover:text-base-800 ring-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-500 focus-visible:ring-offset-1', + 'hover:text-base-800 focus-visible:ring-base-500 rounded px-2 py-0.5 text-xs ring-1 transition-colors focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:outline-none', { variants: { variant: { diff --git a/apps/web/src/environment/configs/projects.config.ts b/apps/web/src/environment/configs/projects.config.ts index a17e5772..ad923f28 100644 --- a/apps/web/src/environment/configs/projects.config.ts +++ b/apps/web/src/environment/configs/projects.config.ts @@ -8,7 +8,8 @@ export const projectsConfig: { { id: 'abstand', name: 'Abstand', - description: 'Desktop app for building intentional distance from distractions', + description: + 'macOS app to schedule focus sessions with automatic app blocking and forced breaks', logo: { type: 'image', src: '/illustrations/logos/abstand.png' }, startedAt: { year: 2026, month: 4 }, status: 'in-progress', @@ -22,7 +23,7 @@ export const projectsConfig: { { id: 'kairos', name: 'Kairos', - description: 'iOS timer app that picks a random finish within a set range', + description: 'iOS timer app that picks a random end time within a set range', logo: { type: 'image', src: '/illustrations/logos/kairos.png' }, startedAt: { year: 2026, month: 3 }, status: 'maintenance', @@ -59,7 +60,7 @@ export const projectsConfig: { { id: 'derive', name: 'Derive', - description: 'iPhone app: pick a color, find 9 things, fill a 3×3 photo grid', + description: 'iPhone app to pick a color, find 9 matching things, and fill a 3×3 photo grid', logo: { type: 'image', src: '/illustrations/logos/derive.png' }, startedAt: { year: 2026, month: 1 }, status: 'maintenance', @@ -92,7 +93,7 @@ export const projectsConfig: { { id: 'learnlinesfaster', name: 'LearnLinesFaster', - description: 'Line-learning tool using the first-letter method', + description: 'Web app for learning scripts using the first-letter mnemonic method', logo: { type: 'emoji', value: '✨' }, startedAt: { year: 2025, month: 10 }, status: 'maintenance', @@ -147,7 +148,8 @@ export const projectsConfig: { { id: 'gazegames', name: 'GazeGames', - description: 'Generates image sets where person gazes at a moving target', + description: + 'Web tool that generates a set of images where eyes and objects gaze at a moving target', logo: { type: 'emoji', value: '👀' }, startedAt: { year: 2025, month: 11 }, endedAt: { year: 2025, month: 11 }, @@ -192,7 +194,7 @@ export const projectsConfig: { { id: 'tasu', name: 'Tasu', - description: 'Video-first customer support tool', + description: 'Open-source, video-first customer support tool', logo: { type: 'image', src: '/illustrations/logos/tasu.png' }, startedAt: { year: 2025, month: 8 }, endedAt: { year: 2025, month: 10 }, @@ -203,7 +205,7 @@ export const projectsConfig: { { id: 'actorpal', name: 'ActorPal', - description: 'Tools for actor line learning, scene practice, and auditions', + description: 'Suite of tools for actor line learning, scene practice, and mock auditions', logo: { type: 'emoji', value: '✨' }, startedAt: { year: 2025, month: 10 }, endedAt: { year: 2025, month: 10 }, @@ -214,7 +216,7 @@ export const projectsConfig: { { id: 'chatsnap', name: 'ChatSnap', - description: 'Automated channel generating chat-conversation videos', + description: 'Automated YouTube channel posting AI-generated chat conversation videos', logo: { type: 'image', src: '/illustrations/logos/chatsnap.png' }, startedAt: { year: 2024, month: 9 }, endedAt: { year: 2024, month: 12 }, @@ -225,7 +227,7 @@ export const projectsConfig: { { id: 'midimarble', name: 'MidiMarble', - description: 'Automated channel generating marble music videos', + description: 'Automated YouTube channel posting AI-generated marble music videos', logo: { type: 'image', src: '/illustrations/logos/midimarble.png' }, startedAt: { year: 2024, month: 9 }, endedAt: { year: 2024, month: 12 }, @@ -236,7 +238,7 @@ export const projectsConfig: { { id: 'eu-blocks', name: 'EU Blocks', - description: 'Shopify app with GDPR and EU energy label compliant UI blocks', + description: 'Shopify app with pre-built GDPR and EU energy label compliant UI blocks', logo: { type: 'image', src: '/illustrations/logos/eu-blocks.png' }, startedAt: { year: 2024, month: 7 }, endedAt: { year: 2024, month: 9 }, @@ -270,7 +272,7 @@ export const projectsConfig: { { id: 'ecsify', name: 'ecsify', - description: 'Typesafe Entity Component System (ECS) library for TypeScript', + description: 'Typesafe ECS library for building entity-component apps in TypeScript', startedAt: { year: 2024 }, status: 'maintenance', category: 'package', @@ -279,10 +281,23 @@ export const projectsConfig: { { type: 'github', url: `${appConfig.social.githubPackages}/ecsify` } ] }, + { + id: 'feature-core', + name: 'feature-core', + description: + 'Typesafe .with() composition primitives for building extensible TypeScript libraries', + startedAt: { year: 2026 }, + status: 'maintenance', + category: 'package', + tags: [ + { type: 'npm', url: 'https://www.npmjs.com/package/feature-core' }, + { type: 'github', url: `${appConfig.social.githubPackages}/feature-core` } + ] + }, { id: 'feature-fetch', name: 'feature-fetch', - description: 'Typesafe fetch wrapper with OpenAPI type support', + description: 'Typesafe fetch wrapper with composable middleware and OpenAPI type support', startedAt: { year: 2024 }, status: 'maintenance', category: 'package', @@ -294,7 +309,8 @@ export const projectsConfig: { { id: 'feature-form', name: 'feature-form', - description: 'Straightforward, typesafe, and feature-based form library', + description: + 'Lightweight typesafe form library with reactive fields and Standard Schema validation', startedAt: { year: 2024 }, status: 'maintenance', category: 'package', @@ -306,7 +322,8 @@ export const projectsConfig: { { id: 'feature-logger', name: 'feature-logger', - description: 'Straightforward, typesafe, and feature-based logging library', + description: + 'Typesafe logger with composable prefixes, labels, timestamps, and output styles', startedAt: { year: 2024 }, status: 'maintenance', category: 'package', @@ -318,7 +335,7 @@ export const projectsConfig: { { id: 'feature-react', name: 'feature-react', - description: 'React hooks and components for feature-state and feature-form', + description: 'React hooks for reactive state and form subscriptions', startedAt: { year: 2024 }, status: 'maintenance', category: 'package', @@ -330,7 +347,7 @@ export const projectsConfig: { { id: 'feature-state', name: 'feature-state', - description: 'Typesafe, feature-based state management library for React', + description: 'Lightweight reactive state container with composable feature extensions', startedAt: { year: 2024 }, status: 'maintenance', category: 'package', @@ -342,7 +359,7 @@ export const projectsConfig: { { id: 'openapi-ts-router', name: 'openapi-ts-router', - description: 'Typesafe OpenAPI router wrapper for Express and Hono', + description: 'OpenAPI-typed router helpers for Express and Hono with runtime validation', startedAt: { year: 2024 }, status: 'maintenance', category: 'package', @@ -354,7 +371,7 @@ export const projectsConfig: { { id: 'xml-tokenizer', name: 'xml-tokenizer', - description: 'Typesafe XML tokenizer with callback-based token streaming', + description: 'Streaming XML tokenizer with callback-based parsing and object helpers', startedAt: { year: 2024 }, status: 'maintenance', category: 'package', @@ -366,7 +383,7 @@ export const projectsConfig: { { id: 'tuple-result', name: 'tuple-result', - description: 'Minimal, tree-shakable Result type library for TypeScript', + description: 'Minimal, tree-shakable Result type using plain arrays for easy serialization', startedAt: { year: 2024 }, status: 'maintenance', category: 'package', @@ -378,7 +395,7 @@ export const projectsConfig: { { id: 'validatenv', name: 'validatenv', - description: 'Typesafe env variable validation with Zod, Valibot, and Yup', + description: 'Environment variable validation using schemas such as Zod, Valibot, and Yup', startedAt: { year: 2024 }, status: 'maintenance', category: 'package', @@ -390,9 +407,9 @@ export const projectsConfig: { { id: 'validation-adapter', name: 'validation-adapter', - description: 'Universal adapter interface for Zod, Valibot, and Yup', + description: 'Universal validation abstraction for validators like Zod, Valibot, and Yup', startedAt: { year: 2024 }, - status: 'maintenance', + status: 'discontinued', category: 'package', tags: [ { type: 'npm', url: 'https://www.npmjs.com/package/validation-adapter' }, @@ -402,9 +419,10 @@ export const projectsConfig: { { id: 'validation-adapters', name: 'validation-adapters', - description: 'Pre-built adapters for validation-adapter: Zod and Valibot', + description: + 'Ready-made validation-adapter implementations for Zod, Valibot, Yup, and Standard Schema', startedAt: { year: 2024 }, - status: 'maintenance', + status: 'discontinued', category: 'package', tags: [ { type: 'npm', url: 'https://www.npmjs.com/package/validation-adapters' }, @@ -414,7 +432,7 @@ export const projectsConfig: { { id: 'head-metadata', name: 'head-metadata', - description: 'Typesafe utility to extract metadata from an HTML <head>', + description: 'HTML head metadata extractor for title, meta, and link tags', startedAt: { year: 2024 }, status: 'maintenance', category: 'package', @@ -426,7 +444,7 @@ export const projectsConfig: { { id: 'mado', name: 'mado', - description: 'A macOS-focused Rust library for active window monitoring and app metadata', + description: 'Rust library for macOS active window monitoring and app metadata', startedAt: { year: 2026, month: 1 }, status: 'maintenance', category: 'package', @@ -441,7 +459,7 @@ export const projectsConfig: { { id: 'utils', name: '@blgc/utils', - description: 'Typesafe, tree-shakable collection of utility functions', + description: 'Tree-shakable TypeScript utilities for colors, IDs, objects, URLs, and math', startedAt: { year: 2024 }, status: 'maintenance', category: 'package', @@ -453,7 +471,7 @@ export const projectsConfig: { { id: 'types', name: '@blgc/types', - description: 'Shared TypeScript types across builder.group packages', + description: 'Shared utility, API, and OpenAPI TypeScript types for builder.group packages', startedAt: { year: 2024 }, status: 'maintenance', category: 'package', @@ -465,9 +483,9 @@ export const projectsConfig: { { id: 'eprel-client', name: 'eprel-client', - description: 'Typesafe fetch client for the EU EPREL energy label API', + description: 'Typesafe API client for the EU EPREL energy label registry', startedAt: { year: 2024 }, - status: 'maintenance', + status: 'discontinued', category: 'package', tags: [ { type: 'npm', url: 'https://www.npmjs.com/package/eprel-client' }, @@ -477,7 +495,7 @@ export const projectsConfig: { { id: 'config', name: '@blgc/config', - description: 'Collection of ESLint, Vite, and TypeScript configurations', + description: 'Shared ESLint, Prettier, Vite, and TypeScript configuration presets', startedAt: { year: 2024 }, status: 'maintenance', category: 'package', @@ -489,7 +507,7 @@ export const projectsConfig: { { id: 'rollup-presets', name: 'rollup-presets', - description: 'Opinionated, production-ready Rollup configuration presets', + description: 'Rollup presets and plugins for building TypeScript libraries', startedAt: { year: 2024 }, status: 'maintenance', category: 'package', @@ -502,14 +520,113 @@ export const projectsConfig: { id: 'split-flap-board', name: 'split-flap-board', description: - 'Web component that simulates a split-flap display inspired by airport and train station boards', + 'Web Components for animated split-flap boards with configurable spools and grids', startedAt: { year: 2026 }, - status: 'maintenance', + status: 'discontinued', category: 'package', tags: [ { type: 'npm', url: 'https://www.npmjs.com/package/split-flap-board' }, { type: 'github', url: `${appConfig.social.githubPackages}/split-flap-board` } ] + }, + { + id: 'figma-connect', + name: 'figma-connect', + description: 'Typed message bridge between Figma plugin UI iframes and sandbox code', + startedAt: { year: 2024 }, + status: 'discontinued', + category: 'package', + tags: [ + { type: 'npm', url: 'https://www.npmjs.com/package/figma-connect' }, + { type: 'github', url: `${appConfig.social.githubPackages}/_deprecated/figma-connect` } + ] + }, + { + id: 'google-webfonts-client', + name: 'google-webfonts-client', + description: 'Typesafe API client for Google Web Fonts metadata and font downloads', + startedAt: { year: 2024 }, + status: 'discontinued', + category: 'package', + tags: [ + { type: 'npm', url: 'https://www.npmjs.com/package/google-webfonts-client' }, + { + type: 'github', + url: `${appConfig.social.githubPackages}/_deprecated/google-webfonts-client` + } + ] + }, + { + id: 'elevenlabs-client', + name: 'elevenlabs-client', + description: 'Typesafe API client for the ElevenLabs text-to-speech API', + startedAt: { year: 2024 }, + status: 'discontinued', + category: 'package', + tags: [ + { type: 'npm', url: 'https://www.npmjs.com/package/elevenlabs-client' }, + { type: 'github', url: `${appConfig.social.githubPackages}/_deprecated/elevenlabs-client` } + ] + }, + { + id: 'kleinanzeigen-client', + name: 'kleinanzeigen-client', + description: 'Typesafe API client for scraping and extracting Kleinanzeigen listings', + startedAt: { year: 2025 }, + status: 'discontinued', + category: 'package', + tags: [ + { type: 'npm', url: 'https://www.npmjs.com/package/kleinanzeigen-client' }, + { + type: 'github', + url: `${appConfig.social.githubPackages}/_deprecated/kleinanzeigen-client` + } + ] + }, + { + id: 'cli', + name: '@blgc/cli', + description: 'Rollup and Esbuild-powered CLI for bundling TypeScript libraries', + startedAt: { year: 2024 }, + status: 'discontinued', + category: 'package', + tags: [ + { type: 'npm', url: 'https://www.npmjs.com/package/@blgc/cli' }, + { type: 'github', url: `${appConfig.social.githubPackages}/_deprecated/cli` } + ] + }, + { + id: 'widget-grid', + name: 'widget-grid', + description: 'Framework-agnostic widget grid model with cell-based layouts and widget data', + startedAt: { year: 2024 }, + status: 'discontinued', + category: 'package', + tags: [{ type: 'npm', url: 'https://www.npmjs.com/package/widget-grid' }] + }, + { + id: 'openapi-express', + name: 'openapi-express', + description: 'OpenAPI-typed Express router wrapper with Zod request validation', + startedAt: { year: 2024 }, + status: 'discontinued', + category: 'package', + tags: [ + { type: 'npm', url: 'https://www.npmjs.com/package/openapi-express' }, + { type: 'github', url: `${appConfig.social.githubPackages}/_deprecated/openapi-express` } + ] + }, + { + id: 'webito', + name: 'webito', + description: 'ECS-powered web editor experiment for Tailwind-style component editing', + startedAt: { year: 2024 }, + status: 'discontinued', + category: 'package', + tags: [ + { type: 'npm', url: 'https://www.npmjs.com/package/webito' }, + { type: 'github', url: `${appConfig.social.githubPackages}/_deprecated/webito` } + ] } ] }; diff --git a/apps/web/src/lib/npm-downloads.ts b/apps/web/src/lib/npm-downloads.ts index b44ee9a0..05d4bfbc 100644 --- a/apps/web/src/lib/npm-downloads.ts +++ b/apps/web/src/lib/npm-downloads.ts @@ -19,12 +19,12 @@ export async function getNpmTotalDownloads( const results = await Promise.allSettled( packageNames.map(async (name) => { - const [ok, , value] = await fetchClient.get<TNpmDownloadResponse>( + const [ok, , data] = await fetchClient.get<TNpmDownloadResponse>( 'https://api.npmjs.org/downloads/point/{range}/{name}', { pathParams: { range, name } } ); if (ok) { - return { name, data: value.data }; + return { name, data }; } return { name, data: null }; }) diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index ceeec938..8c2f5c13 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -57,6 +57,8 @@ function RouteComponent() { const packages = projectsConfig.projects .filter((p) => p.category === 'package') .sort((a, b) => (npmDownloads[b.name] ?? 0) - (npmDownloads[a.name] ?? 0)); + const activePackages = packages.filter((p) => p.status !== 'discontinued'); + const discontinuedPackages = packages.filter((p) => p.status === 'discontinued'); return ( <CanvasBackground className="min-h-screen"> @@ -137,9 +139,21 @@ function RouteComponent() { </a> </p> </div> - {packages.map((pkg) => ( + {activePackages.map((pkg) => ( <PackageRow key={pkg.id} project={pkg} downloads={npmDownloads[pkg.name]} /> ))} + {discontinuedPackages.length > 0 && ( + <> + <div className="bg-base-100 px-5 py-3"> + <p className="text-base-500 text-sm"> + Discontinued libraries, kept available for existing users + </p> + </div> + {discontinuedPackages.map((pkg) => ( + <PackageRow key={pkg.id} project={pkg} downloads={npmDownloads[pkg.name]} /> + ))} + </> + )} </div> </CanvasFrame> diff --git a/crates/mado/README.md b/crates/mado/README.md index 9578db85..06ea3746 100644 --- a/crates/mado/README.md +++ b/crates/mado/README.md @@ -117,13 +117,13 @@ monitor.run()?; **Config options:** -| Option | Default | Description | -| ---------------------- | ------- | -------------------------------------------------------------------- | -| `include_app_icon` | `false` | Extract app icon as base64 PNG | +| Option | Default | Description | +| ---------------------- | ------- | --------------------------------------------------------------------- | +| `include_app_icon` | `false` | Extract app icon as base64 PNG | | `include_app_color` | `false` | Also derive the dominant app color when `include_app_icon` is enabled | -| `include_browser_info` | `false` | Extract browser URL and private mode | -| `include_website_info` | `false` | Extract domain, fetch favicon, and extract color (~50-500ms, cached) | -| `track_window_changes` | `true` | Track window focus/title changes (requires Accessibility permission) | +| `include_browser_info` | `false` | Extract browser URL and private mode | +| `include_website_info` | `false` | Extract domain, fetch favicon, and extract color (~50-500ms, cached) | +| `track_window_changes` | `true` | Track window focus/title changes (requires Accessibility permission) | ### Stop monitoring @@ -175,11 +175,11 @@ if let Some(color) = mado::get_app_color("com.apple.finder") { **Config options:** -| Option | Default | Description | -| -------------- | ------- | ---------------------------------------------- | -| `include_icon` | `false` | Extract icons as base64 PNG | +| Option | Default | Description | +| ------------------- | ------- | ----------------------------------------------------------------- | +| `include_icon` | `false` | Extract icons as base64 PNG | | `include_app_color` | `false` | Also derive the dominant app color when `include_icon` is enabled | -| `icon_size` | `32` | Icon size in pixels | +| `icon_size` | `32` | Icon size in pixels | ## 📐 Architecture diff --git a/examples/feature-fetch/vanilla/open-meteo/.gitignore b/examples/feature-fetch/vanilla/basic/.gitignore similarity index 100% rename from examples/feature-fetch/vanilla/open-meteo/.gitignore rename to examples/feature-fetch/vanilla/basic/.gitignore diff --git a/examples/feature-fetch/vanilla/basic/README.md b/examples/feature-fetch/vanilla/basic/README.md new file mode 100644 index 00000000..f4efbeeb --- /dev/null +++ b/examples/feature-fetch/vanilla/basic/README.md @@ -0,0 +1,32 @@ +# feature-fetch Vanilla Basic + +Vanilla TypeScript example for `feature-fetch`. It shows REST helpers, OpenAPI-generated types, and GraphQL documents using the same tuple-result client model. + +- REST calls with `createApiFetchClient` +- OpenAPI-typed calls with `createOpenApiFetchClient` +- GraphQL calls with `createGraphQLFetchClient` +- tuple-result success and error branches +- generated OpenAPI and GraphQL types +- real requests logged to the browser console + +## Run + +```sh +pnpm dev +``` + +## Refresh Generated Types + +Generated OpenAPI and GraphQL types are committed. Refresh them when upstream schemas change: + +```sh +pnpm openapi:generate +pnpm graphql:generate +``` + +Use `pnpm graphql:schema` first to refresh the committed Countries GraphQL schema. + +## API Sources + +- Open-Meteo OpenAPI schema: [openapi.yml](https://github.com/open-meteo/open-meteo/blob/main/openapi.yml) +- Countries GraphQL API: [countries.trevorblades.com/graphql](https://countries.trevorblades.com/graphql) diff --git a/examples/feature-fetch/vanilla/basic/index.html b/examples/feature-fetch/vanilla/basic/index.html new file mode 100644 index 00000000..ce07746f --- /dev/null +++ b/examples/feature-fetch/vanilla/basic/index.html @@ -0,0 +1,16 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>feature-fetch basic + + +
+

feature-fetch Vanilla basic

+

Open the browser console to see the REST, OpenAPI, and GraphQL requests.

+
+ + + diff --git a/examples/feature-fetch/vanilla/basic/package.json b/examples/feature-fetch/vanilla/basic/package.json new file mode 100644 index 00000000..ac770605 --- /dev/null +++ b/examples/feature-fetch/vanilla/basic/package.json @@ -0,0 +1,23 @@ +{ + "name": "feature-fetch-vanilla-basic", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc && vite build", + "dev": "vite", + "graphql:generate": "gql.tada generate-output -c ./tsconfig.json -o ./src/graphql/gen/countries-env.d.ts", + "graphql:schema": "gql.tada generate-schema https://countries.trevorblades.com/graphql -o ./resources/graphql/countries.graphql", + "openapi:generate": "openapi-typescript ./resources/openapi/open-meteo-v1.yaml -o ./src/openapi/gen/open-meteo-v1.ts", + "preview": "vite preview" + }, + "dependencies": { + "feature-fetch": "workspace:*", + "gql.tada": "^1.9.2" + }, + "devDependencies": { + "openapi-typescript": "^7.10.1", + "typescript": "~6.0.2", + "vite": "^8.0.12" + } +} diff --git a/examples/feature-fetch/vanilla/basic/public/favicon.svg b/examples/feature-fetch/vanilla/basic/public/favicon.svg new file mode 100644 index 00000000..6893eb13 --- /dev/null +++ b/examples/feature-fetch/vanilla/basic/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/feature-fetch/vanilla/basic/resources/graphql/countries.graphql b/examples/feature-fetch/vanilla/basic/resources/graphql/countries.graphql new file mode 100644 index 00000000..6f64a3fb --- /dev/null +++ b/examples/feature-fetch/vanilla/basic/resources/graphql/countries.graphql @@ -0,0 +1,75 @@ +type Continent { + code: ID! + countries: [Country!]! + name: String! +} + +input ContinentFilterInput { + code: StringQueryOperatorInput +} + +type Country { + awsRegion: String! + capital: String + code: ID! + continent: Continent! + currencies: [String!]! + currency: String + emoji: String! + emojiU: String! + languages: [Language!]! + name(lang: String): String! + native: String! + phone: String! + phones: [String!]! + states: [State!]! + subdivisions: [Subdivision!]! +} + +input CountryFilterInput { + code: StringQueryOperatorInput + continent: StringQueryOperatorInput + currency: StringQueryOperatorInput + name: StringQueryOperatorInput +} + +type Language { + code: ID! + countries: [Country!]! + name: String! + native: String! + rtl: Boolean! +} + +input LanguageFilterInput { + code: StringQueryOperatorInput +} + +type Query { + continent(code: ID!): Continent + continents(filter: ContinentFilterInput = {}): [Continent!]! + countries(filter: CountryFilterInput = {}): [Country!]! + country(code: ID!): Country + language(code: ID!): Language + languages(filter: LanguageFilterInput = {}): [Language!]! +} + +type State { + code: String + country: Country! + name: String! +} + +input StringQueryOperatorInput { + eq: String + in: [String!] + ne: String + nin: [String!] + regex: String +} + +type Subdivision { + code: ID! + emoji: String + name: String! +} \ No newline at end of file diff --git a/examples/feature-fetch/vanilla/open-meteo/resources/openapi-v1.yaml b/examples/feature-fetch/vanilla/basic/resources/openapi/open-meteo-v1.yaml similarity index 82% rename from examples/feature-fetch/vanilla/open-meteo/resources/openapi-v1.yaml rename to examples/feature-fetch/vanilla/basic/resources/openapi/open-meteo-v1.yaml index 95f14be4..cb826bd5 100644 --- a/examples/feature-fetch/vanilla/open-meteo/resources/openapi-v1.yaml +++ b/examples/feature-fetch/vanilla/basic/resources/openapi/open-meteo-v1.yaml @@ -95,14 +95,14 @@ paths: description: "WGS84 coordinate" schema: type: number - format: float + format: double - name: longitude in: query required: true description: "WGS84 coordinate" schema: type: number - format: float + format: double - name: current_weather in: query schema: @@ -148,7 +148,7 @@ paths: - 1 - 2 responses: - 200: + "200": description: OK content: application/json: @@ -170,31 +170,28 @@ paths: generationtime_ms: type: number example: 2.2119 - description: Generation time of the weather forecast in milli seconds. This is mainly used for performance monitoring and improvements. + description: Generation time of the weather forecast in milliseconds. This is mainly used for performance monitoring and improvements. utc_offset_seconds: type: integer example: 3600 description: Applied timezone offset from the &timezone= parameter. hourly: - type: HourlyResponse - description: For each selected weather variable, data will be returned as a floating point array. Additionally a `time` array will be returned with ISO8601 timestamps. + $ref: "#/components/schemas/HourlyResponse" hourly_units: type: object additionalProperties: type: string description: For each selected weather variable, the unit will be listed here. daily: - type: DailyResponse - description: For each selected daily weather variable, data will be returned as a floating point array. Additionally a `time` array will be returned with ISO8601 timestamps. + $ref: "#/components/schemas/DailyResponse" daily_units: type: object additionalProperties: type: string description: For each selected daily weather variable, the unit will be listed here. current_weather: - type: CurrentWeather - description: "Current weather conditions with the attributes: time, temperature, wind_speed, wind_direction and weather_code" - 400: + $ref: "#/components/schemas/CurrentWeather" + "400": description: Bad Request content: application/json: @@ -212,6 +209,7 @@ components: schemas: HourlyResponse: type: object + description: For each selected weather variable, data will be returned as a floating point array. Additionally a `time` array will be returned with ISO8601 timestamps. required: - time properties: @@ -222,153 +220,154 @@ components: temperature_2m: type: array items: - type: float + type: number relative_humidity_2m: type: array items: - type: float + type: number dew_point_2m: type: array items: - type: float + type: number apparent_temperature: type: array items: - type: float + type: number pressure_msl: type: array items: - type: float + type: number cloud_cover: type: array items: - type: float + type: number cloud_cover_low: type: array items: - type: float + type: number cloud_cover_mid: type: array items: - type: float + type: number cloud_cover_high: type: array items: - type: float + type: number wind_speed_10m: type: array items: - type: float + type: number wind_speed_80m: type: array items: - type: float + type: number wind_speed_120m: type: array items: - type: float + type: number wind_speed_180m: type: array items: - type: float + type: number wind_direction_10m: type: array items: - type: float + type: number wind_direction_80m: type: array items: - type: float + type: number wind_direction_120m: type: array items: - type: float + type: number wind_direction_180m: type: array items: - type: float + type: number wind_gusts_10m: type: array items: - type: float + type: number shortwave_radiation: type: array items: - type: float + type: number direct_radiation: type: array items: - type: float + type: number direct_normal_irradiance: type: array items: - type: float + type: number diffuse_radiation: type: array items: - type: float + type: number vapour_pressure_deficit: type: array items: - type: float + type: number evapotranspiration: type: array items: - type: float + type: number precipitation: type: array items: - type: float + type: number weather_code: type: array items: - type: float + type: number snow_height: type: array items: - type: float + type: number freezing_level_height: type: array items: - type: float + type: number soil_temperature_0cm: type: array items: - type: float + type: number soil_temperature_6cm: type: array items: - type: float + type: number soil_temperature_18cm: type: array items: - type: float + type: number soil_temperature_54cm: type: array items: - type: float + type: number soil_moisture_0_1cm: type: array items: - type: float + type: number soil_moisture_1_3cm: type: array items: - type: float + type: number soil_moisture_3_9cm: type: array items: - type: float + type: number soil_moisture_9_27cm: type: array items: - type: float + type: number soil_moisture_27_81cm: type: array items: - type: float + type: number DailyResponse: type: object + description: For each selected daily weather variable, data will be returned as a floating point array. Additionally a `time` array will be returned with ISO8601 timestamps. properties: time: type: array @@ -377,82 +376,83 @@ components: temperature_2m_max: type: array items: - type: float + type: number temperature_2m_min: type: array items: - type: float + type: number apparent_temperature_max: type: array items: - type: float + type: number apparent_temperature_min: type: array items: - type: float + type: number precipitation_sum: type: array items: - type: float + type: number precipitation_hours: type: array items: - type: float + type: number weather_code: type: array items: - type: float + type: number sunrise: type: array items: - type: float + type: number sunset: type: array items: - type: float + type: number wind_speed_10m_max: type: array items: - type: float + type: number wind_gusts_10m_max: type: array items: - type: float + type: number wind_direction_10m_dominant: type: array items: - type: float + type: number shortwave_radiation_sum: type: array items: - type: float + type: number uv_index_max: type: array items: - type: float + type: number uv_index_clear_sky_max: type: array items: - type: float + type: number et0_fao_evapotranspiration: type: array items: - type: float + type: number required: - time CurrentWeather: type: object + description: "Current weather conditions with the attributes: time, temperature, wind_speed, wind_direction and weather_code" properties: time: type: string temperature: - type: float - wind_speed: - type: float - wind_direction: - type: float + type: number + windspeed: + type: number + winddirection: + type: number weather_code: - type: int + type: integer required: - time - temperature diff --git a/examples/feature-fetch/vanilla/basic/src/api/index.ts b/examples/feature-fetch/vanilla/basic/src/api/index.ts new file mode 100644 index 00000000..a1d903b1 --- /dev/null +++ b/examples/feature-fetch/vanilla/basic/src/api/index.ts @@ -0,0 +1 @@ +export * from './open-meteo'; diff --git a/examples/feature-fetch/vanilla/basic/src/api/open-meteo.ts b/examples/feature-fetch/vanilla/basic/src/api/open-meteo.ts new file mode 100644 index 00000000..aa6aba9a --- /dev/null +++ b/examples/feature-fetch/vanilla/basic/src/api/open-meteo.ts @@ -0,0 +1,43 @@ +import { createApiFetchClient } from 'feature-fetch'; + +const apiFetchClient = createApiFetchClient({ + baseUrl: 'https://api.open-meteo.com' +}); + +export async function fetchWeatherWithApiFetchClient( + latitude: number, + longitude: number +): Promise { + const [isOk, error, forecast] = await apiFetchClient.get( + '/v1/forecast', + { + queryParams: { + latitude, + longitude, + current_weather: true + } + } + ); + + if (!isOk) { + console.error('[api] Error Result', { error }); + return; + } + + console.log('[api] Ok Result', { forecast }); +} + +export interface TForecastResponse { + current_weather?: { + time: string; + temperature: number; + windspeed: number; + winddirection: number; + weathercode: number; + }; +} + +export interface TForecastError { + error?: boolean; + reason?: string; +} diff --git a/examples/feature-fetch/vanilla/basic/src/graphql/countries.ts b/examples/feature-fetch/vanilla/basic/src/graphql/countries.ts new file mode 100644 index 00000000..5ed34b8f --- /dev/null +++ b/examples/feature-fetch/vanilla/basic/src/graphql/countries.ts @@ -0,0 +1,33 @@ +import { createGraphQLFetchClient } from 'feature-fetch'; +import { gql } from './gql'; + +const countriesFetchClient = createGraphQLFetchClient({ + baseUrl: 'https://countries.trevorblades.com/graphql' +}); + +const COUNTRY_QUERY = gql(` + query Country($code: ID!) { + country(code: $code) { + code + name + emoji + capital + currency + } + } +`); + +export async function fetchCountryWithGraphQLFetchClient(code: string): Promise { + const [isOk, error, countryData] = await countriesFetchClient.query(COUNTRY_QUERY, { + variables: { + code + } + }); + + if (!isOk) { + console.error('[graphql] Error Result', { error }); + return; + } + + console.log('[graphql] Ok Result', { country: countryData.country }); +} diff --git a/examples/feature-fetch/vanilla/basic/src/graphql/gen/countries-env.d.ts b/examples/feature-fetch/vanilla/basic/src/graphql/gen/countries-env.d.ts new file mode 100644 index 00000000..6d0a64b0 --- /dev/null +++ b/examples/feature-fetch/vanilla/basic/src/graphql/gen/countries-env.d.ts @@ -0,0 +1,42 @@ +/* eslint-disable */ +/* prettier-ignore */ + +export type introspection_types = { + 'Boolean': unknown; + 'Continent': { kind: 'OBJECT'; name: 'Continent'; fields: { 'code': { name: 'code'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; } }; 'countries': { name: 'countries'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'Country'; ofType: null; }; }; }; } }; 'name': { name: 'name'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; }; }; + 'ContinentFilterInput': { kind: 'INPUT_OBJECT'; name: 'ContinentFilterInput'; isOneOf: false; inputFields: [{ name: 'code'; type: { kind: 'INPUT_OBJECT'; name: 'StringQueryOperatorInput'; ofType: null; }; defaultValue: null }]; }; + 'Country': { kind: 'OBJECT'; name: 'Country'; fields: { 'awsRegion': { name: 'awsRegion'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'capital': { name: 'capital'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'code': { name: 'code'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; } }; 'continent': { name: 'continent'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'Continent'; ofType: null; }; } }; 'currencies': { name: 'currencies'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; }; } }; 'currency': { name: 'currency'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'emoji': { name: 'emoji'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'emojiU': { name: 'emojiU'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'languages': { name: 'languages'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'Language'; ofType: null; }; }; }; } }; 'name': { name: 'name'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'native': { name: 'native'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'phone': { name: 'phone'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'phones': { name: 'phones'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; }; } }; 'states': { name: 'states'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'State'; ofType: null; }; }; }; } }; 'subdivisions': { name: 'subdivisions'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'Subdivision'; ofType: null; }; }; }; } }; }; }; + 'CountryFilterInput': { kind: 'INPUT_OBJECT'; name: 'CountryFilterInput'; isOneOf: false; inputFields: [{ name: 'code'; type: { kind: 'INPUT_OBJECT'; name: 'StringQueryOperatorInput'; ofType: null; }; defaultValue: null }, { name: 'continent'; type: { kind: 'INPUT_OBJECT'; name: 'StringQueryOperatorInput'; ofType: null; }; defaultValue: null }, { name: 'currency'; type: { kind: 'INPUT_OBJECT'; name: 'StringQueryOperatorInput'; ofType: null; }; defaultValue: null }, { name: 'name'; type: { kind: 'INPUT_OBJECT'; name: 'StringQueryOperatorInput'; ofType: null; }; defaultValue: null }]; }; + 'ID': unknown; + 'Language': { kind: 'OBJECT'; name: 'Language'; fields: { 'code': { name: 'code'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; } }; 'countries': { name: 'countries'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'Country'; ofType: null; }; }; }; } }; 'name': { name: 'name'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'native': { name: 'native'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'rtl': { name: 'rtl'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; }; } }; }; }; + 'LanguageFilterInput': { kind: 'INPUT_OBJECT'; name: 'LanguageFilterInput'; isOneOf: false; inputFields: [{ name: 'code'; type: { kind: 'INPUT_OBJECT'; name: 'StringQueryOperatorInput'; ofType: null; }; defaultValue: null }]; }; + 'Query': { kind: 'OBJECT'; name: 'Query'; fields: { 'continent': { name: 'continent'; type: { kind: 'OBJECT'; name: 'Continent'; ofType: null; } }; 'continents': { name: 'continents'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'Continent'; ofType: null; }; }; }; } }; 'countries': { name: 'countries'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'Country'; ofType: null; }; }; }; } }; 'country': { name: 'country'; type: { kind: 'OBJECT'; name: 'Country'; ofType: null; } }; 'language': { name: 'language'; type: { kind: 'OBJECT'; name: 'Language'; ofType: null; } }; 'languages': { name: 'languages'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'Language'; ofType: null; }; }; }; } }; }; }; + 'State': { kind: 'OBJECT'; name: 'State'; fields: { 'code': { name: 'code'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'country': { name: 'country'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'Country'; ofType: null; }; } }; 'name': { name: 'name'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; }; }; + 'String': unknown; + 'StringQueryOperatorInput': { kind: 'INPUT_OBJECT'; name: 'StringQueryOperatorInput'; isOneOf: false; inputFields: [{ name: 'eq'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; }; defaultValue: null }, { name: 'in'; type: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; }; defaultValue: null }, { name: 'ne'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; }; defaultValue: null }, { name: 'nin'; type: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; }; defaultValue: null }, { name: 'regex'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; }; defaultValue: null }]; }; + 'Subdivision': { kind: 'OBJECT'; name: 'Subdivision'; fields: { 'code': { name: 'code'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; } }; 'emoji': { name: 'emoji'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'name': { name: 'name'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; }; }; +}; + +/** An IntrospectionQuery representation of your schema. + * + * @remarks + * This is an introspection of your schema saved as a file by GraphQLSP. + * It will automatically be used by `gql.tada` to infer the types of your GraphQL documents. + * If you need to reuse this data or update your `scalars`, update `tadaOutputLocation` to + * instead save to a .ts instead of a .d.ts file. + */ +export type introspection = { + name: never; + query: 'Query'; + mutation: never; + subscription: never; + types: introspection_types; +}; + +import * as gqlTada from 'gql.tada'; + +declare module 'gql.tada' { + interface setupSchema { + introspection: introspection + } +} \ No newline at end of file diff --git a/examples/feature-fetch/vanilla/basic/src/graphql/gql.ts b/examples/feature-fetch/vanilla/basic/src/graphql/gql.ts new file mode 100644 index 00000000..213e66d7 --- /dev/null +++ b/examples/feature-fetch/vanilla/basic/src/graphql/gql.ts @@ -0,0 +1,9 @@ +import { initGraphQLTada } from 'gql.tada'; +import type { introspection } from './gen/countries-env'; + +export const gql = initGraphQLTada<{ + introspection: introspection; + scalars: { + ID: string; + }; +}>(); diff --git a/examples/feature-fetch/vanilla/basic/src/graphql/index.ts b/examples/feature-fetch/vanilla/basic/src/graphql/index.ts new file mode 100644 index 00000000..8ab54348 --- /dev/null +++ b/examples/feature-fetch/vanilla/basic/src/graphql/index.ts @@ -0,0 +1 @@ +export * from './countries'; diff --git a/examples/feature-fetch/vanilla/open-meteo/src/main.ts b/examples/feature-fetch/vanilla/basic/src/main.ts similarity index 63% rename from examples/feature-fetch/vanilla/open-meteo/src/main.ts rename to examples/feature-fetch/vanilla/basic/src/main.ts index df6b7e3c..4348e64f 100644 --- a/examples/feature-fetch/vanilla/open-meteo/src/main.ts +++ b/examples/feature-fetch/vanilla/basic/src/main.ts @@ -1,5 +1,8 @@ import { fetchWeatherWithApiFetchClient } from './api'; +import { fetchCountryWithGraphQLFetchClient } from './graphql'; import { fetchWeatherWithOpenApiFetchClient } from './openapi'; +import './style.css'; await fetchWeatherWithApiFetchClient(52.52, 13.41); await fetchWeatherWithOpenApiFetchClient(52.52, 13.41); +await fetchCountryWithGraphQLFetchClient('DE'); diff --git a/examples/feature-fetch/vanilla/basic/src/openapi/gen/open-meteo-v1.ts b/examples/feature-fetch/vanilla/basic/src/openapi/gen/open-meteo-v1.ts new file mode 100644 index 00000000..0fc825d5 --- /dev/null +++ b/examples/feature-fetch/vanilla/basic/src/openapi/gen/open-meteo-v1.ts @@ -0,0 +1,197 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/v1/forecast": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 7 day weather forecast for coordinates + * @description 7 day weather variables in hourly and daily resolution for given WGS84 latitude and longitude coordinates. Available worldwide. + */ + get: { + parameters: { + query: { + hourly?: ("temperature_2m" | "relative_humidity_2m" | "dew_point_2m" | "apparent_temperature" | "pressure_msl" | "cloud_cover" | "cloud_cover_low" | "cloud_cover_mid" | "cloud_cover_high" | "wind_speed_10m" | "wind_speed_80m" | "wind_speed_120m" | "wind_speed_180m" | "wind_direction_10m" | "wind_direction_80m" | "wind_direction_120m" | "wind_direction_180m" | "wind_gusts_10m" | "shortwave_radiation" | "direct_radiation" | "direct_normal_irradiance" | "diffuse_radiation" | "vapour_pressure_deficit" | "evapotranspiration" | "precipitation" | "weather_code" | "snow_height" | "freezing_level_height" | "soil_temperature_0cm" | "soil_temperature_6cm" | "soil_temperature_18cm" | "soil_temperature_54cm" | "soil_moisture_0_1cm" | "soil_moisture_1_3cm" | "soil_moisture_3_9cm" | "soil_moisture_9_27cm" | "soil_moisture_27_81cm")[]; + daily?: ("temperature_2m_max" | "temperature_2m_min" | "apparent_temperature_max" | "apparent_temperature_min" | "precipitation_sum" | "precipitation_hours" | "weather_code" | "sunrise" | "sunset" | "wind_speed_10m_max" | "wind_gusts_10m_max" | "wind_direction_10m_dominant" | "shortwave_radiation_sum" | "uv_index_max" | "uv_index_clear_sky_max" | "et0_fao_evapotranspiration")[]; + /** @description WGS84 coordinate */ + latitude: number; + /** @description WGS84 coordinate */ + longitude: number; + current_weather?: boolean; + temperature_unit?: "celsius" | "fahrenheit"; + wind_speed_unit?: "kmh" | "ms" | "mph" | "kn"; + /** @description If format `unixtime` is selected, all time values are returned in UNIX epoch time in seconds. Please not that all time is then in GMT+0! For daily values with unix timestamp, please apply `utc_offset_seconds` again to get the correct date. */ + timeformat?: "iso8601" | "unixtime"; + /** @description If `timezone` is set, all timestamps are returned as local-time and data is returned starting at 0:00 local-time. Any time zone name from the [time zone database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) is supported. */ + timezone?: string; + /** @description If `past_days` is set, yesterdays or the day before yesterdays data are also returned. */ + past_days?: 1 | 2; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description WGS84 of the center of the weather grid-cell which was used to generate this forecast. This coordinate might be up to 5 km away. + * @example 52.52 + */ + latitude?: number; + /** + * @description WGS84 of the center of the weather grid-cell which was used to generate this forecast. This coordinate might be up to 5 km away. + * @example 13.419.52 + */ + longitude?: number; + /** + * @description The elevation in meters of the selected weather grid-cell. In mountain terrain it might differ from the location you would expect. + * @example 44.812 + */ + elevation?: number; + /** + * @description Generation time of the weather forecast in milliseconds. This is mainly used for performance monitoring and improvements. + * @example 2.2119 + */ + generationtime_ms?: number; + /** + * @description Applied timezone offset from the &timezone= parameter. + * @example 3600 + */ + utc_offset_seconds?: number; + hourly?: components["schemas"]["HourlyResponse"]; + /** @description For each selected weather variable, the unit will be listed here. */ + hourly_units?: { + [key: string]: string; + }; + daily?: components["schemas"]["DailyResponse"]; + /** @description For each selected daily weather variable, the unit will be listed here. */ + daily_units?: { + [key: string]: string; + }; + current_weather?: components["schemas"]["CurrentWeather"]; + }; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Always set true for errors */ + error?: boolean; + /** + * @description Description of the error + * @example Latitude must be in range of -90 to 90°. Given: 300 + */ + reason?: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** @description For each selected weather variable, data will be returned as a floating point array. Additionally a `time` array will be returned with ISO8601 timestamps. */ + HourlyResponse: { + time: string[]; + temperature_2m?: number[]; + relative_humidity_2m?: number[]; + dew_point_2m?: number[]; + apparent_temperature?: number[]; + pressure_msl?: number[]; + cloud_cover?: number[]; + cloud_cover_low?: number[]; + cloud_cover_mid?: number[]; + cloud_cover_high?: number[]; + wind_speed_10m?: number[]; + wind_speed_80m?: number[]; + wind_speed_120m?: number[]; + wind_speed_180m?: number[]; + wind_direction_10m?: number[]; + wind_direction_80m?: number[]; + wind_direction_120m?: number[]; + wind_direction_180m?: number[]; + wind_gusts_10m?: number[]; + shortwave_radiation?: number[]; + direct_radiation?: number[]; + direct_normal_irradiance?: number[]; + diffuse_radiation?: number[]; + vapour_pressure_deficit?: number[]; + evapotranspiration?: number[]; + precipitation?: number[]; + weather_code?: number[]; + snow_height?: number[]; + freezing_level_height?: number[]; + soil_temperature_0cm?: number[]; + soil_temperature_6cm?: number[]; + soil_temperature_18cm?: number[]; + soil_temperature_54cm?: number[]; + soil_moisture_0_1cm?: number[]; + soil_moisture_1_3cm?: number[]; + soil_moisture_3_9cm?: number[]; + soil_moisture_9_27cm?: number[]; + soil_moisture_27_81cm?: number[]; + }; + /** @description For each selected daily weather variable, data will be returned as a floating point array. Additionally a `time` array will be returned with ISO8601 timestamps. */ + DailyResponse: { + time: string[]; + temperature_2m_max?: number[]; + temperature_2m_min?: number[]; + apparent_temperature_max?: number[]; + apparent_temperature_min?: number[]; + precipitation_sum?: number[]; + precipitation_hours?: number[]; + weather_code?: number[]; + sunrise?: number[]; + sunset?: number[]; + wind_speed_10m_max?: number[]; + wind_gusts_10m_max?: number[]; + wind_direction_10m_dominant?: number[]; + shortwave_radiation_sum?: number[]; + uv_index_max?: number[]; + uv_index_clear_sky_max?: number[]; + et0_fao_evapotranspiration?: number[]; + }; + /** @description Current weather conditions with the attributes: time, temperature, wind_speed, wind_direction and weather_code */ + CurrentWeather: { + time: string; + temperature: number; + windspeed?: number; + winddirection?: number; + weather_code: number; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export type operations = Record; diff --git a/examples/feature-fetch/vanilla/basic/src/openapi/index.ts b/examples/feature-fetch/vanilla/basic/src/openapi/index.ts new file mode 100644 index 00000000..a1d903b1 --- /dev/null +++ b/examples/feature-fetch/vanilla/basic/src/openapi/index.ts @@ -0,0 +1 @@ +export * from './open-meteo'; diff --git a/examples/feature-fetch/vanilla/basic/src/openapi/open-meteo.ts b/examples/feature-fetch/vanilla/basic/src/openapi/open-meteo.ts new file mode 100644 index 00000000..f3e1b55d --- /dev/null +++ b/examples/feature-fetch/vanilla/basic/src/openapi/open-meteo.ts @@ -0,0 +1,26 @@ +import { createOpenApiFetchClient } from 'feature-fetch'; +import type { paths } from './gen/open-meteo-v1'; + +const openApiFetchClient = createOpenApiFetchClient({ + baseUrl: 'https://api.open-meteo.com' +}); + +export async function fetchWeatherWithOpenApiFetchClient( + latitude: number, + longitude: number +): Promise { + const [isOk, error, forecast] = await openApiFetchClient.get('/v1/forecast', { + queryParams: { + latitude, + longitude, + current_weather: true + } + }); + + if (!isOk) { + console.error('[openapi] Error Result', { error }); + return; + } + + console.log('[openapi] Ok Result', { forecast }); +} diff --git a/examples/feature-fetch/vanilla/basic/src/style.css b/examples/feature-fetch/vanilla/basic/src/style.css new file mode 100644 index 00000000..031ce139 --- /dev/null +++ b/examples/feature-fetch/vanilla/basic/src/style.css @@ -0,0 +1,33 @@ +:root { + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #172026; + background: #f6f8fb; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + margin: 0; +} + +main { + box-sizing: border-box; + width: min(720px, 100%); + margin: 0 auto; + padding: 48px 24px; +} + +h1 { + margin: 0 0 12px; + font-size: 32px; + line-height: 1.1; + letter-spacing: 0; +} + +p { + margin: 0; + color: #52616b; +} diff --git a/examples/feature-fetch/vanilla/basic/tsconfig.json b/examples/feature-fetch/vanilla/basic/tsconfig.json new file mode 100644 index 00000000..f52b4fe7 --- /dev/null +++ b/examples/feature-fetch/vanilla/basic/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "es2023", + "module": "esnext", + "lib": ["ES2023", "DOM"], + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "plugins": [ + { + "name": "gql.tada/ts-plugin", + "schema": "./resources/graphql/countries.graphql", + "tadaOutputLocation": "./src/graphql/gen/countries-env.d.ts" + } + ] + }, + "include": ["src"] +} diff --git a/examples/feature-fetch/vanilla/open-meteo/ README.md b/examples/feature-fetch/vanilla/open-meteo/ README.md deleted file mode 100644 index 3fb68e23..00000000 --- a/examples/feature-fetch/vanilla/open-meteo/ README.md +++ /dev/null @@ -1,35 +0,0 @@ -# Vanilla Open Meteo Example - -This example demonstrates how to use the [Open Meteo API](https://open-meteo.com/) in a vanilla TypeScript setup, utilizing the Open Mateo OpenApi specification and using [feature-fetch](https://www.npmjs.com/package/feature-fetch). - -## Setup Instructions - -1. **Build the Monorepo** (only required once): - - At the root of the monorepo, run: - ```bash - pnpm build - ``` - -2. **Install Dependencies**: - - In this example directory, run: - ```bash - pnpm install - ``` - -3. **Start the Development Server**: - - In this example directory, run: - ```bash - pnpm dev - ``` - -## About - -This project was bootstrapped using Vite with the Vanilla TypeScript template: - -```bash -pnpm create vite example-name --template vanilla-ts -``` - -## Open Meteo API - -For detailed information about the Open Meteo API, refer to the [OpenAPI v1 YAML source](https://github.com/open-meteo/open-meteo/blob/main/openapi.yml). diff --git a/examples/feature-fetch/vanilla/open-meteo/index.html b/examples/feature-fetch/vanilla/open-meteo/index.html deleted file mode 100644 index 44a93350..00000000 --- a/examples/feature-fetch/vanilla/open-meteo/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - Vite + TS - - -
- - - diff --git a/examples/feature-fetch/vanilla/open-meteo/package.json b/examples/feature-fetch/vanilla/open-meteo/package.json deleted file mode 100644 index a1728ade..00000000 --- a/examples/feature-fetch/vanilla/open-meteo/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "feature-fetch-vanilla-open-meteo", - "version": "0.0.1", - "private": true, - "type": "module", - "scripts": { - "dev": "vite", - "openapi:generate": "npx openapi-typescript ./resources/openapi-v1.yaml -o ./src/gen/v1.ts" - }, - "dependencies": { - "feature-fetch": "workspace:*" - }, - "devDependencies": { - "openapi-typescript": "^7.10.1", - "typescript": "^5.9.3", - "vite": "^7.3.1" - } -} diff --git a/examples/feature-fetch/vanilla/open-meteo/public/vite.svg b/examples/feature-fetch/vanilla/open-meteo/public/vite.svg deleted file mode 100644 index e7b8dfb1..00000000 --- a/examples/feature-fetch/vanilla/open-meteo/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/examples/feature-fetch/vanilla/open-meteo/src/api.ts b/examples/feature-fetch/vanilla/open-meteo/src/api.ts deleted file mode 100644 index da6bba6d..00000000 --- a/examples/feature-fetch/vanilla/open-meteo/src/api.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { createApiFetchClient } from 'feature-fetch'; -import { paths } from './gen/v1'; - -const apiFetchClient = createApiFetchClient({ prefixUrl: 'https://api.open-meteo.com/' }); - -export async function fetchWeatherWithApiFetchClient(latitude: number, longitude: number) { - const result = await apiFetchClient.get< - paths['/v1/forecast']['get']['responses']['200']['content']['application/json'] - >('/v1/forecast', { - queryParams: { - latitude, - longitude - } - }); - - // Handle response - if (result.isOk()) { - console.log('[api] Ok Result', { data: result.value.data }); - } else { - console.error('[api] Error Result', { error: result.error }); - } - - // Or unwrap the response, throwing an exception on error - try { - const data = result.unwrap().data; - console.log('[api/unwrap] Ok Result', { data }); - } catch (error) { - console.error('[api/unwrap] Error Result', { error }); - } -} diff --git a/examples/feature-fetch/vanilla/open-meteo/src/gen/v1.ts b/examples/feature-fetch/vanilla/open-meteo/src/gen/v1.ts deleted file mode 100644 index a900f658..00000000 --- a/examples/feature-fetch/vanilla/open-meteo/src/gen/v1.ts +++ /dev/null @@ -1,252 +0,0 @@ -/** - * This file was auto-generated by openapi-typescript. - * Do not make direct changes to the file. - */ - -export interface paths { - '/v1/forecast': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * 7 day weather forecast for coordinates - * @description 7 day weather variables in hourly and daily resolution for given WGS84 latitude and longitude coordinates. Available worldwide. - */ - get: { - parameters: { - query: { - hourly?: ( - | 'temperature_2m' - | 'relative_humidity_2m' - | 'dew_point_2m' - | 'apparent_temperature' - | 'pressure_msl' - | 'cloud_cover' - | 'cloud_cover_low' - | 'cloud_cover_mid' - | 'cloud_cover_high' - | 'wind_speed_10m' - | 'wind_speed_80m' - | 'wind_speed_120m' - | 'wind_speed_180m' - | 'wind_direction_10m' - | 'wind_direction_80m' - | 'wind_direction_120m' - | 'wind_direction_180m' - | 'wind_gusts_10m' - | 'shortwave_radiation' - | 'direct_radiation' - | 'direct_normal_irradiance' - | 'diffuse_radiation' - | 'vapour_pressure_deficit' - | 'evapotranspiration' - | 'precipitation' - | 'weather_code' - | 'snow_height' - | 'freezing_level_height' - | 'soil_temperature_0cm' - | 'soil_temperature_6cm' - | 'soil_temperature_18cm' - | 'soil_temperature_54cm' - | 'soil_moisture_0_1cm' - | 'soil_moisture_1_3cm' - | 'soil_moisture_3_9cm' - | 'soil_moisture_9_27cm' - | 'soil_moisture_27_81cm' - )[]; - daily?: ( - | 'temperature_2m_max' - | 'temperature_2m_min' - | 'apparent_temperature_max' - | 'apparent_temperature_min' - | 'precipitation_sum' - | 'precipitation_hours' - | 'weather_code' - | 'sunrise' - | 'sunset' - | 'wind_speed_10m_max' - | 'wind_gusts_10m_max' - | 'wind_direction_10m_dominant' - | 'shortwave_radiation_sum' - | 'uv_index_max' - | 'uv_index_clear_sky_max' - | 'et0_fao_evapotranspiration' - )[]; - /** @description WGS84 coordinate */ - latitude: number; - /** @description WGS84 coordinate */ - longitude: number; - current_weather?: boolean; - temperature_unit?: 'celsius' | 'fahrenheit'; - wind_speed_unit?: 'kmh' | 'ms' | 'mph' | 'kn'; - /** @description If format `unixtime` is selected, all time values are returned in UNIX epoch time in seconds. Please not that all time is then in GMT+0! For daily values with unix timestamp, please apply `utc_offset_seconds` again to get the correct date. */ - timeformat?: 'iso8601' | 'unixtime'; - /** @description If `timezone` is set, all timestamps are returned as local-time and data is returned starting at 0:00 local-time. Any time zone name from the [time zone database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) is supported. */ - timezone?: string; - /** @description If `past_days` is set, yesterdays or the day before yesterdays data are also returned. */ - past_days?: 1 | 2; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': { - /** - * @description WGS84 of the center of the weather grid-cell which was used to generate this forecast. This coordinate might be up to 5 km away. - * @example 52.52 - */ - latitude?: number; - /** - * @description WGS84 of the center of the weather grid-cell which was used to generate this forecast. This coordinate might be up to 5 km away. - * @example 13.419.52 - */ - longitude?: number; - /** - * @description The elevation in meters of the selected weather grid-cell. In mountain terrain it might differ from the location you would expect. - * @example 44.812 - */ - elevation?: number; - /** - * @description Generation time of the weather forecast in milli seconds. This is mainly used for performance monitoring and improvements. - * @example 2.2119 - */ - generationtime_ms?: number; - /** - * @description Applied timezone offset from the &timezone= parameter. - * @example 3600 - */ - utc_offset_seconds?: number; - /** @description For each selected weather variable, data will be returned as a floating point array. Additionally a `time` array will be returned with ISO8601 timestamps. */ - hourly?: Record; - /** @description For each selected weather variable, the unit will be listed here. */ - hourly_units?: { - [key: string]: string; - }; - /** @description For each selected daily weather variable, data will be returned as a floating point array. Additionally a `time` array will be returned with ISO8601 timestamps. */ - daily?: Record; - /** @description For each selected daily weather variable, the unit will be listed here. */ - daily_units?: { - [key: string]: string; - }; - /** @description Current weather conditions with the attributes: time, temperature, wind_speed, wind_direction and weather_code */ - current_weather?: Record; - }; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': { - /** @description Always set true for errors */ - error?: boolean; - /** - * @description Description of the error - * @example Latitude must be in range of -90 to 90°. Given: 300 - */ - reason?: string; - }; - }; - }; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; -} -export type webhooks = Record; -export interface components { - schemas: { - HourlyResponse: { - time: string[]; - temperature_2m?: Record[]; - relative_humidity_2m?: Record[]; - dew_point_2m?: Record[]; - apparent_temperature?: Record[]; - pressure_msl?: Record[]; - cloud_cover?: Record[]; - cloud_cover_low?: Record[]; - cloud_cover_mid?: Record[]; - cloud_cover_high?: Record[]; - wind_speed_10m?: Record[]; - wind_speed_80m?: Record[]; - wind_speed_120m?: Record[]; - wind_speed_180m?: Record[]; - wind_direction_10m?: Record[]; - wind_direction_80m?: Record[]; - wind_direction_120m?: Record[]; - wind_direction_180m?: Record[]; - wind_gusts_10m?: Record[]; - shortwave_radiation?: Record[]; - direct_radiation?: Record[]; - direct_normal_irradiance?: Record[]; - diffuse_radiation?: Record[]; - vapour_pressure_deficit?: Record[]; - evapotranspiration?: Record[]; - precipitation?: Record[]; - weather_code?: Record[]; - snow_height?: Record[]; - freezing_level_height?: Record[]; - soil_temperature_0cm?: Record[]; - soil_temperature_6cm?: Record[]; - soil_temperature_18cm?: Record[]; - soil_temperature_54cm?: Record[]; - soil_moisture_0_1cm?: Record[]; - soil_moisture_1_3cm?: Record[]; - soil_moisture_3_9cm?: Record[]; - soil_moisture_9_27cm?: Record[]; - soil_moisture_27_81cm?: Record[]; - }; - DailyResponse: { - time: string[]; - temperature_2m_max?: Record[]; - temperature_2m_min?: Record[]; - apparent_temperature_max?: Record[]; - apparent_temperature_min?: Record[]; - precipitation_sum?: Record[]; - precipitation_hours?: Record[]; - weather_code?: Record[]; - sunrise?: Record[]; - sunset?: Record[]; - wind_speed_10m_max?: Record[]; - wind_gusts_10m_max?: Record[]; - wind_direction_10m_dominant?: Record[]; - shortwave_radiation_sum?: Record[]; - uv_index_max?: Record[]; - uv_index_clear_sky_max?: Record[]; - et0_fao_evapotranspiration?: Record[]; - }; - CurrentWeather: { - time: string; - temperature: Record; - wind_speed: Record; - wind_direction: Record; - weather_code: Record; - }; - }; - responses: never; - parameters: never; - requestBodies: never; - headers: never; - pathItems: never; -} -export type $defs = Record; -export type operations = Record; diff --git a/examples/feature-fetch/vanilla/open-meteo/src/openapi.ts b/examples/feature-fetch/vanilla/open-meteo/src/openapi.ts deleted file mode 100644 index 84b5d234..00000000 --- a/examples/feature-fetch/vanilla/open-meteo/src/openapi.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { createOpenApiFetchClient } from 'feature-fetch'; -import { type paths } from './gen/v1'; - -const openApiFetchClient = createOpenApiFetchClient({ - prefixUrl: 'https://api.open-meteo.com/' -}); - -export async function fetchWeatherWithOpenApiFetchClient(latitude: number, longitude: number) { - const result = await openApiFetchClient.get('/v1/forecast', { - queryParams: { - latitude, - longitude - } - }); - - // Handle response - if (result.isOk()) { - console.log('[openapi] Ok Result', { data: result.value.data }); - } else { - console.error('[openapi] Error Result', { error: result.error }); - } - - // Or unwrap the response, throwing an exception on error - try { - const data = result.unwrap().data; - console.log('[openapi/unwrap] Ok Result', { data }); - } catch (error) { - console.error('[openapi/unwrap] Error Result', { error }); - } -} diff --git a/examples/feature-fetch/vanilla/open-meteo/src/vite-env.d.ts b/examples/feature-fetch/vanilla/open-meteo/src/vite-env.d.ts deleted file mode 100644 index 11f02fe2..00000000 --- a/examples/feature-fetch/vanilla/open-meteo/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/examples/feature-fetch/vanilla/open-meteo/vite.config.ts b/examples/feature-fetch/vanilla/open-meteo/vite.config.ts deleted file mode 100644 index c049f46e..00000000 --- a/examples/feature-fetch/vanilla/open-meteo/vite.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { defineConfig } from 'vite'; - -export default defineConfig({}); diff --git a/examples/feature-form/react/basic/README.md b/examples/feature-form/react/basic/README.md new file mode 100644 index 00000000..cf2e6426 --- /dev/null +++ b/examples/feature-form/react/basic/README.md @@ -0,0 +1,16 @@ +# feature-form React Basic + +React example for `feature-form` and `feature-react/form`. It shows a typed form where fields are reactive state, validation uses Zod through Standard Schema, and components subscribe to the field or form state they render. + +- `createForm` with typed submit data +- field bindings with `useFormField` +- Zod validation through Standard Schema +- form-level validation for the admin age rule +- dirty tracking with `dirtyFeature()` +- render counters for per-field subscriptions + +## Run + +```sh +pnpm dev +``` diff --git a/examples/feature-form/react/basic/eslint.config.cjs b/examples/feature-form/react/basic/eslint.config.cjs deleted file mode 100644 index 3436c292..00000000 --- a/examples/feature-form/react/basic/eslint.config.cjs +++ /dev/null @@ -1,17 +0,0 @@ -const reactRefresh = require('eslint-plugin-react-refresh'); - -/** - * @see https://eslint.org/docs/latest/use/configure/configuration-files - * @type {import("eslint").Linter.Config} - */ -module.exports = [ - ...require('@blgc/config/eslint/react-internal'), - { - plugins: { - 'react-refresh': reactRefresh - }, - rules: { - 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }] - } - } -]; diff --git a/examples/feature-form/react/basic/eslint.config.js b/examples/feature-form/react/basic/eslint.config.js new file mode 100644 index 00000000..ba573c21 --- /dev/null +++ b/examples/feature-form/react/basic/eslint.config.js @@ -0,0 +1,22 @@ +import js from '@eslint/js'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import { defineConfig, globalIgnores } from 'eslint/config'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite + ], + languageOptions: { + globals: globals.browser + } + } +]); diff --git a/examples/feature-form/react/basic/index.html b/examples/feature-form/react/basic/index.html index e4b78eae..14b41a07 100644 --- a/examples/feature-form/react/basic/index.html +++ b/examples/feature-form/react/basic/index.html @@ -2,9 +2,9 @@ - + - Vite + React + TS + feature-form React basic
diff --git a/examples/feature-form/react/basic/package.json b/examples/feature-form/react/basic/package.json index f9bae056..846e7740 100644 --- a/examples/feature-form/react/basic/package.json +++ b/examples/feature-form/react/basic/package.json @@ -1,31 +1,33 @@ { "name": "feature-form-react-basic", - "version": "0.0.1", + "version": "0.0.0", "private": true, "type": "module", "scripts": { - "dev": "vite" + "build": "tsc -b && vite build", + "dev": "vite", + "lint": "eslint .", + "preview": "vite preview" }, "dependencies": { - "@blgc/utils": "workspace:*", "feature-form": "workspace:*", "feature-react": "workspace:*", - "react": "^19.2.3", - "react-dom": "^19.2.3", - "valibot": "1.2.0", - "validation-adapters": "workspace:*", - "zod": "^4.3.5" + "react": "^19.2.6", + "react-dom": "^19.2.6", + "zod": "^4.4.3" }, "devDependencies": { - "@types/react": "^19.2.8", + "@eslint/js": "^10.0.1", + "@types/node": "^24.12.3", + "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@typescript-eslint/eslint-plugin": "^8.53.0", - "@typescript-eslint/parser": "^8.53.0", - "@vitejs/plugin-react": "^5.1.2", - "eslint": "^9.39.2", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.4.26", - "typescript": "^5.9.3", - "vite": "^7.3.1" + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12" } } diff --git a/examples/feature-form/react/basic/public/favicon.svg b/examples/feature-form/react/basic/public/favicon.svg new file mode 100644 index 00000000..6893eb13 --- /dev/null +++ b/examples/feature-form/react/basic/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/feature-form/react/basic/public/vite.svg b/examples/feature-form/react/basic/public/vite.svg deleted file mode 100644 index e7b8dfb1..00000000 --- a/examples/feature-form/react/basic/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/examples/feature-form/react/basic/src/App.css b/examples/feature-form/react/basic/src/App.css index 9fca8d70..543e98ee 100644 --- a/examples/feature-form/react/basic/src/App.css +++ b/examples/feature-form/react/basic/src/App.css @@ -1,76 +1,52 @@ -body { - background: #0e101c; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', - 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; -} - -form { - max-width: 500px; +.app { + display: grid; + gap: 24px; margin: 0 auto; + max-width: 960px; + padding: 40px 24px; } -h1 { - font-weight: 100; - color: white; - text-align: center; - padding-bottom: 10px; - border-bottom: 1px solid rgb(79, 98, 148); -} - -p { - color: white; +form, +.section { + display: grid; + gap: 16px; } -.form { - background: #0e101c; - max-width: 400px; - margin: 0 auto; +.section { + border: 1px solid #d8d8d8; + padding: 16px; } -.error { - color: #bf1650; +.field-grid { + display: grid; + gap: 12px; } -.error::before { - display: inline; - content: '⚠ '; +.stack { + display: grid; + gap: 10px; } -textarea, -input { - display: block; - box-sizing: border-box; - width: 100%; - border-radius: 4px; - border: 1px solid white; - padding: 10px 15px; - margin-bottom: 10px; - font-size: 14px; +.field-row { + align-items: start; + display: grid; + gap: 8px; + grid-template-columns: 140px minmax(160px, 1fr) minmax(180px, 1fr) 110px 180px; } -label { - line-height: 2; - text-align: left; - display: block; - margin-bottom: 13px; - margin-top: 20px; - color: white; - font-size: 14px; - font-weight: 200; +.actions { + display: flex; + flex-wrap: wrap; + gap: 8px; } -.App { - max-width: 600px; - margin: 0 auto; +.render-count { + color: #666; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } -button { - display: block; - appearance: none; - margin-top: 40px; - border: 1px solid #333; - margin-bottom: 20px; - text-transform: uppercase; - padding: 10px 20px; - border-radius: 4px; +@media (max-width: 760px) { + .field-row { + grid-template-columns: 1fr; + } } diff --git a/examples/feature-form/react/basic/src/App.tsx b/examples/feature-form/react/basic/src/App.tsx index dc63091d..e7c2d002 100644 --- a/examples/feature-form/react/basic/src/App.tsx +++ b/examples/feature-form/react/basic/src/App.tsx @@ -1,220 +1,271 @@ -import { randomHex, shortId } from '@blgc/utils'; import { - bitwiseFlag, createForm, - FormFieldReValidateMode, - FormFieldValidateMode, - TFormFieldValidator + dirtyFeature, + type TFormErrors, + type TFormFieldKey, + type TValidationStatusValue } from 'feature-form'; -import { useForm } from 'feature-react/form'; -import { useFeatureState, withGlobalBind } from 'feature-react/state'; +import { useFormField } from 'feature-react/form'; +import { useFeatureState } from 'feature-react/state'; import React from 'react'; -import * as v from 'valibot'; -import { createValidator } from 'validation-adapters'; -import { vValidator } from 'validation-adapters/valibot'; -import { zValidator } from 'validation-adapters/zod'; import * as z from 'zod'; import './App.css'; -import { StatusMessage } from './components'; -import { isLightColor } from './utils'; -type TGender = 'male' | 'female' | 'diverse'; +const $form = createForm({ + fields: { + firstName: { + defaultValue: '', + validator: z.string().min(2, 'Enter at least two characters') + }, + email: { + defaultValue: '', + validator: z.email('Enter a valid email') + }, + age: { + defaultValue: 18, + validator: z.number().min(18, 'You must be at least 18') + }, + role: { + defaultValue: 'reader', + validator: z.enum(['reader', 'admin']) + } + }, + validator: z.custom( + (value) => { + const data = value as TFormData; + return data.role !== 'admin' || data.age >= 21; + }, + { + error: 'Admins must be at least 21', + path: ['age'] + } + ), + validateOn: ['submit'], + revalidateOn: ['change', 'blur', 'submit'] +}).with(dirtyFeature()); -type TFormData = { - // [key: string]: string; // https://stackoverflow.com/questions/65799316/why-cant-an-interface-be-assigned-to-recordstring-unknown +interface TFormData { firstName: string; - lastName: string; - gender: TGender; email: string; - image: { - id: string; - color: string; - }; -}; + age: number; + role: TRole; +} -const valibotNameValidator = vValidator( - v.pipe(v.string(), v.minLength(2), v.maxLength(10), v.regex(/^([^0-9]*)$/)) -); - -const $form = withGlobalBind( - '__form', - createForm({ - fields: { - firstName: { - validator: valibotNameValidator.clone().append( - createValidator([ - { - key: 'jeff', - validate: (cx) => { - if (cx.value !== 'Jeff') { - cx.registerError({ - code: 'jeff', - message: 'Only the name Jeff is allowed.' - }); - } - } - } - ]) - ), - defaultValue: '' - }, - lastName: { - validator: valibotNameValidator, - defaultValue: '' - }, - gender: { - validator: createValidator([ - { - key: 'gender', - validate: (cx) => { - if (cx.value !== 'female' && cx.value !== 'male' && cx.value !== 'diverse') { - cx.registerError({ - code: 'invalid-gender', - message: 'Unknown gender.' - }); - } - } - } - ]) as TFormFieldValidator, - defaultValue: 'female' - }, - email: { - validator: zValidator(z.email().max(30).min(1)), - defaultValue: '' +type TRole = 'reader' | 'admin'; + +export const App: React.FC = () => { + const [submitResult, setSubmitResult] = React.useState(null); + + const handleSubmit = React.useCallback((event: React.FormEvent) => { + event.preventDefault(); + void $form.submit({ + onValidSubmit(data) { + setSubmitResult({ type: 'valid', data }); }, - image: { - validator: createValidator([ - { - key: 'color', - validate: (cx) => { - const value = cx.value as TFormData['image'] | undefined; - const color = value?.color; - if (color != null && !isLightColor(color)) { - cx.registerError({ - code: 'too-dark', - message: 'The image is too dark.' - }); - } - } - } - ]), - defaultValue: { - id: shortId(), - color: randomHex() - } + onInvalidSubmit(errors) { + setSubmitResult({ type: 'invalid', errors }); } - }, - onValidSubmit: (data, listenerContext) => { - console.log('ValidSubmit', { data, listenerContext }); - return { valid: true }; - }, - onInvalidSubmit: (errors, listenerContext) => { - console.log('Invalid Submit', { errors, listenerContext }); - return { valid: false }; - }, - notifyOnStatusChange: false, - validateMode: bitwiseFlag(FormFieldValidateMode.OnSubmit), - reValidateMode: bitwiseFlag(FormFieldReValidateMode.OnBlur, FormFieldReValidateMode.OnChange), - collectErrorMode: 'firstError' - }) -); + }); + }, []); + + const handleReset = React.useCallback(() => { + $form.reset(); + setSubmitResult(null); + }, []); + + return ( +
+
+

feature-form React basic

+

+ Per-field subscriptions with Standard Schema validation, dirty tracking, and visible + render counts. +

+ +
+ +
+ +
+ + + + +
+
-let renderCount = 0; + + + -function App() { - const { handleSubmit, status, field, register } = useForm($form); - const [data, setData] = React.useState(''); - const isValid = useFeatureState($form.isValid); + +
+ + +
+ {submitResult == null ? null :
{JSON.stringify(submitResult, null, 2)}
} +
+
+
+ ); +}; + +type TSubmitResult = + | { + type: 'valid'; + data: Readonly; + } + | { + type: 'invalid'; + errors: TFormErrors; + } + | null; + +const FormStateExample: React.FC = () => { + const formStatus = useFeatureState($form.status); + const isDirty = useFeatureState($form.isDirty); + const dirtyFields = useFeatureState($form.dirtyFields); + + return ( +
+ +

Status: {formStatus.type}

+

Dirty: {isDirty ? 'yes' : 'no'}

+

Dirty fields: {JSON.stringify(dirtyFields)}

+
+ ); +}; + +const FirstNameField: React.FC = () => { + const { input, status } = useFormField($form, 'firstName'); + + return ( + + + + ); +}; + +const EmailField: React.FC = () => { + const { input, status } = useFormField($form, 'email'); + + return ( + + + + ); +}; - renderCount++; +const AgeField: React.FC = () => { + const { input, status } = useFormField($form, 'age'); return ( -
{ - setData(JSON.stringify(data)); - }, - preventDefault: true, - postSubmitCallback: (form, data) => { - console.log('postSubmit', { form, data }); - } - })} - > -

Sign Up

- - - - - - - - - - + + String(value), + parse: (value) => Number(value) + })} + id="age" + type="number" + /> + + ); +}; + +const RoleField: React.FC = () => { + const { input, status } = useFormField($form, 'role'); + + return ( + - - - - - - - -
-
- -
- - -
- - -
- -

Is Valid: {isValid.toString()}

-

Render Count: {renderCount}

-

Data: {data}

- + + ); +}; + +const FieldRow: React.FC = (props) => { + const { children, fieldKey, label, renderLabel, status } = props; + + return ( +
+ + {children} + + Field key: {fieldKey} + +
); +}; + +interface TFieldRowProps { + children: React.ReactElement<{ id: string }>; + fieldKey: TFormFieldKey; + label: string; + renderLabel: string; + status: TValidationStatusValue; +} + +const StatusMessage: React.FC = (props) => { + const { status } = props; + + if (status.type !== 'invalid') { + return No error; + } + + return {status.errors[0].message}; +}; + +interface TStatusMessageProps { + status: TValidationStatusValue; +} + +const ExampleSection: React.FC = (props) => { + const { children, title } = props; + + return ( +
+

{title}

+ {children} +
+ ); +}; + +interface TExampleSectionProps { + children: React.ReactNode; + title: string; +} + +const RenderCount: React.FC = (props) => { + const { label } = props; + const renderCount = useRenderCount(); + + return ( + + {label} renders: {renderCount} + + ); +}; + +interface TRenderCountProps { + label: string; +} + +function useRenderCount(): number { + const renderCount = React.useRef(0); + // eslint-disable-next-line react-hooks/refs -- this diagnostic helper intentionally counts render passes + renderCount.current++; + + // eslint-disable-next-line react-hooks/refs -- render count is displayed for the example UI + return renderCount.current; } export default App; diff --git a/examples/feature-form/react/basic/src/components/StatusMessage.tsx b/examples/feature-form/react/basic/src/components/StatusMessage.tsx deleted file mode 100644 index c0357451..00000000 --- a/examples/feature-form/react/basic/src/components/StatusMessage.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { TFormFieldStatus } from 'feature-form'; -import { useFeatureState } from 'feature-react/state'; -import React from 'react'; - -export const StatusMessage: React.FC = (props) => { - const { $status } = props; - const status = useFeatureState($status); - - if (status.type === 'INVALID') { - return

{status.errors[0].message}

; - } - - return null; -}; - -interface TProps { - $status: TFormFieldStatus; -} diff --git a/examples/feature-form/react/basic/src/components/index.ts b/examples/feature-form/react/basic/src/components/index.ts deleted file mode 100644 index 82bd8c0c..00000000 --- a/examples/feature-form/react/basic/src/components/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './StatusMessage'; diff --git a/examples/feature-form/react/basic/src/index.css b/examples/feature-form/react/basic/src/index.css new file mode 100644 index 00000000..bdd19652 --- /dev/null +++ b/examples/feature-form/react/basic/src/index.css @@ -0,0 +1,20 @@ +body { + font-family: system-ui, sans-serif; + line-height: 1.5; + margin: 0; +} + +button, +input, +select { + font: inherit; +} + +h1 { + margin: 0; +} + +h2, +p { + margin: 0; +} diff --git a/examples/feature-form/react/basic/src/main.tsx b/examples/feature-form/react/basic/src/main.tsx index fc03c18f..98c2e6cb 100644 --- a/examples/feature-form/react/basic/src/main.tsx +++ b/examples/feature-form/react/basic/src/main.tsx @@ -1,9 +1,10 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import './index.css'; import App from './App.tsx'; -ReactDOM.createRoot(document.getElementById('root')!).render( - +createRoot(document.getElementById('root')!).render( + - + ); diff --git a/examples/feature-form/react/basic/src/utils.ts b/examples/feature-form/react/basic/src/utils.ts deleted file mode 100644 index 0ee6c8bf..00000000 --- a/examples/feature-form/react/basic/src/utils.ts +++ /dev/null @@ -1,9 +0,0 @@ -export function isLightColor(color: string): boolean { - const hex = color.replace('#', ''); - const c_r = parseInt(hex.substr(0, 2), 16); - const c_g = parseInt(hex.substr(2, 2), 16); - const c_b = parseInt(hex.substr(4, 2), 16); - const brightness = (c_r * 299 + c_g * 587 + c_b * 114) / 1000; - - return brightness > 155; -} diff --git a/examples/feature-form/react/basic/src/vite-env.d.ts b/examples/feature-form/react/basic/src/vite-env.d.ts deleted file mode 100644 index 11f02fe2..00000000 --- a/examples/feature-form/react/basic/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/examples/tuple-result/vanilla/basic/tsconfig.json b/examples/feature-form/react/basic/tsconfig.app.json similarity index 56% rename from examples/tuple-result/vanilla/basic/tsconfig.json rename to examples/feature-form/react/basic/tsconfig.app.json index ef75cb42..5f9a3c7c 100644 --- a/examples/tuple-result/vanilla/basic/tsconfig.json +++ b/examples/feature-form/react/basic/tsconfig.app.json @@ -1,23 +1,24 @@ { "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "module": "ESNext", - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], "skipLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, + "verbatimModuleSyntax": true, "moduleDetection": "force", "noEmit": true, + "jsx": "react-jsx", /* Linting */ - "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, + "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true }, "include": ["src"] diff --git a/examples/feature-form/react/basic/tsconfig.json b/examples/feature-form/react/basic/tsconfig.json index aca48955..88a659fb 100644 --- a/examples/feature-form/react/basic/tsconfig.json +++ b/examples/feature-form/react/basic/tsconfig.json @@ -1,25 +1,4 @@ { - "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["src"], - "references": [{ "path": "./tsconfig.node.json" }] + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] } diff --git a/examples/feature-form/react/basic/tsconfig.node.json b/examples/feature-form/react/basic/tsconfig.node.json index 1caabefc..6a12664e 100644 --- a/examples/feature-form/react/basic/tsconfig.node.json +++ b/examples/feature-form/react/basic/tsconfig.node.json @@ -1,11 +1,24 @@ { "compilerOptions": { - "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], "skipLibCheck": true, - "module": "ESNext", + + /* Bundler mode */ "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true, - "strict": true + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true }, "include": ["vite.config.ts"] } diff --git a/examples/feature-form/react/basic/vite.config.ts b/examples/feature-form/react/basic/vite.config.ts index dd441e44..d3dd1b23 100644 --- a/examples/feature-form/react/basic/vite.config.ts +++ b/examples/feature-form/react/basic/vite.config.ts @@ -1,7 +1,7 @@ import react from '@vitejs/plugin-react'; import { defineConfig } from 'vite'; -// https://vitejs.dev/config/ +// https://vite.dev/config/ export default defineConfig({ plugins: [react()] }); diff --git a/examples/feature-state/react/counter/.gitignore b/examples/feature-state/react/basic/.gitignore similarity index 100% rename from examples/feature-state/react/counter/.gitignore rename to examples/feature-state/react/basic/.gitignore diff --git a/examples/feature-state/react/basic/README.md b/examples/feature-state/react/basic/README.md new file mode 100644 index 00000000..fc9f668f --- /dev/null +++ b/examples/feature-state/react/basic/README.md @@ -0,0 +1,15 @@ +# feature-state React Basic + +React example for `feature-state` and `feature-react/state`. It shows how one state model can drive React UI, derived values, feature-added methods, and persisted preferences without moving everything into component-local state. + +- `createState` with `useFeatureState` +- derived values with `useCompute` +- typed undo behavior with `undoFeature()` +- persisted settings with `localStorageFeature()` +- render counters for subscription behavior + +## Run + +```sh +pnpm dev +``` diff --git a/examples/feature-state/react/basic/eslint.config.js b/examples/feature-state/react/basic/eslint.config.js new file mode 100644 index 00000000..ba573c21 --- /dev/null +++ b/examples/feature-state/react/basic/eslint.config.js @@ -0,0 +1,22 @@ +import js from '@eslint/js'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import { defineConfig, globalIgnores } from 'eslint/config'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite + ], + languageOptions: { + globals: globals.browser + } + } +]); diff --git a/examples/feature-state/react/counter/index.html b/examples/feature-state/react/basic/index.html similarity index 70% rename from examples/feature-state/react/counter/index.html rename to examples/feature-state/react/basic/index.html index e4b78eae..bdb78a82 100644 --- a/examples/feature-state/react/counter/index.html +++ b/examples/feature-state/react/basic/index.html @@ -2,9 +2,9 @@ - + - Vite + React + TS + feature-state React basic
diff --git a/examples/feature-state/react/basic/package.json b/examples/feature-state/react/basic/package.json new file mode 100644 index 00000000..db0aa723 --- /dev/null +++ b/examples/feature-state/react/basic/package.json @@ -0,0 +1,32 @@ +{ + "name": "feature-state-react-basic", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc -b && vite build", + "dev": "vite", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "feature-react": "workspace:*", + "feature-state": "workspace:*", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.12.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12" + } +} diff --git a/examples/feature-state/react/basic/public/favicon.svg b/examples/feature-state/react/basic/public/favicon.svg new file mode 100644 index 00000000..6893eb13 --- /dev/null +++ b/examples/feature-state/react/basic/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/feature-state/react/basic/src/App.css b/examples/feature-state/react/basic/src/App.css new file mode 100644 index 00000000..fe567a1c --- /dev/null +++ b/examples/feature-state/react/basic/src/App.css @@ -0,0 +1,35 @@ +.app { + display: grid; + gap: 24px; + margin: 0 auto; + max-width: 960px; + padding: 40px 24px; +} + +.grid { + display: grid; + gap: 16px; +} + +.section { + border: 1px solid #d8d8d8; + display: grid; + gap: 12px; + padding: 16px; +} + +.stack { + display: grid; + gap: 10px; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.render-count { + color: #666; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} diff --git a/examples/feature-state/react/basic/src/App.tsx b/examples/feature-state/react/basic/src/App.tsx new file mode 100644 index 00000000..15bb1f66 --- /dev/null +++ b/examples/feature-state/react/basic/src/App.tsx @@ -0,0 +1,194 @@ +import { localStorageFeature, useCompute, useFeatureState } from 'feature-react/state'; +import { createState, undoFeature } from 'feature-state'; +import React from 'react'; +import './App.css'; + +const $count = createState(0).with(undoFeature()); + +const $profile = createState({ + name: 'Ada', + role: 'editor' +}); + +const $settings = createState({ + theme: 'light', + compact: false +}).with(localStorageFeature('feature-state-react-basic-settings')); + +export const App: React.FC = () => { + return ( +
+
+

feature-state React basic

+

+ Small examples for `useFeatureState`, `useCompute`, feature chaining, and render scoping. +

+ +
+ +
+ + + + + + + + + + + +
+
+ ); +}; + +const CounterExample: React.FC = () => { + const count = useFeatureState($count); + const doubled = useCompute($count, (value) => value * 2); + + return ( +
+ +

Count: {count}

+

Doubled: {doubled}

+
+ + + +
+
+ ); +}; + +const ProfileExample: React.FC = () => { + const profile = useFeatureState($profile); + const summary = useCompute([$count, $profile], ([count, profile]) => { + return `${profile.name} changed the counter ${count} ${count === 1 ? 'time' : 'times'}`; + }); + + return ( +
+ + + +

{summary}

+
+ ); +}; + +const SettingsExample: React.FC = () => { + const settings = useFeatureState($settings); + + React.useEffect(() => { + void $settings.persist(); + }, []); + + return ( +
+ +

Theme: {settings.theme}

+

Compact: {settings.compact ? 'yes' : 'no'}

+
+ + + +
+
+ ); +}; + +const ExampleSection: React.FC = (props) => { + const { children, title } = props; + + return ( +
+

{title}

+ {children} +
+ ); +}; + +interface TExampleSectionProps { + children: React.ReactNode; + title: string; +} + +const RenderCount: React.FC = (props) => { + const { label } = props; + const renderCount = useRenderCount(); + + return ( + + {label} renders: {renderCount} + + ); +}; + +interface TRenderCountProps { + label: string; +} + +function useRenderCount(): number { + const renderCount = React.useRef(0); + // eslint-disable-next-line react-hooks/refs -- this diagnostic helper intentionally counts render passes + renderCount.current++; + + // eslint-disable-next-line react-hooks/refs -- render count is displayed for the example UI + return renderCount.current; +} + +export default App; diff --git a/examples/feature-state/react/basic/src/index.css b/examples/feature-state/react/basic/src/index.css new file mode 100644 index 00000000..bdd19652 --- /dev/null +++ b/examples/feature-state/react/basic/src/index.css @@ -0,0 +1,20 @@ +body { + font-family: system-ui, sans-serif; + line-height: 1.5; + margin: 0; +} + +button, +input, +select { + font: inherit; +} + +h1 { + margin: 0; +} + +h2, +p { + margin: 0; +} diff --git a/examples/feature-state/react/basic/src/main.tsx b/examples/feature-state/react/basic/src/main.tsx new file mode 100644 index 00000000..98c2e6cb --- /dev/null +++ b/examples/feature-state/react/basic/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import './index.css'; +import App from './App.tsx'; + +createRoot(document.getElementById('root')!).render( + + + +); diff --git a/examples/feature-fetch/vanilla/open-meteo/tsconfig.json b/examples/feature-state/react/basic/tsconfig.app.json similarity index 56% rename from examples/feature-fetch/vanilla/open-meteo/tsconfig.json rename to examples/feature-state/react/basic/tsconfig.app.json index ef75cb42..5f9a3c7c 100644 --- a/examples/feature-fetch/vanilla/open-meteo/tsconfig.json +++ b/examples/feature-state/react/basic/tsconfig.app.json @@ -1,23 +1,24 @@ { "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "module": "ESNext", - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], "skipLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, + "verbatimModuleSyntax": true, "moduleDetection": "force", "noEmit": true, + "jsx": "react-jsx", /* Linting */ - "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, + "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true }, "include": ["src"] diff --git a/examples/feature-state/react/basic/tsconfig.json b/examples/feature-state/react/basic/tsconfig.json new file mode 100644 index 00000000..88a659fb --- /dev/null +++ b/examples/feature-state/react/basic/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] +} diff --git a/examples/feature-state/react/basic/tsconfig.node.json b/examples/feature-state/react/basic/tsconfig.node.json new file mode 100644 index 00000000..6a12664e --- /dev/null +++ b/examples/feature-state/react/basic/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/examples/feature-state/react/counter/vite.config.ts b/examples/feature-state/react/basic/vite.config.ts similarity index 81% rename from examples/feature-state/react/counter/vite.config.ts rename to examples/feature-state/react/basic/vite.config.ts index dd441e44..d3dd1b23 100644 --- a/examples/feature-state/react/counter/vite.config.ts +++ b/examples/feature-state/react/basic/vite.config.ts @@ -1,7 +1,7 @@ import react from '@vitejs/plugin-react'; import { defineConfig } from 'vite'; -// https://vitejs.dev/config/ +// https://vite.dev/config/ export default defineConfig({ plugins: [react()] }); diff --git a/examples/feature-state/react/counter/eslint.config.cjs b/examples/feature-state/react/counter/eslint.config.cjs deleted file mode 100644 index 3436c292..00000000 --- a/examples/feature-state/react/counter/eslint.config.cjs +++ /dev/null @@ -1,17 +0,0 @@ -const reactRefresh = require('eslint-plugin-react-refresh'); - -/** - * @see https://eslint.org/docs/latest/use/configure/configuration-files - * @type {import("eslint").Linter.Config} - */ -module.exports = [ - ...require('@blgc/config/eslint/react-internal'), - { - plugins: { - 'react-refresh': reactRefresh - }, - rules: { - 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }] - } - } -]; diff --git a/examples/feature-state/react/counter/package.json b/examples/feature-state/react/counter/package.json deleted file mode 100644 index f2de79e4..00000000 --- a/examples/feature-state/react/counter/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "feature-state-react-counter", - "version": "0.0.1", - "private": true, - "type": "module", - "scripts": { - "dev": "vite" - }, - "dependencies": { - "@blgc/utils": "workspace:*", - "feature-react": "workspace:*", - "feature-state": "workspace:*", - "react": "^19.2.3", - "react-dom": "^19.2.3" - }, - "devDependencies": { - "@types/react": "^19.2.8", - "@types/react-dom": "^19.2.3", - "@typescript-eslint/eslint-plugin": "^8.53.0", - "@typescript-eslint/parser": "^8.53.0", - "@vitejs/plugin-react": "^5.1.2", - "eslint": "^9.39.2", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.4.26", - "typescript": "^5.9.3", - "vite": "^7.3.1" - } -} diff --git a/examples/feature-state/react/counter/public/vite.svg b/examples/feature-state/react/counter/public/vite.svg deleted file mode 100644 index e7b8dfb1..00000000 --- a/examples/feature-state/react/counter/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/examples/feature-state/react/counter/src/App.tsx b/examples/feature-state/react/counter/src/App.tsx deleted file mode 100644 index 0861b328..00000000 --- a/examples/feature-state/react/counter/src/App.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import { deepEqual } from '@blgc/utils'; -import { useCombinedCompute, useCompute, useFeatureState, useSelector } from 'feature-react/state'; -import React from 'react'; -import { $counter, $persistentSettings, $userState } from './store'; -import { useRenderCount } from './use-render-count'; - -export default function App() { - return ( -
- {/* Example 1: Basic counter with undo */} -
-

Example 1: Counter with Undo

- -
- - {/* Example 2: Object state with property selection */} -
-

Example 2: Property Selection

- -
- - {/* Example 3: Persistent state with localStorage */} -
-

Example 3: Local Storage

- -
- - {/* Example 4: Computed state */} -
-

Example 4: Computed State

- -
- - {/* Example 5: Combined Computed State */} -
-

Example 5: Combined Computed State

- -
-
- ); -} - -// Example 1: Basic counter with undo functionality -function CounterExample() { - const count = useFeatureState($counter); - const renderCount = useRenderCount(); - - return ( -
-

Count: {count}

- - - -

Render Count: {renderCount}

-
- ); -} - -// Example 2: Demonstrating property selection from "complex" state -function UserSettingsExample() { - const name = useSelector($userState, 'name'); - const theme = useSelector($userState, 'settings.theme'); - const renderCount = useRenderCount(); - - const toggleTheme = () => { - $userState.set((state) => ({ - ...state, - settings: { - ...state.settings, - theme: state.settings.theme === 'dark' ? 'light' : 'dark' - } - })); - }; - - const toggleNotifications = () => { - $userState.set((state) => ({ - ...state, - settings: { - ...state.settings, - notifications: !state.settings.notifications - } - })); - }; - - return ( -
-

Name: {name}

-

Theme: {theme}

-

Notifications: {$userState.get().settings.notifications ? 'On' : 'Off'}

- - -

Render Count: {renderCount}

-
- ); -} - -// Example 3: Demonstrating localStorage persistence -function PersistentSettingsExample() { - const settings = useFeatureState($persistentSettings); - const renderCount = useRenderCount(); - - React.useEffect(() => { - $persistentSettings.persist(); - }, []); - - return ( -
-

Stored Volume: {settings.volume}

-

Stored Language: {settings.language}

- - - -

Render Count: {renderCount}

-
- ); -} - -// Example 4: Demonstrating computed state -function ComputedStateExample() { - const renderCount = useRenderCount(); - - // Compute doubled count value - const doubledCount = useCompute($counter, ({ value: count }) => { - if (count > 5) { - return count * 2; - } - return 0; - }); - - return ( -
-

Original Count: {$counter.get()}

-

Doubled Count: {doubledCount}

- -

Render Count: {renderCount}

-
- ); -} - -// Example 5: Demonstrating combined computed state -function CombinedComputeExample() { - const renderCount = useRenderCount(); - - // Combine counter and user theme for a dynamic message - const combinedState = useCombinedCompute< - typeof $counter, - typeof $userState, - { - message: string; - isDarkWithHighCount: boolean; - } - >( - [$counter, $userState], - ([{ value: count }, { value: userState }]) => { - if (count > 5) { - return { - message: `Count is ${count} and theme is ${userState.settings.theme}`, - isDarkWithHighCount: userState.settings.theme === 'dark' - }; - } - return { - message: `Count is smaller than 5`, - isDarkWithHighCount: false - }; - }, - [], - { isEqual: deepEqual } - ); - - return ( -
-

{combinedState.message}

-

Dark theme with high count: {combinedState.isDarkWithHighCount ? 'Yes' : 'No'}

-
- - -
-

Render Count: {renderCount}

-
- ); -} diff --git a/examples/feature-state/react/counter/src/main.tsx b/examples/feature-state/react/counter/src/main.tsx deleted file mode 100644 index fc03c18f..00000000 --- a/examples/feature-state/react/counter/src/main.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import App from './App.tsx'; - -ReactDOM.createRoot(document.getElementById('root')!).render( - - - -); diff --git a/examples/feature-state/react/counter/src/store.ts b/examples/feature-state/react/counter/src/store.ts deleted file mode 100644 index 1fec763e..00000000 --- a/examples/feature-state/react/counter/src/store.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { withLocalStorage } from 'feature-react/state'; -import { createState, withSelector, withUndo } from 'feature-state'; - -// Example 1: Basic counter with undo functionality -export const $counter = withUndo(createState(0)); - -// Example 2: Object state with property selection -export const $userState = withSelector( - createState({ - name: 'John Doe', - settings: { - theme: 'dark', - notifications: true - }, - stats: { - lastLogin: new Date().toISOString() - } - }) -); - -// Example 3: Persistent state with localStorage -export const $persistentSettings = withLocalStorage( - createState({ - volume: 50, - language: 'en' - }), - 'app-settings' -); diff --git a/examples/feature-state/react/counter/src/use-render-count.ts b/examples/feature-state/react/counter/src/use-render-count.ts deleted file mode 100644 index 788cc1c9..00000000 --- a/examples/feature-state/react/counter/src/use-render-count.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { useEffect, useRef } from 'react'; - -export function useRenderCount() { - const renderCount = useRef(0); - useEffect(() => { - renderCount.current += 1; - }); - return renderCount.current; -} diff --git a/examples/feature-state/react/counter/src/vite-env.d.ts b/examples/feature-state/react/counter/src/vite-env.d.ts deleted file mode 100644 index 11f02fe2..00000000 --- a/examples/feature-state/react/counter/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/examples/feature-state/react/counter/tsconfig.json b/examples/feature-state/react/counter/tsconfig.json deleted file mode 100644 index aca48955..00000000 --- a/examples/feature-state/react/counter/tsconfig.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["src"], - "references": [{ "path": "./tsconfig.node.json" }] -} diff --git a/examples/feature-state/react/counter/tsconfig.node.json b/examples/feature-state/react/counter/tsconfig.node.json deleted file mode 100644 index 1caabefc..00000000 --- a/examples/feature-state/react/counter/tsconfig.node.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "skipLibCheck": true, - "module": "ESNext", - "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true, - "strict": true - }, - "include": ["vite.config.ts"] -} diff --git a/examples/tuple-result/vanilla/basic/.gitignore b/examples/tuple-result/vanilla/basic/.gitignore deleted file mode 100644 index a547bf36..00000000 --- a/examples/tuple-result/vanilla/basic/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? diff --git a/examples/tuple-result/vanilla/basic/index.html b/examples/tuple-result/vanilla/basic/index.html deleted file mode 100644 index 30d4dfb2..00000000 --- a/examples/tuple-result/vanilla/basic/index.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - tuple-result - - - - - diff --git a/examples/tuple-result/vanilla/basic/package.json b/examples/tuple-result/vanilla/basic/package.json deleted file mode 100644 index 51f7dac1..00000000 --- a/examples/tuple-result/vanilla/basic/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "tuple-result-vanilla-basic", - "version": "0.0.1", - "private": true, - "type": "module", - "scripts": { - "build": "tsc && vite build", - "dev": "vite", - "preview": "vite preview" - }, - "dependencies": { - "tuple-result": "workspace:*" - }, - "devDependencies": { - "typescript": "~5.9.3", - "vite": "^7.3.1" - } -} diff --git a/examples/tuple-result/vanilla/basic/public/vite.svg b/examples/tuple-result/vanilla/basic/public/vite.svg deleted file mode 100644 index e7b8dfb1..00000000 --- a/examples/tuple-result/vanilla/basic/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/examples/tuple-result/vanilla/basic/src/main.ts b/examples/tuple-result/vanilla/basic/src/main.ts deleted file mode 100644 index ca4e04e7..00000000 --- a/examples/tuple-result/vanilla/basic/src/main.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { Err, isOk, match, Ok, t, tAsync, unwrapOr, type TResult } from 'tuple-result'; - -// Basic Ok/Err creation and destructuring -function basicExample(): void { - const success = Ok(42); - const failure = Err('Something went wrong'); - - // Destructure as array: [ok, error, value] - const [ok1, _err1, value1] = success; - console.log('Success:', { ok: ok1, value: value1 }); - - const [ok2, err2] = failure; - console.log('Failure:', { ok: ok2, error: err2 }); -} - -// Function that returns a Result -function divide(a: number, b: number): TResult { - if (b === 0) { - return Err('Division by zero'); - } - return Ok(a / b); -} - -// Using type guards and unwrap helpers -function typeGuardsExample(): void { - const result = divide(10, 2); - - if (isOk(result)) { - console.log('Division result:', result.value); - } - - // Using unwrapOr for default values - const safeResult = unwrapOr(divide(10, 0), 0); - console.log('Safe division (with default):', safeResult); -} - -// Pattern matching -function matchExample(): void { - const result = divide(10, 3); - - const message = match(result, { - ok: (value) => `Result: ${value.toFixed(2)}`, - err: (error) => `Error: ${error}` - }); - - console.log(message); -} - -// Wrapping functions with t() and tAsync() -function wrapperExample(): void { - // Wrap a function that might throw - const parsed = t(JSON.parse, '{"name": "test"}'); - const [ok, _err, value] = parsed; - console.log('Parsed JSON:', { ok, value }); - - // Wrap invalid JSON - const invalid = t(JSON.parse, 'not json'); - console.log('Invalid JSON:', { ok: invalid[0], error: invalid[1] }); -} - -async function asyncExample(): Promise { - // Wrap a promise - const result = await tAsync(Promise.resolve('async value')); - console.log('Async result:', { ok: result[0], value: result[2] }); - - // Wrap a rejected promise - const rejected = await tAsync(Promise.reject(new Error('async error'))); - console.log('Rejected:', { ok: rejected[0], error: rejected[1] }); -} - -// Run examples -console.log('--- Basic Example ---'); -basicExample(); - -console.log('\n--- Type Guards Example ---'); -typeGuardsExample(); - -console.log('\n--- Match Example ---'); -matchExample(); - -console.log('\n--- Wrapper Example ---'); -wrapperExample(); - -console.log('\n--- Async Example ---'); -asyncExample(); diff --git a/examples/tuple-result/vanilla/basic/vite.config.ts b/examples/tuple-result/vanilla/basic/vite.config.ts deleted file mode 100644 index c049f46e..00000000 --- a/examples/tuple-result/vanilla/basic/vite.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { defineConfig } from 'vite'; - -export default defineConfig({}); diff --git a/package.json b/package.json index f40cf621..dd90fcb9 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ }, "devDependencies": { "@blgc/config": "workspace:*", - "@changesets/changelog-github": "^0.6.0", + "@changesets/changelog-github": "^0.7.0", "@changesets/cli": "^2.31.0", "@eslint/js": "^9.39.2", "@ianvs/prettier-plugin-sort-imports": "^4.7.1", @@ -37,13 +37,13 @@ "@size-limit/preset-small-lib": "^12.1.0", "eslint": "^9.39.2", "prettier": "^3.8.3", - "rollup": "^4.60.2", + "rollup": "^4.60.4", "shx": "^0.4.0", "size-limit": "^12.1.0", - "turbo": "^2.9.8", + "turbo": "^2.9.14", "typescript": "^6.0.3", - "vite": "^8.0.10", - "vitest": "^4.1.5" + "vite": "^8.0.14", + "vitest": "^4.1.7" }, "packageManager": "pnpm@10.33.2", "engines": { diff --git a/packages/_deprecated/README.md b/packages/_deprecated/README.md deleted file mode 100644 index a7631f1c..00000000 --- a/packages/_deprecated/README.md +++ /dev/null @@ -1 +0,0 @@ -# Deprecated/Archived packages diff --git a/packages/eprel-client/.github/banner.svg b/packages/_deprecated/eprel-client/.github/banner.svg similarity index 100% rename from packages/eprel-client/.github/banner.svg rename to packages/_deprecated/eprel-client/.github/banner.svg diff --git a/packages/eprel-client/CHANGELOG.md b/packages/_deprecated/eprel-client/CHANGELOG.md similarity index 100% rename from packages/eprel-client/CHANGELOG.md rename to packages/_deprecated/eprel-client/CHANGELOG.md diff --git a/packages/eprel-client/README.md b/packages/_deprecated/eprel-client/README.md similarity index 100% rename from packages/eprel-client/README.md rename to packages/_deprecated/eprel-client/README.md diff --git a/packages/eprel-client/eslint.config.js b/packages/_deprecated/eprel-client/eslint.config.js similarity index 100% rename from packages/eprel-client/eslint.config.js rename to packages/_deprecated/eprel-client/eslint.config.js diff --git a/packages/eprel-client/package.json b/packages/_deprecated/eprel-client/package.json similarity index 100% rename from packages/eprel-client/package.json rename to packages/_deprecated/eprel-client/package.json diff --git a/packages/eprel-client/resources/openapi_v1-0-58.yaml b/packages/_deprecated/eprel-client/resources/openapi_v1-0-58.yaml similarity index 100% rename from packages/eprel-client/resources/openapi_v1-0-58.yaml rename to packages/_deprecated/eprel-client/resources/openapi_v1-0-58.yaml diff --git a/packages/eprel-client/rollup.config.js b/packages/_deprecated/eprel-client/rollup.config.js similarity index 100% rename from packages/eprel-client/rollup.config.js rename to packages/_deprecated/eprel-client/rollup.config.js diff --git a/packages/eprel-client/src/__tests__/playground.test.ts b/packages/_deprecated/eprel-client/src/__tests__/playground.test.ts similarity index 100% rename from packages/eprel-client/src/__tests__/playground.test.ts rename to packages/_deprecated/eprel-client/src/__tests__/playground.test.ts diff --git a/packages/eprel-client/src/create-eprel-client.ts b/packages/_deprecated/eprel-client/src/create-eprel-client.ts similarity index 100% rename from packages/eprel-client/src/create-eprel-client.ts rename to packages/_deprecated/eprel-client/src/create-eprel-client.ts diff --git a/packages/eprel-client/src/gen/v1.ts b/packages/_deprecated/eprel-client/src/gen/v1.ts similarity index 100% rename from packages/eprel-client/src/gen/v1.ts rename to packages/_deprecated/eprel-client/src/gen/v1.ts diff --git a/packages/eprel-client/src/helper/get-label-url.ts b/packages/_deprecated/eprel-client/src/helper/get-label-url.ts similarity index 100% rename from packages/eprel-client/src/helper/get-label-url.ts rename to packages/_deprecated/eprel-client/src/helper/get-label-url.ts diff --git a/packages/eprel-client/src/helper/get-language-set.ts b/packages/_deprecated/eprel-client/src/helper/get-language-set.ts similarity index 100% rename from packages/eprel-client/src/helper/get-language-set.ts rename to packages/_deprecated/eprel-client/src/helper/get-language-set.ts diff --git a/packages/eprel-client/src/helper/get-sheet-url.ts b/packages/_deprecated/eprel-client/src/helper/get-sheet-url.ts similarity index 100% rename from packages/eprel-client/src/helper/get-sheet-url.ts rename to packages/_deprecated/eprel-client/src/helper/get-sheet-url.ts diff --git a/packages/eprel-client/src/helper/index.ts b/packages/_deprecated/eprel-client/src/helper/index.ts similarity index 100% rename from packages/eprel-client/src/helper/index.ts rename to packages/_deprecated/eprel-client/src/helper/index.ts diff --git a/packages/eprel-client/src/index.ts b/packages/_deprecated/eprel-client/src/index.ts similarity index 100% rename from packages/eprel-client/src/index.ts rename to packages/_deprecated/eprel-client/src/index.ts diff --git a/packages/eprel-client/src/types.ts b/packages/_deprecated/eprel-client/src/types.ts similarity index 100% rename from packages/eprel-client/src/types.ts rename to packages/_deprecated/eprel-client/src/types.ts diff --git a/packages/eprel-client/src/with-eprel.ts b/packages/_deprecated/eprel-client/src/with-eprel.ts similarity index 100% rename from packages/eprel-client/src/with-eprel.ts rename to packages/_deprecated/eprel-client/src/with-eprel.ts diff --git a/packages/eprel-client/tsconfig.json b/packages/_deprecated/eprel-client/tsconfig.json similarity index 100% rename from packages/eprel-client/tsconfig.json rename to packages/_deprecated/eprel-client/tsconfig.json diff --git a/packages/eprel-client/tsconfig.prod.json b/packages/_deprecated/eprel-client/tsconfig.prod.json similarity index 100% rename from packages/eprel-client/tsconfig.prod.json rename to packages/_deprecated/eprel-client/tsconfig.prod.json diff --git a/packages/eprel-client/vitest.config.mjs b/packages/_deprecated/eprel-client/vitest.config.mjs similarity index 100% rename from packages/eprel-client/vitest.config.mjs rename to packages/_deprecated/eprel-client/vitest.config.mjs diff --git a/packages/split-flap-board/.github/banner.svg b/packages/_deprecated/split-flap-board/.github/banner.svg similarity index 100% rename from packages/split-flap-board/.github/banner.svg rename to packages/_deprecated/split-flap-board/.github/banner.svg diff --git a/packages/split-flap-board/CHANGELOG.md b/packages/_deprecated/split-flap-board/CHANGELOG.md similarity index 100% rename from packages/split-flap-board/CHANGELOG.md rename to packages/_deprecated/split-flap-board/CHANGELOG.md diff --git a/packages/split-flap-board/README.md b/packages/_deprecated/split-flap-board/README.md similarity index 100% rename from packages/split-flap-board/README.md rename to packages/_deprecated/split-flap-board/README.md diff --git a/packages/split-flap-board/dev/index.html b/packages/_deprecated/split-flap-board/dev/index.html similarity index 100% rename from packages/split-flap-board/dev/index.html rename to packages/_deprecated/split-flap-board/dev/index.html diff --git a/packages/split-flap-board/dev/main.ts b/packages/_deprecated/split-flap-board/dev/main.ts similarity index 100% rename from packages/split-flap-board/dev/main.ts rename to packages/_deprecated/split-flap-board/dev/main.ts diff --git a/packages/split-flap-board/dev/vite.config.mjs b/packages/_deprecated/split-flap-board/dev/vite.config.mjs similarity index 100% rename from packages/split-flap-board/dev/vite.config.mjs rename to packages/_deprecated/split-flap-board/dev/vite.config.mjs diff --git a/packages/split-flap-board/eslint.config.js b/packages/_deprecated/split-flap-board/eslint.config.js similarity index 100% rename from packages/split-flap-board/eslint.config.js rename to packages/_deprecated/split-flap-board/eslint.config.js diff --git a/packages/split-flap-board/package.json b/packages/_deprecated/split-flap-board/package.json similarity index 100% rename from packages/split-flap-board/package.json rename to packages/_deprecated/split-flap-board/package.json diff --git a/packages/split-flap-board/rollup.config.js b/packages/_deprecated/split-flap-board/rollup.config.js similarity index 100% rename from packages/split-flap-board/rollup.config.js rename to packages/_deprecated/split-flap-board/rollup.config.js diff --git a/packages/split-flap-board/src/SplitFlapBoard.ts b/packages/_deprecated/split-flap-board/src/SplitFlapBoard.ts similarity index 100% rename from packages/split-flap-board/src/SplitFlapBoard.ts rename to packages/_deprecated/split-flap-board/src/SplitFlapBoard.ts diff --git a/packages/split-flap-board/src/index.ts b/packages/_deprecated/split-flap-board/src/index.ts similarity index 100% rename from packages/split-flap-board/src/index.ts rename to packages/_deprecated/split-flap-board/src/index.ts diff --git a/packages/split-flap-board/src/lib/board.test.ts b/packages/_deprecated/split-flap-board/src/lib/board.test.ts similarity index 100% rename from packages/split-flap-board/src/lib/board.test.ts rename to packages/_deprecated/split-flap-board/src/lib/board.test.ts diff --git a/packages/split-flap-board/src/lib/board.ts b/packages/_deprecated/split-flap-board/src/lib/board.ts similarity index 100% rename from packages/split-flap-board/src/lib/board.ts rename to packages/_deprecated/split-flap-board/src/lib/board.ts diff --git a/packages/split-flap-board/src/lib/flap.ts b/packages/_deprecated/split-flap-board/src/lib/flap.ts similarity index 100% rename from packages/split-flap-board/src/lib/flap.ts rename to packages/_deprecated/split-flap-board/src/lib/flap.ts diff --git a/packages/split-flap-board/src/lib/index.ts b/packages/_deprecated/split-flap-board/src/lib/index.ts similarity index 100% rename from packages/split-flap-board/src/lib/index.ts rename to packages/_deprecated/split-flap-board/src/lib/index.ts diff --git a/packages/split-flap-board/src/lib/spool-layout.test.ts b/packages/_deprecated/split-flap-board/src/lib/spool-layout.test.ts similarity index 100% rename from packages/split-flap-board/src/lib/spool-layout.test.ts rename to packages/_deprecated/split-flap-board/src/lib/spool-layout.test.ts diff --git a/packages/split-flap-board/src/lib/spool-layout.ts b/packages/_deprecated/split-flap-board/src/lib/spool-layout.ts similarity index 100% rename from packages/split-flap-board/src/lib/spool-layout.ts rename to packages/_deprecated/split-flap-board/src/lib/spool-layout.ts diff --git a/packages/split-flap-board/src/spools/SplitFlapSpool.test.ts b/packages/_deprecated/split-flap-board/src/spools/SplitFlapSpool.test.ts similarity index 100% rename from packages/split-flap-board/src/spools/SplitFlapSpool.test.ts rename to packages/_deprecated/split-flap-board/src/spools/SplitFlapSpool.test.ts diff --git a/packages/split-flap-board/src/spools/SplitFlapSpool.ts b/packages/_deprecated/split-flap-board/src/spools/SplitFlapSpool.ts similarity index 100% rename from packages/split-flap-board/src/spools/SplitFlapSpool.ts rename to packages/_deprecated/split-flap-board/src/spools/SplitFlapSpool.ts diff --git a/packages/split-flap-board/src/spools/SplitFlapSpoolBase.ts b/packages/_deprecated/split-flap-board/src/spools/SplitFlapSpoolBase.ts similarity index 100% rename from packages/split-flap-board/src/spools/SplitFlapSpoolBase.ts rename to packages/_deprecated/split-flap-board/src/spools/SplitFlapSpoolBase.ts diff --git a/packages/split-flap-board/src/spools/SplitFlapSpoolMinimal.ts b/packages/_deprecated/split-flap-board/src/spools/SplitFlapSpoolMinimal.ts similarity index 100% rename from packages/split-flap-board/src/spools/SplitFlapSpoolMinimal.ts rename to packages/_deprecated/split-flap-board/src/spools/SplitFlapSpoolMinimal.ts diff --git a/packages/split-flap-board/src/spools/SplitFlapSpoolRealistic.ts b/packages/_deprecated/split-flap-board/src/spools/SplitFlapSpoolRealistic.ts similarity index 100% rename from packages/split-flap-board/src/spools/SplitFlapSpoolRealistic.ts rename to packages/_deprecated/split-flap-board/src/spools/SplitFlapSpoolRealistic.ts diff --git a/packages/split-flap-board/src/spools/index.ts b/packages/_deprecated/split-flap-board/src/spools/index.ts similarity index 100% rename from packages/split-flap-board/src/spools/index.ts rename to packages/_deprecated/split-flap-board/src/spools/index.ts diff --git a/packages/split-flap-board/src/spools/presets.ts b/packages/_deprecated/split-flap-board/src/spools/presets.ts similarity index 100% rename from packages/split-flap-board/src/spools/presets.ts rename to packages/_deprecated/split-flap-board/src/spools/presets.ts diff --git a/packages/split-flap-board/src/types.ts b/packages/_deprecated/split-flap-board/src/types.ts similarity index 100% rename from packages/split-flap-board/src/types.ts rename to packages/_deprecated/split-flap-board/src/types.ts diff --git a/packages/split-flap-board/tsconfig.json b/packages/_deprecated/split-flap-board/tsconfig.json similarity index 100% rename from packages/split-flap-board/tsconfig.json rename to packages/_deprecated/split-flap-board/tsconfig.json diff --git a/packages/split-flap-board/tsconfig.prod.json b/packages/_deprecated/split-flap-board/tsconfig.prod.json similarity index 100% rename from packages/split-flap-board/tsconfig.prod.json rename to packages/_deprecated/split-flap-board/tsconfig.prod.json diff --git a/packages/split-flap-board/vitest.config.mjs b/packages/_deprecated/split-flap-board/vitest.config.mjs similarity index 100% rename from packages/split-flap-board/vitest.config.mjs rename to packages/_deprecated/split-flap-board/vitest.config.mjs diff --git a/packages/config/eslint/base.js b/packages/config/eslint/base.js index 5a933dbf..712b62e9 100644 --- a/packages/config/eslint/base.js +++ b/packages/config/eslint/base.js @@ -30,6 +30,14 @@ module.exports = [ onlyWarn } }, + { + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { ignoreRestSiblings: true, varsIgnorePattern: '^_', argsIgnorePattern: '^_' } + ] + } + }, // Tooling config files run in Node and may intentionally use CommonJS { files: ['*.config.js', '*.config.cjs'], diff --git a/packages/config/package.json b/packages/config/package.json index b6b14c68..c9d86a31 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -42,20 +42,20 @@ "dependencies": { "@eslint/js": "^9.39.2", "@ianvs/prettier-plugin-sort-imports": "^4.7.1", - "@next/eslint-plugin-next": "^16.2.4", - "@typescript-eslint/eslint-plugin": "^8.59.1", - "@typescript-eslint/parser": "^8.59.1", + "@next/eslint-plugin-next": "^16.2.6", + "@typescript-eslint/eslint-plugin": "^8.59.4", + "@typescript-eslint/parser": "^8.59.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-only-warn": "^1.2.1", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-turbo": "^2.9.8", + "eslint-plugin-turbo": "^2.9.14", "globals": "^17.6.0", "prettier-plugin-css-order": "^2.2.0", "prettier-plugin-packagejson": "^3.0.2", "prettier-plugin-tailwindcss": "^0.8.0", - "typescript-eslint": "^8.59.1", - "vitest": "^4.1.5" + "typescript-eslint": "^8.59.4", + "vitest": "^4.1.7" }, "devDependencies": { "eslint": "^9.39.2", diff --git a/packages/feature-core/.github/banner.svg b/packages/feature-core/.github/banner.svg new file mode 100644 index 00000000..25b9a646 --- /dev/null +++ b/packages/feature-core/.github/banner.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/feature-core/README.md b/packages/feature-core/README.md new file mode 100644 index 00000000..381fb42f --- /dev/null +++ b/packages/feature-core/README.md @@ -0,0 +1,296 @@ +

+ feature-core banner +

+ +

+ + GitHub License + + + NPM bundle minzipped size + + + NPM total downloads + + + Join Discord + +

+ +> Status: Experimental + +The `.with(feature())` composition layer for extensible TypeScript libraries. Write the feature logic; `feature-core` handles type tracking, dependency validation, and API merging. + +- TypeScript enforces feature dependencies at the call site: a missing required feature is a compile error, not a runtime crash +- No registries, no lifecycles, no decorators: features install directly onto a plain object via `.with()` +- No custom type gymnastics: the `.with()` signature, dependency checking, and API merging come built in +- Features are host-agnostic: the same feature installs on any object that provides the base API it needs + +```ts +const counter = createCounter(0).with(resetFeature(), resetTwiceFeature()); + +counter.reset(); // typed +counter.resetTwice(); // typed: type error if resetFeature() was not passed first +counter.missing(); // type error: property does not exist on this type + +if (hasFeature(counter, 'reset')) { + counter.reset(); // narrowed +} +``` + +## Install + +```bash +npm install feature-core +``` + +## Three Roles + +There are three roles in the feature model: + +| Role | Responsibility | +| ------------------ | ---------------------------------------------- | +| **Consumer** | Composes features on a host with `.with()` | +| **Library author** | Wraps a base object with `createFeatureHost()` | +| **Feature author** | Creates features with `defineFeature()` | + +## Consumer + +Call `.with()` with one or more features. Features are validated in order. Each one is checked against the host produced by all preceding features: + +```ts +// Chained: one feature at a time +const counter = createCounter(0).with(resetFeature()).with(resetTwiceFeature()); + +// Variadic: all at once, validated left to right +const counter = createCounter(0).with(resetFeature(), resetTwiceFeature()); +``` + +Both forms are equivalent. Prefer chained calls when the list gets long. + +`.with()` mutates the original object and returns it. The base reference and the result are the same object: + +```ts +const base = createCounter(0); +const withReset = base.with(resetFeature()); +// base === withReset, same object, reset() is now on both +``` + +Use `hasFeature()` for runtime checks: + +```ts +if (hasFeature(value, 'reset')) { + value.reset(); // narrowed +} +``` + +## Library Author + +Wrap the base object with `createFeatureHost()` and export a typed host alias: + +```ts +import { createFeatureHost, type TFeature, type TFeatureHost } from 'feature-core'; + +interface TCounterBase { + get: () => number; + set: (nextValue: number) => void; +} + +type TResetFeature = TFeature<'reset', { reset(): void }>; +type TResetTwiceFeature = TFeature<'resetTwice', { resetTwice(): void }, [TResetFeature]>; + +type TCounterFeature = TResetFeature | TResetTwiceFeature; +export type TCounter = TFeatureHost; + +export function createCounter(initialValue: number): TCounter<[]> { + let value = initialValue; + + return createFeatureHost({ + get() { + return value; + }, + set(nextValue) { + value = nextValue; + } + }); +} +``` + +`TFeature` has four parts: + +```ts +type TMyFeature = TFeature<'my-feature', TMyFeatureApi, [TRequiredFeature], 'methodToOverride'>; +// ^ runtime key ^ API shape ^ required features ^ override keys +``` + +The third and fourth generics are optional and default to `[]` and `never`. + +**Checklist:** + +- Define the base API as an interface +- Define feature contracts with named `TFeature` aliases +- Export the host type as `TFeatureHost` +- Return `createFeatureHost(base)` from the factory +- Keep feature keys unique. Known duplicates fail at type level; dynamic duplicates still throw at runtime. + +Use `TAnyFeature` as the feature type in generic utilities that work across any feature type, for example a function that accepts any feature host. + +Use `TInstalledFeaturesOf` when a generic utility needs to recover the installed feature tuple from a host type. + +## Feature Author + +Use `defineFeature()`. Pass the feature type explicitly to get a typed install host and validated `requires`: + +```ts +import { defineFeature, type TFeature } from 'feature-core'; + +type TResetFeature = TFeature<'reset', { reset(): void }>; + +export function resetFeature(): TResetFeature { + return defineFeature({ + key: 'reset', + install(counter: TCounterBase) { + const initialValue = counter.get(); + + return { + reset() { + counter.set(initialValue); + } + }; + } + }); +} +``` + +Annotate the `install()` parameter with the base host type when the feature needs base APIs. The parameter is `never` by default. There is no implicit host type because features are designed to work across different host shapes. + +For local or one-off features, the generic can be omitted and the type is inferred: + +```ts +const debugFeature = () => + defineFeature({ + key: 'debug', + install() { + return { + debug() { + return true; + } + }; + } + }); +``` + +### Dependent Features + +List required feature types in the third `TFeature` generic and mirror those keys in `requires`. The order must match: + +```ts +type TResetTwiceFeature = TFeature<'resetTwice', { resetTwice(): void }, [TResetFeature]>; + +export function resetTwiceFeature(): TResetTwiceFeature { + return defineFeature({ + key: 'resetTwice', + requires: ['reset'], + install(counter) { + return { + resetTwice() { + counter.reset(); + counter.reset(); + } + }; + } + }); +} +``` + +The `install()` host is typed from the required feature APIs, so `counter.reset()` above is available without any cast. If the feature also needs base APIs, annotate the parameter with the full host type: + +```ts +install(counter: TCounter<[TResetFeature]>) { ... } +``` + +### API-less Features + +Features that only mutate internal configuration return an empty object: + +```ts +type TCacheFeature = TFeature<'cache', Record>; + +export function cacheFeature(): TCacheFeature { + return defineFeature({ + key: 'cache', + install(client: TFetchClientBase) { + client._config.requestMiddlewares.push(cacheMiddleware()); + return {}; + } + }); +} +``` + +### Overriding Host APIs + +Most features add new methods. When a feature intentionally replaces an existing host method, declare that in `overrides` and in the fourth `TFeature` generic: + +```ts +type TLoggedSetFeature = TFeature<'logged-set', { set(nextValue: number): void }, [], 'set'>; + +export function loggedSetFeature(): TLoggedSetFeature { + return defineFeature({ + key: 'logged-set', + overrides: ['set'], + install(counter: TCounterBase) { + // Capture the original before Object.assign replaces it with this override + const originalSet = counter.set.bind(counter); + + return { + set(nextValue) { + console.log('set', nextValue); + originalSet(nextValue); + } + }; + } + }); +} +``` + +Capture the previous method before returning the override when you want `super`-style behavior. Calling `counter.set()` inside the returned `set()` method would call the override again after installation. + +## FAQ + +### Why "features" instead of "plugins"? + +"Plugin" implies discovery, lifecycle hooks, registries, or installable packages. `feature-core` composes typed capabilities directly onto a host object. That narrower contract is better described as a feature. + +### Why does the host get mutated instead of copied? + +Feature installation happens at construction time, not at runtime. Mutation keeps the model simple: there is one object, its identity never changes, and installed feature APIs are just properties on it. Copying would require re-typing the result on every `.with()` call anyway, so there is no practical benefit. + +### Why does `requires` order have to mirror the dependency tuple? + +An unordered approach (union array) would only validate that listed keys are _allowed_, not that _every_ required key is present. A partial `requires` would silently pass. The positional tuple enforces completeness, with one rule: the `requires` array order must mirror the `GRequiredFeatures` tuple order. + +### Why do I have to annotate the `install()` parameter myself? + +Features are host-agnostic. The same feature can be installed on different host shapes, so there is no single type to infer. Annotate the parameter with whatever the feature actually needs: the library base type, a full host type, or nothing at all if the feature does not use the host: + +```ts +install(host: TCounterBase) { ... } // needs base APIs +install(host: TCounter<[TResetFeature]>) { ... } // needs base + reset +install() { ... } // does not use the host +``` + +### Does `feature-core` validate the base host type? + +No. `feature-core` validates feature dependencies and the APIs added by installed features, but it does not carry a global base-host constraint per feature. If a feature needs base APIs, annotate the `install()` parameter with the host shape it actually uses. + +This keeps features reusable across libraries and avoids making every feature carry extra generic state. Package-specific tests should cover whether a feature is valid for that package's base host. + +### When should I use explicit `defineFeature()` vs inferred? + +Use explicit when the feature is exported or referenced by name elsewhere. TypeScript will validate that the key, API shape, and `requires` all match the declared type contract, catching mismatches at definition time. Use inferred for local or one-off features where no external contract exists. + +### Why does `overrides` require an explicit declaration instead of allowing any collision? + +The explicit declaration is what distinguishes an intentional replacement from a mistake. Without it, a typo in a returned method name would silently overwrite an existing host method instead of throwing. `overrides` makes the intent auditable at a glance and keeps the collision guard intact for everything not listed. + +It also avoids implicit chaining. An automatic `super`-style approach would call the previous method on your behalf, but that hides whether the original runs at all, and when. Capturing the previous method in a closure is one extra line and makes both facts explicit in the code. diff --git a/packages/feature-core/package.json b/packages/feature-core/package.json index c50dca8d..93076f40 100644 --- a/packages/feature-core/package.json +++ b/packages/feature-core/package.json @@ -2,8 +2,21 @@ "name": "feature-core", "version": "0.0.1", "private": false, - "description": "", - "keywords": [], + "description": "Typed .with() feature composition for extensible TypeScript objects.", + "keywords": [ + "typescript", + "composition", + "feature-composition", + "extensible", + "composable", + "library-authoring", + "feature", + "typesafe", + "with", + "mixin", + "plugin", + "dependency-validation" + ], "homepage": "https://builder.group/?utm_source=package-json", "bugs": { "url": "https://github.com/builder-group/community/issues" @@ -32,6 +45,7 @@ "size": "size-limit --why", "start:dev": "tsc -w", "test": "vitest run", + "test:types": "vitest run --typecheck.only", "update:latest": "pnpm update --latest" }, "devDependencies": { diff --git a/packages/feature-core/src/index.test-d.ts b/packages/feature-core/src/index.test-d.ts new file mode 100644 index 00000000..52ea9fe6 --- /dev/null +++ b/packages/feature-core/src/index.test-d.ts @@ -0,0 +1,442 @@ +import { assertType, describe, expectTypeOf, it } from 'vitest'; +import { + createFeatureHost, + defineFeature, + hasFeature, + installFeature, + type TAnyFeature, + type TFeature, + type TFeatureHost, + type TInstalledFeaturesOf +} from './index'; + +describe('feature-core package', () => { + describe('createFeatureHost function', () => { + it('should preserve the base API and expose an empty feature tuple', () => { + const counter = createCounter(0); + + assertType(counter.get()); + expectTypeOf(counter).toEqualTypeOf>(); + }); + }); + + describe('defineFeature function', () => { + it('should infer feature keys and APIs without an explicit feature type', () => { + const feature = inferredFeature(); + const counter = createCounter(0).with(feature); + + assertType>(feature); + assertType(counter.inferred()); + }); + + it('should allow declaring override keys', () => { + const feature = getAsStringFeature(); + + assertType(feature); + expectTypeOf(feature.overrides).toEqualTypeOf(); + }); + + it('should reject missing runtime override keys for a declared override feature', () => { + // @ts-expect-error declared override features must include runtime overrides. + defineFeature({ + key: 'getAsString', + install() { + return { + get() { + return '0'; + } + }; + } + }); + }); + + it('should type the install host from required feature APIs', () => { + const feature = defineFeature({ + key: 'resetTwice', + requires: ['reset'], + install(counter) { + assertType<() => void>(counter.reset); + + return { + resetTwice() { + counter.reset(); + counter.reset(); + } + }; + } + }); + + assertType(feature); + }); + + it('should allow dependent features to annotate a full host when base APIs are needed', () => { + const feature = defineFeature({ + key: 'resetAndRead', + requires: ['reset'], + install(counter: TCounter<[TResetFeature]>) { + assertType(counter.get()); + assertType<() => void>(counter.reset); + + return { + resetAndRead() { + counter.reset(); + + return counter.get(); + } + }; + } + }); + + assertType(feature); + }); + + it('should reject a runtime key that does not match the declared feature', () => { + defineFeature({ + // @ts-expect-error declared feature key must match the runtime feature key. + key: 'wrong', + install(counter: TCounterBase) { + return { + reset() { + counter.set(0); + } + }; + } + }); + }); + + it('should reject an API that does not match the declared feature', () => { + defineFeature({ + key: 'reset', + // @ts-expect-error declared feature API must match the install return value. + install() { + return { + wrong() { + return undefined; + } + }; + } + }); + }); + + it('should reject a required key that is not declared by the feature', () => { + defineFeature({ + key: 'resetTwice', + // @ts-expect-error declared required feature keys must match runtime requires. + requires: ['logger'], + install() { + return { resetTwice() {} }; + } + }); + }); + + it('should reject missing required feature keys', () => { + defineFeature({ + key: 'resetLogger', + // @ts-expect-error every declared required feature key must be listed. + requires: ['reset'], + install(counter) { + return { + resetLogger() { + counter.reset(); + counter.log(); + } + }; + } + }); + }); + + it('should reject required feature keys in the wrong order', () => { + defineFeature({ + key: 'resetLogger', + // @ts-expect-error requires mirrors the declared required feature tuple. + requires: ['logger', 'reset'], + install(counter) { + return { + resetLogger() { + counter.reset(); + counter.log(); + } + }; + } + }); + }); + }); + + describe('installFeature function', () => { + it('should append an installed feature to the host tuple', () => { + const counter = installFeature(createCounter(0), resetFeature()); + + assertType<() => void>(counter.reset); + expectTypeOf(counter).toEqualTypeOf>(); + }); + + it('should type overridden base APIs from the feature API', () => { + const counter = installFeature(createCounter(0), getAsStringFeature()); + + assertType(counter.get()); + assertType<(nextValue: number) => void>(counter.set); + expectTypeOf(counter).toEqualTypeOf>(); + }); + + it('should reject a feature with missing dependencies', () => { + const counter = createCounter(0); + + // @ts-expect-error resetTwiceFeature requires resetFeature first. + installFeature(counter, resetTwiceFeature()); + }); + + it('should reject a feature key that is already installed', () => { + const counter = createCounter(0).with(resetFeature()); + + // @ts-expect-error resetFeature is already installed. + installFeature(counter, resetFeature()); + }); + }); + + describe('host.with method', () => { + it('should infer chained feature order', () => { + const counter = createCounter(0) + .with(resetFeature()) + .with(resetTwiceFeature()) + .with(loggerFeature()); + + assertType(counter.log()); + expectTypeOf(counter.resetTwice).returns.toBeVoid(); + expectTypeOf(counter).toEqualTypeOf< + TCounter<[TResetFeature, TResetTwiceFeature, TLoggerFeature]> + >(); + }); + + it('should infer variadic feature order', () => { + const counter = createCounter(0).with( + resetFeature(), + resetTwiceFeature(), + loggerFeature(), + labelFeature(), + metaFeature(), + auditFeature() + ); + + assertType(counter.label()); + assertType(counter.audit()); + expectTypeOf(counter._features).toEqualTypeOf< + readonly ('reset' | 'resetTwice' | 'logger' | 'label' | 'meta' | 'audit')[] + >(); + }); + + it('should extract the installed feature tuple from a host type', () => { + const counter = createCounter(0) + .with(resetFeature()) + .with(resetTwiceFeature()) + .with(loggerFeature()); + + assertType(counter.log()); + expectTypeOf>().toEqualTypeOf< + [TResetFeature, TResetTwiceFeature, TLoggerFeature] + >(); + }); + + it('should reject unavailable APIs before their feature is installed', () => { + const counter = createCounter(0); + + // @ts-expect-error reset is not available before resetFeature is installed. + counter.reset(); + }); + + it('should reject missing dependencies in install order', () => { + const counter = createCounter(0); + + // @ts-expect-error resetTwiceFeature requires resetFeature first. + counter.with(resetTwiceFeature()); + }); + + it('should reject a feature key that is already installed in a later chain call', () => { + const counter = createCounter(0).with(resetFeature()); + + // @ts-expect-error resetFeature is already installed. + counter.with(resetFeature()); + }); + + it('should reject a feature key that is repeated in the same variadic call', () => { + const counter = createCounter(0); + + // @ts-expect-error resetFeature is already installed earlier in the same call. + counter.with(resetFeature(), resetFeature()); + }); + + it('should allow installing a feature on a broadly typed host', () => { + const counter = createCounter(0) as TFeatureHost; + const counterWithReset = counter.with(resetFeature()); + + assertType(counterWithReset._features); + }); + + it('should expose readonly feature metadata', () => { + const counter = createCounter(0); + + // @ts-expect-error _features is visible but readonly. + counter._features.push('reset'); + }); + }); + + describe('hasFeature function', () => { + it('should narrow a matching feature key capability', () => { + const counter = createCounter(0).with(loggerFeature()); + const unknownCounter: unknown = counter; + + if (hasFeature(unknownCounter, 'logger')) { + assertType(unknownCounter.log()); + } + }); + }); +}); + +function createCounter(initialValue: number): TCounter<[]> { + let value = initialValue; + const base: TCounterBase = { + get() { + return value; + }, + set(nextValue) { + value = nextValue; + } + }; + + return createFeatureHost(base); +} + +function resetFeature(): TResetFeature { + return defineFeature({ + key: 'reset', + install(counter: TCounterBase) { + const initialValue = counter.get(); + + return { + reset() { + counter.set(initialValue); + } + }; + } + }); +} + +function resetTwiceFeature(): TResetTwiceFeature { + return defineFeature({ + key: 'resetTwice', + requires: ['reset'], + install(counter) { + return { + resetTwice() { + counter.reset(); + counter.reset(); + } + }; + } + }); +} + +function loggerFeature(): TLoggerFeature { + return defineFeature({ + key: 'logger', + install(counter: TCounterBase) { + return { + log() { + return counter.get(); + } + }; + } + }); +} + +function labelFeature(): TLabelFeature { + return defineFeature({ + key: 'label', + install(counter: TCounterBase) { + return { + label() { + return `Count: ${counter.get()}`; + } + }; + } + }); +} + +function metaFeature(): TMetaFeature { + return defineFeature({ + key: 'meta', + install() { + return {}; + } + }); +} + +function auditFeature(): TAuditFeature { + return defineFeature({ + key: 'audit', + install(counter: TCounterBase) { + return { + audit() { + return counter.get(); + } + }; + } + }); +} + +function inferredFeature() { + return defineFeature({ + key: 'inferred', + install() { + return { + inferred() { + return true; + } + }; + } + }); +} + +function getAsStringFeature(): TGetAsStringFeature { + return defineFeature({ + key: 'getAsString', + overrides: ['get'], + install() { + return { + get() { + return '0'; + } + }; + } + }); +} + +interface TCounterBase { + get: () => number; + set: (nextValue: number) => void; +} + +type TCounter = TFeatureHost; + +type TResetFeature = TFeature<'reset', { reset(): void }>; +type TResetTwiceFeature = TFeature<'resetTwice', { resetTwice(): void }, [TResetFeature]>; +type TResetAndReadFeature = TFeature<'resetAndRead', { resetAndRead(): number }, [TResetFeature]>; +type TResetLoggerFeature = TFeature< + 'resetLogger', + { resetLogger(): void }, + [TResetFeature, TLoggerFeature] +>; +type TLoggerFeature = TFeature<'logger', { log(): number }>; +type TLabelFeature = TFeature<'label', { label(): string }>; +type TMetaFeature = TFeature<'meta', Record>; +type TAuditFeature = TFeature<'audit', { audit(): number }>; +type TGetAsStringFeature = TFeature<'getAsString', { get(): string }, [], 'get'>; + +type TCounterFeature = + | TResetFeature + | TResetTwiceFeature + | TResetAndReadFeature + | TLoggerFeature + | TLabelFeature + | TMetaFeature + | TAuditFeature + | TGetAsStringFeature; diff --git a/packages/feature-core/src/index.test.ts b/packages/feature-core/src/index.test.ts new file mode 100644 index 00000000..c603b25c --- /dev/null +++ b/packages/feature-core/src/index.test.ts @@ -0,0 +1,487 @@ +import { describe, expect, it } from 'vitest'; +import { + createFeatureHost, + defineFeature, + hasFeature, + installFeature, + type TFeature, + type TFeatureHost +} from './index'; + +describe('feature-core package', () => { + describe('createFeatureHost function', () => { + it('should add feature host metadata to a base object', () => { + // Prepare + const base = { value: 1 }; + + // Act + const host = createFeatureHost(base); + + // Assert + expect(host).toBe(base); + expect(host.value).toBe(1); + expect(host._features).toStrictEqual([]); + expect(host.with).toBeTypeOf('function'); + }); + + it('should throw when the base object already defines with', () => { + // Prepare + const base = { + with() { + return undefined; + } + }; + + // Act & Assert + expect(() => { + createFeatureHost(base); + }).toThrow('Feature host cannot overwrite existing property "with"'); + }); + + it('should throw when the base object already defines _features', () => { + // Prepare + const base = { + _features: [] + }; + + // Act & Assert + expect(() => { + createFeatureHost(base); + }).toThrow('Feature host cannot overwrite existing property "_features"'); + }); + }); + + describe('defineFeature function', () => { + it('should create a feature with empty requirements by default', () => { + // Prepare + const feature = defineFeature({ + key: 'enabled', + install() { + return { + isEnabled() { + return true; + } + }; + } + }); + + // Act + const api = feature.install(undefined as never); + + // Assert + expect(feature.key).toBe('enabled'); + expect(feature.overrides).toStrictEqual([]); + expect(feature.requires).toStrictEqual([]); + expect(api.isEnabled()).toBe(true); + }); + + it('should keep declared runtime requirements', () => { + // Act + const feature = resetTwiceFeature(); + + // Assert + expect(feature.key).toBe('resetTwice'); + expect(feature.overrides).toStrictEqual([]); + expect(feature.requires).toStrictEqual(['reset']); + }); + }); + + describe('installFeature function', () => { + it('should install a feature API on a host', () => { + // Prepare + const counter = createCounter(0); + + // Act + const counterWithReset = installFeature(counter, resetFeature()); + counterWithReset.set(5); + counterWithReset.reset(); + + // Assert + expect(counterWithReset.get()).toBe(0); + expect(counterWithReset._features).toStrictEqual(['reset']); + }); + + it('should throw when a required feature is missing', () => { + // Prepare + const counter = createCounter(0); + const feature = resetTwiceFeature(); + + // Act & Assert + expect(() => { + installFeature(counter, feature as unknown as TFeature); + }).toThrow('Feature "resetTwice" requires missing feature "reset"'); + }); + + it('should throw when a feature is installed twice', () => { + // Prepare + const counter = createCounter(0).with(resetFeature()); + + // Act & Assert + expect(() => { + installFeature(counter as unknown as TCounter<[]>, resetFeature()); + }).toThrow('Feature "reset" is already installed'); + }); + + it('should throw when a feature API overwrites an existing string property', () => { + // Prepare + const counter = createCounter(0); + + // Act & Assert + expect(() => { + installFeature(counter, overwriteGetFeature()); + }).toThrow('Feature "overwriteGet" cannot overwrite existing property "get"'); + }); + + it('should allow a feature API to override an explicitly listed property', () => { + // Prepare + const counter = createCounter(0); + + // Act + const counterWithOverride = installFeature(counter, overrideGetFeature()); + + // Assert + expect(counterWithOverride.get()).toBe(42); + expect(counterWithOverride._features).toStrictEqual(['overrideGet']); + }); + + it('should allow an override to call the previous host method', () => { + // Prepare + const counter = createCounter(0); + + // Act + const counterWithOverride = installFeature(counter, overrideSetFeature()); + counterWithOverride.set(4); + + // Assert + expect(counterWithOverride.get()).toBe(5); + expect(counterWithOverride._features).toStrictEqual(['overrideSet']); + }); + + it('should throw when a feature overrides a missing host property', () => { + // Prepare + const counter = createCounter(0); + + // Act & Assert + expect(() => { + installFeature(counter, overrideMissingFeature()); + }).toThrow('Feature "overrideMissing" cannot override missing property "missing"'); + }); + + it('should throw when a feature declares an override without returning that API', () => { + // Prepare + const counter = createCounter(0); + + // Act & Assert + expect(() => { + installFeature(counter, missingOverrideApiFeature()); + }).toThrow('Feature "missingOverrideApi" declares override "get" but does not return it'); + }); + + it('should throw when a feature overrides a reserved host property', () => { + // Prepare + const counter = createCounter(0); + + // Act & Assert + expect(() => { + installFeature(counter, overrideWithFeature()); + }).toThrow('Feature "overrideWith" cannot override reserved property "with"'); + }); + + it('should throw when a feature API overwrites an existing symbol property', () => { + // Prepare + const symbolKey = Symbol('value'); + const host = createFeatureHost({ [symbolKey]: 1 }); + + // Act & Assert + expect(() => { + installFeature(host, overwriteSymbolFeature(symbolKey)); + }).toThrow('Feature "overwriteSymbol" cannot overwrite existing property "Symbol(value)"'); + }); + }); + + describe('hasFeature function', () => { + it('should return true when a feature key is installed', () => { + // Prepare + const counter = createCounter(0).with(resetFeature()); + + // Act + const result = hasFeature(counter, 'reset'); + + // Assert + expect(result).toBe(true); + }); + + it('should return false when a feature key is missing', () => { + // Prepare + const counter = createCounter(0).with(resetFeature()); + + // Act + const result = hasFeature(counter, 'logger'); + + // Assert + expect(result).toBe(false); + }); + + it('should return false for values that are not feature hosts', () => { + // Act & Assert + expect(hasFeature({}, 'reset')).toBe(false); + expect(hasFeature({ _features: ['reset'] }, 'reset')).toBe(false); + expect(hasFeature(null, 'reset')).toBe(false); + }); + }); + + describe('host.with method', () => { + it('should install chained features in order', () => { + // Prepare + const counter = createCounter(0) + .with(resetFeature()) + .with(resetTwiceFeature()) + .with(loggerFeature()); + + // Act + counter.set(5); + const loggedValue = counter.log(); + counter.resetTwice(); + + // Assert + expect(loggedValue).toBe(5); + expect(counter.get()).toBe(0); + expect(counter._features).toStrictEqual(['reset', 'resetTwice', 'logger']); + }); + + it('should install variadic features in order', () => { + // Prepare + const counter = createCounter(0).with(resetFeature(), resetTwiceFeature(), loggerFeature()); + + // Act + counter.set(5); + const loggedValue = counter.log(); + counter.resetTwice(); + + // Assert + expect(loggedValue).toBe(5); + expect(counter.get()).toBe(0); + expect(counter._features).toStrictEqual(['reset', 'resetTwice', 'logger']); + }); + + it('should support api-less features', () => { + // Prepare + const counter = createCounter(0); + + // Act + const counterWithMeta = counter.with(metaFeature()); + + // Assert + expect(counterWithMeta._features).toStrictEqual(['meta']); + }); + + it('should support feature methods that declare this explicitly', () => { + // Prepare + const counter = createCounter(0).with(resetWithThisFeature()); + + // Act + counter.set(10); + counter.resetWithThis(); + + // Assert + expect(counter.get()).toBe(0); + }); + }); +}); + +function createCounter(initialValue: number): TCounter<[]> { + let value = initialValue; + const base: TCounterBase = { + get() { + return value; + }, + set(nextValue) { + value = nextValue; + } + }; + + return createFeatureHost(base); +} + +function resetFeature(): TResetFeature { + return defineFeature({ + key: 'reset', + install(counter: TCounterBase) { + const initialValue = counter.get(); + + return { + reset() { + counter.set(initialValue); + } + }; + } + }); +} + +function resetTwiceFeature(): TResetTwiceFeature { + return defineFeature({ + key: 'resetTwice', + requires: ['reset'], + install(counter) { + return { + resetTwice() { + counter.reset(); + counter.reset(); + } + }; + } + }); +} + +function resetWithThisFeature(): TResetWithThisFeature { + return defineFeature({ + key: 'resetWithThis', + install(counter: TCounterBase) { + const initialValue = counter.get(); + + return { + resetWithThis(this: TCounterBase) { + this.set(initialValue); + } + }; + } + }); +} + +function loggerFeature(): TLoggerFeature { + return defineFeature({ + key: 'logger', + install(counter: TCounterBase) { + return { + log() { + return counter.get(); + } + }; + } + }); +} + +function metaFeature(): TMetaFeature { + return defineFeature({ + key: 'meta', + install() { + return {}; + } + }); +} + +function overwriteGetFeature() { + return defineFeature({ + key: 'overwriteGet', + install() { + return { + get() { + return 1; + } + }; + } + }); +} + +function overwriteSymbolFeature(symbolKey: symbol) { + return defineFeature({ + key: 'overwriteSymbol', + install() { + return { + [symbolKey]: 2 + }; + } + }); +} + +function overrideGetFeature(): TOverrideGetFeature { + return defineFeature({ + key: 'overrideGet', + overrides: ['get'], + install() { + return { + get() { + return 42; + } + }; + } + }); +} + +function overrideSetFeature(): TOverrideSetFeature { + return defineFeature({ + key: 'overrideSet', + overrides: ['set'], + install(counter: TCounterBase) { + const set = counter.set.bind(counter); + + return { + set(nextValue) { + set(nextValue + 1); + } + }; + } + }); +} + +function overrideMissingFeature() { + return defineFeature({ + key: 'overrideMissing', + overrides: ['missing'] as const, + install() { + return { + missing() { + return undefined; + } + }; + } + }); +} + +function missingOverrideApiFeature() { + return defineFeature({ + key: 'missingOverrideApi', + overrides: ['get'] as const, + install() { + return {}; + } + }); +} + +function overrideWithFeature() { + return defineFeature({ + key: 'overrideWith', + overrides: ['with'] as const, + install() { + return { + with() { + return undefined; + } + }; + } + }); +} + +interface TCounterBase { + get: () => number; + set: (nextValue: number) => void; +} + +type TCounter = TFeatureHost; + +type TResetFeature = TFeature<'reset', { reset(): void }>; +type TResetTwiceFeature = TFeature<'resetTwice', { resetTwice(): void }, [TResetFeature]>; +type TResetWithThisFeature = TFeature<'resetWithThis', { resetWithThis(this: TCounterBase): void }>; +type TLoggerFeature = TFeature<'logger', { log(): number }>; +type TMetaFeature = TFeature<'meta', Record>; +type TOverrideGetFeature = TFeature<'overrideGet', { get(): number }, [], 'get'>; +type TOverrideSetFeature = TFeature<'overrideSet', { set(nextValue: number): void }, [], 'set'>; + +type TCounterFeature = + | TResetFeature + | TResetTwiceFeature + | TResetWithThisFeature + | TLoggerFeature + | TMetaFeature + | TOverrideGetFeature + | TOverrideSetFeature; diff --git a/packages/feature-core/src/index.ts b/packages/feature-core/src/index.ts index 33db7a95..f3c4bf10 100644 --- a/packages/feature-core/src/index.ts +++ b/packages/feature-core/src/index.ts @@ -1,3 +1,352 @@ -export function helloWorld() { - return 'hello world'; +/** + * Adds feature metadata and `.with()` to a plain object. + * + * Mutates and returns the provided object. The base object must not already define + * reserved host keys such as `_features` or `with`. + */ +export function createFeatureHost(base: GBase): TFeatureHost { + // Reserved host properties must not already exist on base + for (const key of reservedFeatureHostKeys) { + if (key in base) { + throw new Error(`Feature host cannot overwrite existing property "${String(key)}"`); + } + } + + return Object.assign(base, { + _features: [], + with: withFeature + }) as TFeatureHost; } + +/** + * Defines a composable feature that can be installed on a feature host. + * + * Pass the feature type explicitly to get typed install host and validated `requires`: + * ```ts + * defineFeature({ key: 'my-feature', install(host) { ... } }) + * ``` + */ +export function defineFeature(definition: { + key: GKey; + install: (host: never) => GApi; +}): TFeature; +export function defineFeature< + const GKey extends string, + GApi extends object, + const GOverrideKeys extends readonly TFeatureOverrideKey[] +>(definition: { + key: GKey; + overrides: GOverrideKeys; + install: (host: never) => GApi; +}): TFeature; +export function defineFeature( + definition: TFeatureDefinition +): GFeature; +export function defineFeature(definition: { + key: string; + overrides?: readonly TFeatureOverrideKey[]; + requires?: readonly string[]; + install: (host: never) => object; +}): TAnyFeature { + return { + key: definition.key, + overrides: definition.overrides ?? [], + requires: definition.requires ?? [], + install: definition.install + }; +} + +/** + * Installs one feature on a host. + * + * Mutates and returns the host. Throws when requirements are missing, the feature is already + * installed, or the feature API would overwrite an existing property. + */ +export function installFeature< + GBase extends object, + GInstalledFeatures extends TAnyFeature[], + GFeatureToInstall extends TAnyFeature +>( + host: TFeatureHost, + feature: GFeatureToInstall & TInstallableFeature +): TFeatureHost { + // Required features must already be installed + const missingFeatureKey = feature.requires.find((key) => !host._features.includes(key)); + if (missingFeatureKey != null) { + throw new Error(`Feature "${feature.key}" requires missing feature "${missingFeatureKey}"`); + } + + // Feature must not be installed twice + if (host._features.includes(feature.key)) { + throw new Error(`Feature "${feature.key}" is already installed`); + } + + const api = feature.install(host as never); + + const apiKeys = Reflect.ownKeys(api); + const overrideKeys = new Set(feature.overrides); + + // Each declared override must exist on the host, not be reserved, and be returned by the feature + for (const key of feature.overrides) { + const isReservedHostKey = reservedFeatureHostKeys.includes(key); + if (isReservedHostKey) { + throw new Error( + `Feature "${feature.key}" cannot override reserved property "${String(key)}"` + ); + } + if (!(key in host)) { + throw new Error(`Feature "${feature.key}" cannot override missing property "${String(key)}"`); + } + if (!apiKeys.includes(key)) { + throw new Error( + `Feature "${feature.key}" declares override "${String(key)}" but does not return it` + ); + } + } + + // API keys must not collide with existing host properties + for (const key of apiKeys) { + if (key in host && !overrideKeys.has(key)) { + throw new Error( + `Feature "${feature.key}" cannot overwrite existing property "${String(key)}"` + ); + } + } + + Object.assign(host, api); + (host._features as string[]).push(feature.key); + + return host as unknown as TFeatureHost; +} + +/** Checks for an installed feature and narrows matching values to a feature host. */ +export function hasFeature( + host: unknown, + key: GFeature['key'] +): host is TFeatureHost; +export function hasFeature( + host: unknown, + key: GKey +): host is TFeatureHost]>; +export function hasFeature(host: unknown, key: string): boolean { + if (typeof host !== 'object' || host == null) { + return false; + } + if (!('_features' in host) || !Array.isArray(host._features)) { + return false; + } + if (!('with' in host) || typeof host.with !== 'function') { + return false; + } + + return host._features.includes(key); +} + +function withFeature( + this: TFeatureHost, + ...features: TAnyFeature[] +): TFeatureHost { + for (const feature of features) { + // Note: TWithFeatureMethod enforces type safety on the public signature; the casts here are safe because installFeature validates at runtime + installFeature(this as TFeatureHost, feature as TFeature); + } + + return this; +} + +const reservedFeatureHostKeys: readonly TFeatureOverrideKey[] = ['_features', 'with']; + +/** + * A composable feature with a key, API contract, and optional required features. + * + * Declare feature types as named aliases for use across the codebase: + * ```ts + * type TResetFeature = TFeature<'reset', { reset(): void }>; + * type TResetTwiceFeature = TFeature<'resetTwice', { resetTwice(): void }, [TResetFeature]>; + * ``` + */ +export interface TFeature< + GKey extends string = string, + GApi extends object = object, + GRequiredFeatures extends readonly TAnyFeature[] = [], + GOverrideKeys extends TFeatureOverrideKey = never +> { + /** Unique identifier. Used to detect duplicates and satisfy dependency checks. */ + key: GKey; + /** Host property keys this feature replaces. Declared overrides bypass the collision check. */ + overrides: readonly GOverrideKeys[]; + /** Keys of features that must be installed before this one. */ + requires: TRequiredFeatureKeyTuple; + /** Called during `.with()` to merge the feature's API onto the host. */ + install: (host: never) => GApi; +} + +// Note: Positional tuple rather than a union array so requires must list every key in order. +// A union array would accept partial lists, e.g. ['foo'] when ['foo', 'bar'] is required. +type TRequiredFeatureKeyTuple = + GFeatures extends readonly [ + infer GFeature extends TAnyFeature, + ...infer GRest extends TAnyFeature[] + ] + ? readonly [GFeature['key'], ...TRequiredFeatureKeyTuple] + : readonly []; + +/** + * Structural feature type for generic implementation code. + */ +// Note: A plain interface rather than `TFeature` to avoid a +// self-referential type definition. +export interface TAnyFeature { + key: string; + overrides: readonly TFeatureOverrideKey[]; + requires: readonly string[]; + install: (host: never) => object; +} + +type TFeatureOverrideKey = string | symbol; + +/** + * A base object extended with installed feature APIs and the `.with()` method. + * Each `.with()` call returns the same host object with a wider static type. + */ +export type TFeatureHost = Omit< + TApplyFeatureApis>, GInstalledFeatures>, + keyof TFeatureHostApi +> & + TFeatureHostApi; + +// Note: Omit before & so overriding features replace the overridden keys rather than intersecting with them +type TApplyFeatureApis< + GApi extends object, + GFeatures extends readonly TAnyFeature[] +> = GFeatures extends readonly [ + infer GFeature extends TAnyFeature, + ...infer GRest extends TAnyFeature[] +] + ? TApplyFeatureApis> & TFeatureApi, GRest> + : GApi; + +// Note: Cannot simplify to ReturnType because through the TAnyFeature constraint, +// install is typed as (host: never) => object, so ReturnType degrades to object +// and the return type check in TFeatureDefinition stops catching wrong implementations. +type TFeatureApi = + GFeature extends TFeature + ? GApi + : ReturnType; + +type TFeatureOverrideKeys = + GFeature extends TFeature + ? GOverrideKeys + : never; + +interface TFeatureHostApi { + /** @internal Prefer `hasFeature()` to check installed features. */ + readonly _features: readonly GInstalledFeatures[number]['key'][]; + /** Installs one or more features and returns the updated host with their APIs merged in. */ + with: TWithFeatureMethod; +} + +interface TWithFeatureMethod { + ( + ...features: GFeaturesToInstall & + TInstallableFeatureTuple + ): TFeatureHost; +} + +// Note: GInstalledFeatures grows with each recursive step so a feature can declare dependencies +// on features earlier in the same .with() call, not only on already-installed ones. +type TInstallableFeatureTuple< + GInstalledFeatures extends TAnyFeature[], + GFeaturesToInstall extends TAnyFeature[] +> = GFeaturesToInstall extends [ + infer GFeatureToInstall extends TAnyFeature, + ...infer GRest extends TAnyFeature[] +] + ? [ + TInstallableFeature, + ...TInstallableFeatureTuple<[...GInstalledFeatures, GFeatureToInstall], GRest> + ] + : []; + +type TInstallableFeature< + GFeatureToInstall extends TAnyFeature, + GInstalledFeatures extends TAnyFeature[] +> = + TDuplicateFeatureKey extends never + ? TMissingRequiredFeatureKeys extends never + ? GFeatureToInstall + : TMissingFeatureRequirementError< + TMissingRequiredFeatureKeys + > + : TDuplicateFeatureError>; + +// Note: When installed keys widen to string (e.g. TFeatureHost), +// literal comparison is meaningless; return never to skip duplicate detection for broad hosts. +type TDuplicateFeatureKey< + GInstalledFeatures extends TAnyFeature[], + GFeatureToInstall extends TAnyFeature +> = string extends GInstalledFeatures[number]['key'] + ? never + : Extract; + +interface TDuplicateFeatureError { + error: 'Feature already installed'; + duplicate: GDuplicateFeatureKey; +} + +type TMissingRequiredFeatureKeys< + GInstalledFeatures extends TAnyFeature[], + GRequiredKeys extends readonly string[] +> = Exclude; + +interface TMissingFeatureRequirementError { + error: 'Missing required features'; + missing: GMissingFeatureKeys; +} + +/** + * Definition object accepted by `defineFeature()`. + * + * When `GFeature` has required features, `requires` is mandatory and type-checked against + * the declared requirements. + */ +// Note: Install uses method syntax (not property syntax) for bivariant parameter checking, +// so feature authors can annotate a wider host type than the required-feature intersection. +export type TFeatureDefinition = { + key: GFeature['key']; +} & TFeatureRequirementDefinition & + TFeatureOverrideDefinition; + +type TFeatureRequirementDefinition = + TRequiredFeaturesOf extends readonly [] + ? { + install(host: never): TFeatureApi; + } + : { + requires: GFeature['requires']; + install( + host: TApplyFeatureApis> + ): TFeatureApi; + }; + +type TFeatureOverrideDefinition = [ + TFeatureOverrideKeys +] extends [never] + ? { overrides?: readonly never[] } + : { overrides: readonly TFeatureOverrideKeys[] }; + +type TRequiredFeaturesOf = + GFeature extends TFeature< + string, + object, + infer GRequiredFeatures extends readonly TAnyFeature[], + TFeatureOverrideKey + > + ? GRequiredFeatures + : readonly []; + +/** + * Extracts the installed features tuple from a feature host type. + */ +export type TInstalledFeaturesOf = + GHost extends TFeatureHost ? GFeatures : []; diff --git a/packages/feature-core/tsconfig.json b/packages/feature-core/tsconfig.json index 71fff84f..0a40e7e7 100644 --- a/packages/feature-core/tsconfig.json +++ b/packages/feature-core/tsconfig.json @@ -6,5 +6,5 @@ "declarationDir": "./dist/types" }, "include": ["src"], - "exclude": ["**/__tests__/*", "**/*.test.ts"] + "exclude": ["**/__tests__/*", "**/*.test.ts", "**/*.test-d.ts"] } diff --git a/packages/feature-fetch/README.md b/packages/feature-fetch/README.md index 8dbae127..8cc83193 100644 --- a/packages/feature-fetch/README.md +++ b/packages/feature-fetch/README.md @@ -17,269 +17,498 @@

-> Status: Experimental +`feature-fetch` turns `fetch` into a typed API client with explicit success and error branches. Requests return tuple results instead of throwing, TypeScript checks request shapes, and `.with()` features add REST helpers, OpenAPI, GraphQL, retry, cache, auth, or tracing without changing the client model. -`feature-fetch` is a straightforward, typesafe, and feature-based [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) wrapper supporting [OpenAPI](https://www.openapis.org/) types. +- Handle network errors, HTTP errors, and client errors without `try/catch` +- Add REST helpers, OpenAPI types, GraphQL, retry, cache, or delay only when needed +- Catch unknown OpenAPI paths, missing params, and wrong bodies at compile time +- Move auth, logging, tracing, and other cross-cutting behavior into client features -- **Lightweight & Tree Shakable**: Function-based and modular design (< 6KB minified) -- **Fast**: Thin wrapper around the native [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API), maintaining near-native performance -- **Modular & Extendable**: Easily extendable with features like `withRetry()`, `withOpenApi()`, .. -- **Typesafe**: Build with TypeScript for strong type safety and support for [`openapi-typescript`](https://github.com/drwpow/openapi-typescript) types -- **Standalone**: Only dependent on `fetch`, ensuring ease of use in various environments +```ts +import { createApiFetchClient, retryFeature } from 'feature-fetch'; -### 📚 Examples +const api = createApiFetchClient({ + baseUrl: 'https://api.example.com/v1', + headers: { Authorization: `Bearer ${token}` } +}).with(retryFeature({ maxRetries: 3 })); -- [Vanilla Open-Meteo](https://github.com/builder-group/community/tree/develop/examples/feature-fetch/vanilla/open-meteo) +const [isPostOk, postErr, post] = await api.get<{ id: string; title: string }>('/posts/{postId}', { + pathParams: { postId: '123' } +}); -### 🌟 Motivation +if (!isPostOk) { + console.error(postErr.message); // NetworkError | HttpError | FetchError +} else { + console.log(post.title); // typed as { id: string; title: string } +} +``` -Create a typesafe, straightforward, and lightweight `fetch` wrapper that seamlessly integrates with OpenAPI schemas using `openapi-typescript`. It aims to simplify error handling by returning results in a predictable manner with [`ts-results-es`](https://github.com/lune-climate/ts-results-es#readme). Additionally, it is designed to be modular & extendable, enabling the creation of straightforward API wrappers, such as for the Google Web Fonts API (see [`google-webfonts-client`](https://github.com/builder-group/community/tree/develop/packages/google-webfonts-client)). `feature-fetch` only depends on `fetch`, making it usable in most sandboxed environments like Figma plugins. +## Install -### ⚖️ Alternatives +```bash +npm install feature-fetch +``` + +## Usage -- [wretch](https://github.com/elbywan/wretch) -- [openapi-fetch](https://github.com/drwpow/openapi-typescript/tree/main/packages/openapi-fetch) +Pick the client that matches your API shape: -## 📖 Usage +- `createApiFetchClient`: REST endpoints without generated schema types +- `createOpenApiFetchClient`: REST endpoints typed from an OpenAPI schema +- `createGraphQLFetchClient`: GraphQL endpoints with operation and variable types + +For REST endpoints, start with `createApiFetchClient`: ```ts import { createApiFetchClient } from 'feature-fetch'; -const fetchClient = createApiFetchClient({ - prefixUrl: 'https://api.example.com/v1' +const api = createApiFetchClient({ + baseUrl: 'https://api.example.com/v1', + headers: { Authorization: `Bearer ${token}` } }); -// Send request -const response = await fetchClient.get<{ id: string }>('/blogposts/{postId}', { - pathParams: { - postId: '123' - } +const [isPostOk, postErr, post] = await api.get<{ id: string; title: string }>('/posts/{postId}', { + pathParams: { postId: '123' } }); -// Handle response -if (response.isOk()) { - console.log(response.value.data); // Handle successful response +if (!isPostOk) { + console.error(postErr.message); } else { - console.error(response.error.message); // Handle error response or network exception + console.log(post.title); } +``` + +For OpenAPI schemas, generate a `paths` type and pass it to `createOpenApiFetchClient`: + +```ts +import { createOpenApiFetchClient } from 'feature-fetch'; +import type { paths } from './schema'; + +const api = createOpenApiFetchClient({ + baseUrl: 'https://api.example.com/v1' +}); + +const [isPetOk, petErr, pet] = await api.get('/pets/{petId}', { + pathParams: { petId: 123 } +}); -// Or unwrap the response, throwing an exception on error -try { - const data = response.unwrap().data; - console.log(data); -} catch (error) { - console.error(error.message); +if (!isPetOk) { + throw petErr; } + +console.log(pet); ``` -### `withApi()` +For GraphQL endpoints, use `createGraphQLFetchClient`: -Enhance `feature-fetch` to create a typesafe `fetch` wrapper. This feature provides common HTTP methods (`get`, `post`, `put`, `del`) ensuring requests and responses are typed. +```ts +import { createGraphQLFetchClient, gql } from 'feature-fetch'; -1. **Create an API Fetch Client**: - Use `createApiFetchClient` to create a fetch client with a specified base URL. +const graphql = createGraphQLFetchClient({ + baseUrl: 'https://api.example.com/graphql' +}); - ```ts - import { createApiFetchClient } from 'feature-fetch'; +const getUser = gql` + query GetUser($id: ID!) { + user(id: $id) { + id + name + } + } +`; - const fetchClient = createApiFetchClient({ - prefixUrl: 'https://api.example.com/v1' - }); - ``` +const [isUserOk, userErr, userResult] = await graphql.query< + { user: { id: string; name: string } }, + { id: string } +>(getUser, { variables: { id: '123' } }); -2. **Send Requests**: - Use the fetch client to send requests, specifying the response type for better type safety. +if (!isUserOk) { + throw userErr; +} - ```ts - // Send request - const response = await fetchClient.get<{ id: string }>('/blogposts/{postId}', { - pathParams: { - postId: '123' - } - }); - ``` +console.log(userResult.user.name); +``` -### `withOpenApi()` +Compose features with `createFetchClient` when you want control over which features are installed and in what order: -Enhance `feature-fetch` with [OpenAPI](https://www.openapis.org/) support to create a typesafe `fetch` wrapper. This feature provides common HTTP methods (`get`, `post`, `put`, `del`) that are fully typed by leveraging your OpenAPI schema using [`openapi-typescript`](https://github.com/drwpow/openapi-typescript/). +```ts +import { apiFeature, cacheFeature, createFetchClient, retryFeature } from 'feature-fetch'; -1. **Generate TypeScript Definitions**: - Use `openapi-typescript` to generate TypeScript definitions from your OpenAPI schema. +const api = createFetchClient({ + baseUrl: 'https://api.example.com/v1' +}).with(apiFeature(), retryFeature(), cacheFeature({ maxAgeMs: 30_000 })); +``` - ```bash - npx openapi-typescript ./path/to/my/schema.yaml -o ./path/to/my/schema.d.ts - ``` +## Client - [More info](https://github.com/drwpow/openapi-typescript/tree/main/packages/openapi-typescript) +### `createFetchClient(options)` -2. **Create an OpenAPI Fetch Client**: - Import the generated `paths` and use `createOpenApiFetchClient()` to create a fetch client. +Creates a base fetch client with a single `request()` method. All built-in features build on top of this method. - ```ts - import { createOpenApiFetchClient } from 'feature-fetch'; - import { paths } from './openapi-paths'; +```ts +const client = createFetchClient({ + baseUrl: 'https://api.example.com', + headers: { Authorization: `Bearer ${token}` } +}); - const fetchClient = createOpenApiFetchClient({ - prefixUrl: 'https://api.example.com/v1' - }); - ``` +const [isHealthOk, healthErr, health] = await client.request<{ status: string }>('GET', '/health'); -3. **Send Requests**: - Use the fetch client to send requests, ensuring typesafe parameters and responses. +if (!isHealthOk) { + throw healthErr; +} - ```ts - // Send request - const response = await fetchClient.get('/blogposts/{postId}', { - pathParams: { - postId: '123' - } - }); - ``` +console.log(health.data.status); +``` -### `withGraphQL()` +| Option | Default | Description | +| ----------------- | ---------------------- | ------------------------------------------------------------------- | +| `baseUrl` | `''` | Prepended to every request path | +| `headers` | `{}` | Default headers applied to every request | +| `fetch` | `globalThis.fetch` | Custom fetch implementation | +| `requestInit` | `{}` | Default `RequestInit` values except `body`, `method`, and `headers` | +| `pathSerializer` | `serializePathParams` | Serializes `{path}` parameters | +| `querySerializer` | `serializeQueryParams` | Serializes query parameters | +| `bodySerializer` | `serializeBody` | Serializes request bodies | +| `prepareRequest` | `[]` | Hooks called before URL and body are built | +| `prepareResponse` | `[]` | Hooks called after a response is received, before parsing | +| `middleware` | `[]` | Wrappers around the final fetch call | -Enhance `feature-fetch` to create a typesafe `fetch` wrapper specifically for GraphQL requests. This feature allows you to send GraphQL queries and mutations, ensuring requests and responses are typed. +**Request options** -1. **Create a GraphQL Fetch Client**: - Use `withGraphQL` to extend your existing fetch client with GraphQL capabilities. +Each call to `request()` and the method helpers from installed features accept these core options: - ```ts - import { gql, withGraphQL } from 'feature-fetch'; - import createFetchClient from './createFetchClient'; +| Option | Default | Description | +| ------------- | -------- | --------------------------------------------------------------------------------- | +| `pathParams` | `{}` | Values for `{param}` placeholders in the path | +| `queryParams` | `{}` | Appended to the URL as a query string | +| `headers` | | Per-request headers merged after client defaults. `null` removes a default header | +| `body` | | Request body. Objects are JSON-serialized when no `Content-Type` is set | +| `parseAs` | `'json'` | Response parser: `'json'`, `'text'`, `'blob'`, `'arrayBuffer'`, or `'stream'` | +| `signal` | | `AbortSignal` for cancellation | +| `meta` | `{}` | Request-scoped metadata passed to `prepareRequest` and `prepareResponse` hooks | +| `middleware` | `[]` | Request-scoped middleware appended after client middleware | +| `baseUrl` | | Overrides the client base URL for this request | +| `requestInit` | | Overrides native `RequestInit` values except `body`, `method`, and `headers` | - const baseFetchClient = createFetchClient({ - prefixUrl: 'https://api.example.com/v1/graphql' - }); +The REST, OpenAPI, and GraphQL helpers also accept `withResponse: true` when you need the raw `Response` on the success branch. - const graphqlClient = withGraphQL(baseFetchClient); - ``` +`FormData` bodies automatically have `Content-Type` removed so the browser can add the required multipart boundary. -2. **Define GraphQL Queries**: - Use the `gql` tagged template literal to define your GraphQL queries with syntax highlighting. +## Built-in Features - ```ts - const GET_USER = gql` - query GetUser($id: ID!) { - user(id: $id) { - id - name - email - } - } - `; - ``` +### `apiFeature()` -3. **Send GraphQL Requests**: - Use the GraphQL-enabled fetch client to send requests, specifying the response type for better type safety. +Adds typed HTTP method helpers: `get`, `post`, `put`, `patch`, `delete`, `options`, `head`, and `trace`. - ```ts - // Send GraphQL query - const response = await graphqlClient.query< - { id: number }, - { user: { id: string; name: string; email: string } } - >(GET_USER, { - variables: { - id: '123' - } - }); - ``` +```ts +import { apiFeature, createFetchClient } from 'feature-fetch'; -## 🚨 Errors +const api = createFetchClient({ baseUrl: '/api' }).with(apiFeature()); -When handling API error responses (`response.isErr()`), `response` can be one of three [`Error`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error) types, each representing a different kind of failure. +const [isPostOk, postErr, post] = await api.post<{ id: string }>('/posts', { + body: { title: 'Hello' } +}); -### `NetworkError` (extends `FetchError`) +if (!isPostOk) { + throw postErr; +} -Indicates a failure in network communication, such as loss of connectivity. +console.log(post.id); + +await api.delete('/posts/{postId}', { pathParams: { postId: '123' } }); +``` + +`createApiFetchClient(options)` is shorthand for `createFetchClient(options).with(apiFeature())`. + +### `openApiFeature()` + +Adds HTTP helpers fully typed from an OpenAPI schema. Unknown paths, missing required params, wrong bodies, and unexpected fields are rejected at compile time. + +Generate the `paths` type from your schema: + +```bash +npx openapi-typescript ./schema.yaml -o ./schema.d.ts +``` ```ts -if (response.isErr() && response.error instanceof NetworkError) { - console.error('Network error:', response.error.message); +import { createOpenApiFetchClient } from 'feature-fetch'; +import type { paths } from './schema'; + +const api = createOpenApiFetchClient({ + baseUrl: 'https://api.example.com/v1' +}); + +const [isPetOk, petErr, pet] = await api.get('/pets/{petId}', { + pathParams: { petId: 123 } +}); + +if (!isPetOk) { + throw petErr; +} + +const [isCreated, createErr, created] = await api.post('/pets', { + body: { name: 'Jeff', photoUrls: [] } +}); + +if (!isCreated) { + throw createErr; } + +console.log(pet, created); ``` -### `RequestError` (extends `FetchError`) +Schema-declared header parameters are typed in `headers`. Extra transport headers such as `Authorization` remain allowed alongside them. + +`createOpenApiFetchClient(options)` is shorthand for `createFetchClient(options).with(openApiFeature())`. -Occurs when the server returns a response with a status code indicating an error (e.g., 4xx or 5xx). +### `graphqlFeature()` + +Adds `query()`, `mutate()`, and raw variants for GraphQL POST requests. Set `baseUrl` to the full GraphQL endpoint. ```ts -if (response.isErr() && response.error instanceof RequestError) { - console.error('Request error:', response.error.message, 'Status:', response.error.status); +import { createGraphQLFetchClient, gql } from 'feature-fetch'; + +const graphql = createGraphQLFetchClient({ + baseUrl: 'https://api.example.com/graphql' +}); + +const getUser = gql` + query GetUser($id: ID!) { + user(id: $id) { + id + name + } + } +`; + +const [isUserOk, userErr, userResult] = await graphql.query< + { user: { id: string; name: string } }, + { id: string } +>(getUser, { variables: { id: '123' } }); + +if (!isUserOk) { + throw userErr; } + +console.log(userResult.user.name); ``` -### `FetchError` +`query()` and `mutate()` return operation data by default and unwrap GraphQL `errors` arrays into a `GraphQLError` on the error branch. Pass `withResponse: true` to receive `{ data, extensions?, response }`, or use `queryRaw()` and `mutateRaw()` to receive the raw `{ data, errors, extensions }` response without that unwrapping. + +`createGraphQLFetchClient(options)` is shorthand for `createFetchClient(options).with(graphqlFeature())`. + +### `retryFeature(options)` -A general exception type that can encompass other error scenarios not covered by `NetworkError` or `RequestError`, for example when the response couldn't be parsed, .. +Retries failed requests. Network errors use exponential backoff. HTTP responses are retried when `shouldRetryResponse` returns `true`, defaulting to HTTP 429. Respects `Retry-After` and `x-rate-limit-reset` response headers when present. ```ts -if (response.isErr() && response.error instanceof FetchError) { - console.error('Service error:', response.error.message); -} +import { createApiFetchClient, retryFeature } from 'feature-fetch'; + +const api = createApiFetchClient({ baseUrl: '/api' }).with(retryFeature({ maxRetries: 3 })); ``` -### Example +| Option | Default | Description | +| -------------------------- | ---------- | --------------------------------------------- | +| `maxRetries` | `3` | Number of retries after the initial request | +| `networkError.baseDelayMs` | `1000` | Base delay in ms for exponential backoff | +| `networkError.maxDelayMs` | `30000` | Maximum delay in ms for network-error backoff | +| `shouldRetryResponse` | HTTP `429` | Predicate for retryable HTTP responses | + +### `cacheFeature(options)` + +Caches successful GET responses in memory. Respects `Cache-Control` response headers. Skips requests with `Authorization` or `Cookie` headers to avoid mixing user-specific responses. ```ts -if (response.isErr()) { - const error = response.error; +import { cacheFeature, createApiFetchClient } from 'feature-fetch'; - if (isStatusCode(error, 404)) { - console.error('Not found:', error.data); - } +const api = createApiFetchClient({ baseUrl: '/api' }).with(cacheFeature({ maxAgeMs: 30_000 })); - if (error instanceof NetworkError) { - console.error('Network error:', error.message); - } else if (error instanceof RequestError) { - console.error('Request error:', error.message, 'Status:', error.status); - } else if (error instanceof FetchError) { - console.error('Service error:', error.message); - } else { - console.error('Unexpected error:', error); - } +// Clear all cached responses +api.cache.clear(); + +// Invalidate entries matching a predicate +api.cache.invalidate((key) => key.includes('/posts')); +``` + +| Option | Default | Description | +| ------------- | ------------------------- | ----------------------------------------------------------------------------------------- | +| `maxAgeMs` | `300000` (5 minutes) | Maximum age in milliseconds | +| `getCacheKey` | GET URL, no auth headers | Returns the cache key for a request, or `null` to skip caching | +| `shouldCache` | OK, non-private responses | Returns whether a response should be cached. Skips `no-store`, `no-cache`, and `private`. | + +### `delayFeature(ms)` + +Waits the given number of milliseconds before forwarding each request. Useful in tests and demos. + +```ts +import { createApiFetchClient, delayFeature } from 'feature-fetch'; + +const api = createApiFetchClient({ baseUrl: '/api' }).with(delayFeature(500)); +``` + +## Extending with Features + +Fetch features are regular `feature-core` features. A feature can add new methods, push hooks or middleware into the client config, or both. + +```ts +import { defineFeature, type TFeature } from 'feature-core'; +import type { TFetchClientBase } from 'feature-fetch'; + +export function authFeature(getToken: () => string): TAuthFeature { + return defineFeature({ + key: 'auth', + install(client: TFetchClientBase) { + client._config.prepareRequest.push((cx) => { + cx.headers.authorization = `Bearer ${getToken()}`; + }); + + return {}; + } + }); } + +type TAuthFeature = TFeature<'auth', object>; ``` -## 📙 Features +Three extension points are available in `_config`: -### `withRetry()` +- `prepareRequest`: hooks that mutate the request context before URL and body are built +- `middleware`: wrappers around the final `fetch` call (receives the next function and returns a new one) +- `prepareResponse`: hooks that can inspect or replace the raw response before parsing -Retries each request using an exponential backoff strategy if a network exceptions (`NetworkError`) or HTTP `429` (Too Many Requests) response occur. +Feature order matters because middleware is applied outermost-first. Install `cacheFeature` before `retryFeature` so cache is checked first and the retry logic only runs on cache misses. + +## Errors + +All request methods return a `tuple-result`. The error branch is one of three types: + +| Error | When it occurs | +| -------------- | -------------------------------------------------------------------------- | +| `NetworkError` | The fetch call threw before any HTTP response was received | +| `HttpError` | The server returned a non-2xx response | +| `FetchError` | Request preparation, serialization, middleware, or response parsing failed | ```ts -import { createApiFetchClient, withRetry } from 'feature-fetch'; - -const fetchClient = withRetry( - createApiFetchClient({ - prefixUrl: 'https://api.example.com/v1' - }), - { - maxRetries: 3 +import { FetchError, hasStatusCode, HttpError, NetworkError } from 'feature-fetch'; + +const [isUserOk, userErr, user] = await api.get('/users/123'); + +if (!isUserOk) { + if (hasStatusCode(userErr, 404)) { + console.error('Not found'); + } else if (userErr instanceof NetworkError) { + console.error('Network error:', userErr.message); + } else if (userErr instanceof HttpError) { + console.error('HTTP error:', userErr.status, userErr.data); // userErr.data is the parsed error body + } else if (userErr instanceof FetchError) { + console.error('Client error:', userErr.code, userErr.message); // userErr.code e.g. '#ERR_SERIALIZE_BODY' } -); +} +``` + +`hasStatusCode(error, code)` narrows to `HttpError` and checks the status code. + +## Examples + +- [Vanilla basic](https://github.com/builder-group/community/tree/develop/examples/feature-fetch/vanilla/basic) + +## FAQ + +### How does it compare to ky, openapi-fetch, and graphql-request? + +`feature-fetch` focuses on typed results and feature composition. Use it when you want one client model for REST, OpenAPI, GraphQL, retry, cache, auth, and custom transport behavior. + +- [ky](https://www.npmjs.com/package/ky): compact fetch wrapper with a polished request API +- [openapi-fetch](https://www.npmjs.com/package/openapi-fetch): OpenAPI-first typed fetch client +- [graphql-request](https://www.npmjs.com/package/graphql-request): focused GraphQL request client + +### How do I add auth headers to every request? + +Pass static headers in the client options: + +```ts +const api = createApiFetchClient({ + baseUrl: 'https://api.example.com/v1', + headers: { Authorization: `Bearer ${token}` } +}); +``` + +For dynamic tokens that may change between requests, use a `prepareRequest` hook instead. See [Extending with Features](#extending-with-features) for an `authFeature` example that calls `getToken()` on each request. + +### What is the difference between `middleware` and `prepareRequest`? + +Use `prepareRequest` to mutate the structured request context: headers, path, query params, body, and metadata. It runs before the URL is built and before the body is serialized. + +Use `middleware` for transport-level concerns that wrap the fetch call itself: retry, caching, timing, and tracing. Middleware receives `(next) => (url, requestInit) => Promise` and can call `next` zero or more times. + +If you need to read or modify the final URL or `RequestInit`, use middleware. If you need to modify request inputs in a structured way, use `prepareRequest`. + +### Can I type the error response body? + +Yes. Pass it as the second generic parameter on any request method: + +```ts +interface ApiError { + code: string; + message: string; +} + +const [isUserOk, userErr, user] = await api.get('/users/123'); + +if (!isUserOk && userErr instanceof HttpError) { + console.error(userErr.data.code); // typed as ApiError +} ``` -- **`maxRetries`**: Maximum number of retry attempts +`userErr.data` is typed as `ApiError` when the error is an `HttpError`. It remains `unknown` for `NetworkError` and `FetchError`. + +### In what order should I install features? -### `withDelay()` +Install features in the order you want them to intercept requests, outermost first. For common combinations: -Delays each request by a specified number of milliseconds before sending it. +- `cacheFeature` before `retryFeature`: cache is checked first; retry only runs on cache misses +- `retryFeature` before a logging middleware: retry attempts are each logged individually + +Features that add methods (`apiFeature`, `openApiFeature`, `graphqlFeature`) can go in any position relative to middleware features. + +### How do I mock requests in tests? + +Pass a custom `fetch` function to `createFetchClient`. Return any `Response` you need: ```ts -import { createApiFetchClient, withDelay } from 'feature-fetch'; - -const fetchClient = withDelay( - createApiFetchClient({ - prefixUrl: 'https://api.example.com/v1' - }), - 1000 -); +const api = createApiFetchClient({ + baseUrl: '/api', + fetch: async () => new Response(JSON.stringify({ id: '1' }), { status: 200 }) +}); ``` -- **`delayInMs`**: Delay duration in milliseconds +For more control, use `delayFeature` in development or a mock server in integration tests. + +### How do I cancel a request? + +Pass an `AbortSignal` in the request options: + +```ts +const controller = new AbortController(); + +const postsRequest = api.get('/posts', { + signal: controller.signal +}); + +// Cancel from anywhere +controller.abort(); + +const [isPostsOk, postsErr, posts] = await postsRequest; + +if (!isPostsOk) { + console.error(postsErr.message); +} else { + console.log(posts); +} +``` -## ❓ FAQ +A cancelled request returns `NetworkError` on the error branch. -### Why is `@0no-co/graphql.web` a dependency if it's not always used? +### Why is `@0no-co/graphql.web` a dependency if GraphQL is opt-in? -`@0no-co/graphql.web` is listed as a dependency because it's dynamically imported in the `getQueryString()` function. If the function isn’t used, Webpack's tree shaking should exclude it from the final bundle. This ensures that only necessary modules are included, keeping your build clean. +`@0no-co/graphql.web` is imported dynamically only when a `DocumentNode` input needs to be printed to a string. If you pass operation strings directly or never install `graphqlFeature()`, modern bundlers tree-shake that path out of the bundle. diff --git a/packages/feature-fetch/package.json b/packages/feature-fetch/package.json index 3edcc6b1..e4d02913 100644 --- a/packages/feature-fetch/package.json +++ b/packages/feature-fetch/package.json @@ -1,9 +1,23 @@ { "name": "feature-fetch", - "version": "0.0.55", + "version": "0.1.0-beta.1", "private": false, - "description": "Straightforward, typesafe, and feature-based fetch wrapper supporting OpenAPI types", - "keywords": [], + "description": "Typed fetch client with explicit error branches, request type safety, and opt-in REST, OpenAPI, GraphQL, retry, and cache features.", + "keywords": [ + "fetch", + "typed-fetch", + "http-client", + "api-client", + "rest", + "typescript", + "tuple-result", + "error-handling", + "openapi", + "graphql", + "retry", + "cache", + "feature-composition" + ], "homepage": "https://builder.group/?utm_source=package-json", "bugs": { "url": "https://github.com/builder-group/community/issues" @@ -32,18 +46,18 @@ "size": "size-limit --why", "start:dev": "tsc -w", "test": "vitest run", + "test:types": "vitest run --typecheck.only", "update:latest": "pnpm update --latest" }, "dependencies": { "@0no-co/graphql.web": "^1.2.0", - "@blgc/types": "workspace:*", - "@blgc/utils": "workspace:*", + "feature-core": "workspace:*", + "openapi-typescript-helpers": "^0.1.0", "tuple-result": "workspace:*" }, "devDependencies": { "@blgc/config": "workspace:*", - "@types/url-parse": "^1.4.11", - "msw": "^2.14.2", + "msw": "^2.14.6", "rollup-presets": "workspace:*" }, "size-limit": [ diff --git a/packages/feature-fetch/src/__tests__/playground.test.ts b/packages/feature-fetch/src/__tests__/playground.test.ts deleted file mode 100644 index 196014ba..00000000 --- a/packages/feature-fetch/src/__tests__/playground.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { createGraphQLFetchClient, createOpenApiFetchClient, withGraphQLCache } from '../features'; -import { paths } from './resources/mock-openapi-types'; - -describe('playground', () => { - it('should pass', () => { - expect(true).toBe(true); - }); - - describe.skip('should work', () => { - it('types should work', async () => { - const fetchClient = createOpenApiFetchClient(); - - const getPetByIdResult = await fetchClient.get('/pet/{petId}', { - pathParams: { - petId: 10 - }, - pathSerializer: (path, params) => { - return path; - }, - querySerializer: (query) => { - return query.toString(); - }, - bodySerializer: (body) => { - return JSON.stringify(body); - }, - parseAs: 'json' - }); - - getPetByIdResult.unwrap().data; - - const postPet = await fetchClient.post('/pet', { - name: 'jeff', - photoUrls: [] - }); - - postPet.unwrap().data; - - const graphqlClient = createGraphQLFetchClient(); - const graphqlClientWithCache = withGraphQLCache(graphqlClient); - expect(graphqlClientWithCache._features).toStrictEqual(['graphql', 'graphqlCache']); - }); - }); -}); diff --git a/packages/feature-fetch/src/create-fetch-client.test.ts b/packages/feature-fetch/src/create-fetch-client.test.ts index 8c179694..774ff00d 100644 --- a/packages/feature-fetch/src/create-fetch-client.test.ts +++ b/packages/feature-fetch/src/create-fetch-client.test.ts @@ -1,100 +1,595 @@ -import { http, HttpResponse } from 'msw'; -import { setupServer } from 'msw/node'; import { unwrapErr } from 'tuple-result'; -import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { describe, expect, expectTypeOf, it, vi } from 'vitest'; import { createFetchClient } from './create-fetch-client'; +import { FetchError, HttpError, NetworkError } from './errors'; +import type { TFetchLike } from './types'; -const server = setupServer(); +describe('createFetchClient function', () => { + describe('request building', () => { + it('should build the request from client defaults and request options', async () => { + // Prepare + const fetchLike = vi.fn(async () => { + return Response.json({ id: 'post-1' }); + }); + const client = createFetchClient({ + baseUrl: 'https://api.example.com', + fetch: fetchLike, + headers: { + Authorization: 'Bearer default' + }, + requestInit: { + credentials: 'include' + } + }); -const BASE_URL = 'https://api.example.com'; + // Act + const result = await client.request('POST', '/posts/{postId}', { + body: { + title: 'Hello' + }, + headers: { + Authorization: 'Bearer request' + }, + pathParams: { postId: 'post-1' }, + queryParams: { preview: true }, + requestInit: { + cache: 'no-store' + } + }); -describe('createFetchClient function', () => { - beforeAll(() => { - server.listen(); + // Assert + expect(result.unwrap().data).toEqual({ id: 'post-1' }); + expect(fetchLike).toHaveBeenCalledWith( + 'https://api.example.com/posts/post-1?preview=true', + expect.objectContaining({ + body: JSON.stringify({ title: 'Hello' }), + cache: 'no-store', + credentials: 'include', + headers: { + 'authorization': 'Bearer request', + 'content-type': 'application/json; charset=utf-8' + }, + method: 'POST' + }) + ); + }); + + it('should return response details', async () => { + // Prepare + const fetchLike = vi.fn(async () => { + return Response.json({ id: 'post-1' }); + }); + const client = createFetchClient({ fetch: fetchLike }); + + // Act + const result = await client.request<{ id: string }>('GET', '/posts/post-1'); + + // Assert + const value = result.unwrap(); + expect(value.data).toEqual({ id: 'post-1' }); + expect(value.response).toBeInstanceOf(Response); + expectTypeOf(value).toEqualTypeOf<{ + data: { + id: string; + }; + response: Response; + }>(); + }); + + it('should leave multipart content type to fetch for FormData bodies', async () => { + // Prepare + const formData = new FormData(); + formData.append('file', new Blob(['content']), 'file.txt'); + const fetchLike = vi.fn(async () => { + return Response.json({ ok: true }); + }); + const client = createFetchClient({ + fetch: fetchLike, + headers: { + 'Content-Type': 'application/json' + } + }); + + // Act + await client.request('POST', '/upload', { + body: formData + }); + + // Assert + expect(fetchLike).toHaveBeenCalledWith( + '/upload', + expect.objectContaining({ + body: formData, + headers: {} + }) + ); + }); + + it('should serialize explicit null bodies as JSON', async () => { + // Prepare + const fetchLike = vi.fn(async () => { + return Response.json({ ok: true }); + }); + const client = createFetchClient({ fetch: fetchLike }); + + // Act + await client.request('POST', '/items', { + body: null + }); + + // Assert + expect(fetchLike).toHaveBeenCalledWith( + '/items', + expect.objectContaining({ + body: 'null', + headers: { + 'content-type': 'application/json; charset=utf-8' + } + }) + ); + }); + + it('should omit body headers when no body is provided', async () => { + // Prepare + const fetchLike = vi.fn(async () => { + return Response.json({ ok: true }); + }); + const client = createFetchClient({ fetch: fetchLike }); + + // Act + await client.request('GET', '/items'); + + // Assert + expect(fetchLike).toHaveBeenCalledWith( + '/items', + expect.objectContaining({ + body: undefined, + headers: {} + }) + ); + }); + + it('should use top-level abort signal over request init signal', async () => { + // Prepare + const defaultController = new AbortController(); + const requestController = new AbortController(); + const fetchLike = vi.fn(async () => { + return Response.json({ ok: true }); + }); + const client = createFetchClient({ + fetch: fetchLike, + requestInit: { + signal: defaultController.signal + } + }); + + // Act + await client.request('GET', '/items', { + signal: requestController.signal + }); + + // Assert + expect(fetchLike).toHaveBeenCalledWith( + '/items', + expect.objectContaining({ + signal: requestController.signal + }) + ); + }); }); - afterEach(() => { - server.resetHandlers(); + + describe('lifecycle hooks', () => { + it('should prepare request data before URL and body are built', async () => { + // Prepare + const fetchLike = vi.fn(async () => { + return Response.json({ ok: true }); + }); + const prepareRequest = vi.fn((cx) => { + cx.headers.authorization = 'Bearer prepare'; + cx.pathParams.itemId = 'item-1'; + cx.queryParams.trace = '1'; + cx.requestInit.cache = 'no-store'; + }); + const client = createFetchClient({ + fetch: fetchLike, + prepareRequest: [prepareRequest] + }); + + // Act + await client.request('GET', '/items/{itemId}', { + meta: { + requestId: 'request-1' + } + }); + + // Assert + expect(prepareRequest).toHaveBeenCalledWith( + expect.objectContaining({ + meta: { + requestId: 'request-1' + }, + method: 'GET', + path: '/items/{itemId}' + }) + ); + expect(fetchLike).toHaveBeenCalledWith( + '/items/item-1?trace=1', + expect.objectContaining({ + cache: 'no-store', + headers: { + authorization: 'Bearer prepare' + } + }) + ); + }); + + it('should prepare responses before parsing', async () => { + // Prepare + const fetchLike = vi.fn(async () => { + return Response.json({ status: 'raw' }); + }); + const prepareResponse = vi.fn((cx) => { + expect(cx.request.meta).toEqual({ requestId: 'request-1' }); + expect(cx.request.url).toBe('/items'); + expect(cx.request.requestInit.method).toBe('GET'); + + cx.response = Response.json({ status: 'prepared' }); + }); + const client = createFetchClient({ + fetch: fetchLike, + prepareResponse: [prepareResponse] + }); + + // Act + const result = await client.request<{ status: string }>('GET', '/items', { + meta: { + requestId: 'request-1' + } + }); + + // Assert + expect(result.unwrap().data).toEqual({ status: 'prepared' }); + expect(prepareResponse).toHaveBeenCalledOnce(); + }); + + it('should keep mutable metadata scoped to prepare hooks', async () => { + // Prepare + const fetchLike = vi.fn(async () => { + return Response.json({ ok: true }); + }); + const prepareRequest = vi.fn((cx) => { + cx.meta.requestId = 'request-1'; + cx.meta.stage = 'prepared'; + }); + const prepareResponse = vi.fn((cx) => { + expect(cx.request.meta).toEqual({ + requestId: 'request-1', + stage: 'prepared' + }); + }); + const client = createFetchClient({ + fetch: fetchLike, + prepareRequest: [prepareRequest], + prepareResponse: [prepareResponse] + }); + + // Act + const result = await client.request('GET', '/items'); + + // Assert + expect(result.isOk()).toBe(true); + expect(prepareRequest).toHaveBeenCalledOnce(); + expect(prepareResponse).toHaveBeenCalledOnce(); + expect(fetchLike).toHaveBeenCalledWith( + '/items', + expect.not.objectContaining({ + meta: expect.anything() + }) + ); + }); }); - afterAll(() => { - server.close(); + + describe('middleware', () => { + it('should apply global and request middleware around fetch', async () => { + // Prepare + const calls: string[] = []; + const fetchLike = vi.fn(async () => { + calls.push('fetch'); + return Response.json({ ok: true }); + }); + const globalMiddleware = + (next: TFetchLike): TFetchLike => + async (url, init) => { + calls.push('global:before'); + const response = await next(url, init); + calls.push('global:after'); + return response; + }; + const requestMiddleware = + (next: TFetchLike): TFetchLike => + async (url, init) => { + calls.push('request:before'); + const response = await next(url, init); + calls.push('request:after'); + return response; + }; + const client = createFetchClient({ + fetch: fetchLike, + middleware: [globalMiddleware] + }); + + // Act + await client.request('GET', '/items', { + middleware: [requestMiddleware] + }); + + // Assert + expect(calls).toEqual([ + 'global:before', + 'request:before', + 'fetch', + 'request:after', + 'global:after' + ]); + }); }); - it('should make a GET request successfully', async () => { - server.use( - http.get(new URL('/test', BASE_URL).toString(), () => { - return HttpResponse.json( - { message: 'Success' }, - { - status: 200 + describe('response parsing', () => { + it('should normalize method casing for empty response handling', async () => { + // Prepare + const fetchLike = vi.fn(async () => { + return new Response('invalid-json', { + status: 200, + headers: { + 'Content-Type': 'application/json' } - ); - }) + }); + }); + const client = createFetchClient({ fetch: fetchLike }); + + // Act + const result = await client.request('head', '/items'); + + // Assert + expect(result.unwrap().data).toBeUndefined(); + expect(fetchLike).toHaveBeenCalledWith( + '/items', + expect.objectContaining({ + method: 'HEAD' + }) + ); + }); + + it.each([204, 205])( + 'should return undefined data for %i responses without parsing', + async (status) => { + // Prepare + const fetchLike = vi.fn(async () => { + return new Response(null, { status }); + }); + const client = createFetchClient({ fetch: fetchLike }); + + // Act + const result = await client.request('GET', '/items', { + parseAs: 'text' + }); + + // Assert + expect(result.unwrap().data).toBeUndefined(); + } ); - const client = createFetchClient({ prefixUrl: BASE_URL }); - const result = await client._baseFetch('/test', 'GET', {}); + it('should return undefined data for empty successful responses', async () => { + // Prepare + const fetchLike = vi.fn(async () => { + return new Response('', { + status: 200, + headers: { + 'Content-Type': 'application/json' + } + }); + }); + const client = createFetchClient({ fetch: fetchLike }); - expect(result.isOk()).toBe(true); - expect(result.unwrap().data).toEqual({ message: 'Success' }); - }); + // Act + const result = await client.request('GET', '/items'); - it('should handle network errors gracefully', async () => { - server.use( - http.get(new URL('/test', BASE_URL).toString(), () => { - return HttpResponse.json( - { code: 500, message: 'Internal Server Error' }, - { - status: 500 + // Assert + expect(result.unwrap().data).toBeUndefined(); + }); + + it('should parse chunked responses with zero content length', async () => { + // Prepare + const fetchLike = vi.fn(async () => { + return new Response(JSON.stringify({ ok: true }), { + headers: { + 'Content-Length': '0', + 'Transfer-Encoding': 'Chunked' } - ); - }) - ); + }); + }); + const client = createFetchClient({ fetch: fetchLike }); - const client = createFetchClient({ prefixUrl: BASE_URL }); - const result = await client._baseFetch('/test', 'GET', {}); + // Act + const result = await client.request<{ ok: boolean }>('GET', '/items'); - expect(result.isErr()).toBe(true); - expect(unwrapErr(result)).toBeInstanceOf(Error); + // Assert + expect(result.unwrap().data).toEqual({ ok: true }); + }); }); - it('should handle FormData uploads correctly', async () => { - // Prepare - const formData = new FormData(); - formData.append('file', new Blob(['test content'], { type: 'text/plain' }), 'test.txt'); - formData.append('description', 'Test file upload'); + describe('error mapping', () => { + it('should map missing global fetch to a fetch error', async () => { + // Prepare + const originalFetch = globalThis.fetch; + vi.stubGlobal('fetch', undefined); + try { + const client = createFetchClient(); - let receivedFormData: FormData | undefined; + // Act + const result = await client.request('GET', '/items'); - server.use( - http.post(new URL('/upload', BASE_URL).toString(), async ({ request }) => { - // Store the received FormData for assertion - receivedFormData = await request.formData(); - return HttpResponse.json({ message: 'Upload successful' }, { status: 200 }); - }) - ); + // Assert + const error = unwrapErr(result); + expect(error).toBeInstanceOf(FetchError); + expect((error as FetchError).code).toBe('#ERR_MISSING_FETCH'); + } finally { + vi.stubGlobal('fetch', originalFetch); + } + }); + + it('should map non-OK responses to http errors', async () => { + // Prepare + const fetchLike = vi.fn(async () => { + return Response.json({ message: 'Not found' }, { status: 404 }); + }); + const client = createFetchClient({ fetch: fetchLike }); + + // Act + const result = await client.request('GET', '/missing'); - // Act - const client = createFetchClient({ prefixUrl: BASE_URL }); - const result = await client._baseFetch('/upload', 'POST', { - body: formData + // Assert + const error = unwrapErr(result); + expect(error).toBeInstanceOf(HttpError); + expect((error as HttpError).status).toBe(404); + expect((error as HttpError).data).toEqual({ message: 'Not found' }); }); - // Assert - expect(result.isOk()).toBe(true); - expect(result.unwrap().data).toEqual({ message: 'Upload successful' }); + it('should map thrown fetch errors to network errors', async () => { + // Prepare + const fetchLike = vi.fn(async () => { + throw new Error('offline'); + }); + const client = createFetchClient({ fetch: fetchLike }); - // Verify FormData was received correctly - expect(receivedFormData).toBeDefined(); - expect(receivedFormData?.get('description')).toBe('Test file upload'); + // Act + const result = await client.request('GET', '/items'); - // Verify file content - const uploadedFile = receivedFormData?.get('file') as File; - expect(uploadedFile).toBeInstanceOf(File); - expect(uploadedFile.name).toBe('test.txt'); - expect(uploadedFile.type).toBe('text/plain'); + // Assert + expect(unwrapErr(result)).toBeInstanceOf(NetworkError); + }); + + it('should keep fetch errors thrown by middleware unchanged', async () => { + // Prepare + const thrownError = new FetchError('#ERR_CUSTOM', { + message: 'Middleware failed' + }); + const client = createFetchClient({ + middleware: [ + () => async () => { + throw thrownError; + } + ] + }); + + // Act + const result = await client.request('GET', '/items'); + + // Assert + expect(unwrapErr(result)).toBe(thrownError); + }); + + it('should map prepare request failures to fetch errors', async () => { + // Prepare + const fetchLike = vi.fn(async () => { + return Response.json({ ok: true }); + }); + const client = createFetchClient({ + fetch: fetchLike, + prepareRequest: [ + () => { + throw new Error('Prepare failed'); + } + ] + }); - // Verify file content - const fileContent = await uploadedFile.text(); - expect(fileContent).toBe('test content'); + // Act + const result = await client.request('GET', '/items'); + + // Assert + const error = unwrapErr(result); + expect(error).toBeInstanceOf(FetchError); + expect((error as FetchError).code).toBe('#ERR_PREPARE_REQUEST'); + expect(fetchLike).not.toHaveBeenCalled(); + }); + + it('should map prepare response failures to fetch errors', async () => { + // Prepare + const client = createFetchClient({ + fetch: async () => Response.json({ ok: true }), + prepareResponse: [ + () => { + throw new Error('Prepare response failed'); + } + ] + }); + + // Act + const result = await client.request('GET', '/items'); + + // Assert + const error = unwrapErr(result); + expect(error).toBeInstanceOf(FetchError); + expect((error as FetchError).code).toBe('#ERR_PREPARE_RESPONSE'); + }); + + it('should map middleware setup failures to fetch errors', async () => { + // Prepare + const client = createFetchClient({ + middleware: [ + () => { + throw new Error('Middleware setup failed'); + } + ] + }); + + // Act + const result = await client.request('GET', '/items'); + + // Assert + const error = unwrapErr(result); + expect(error).toBeInstanceOf(FetchError); + expect((error as FetchError).code).toBe('#ERR_FETCH_MIDDLEWARE'); + }); + + it('should map response parse failures to fetch errors', async () => { + // Prepare + const fetchLike = vi.fn(async () => { + return new Response('invalid-json', { + status: 200, + headers: { + 'Content-Type': 'application/json' + } + }); + }); + const client = createFetchClient({ fetch: fetchLike }); + + // Act + const result = await client.request('GET', '/items'); + + // Assert + expect(unwrapErr(result)).toBeInstanceOf(FetchError); + }); + + it('should map URL build failures to fetch errors', async () => { + // Prepare + const fetchLike = vi.fn(async () => { + return Response.json({ ok: true }); + }); + const client = createFetchClient({ fetch: fetchLike }); + + // Act + const result = await client.request('GET', '/items', { + pathSerializer: () => { + throw new Error('Failed to serialize path'); + } + }); + + // Assert + const error = unwrapErr(result); + expect(error).toBeInstanceOf(FetchError); + expect((error as FetchError).code).toBe('#ERR_BUILD_URL'); + expect(fetchLike).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/feature-fetch/src/create-fetch-client.ts b/packages/feature-fetch/src/create-fetch-client.ts index 9480980d..c35e6e44 100644 --- a/packages/feature-fetch/src/create-fetch-client.ts +++ b/packages/feature-fetch/src/create-fetch-client.ts @@ -1,155 +1,342 @@ +import { createFeatureHost } from 'feature-core'; import { Err, Ok } from 'tuple-result'; -import { FetchError } from './exceptions'; import { - buildUrl, - FetchHeaders, + FetchError, mapErrorToFetchError, mapErrorToNetworkError, - mapResponseToRequestError, + mapResponseToHttpError +} from './errors'; +import { + buildUrl, + deleteHeader, + getHeader, + hasHeader, + isFormData, + isNativeBody, + mergeHeaders, + normalizeHeaders, serializeBody, serializePathParams, - serializeQueryParams -} from './helper'; + serializeQueryParams, + setHeader +} from './lib'; import type { + TBodySerializer, TFetchClient, - TFetchClientConfig, - TFetchClientOptions, + TFetchClientBase, + TFetchHeadersInit, TFetchLike, - TSerializedBody + TFetchMiddleware, + TFetchOptionsWithBody, + TFetchRequestInit, + TFetchRequestResponse, + TParseAs, + TParseAsResponse, + TPathSerializer, + TPrepareRequestContext, + TPrepareRequestHook, + TPrepareResponseContext, + TPrepareResponseHook, + TQuerySerializer, + TRequestInitWithResolvedHeaders, + TRequestMethod, + TResolvedFetchHeaders, + TSerializedBody, + TUnserializedBody } from './types'; -import { type TRequestInitWithHeadersObject } from './types/fetch'; - -export function createFetchClient(options: TFetchClientOptions = {}): TFetchClient<[]> { - const config: TFetchClientConfig = { - prefixUrl: options.prefixUrl ?? '', - fetchProps: options.fetchProps ?? {}, - headers: options.headers != null ? new FetchHeaders(options.headers) : new FetchHeaders(), - bodySerializer: options.bodySerializer ?? serializeBody, - pathSerializer: options.pathSerializer ?? serializePathParams, - querySerializer: - options.querySerializer ?? ((queryParams) => serializeQueryParams(queryParams)), - beforeRequestMiddlewares: options.beforeRequestMiddlewares ?? [], - requestMiddlewares: options.requestMiddlewares ?? [] - }; - let fetchLike: TFetchLike; - if (typeof options.fetch === 'function') { - fetchLike = options.fetch; - } else if (typeof fetch === 'function') { - fetchLike = fetch; - } else { - throw new FetchError('#ERR_MISSING_FETCH', { - description: "Failed to find valid 'fetch' function to wrap around!" - }); - } - // Apply default headers - if (!config.headers.has('Content-Type')) { - config.headers.set('Content-Type', 'application/json; charset=utf-8'); - } +/** + * Creates a typed fetch client with the low-level `request()` method. + * + * Use `.with()` to install REST, OpenAPI, GraphQL, retry, cache, or custom features. + * `request()` returns a tuple result whose success branch contains `{ data, response }`. + */ +export function createFetchClient(options: TCreateFetchClientOptions = {}): TFetchClient<[]> { + const { + baseUrl = '', + requestInit = {}, + headers, + bodySerializer = serializeBody, + pathSerializer = serializePathParams, + querySerializer = serializeQueryParams, + prepareRequest = [], + prepareResponse = [], + middleware = [], + fetch + } = options; - return { - _features: [], - _fetchLike: fetchLike, - _config: config, - async _baseFetch(this: TFetchClient<[]>, path, method, baseFetchOptions = {}) { + return createFeatureHost({ + _config: { + baseUrl, + requestInit, + headers: normalizeHeaders(headers), + bodySerializer, + pathSerializer, + querySerializer, + prepareRequest, + middleware, + prepareResponse + }, + _fetchLike: resolveFetchLike(fetch), + async request< + GSuccessResponseBody = unknown, + GErrorResponseBody = unknown, + GParseAs extends TParseAs = 'json' + >( + this: TFetchClientBase, + method: TRequestMethod, + path: string, + requestOptions: TFetchOptionsWithBody = {} + ): Promise> { const { parseAs = 'json', - pathSerializer = this._config.pathSerializer, - querySerializer = this._config.querySerializer, + body, bodySerializer = this._config.bodySerializer, - body = undefined, - prefixUrl = this._config.prefixUrl, - fetchProps = {}, - middlewareProps, - requestMiddlewares = [], + meta = {}, + requestInit: requestInitOverrides = {}, + signal, pathParams = {}, - queryParams = {} - } = baseFetchOptions; - const headers = new FetchHeaders(baseFetchOptions.headers); - const mergedHeaders = FetchHeaders.merge(headers, this._config.headers); + pathSerializer = this._config.pathSerializer, + baseUrl = this._config.baseUrl, + queryParams = {}, + querySerializer = this._config.querySerializer, + middleware: requestMiddleware = [] + } = requestOptions; - // Serialize body - let serializedBody: TSerializedBody; - if (body != null) { - try { - serializedBody = bodySerializer(body, mergedHeaders.get('Content-Type') ?? undefined); - } catch (error) { - return Err(mapErrorToFetchError(error, '#ERR_SERIALIZE_BODY')); + const cx: TPrepareRequestContext = { + baseUrl, + body, + meta, + headers: mergeHeaders(this._config.headers, requestOptions.headers), + method, + path, + pathParams: { ...pathParams }, + queryParams: { ...queryParams }, + requestInit: { + ...this._config.requestInit, + ...requestInitOverrides, + ...(signal !== undefined ? { signal } : {}) } + }; + + try { + for (const prepareRequest of this._config.prepareRequest) { + await prepareRequest(cx); + } + } catch (error) { + return Err( + mapErrorToFetchError(error, '#ERR_PREPARE_REQUEST', 'Failed to prepare request') + ); } - // Remove `Content-Type` if body is FormData. - // Browser will correctly set Content-Type & boundary expression. - if (typeof FormData !== 'undefined' && serializedBody instanceof FormData) { - mergedHeaders.delete('Content-Type'); + let serializedBody: TSerializedBody; + try { + serializedBody = prepareRequestBody(cx.body, bodySerializer, cx.headers); + } catch (error) { + return Err( + mapErrorToFetchError(error, '#ERR_SERIALIZE_BODY', 'Failed to serialize request body') + ); + } + + let url: string; + try { + url = buildUrl(cx.baseUrl, { + path: cx.path, + pathParams: cx.pathParams, + pathSerializer, + queryParams: cx.queryParams, + querySerializer + }); + } catch (error) { + return Err(mapErrorToFetchError(error, '#ERR_BUILD_URL', 'Failed to build request URL')); } - // Build request init object - const requestInit: TRequestInitWithHeadersObject = { + const requestMethod = cx.method.toUpperCase(); + const requestInit: TRequestInitWithResolvedHeaders = { redirect: 'follow', - ...this._config.fetchProps, - ...fetchProps, - method, - headers: mergedHeaders.toHeadersInit(), + ...cx.requestInit, + method: requestMethod, + headers: cx.headers, body: serializedBody }; - // Process before request middlewares + let fetchLike: TFetchLike; try { - for (const middleware of this._config.beforeRequestMiddlewares) { - await middleware({ - path, - props: middlewareProps, - requestInit, - queryParams, - pathParams - }); + const hasMiddleware = this._config.middleware.length > 0 || requestMiddleware.length > 0; + if (hasMiddleware) { + fetchLike = this._config.middleware + .concat(requestMiddleware) + .reduceRight((next, fetchMiddleware) => fetchMiddleware(next), this._fetchLike); + } else { + fetchLike = this._fetchLike; } } catch (error) { - return Err(mapErrorToFetchError(error, '#ERR_MIDDLEWARE')); + return Err( + mapErrorToFetchError(error, '#ERR_FETCH_MIDDLEWARE', 'Failed to compose fetch middleware') + ); } - // Build final Url - const finalUrl = buildUrl(prefixUrl, { - path, - pathParams, - queryParams, - pathSerializer, - querySerializer - }); - - // Process request middlewares - const baseFetch = this._config.requestMiddlewares - .concat(requestMiddlewares) - .reduceRight((acc, middleware) => middleware(acc), this._fetchLike); - - // Send request let response: Response; try { - response = await baseFetch(finalUrl, requestInit as unknown as RequestInit); + response = await fetchLike(url, requestInit); } catch (error) { + if (error instanceof FetchError) { + return Err(error); + } return Err(mapErrorToNetworkError(error)); } - // Handle ok response (parse as "parseAs" and falling back to .text() when necessary) - if (response.ok) { - let data: any = response.body; - if (parseAs !== 'stream') { - try { - data = await response[parseAs](); - } catch (error) { - return Err( - new FetchError('#ERR_PARSE_RESPONSE_DATA', { - description: `Failed to parse response as '${parseAs}'` - }) - ); + if (this._config.prepareResponse.length > 0) { + const responseCx: TPrepareResponseContext = { + request: { + ...cx, + method: requestMethod, + requestInit, + url + }, + response + }; + + try { + for (const prepareResponse of this._config.prepareResponse) { + await prepareResponse(responseCx); } + response = responseCx.response; + } catch (error) { + return Err( + mapErrorToFetchError(error, '#ERR_PREPARE_RESPONSE', 'Failed to prepare response') + ); + } + } + + if (response.ok) { + try { + const data = await parseResponseData( + response, + requestMethod, + parseAs as GParseAs + ); + return Ok({ data, response }); + } catch (error) { + return Err(mapErrorToFetchError(error, '#ERR_PARSE_RESPONSE_DATA')); } - return Ok({ data, response }); } - // Handle errors (always parse as .json() or .text()) - return Err(await mapResponseToRequestError(response)); + return Err(await mapResponseToHttpError(response)); } + }); +} + +export interface TCreateFetchClientOptions { + /** Base URL prepended to relative request paths. Absolute request paths ignore it. */ + baseUrl?: string; + /** Native fetch init defaults except `body`, `method`, and `headers`. */ + requestInit?: TFetchRequestInit; + /** Headers applied to every request. Per-request headers can override or remove them. */ + headers?: TFetchHeadersInit; + /** Default body serializer used before the request is sent. */ + bodySerializer?: TBodySerializer; + /** Default serializer for `{param}` placeholders in request paths. */ + pathSerializer?: TPathSerializer; + /** Default serializer for request query params. */ + querySerializer?: TQuerySerializer; + /** Hooks that can mutate request inputs before URL and body serialization. */ + prepareRequest?: TPrepareRequestHook[]; + /** Fetch wrappers for transport concerns such as retries, caching, tracing, or timing. */ + middleware?: TFetchMiddleware[]; + /** Hooks that can inspect or replace the raw response before parsing and HTTP error mapping. */ + prepareResponse?: TPrepareResponseHook[]; + /** Fetch implementation. Defaults to `globalThis.fetch`. */ + fetch?: TFetchLike; +} + +function resolveFetchLike(fetchLike?: TFetchLike): TFetchLike { + if (typeof fetchLike === 'function') { + return fetchLike; + } + if (typeof globalThis.fetch === 'function') { + return globalThis.fetch.bind(globalThis) as TFetchLike; + } + + return async () => { + throw new FetchError('#ERR_MISSING_FETCH', { + message: "Failed to find a valid 'fetch' function" + }); }; } + +function prepareRequestBody( + body: TUnserializedBody | undefined, + bodySerializer: TBodySerializer, + headers: TResolvedFetchHeaders +): TSerializedBody | undefined { + if (body === undefined) { + return undefined; + } + + if (!isNativeBody(body) && !hasHeader(headers, 'Content-Type')) { + setHeader(headers, 'Content-Type', 'application/json; charset=utf-8'); + } + + const serializedBody = bodySerializer(body, getHeader(headers, 'Content-Type') ?? undefined); + // Note: Delete Content-Type so fetch can add the required multipart boundary for FormData + if (isFormData(serializedBody)) { + deleteHeader(headers, 'Content-Type'); + } + + return serializedBody; +} + +async function parseResponseData( + response: Response, + method: TRequestMethod, + parseAs: GParseAs +): Promise> { + if (isEmptyResponse(response, method)) { + return undefined as TParseAsResponse; + } + if (parseAs === 'stream') { + return response.body as TParseAsResponse; + } + + try { + switch (parseAs) { + case 'arrayBuffer': + return (await response.arrayBuffer()) as TParseAsResponse; + case 'blob': + return (await response.blob()) as TParseAsResponse; + case 'json': { + // Note: response.json() throws on empty bodies, so parse text only when present + const text = await response.text(); + return (text.length ? JSON.parse(text) : undefined) as TParseAsResponse< + GParseAs, + GSuccessResponseBody + >; + } + case 'text': + return (await response.text()) as TParseAsResponse; + default: + throw new FetchError('#ERR_PARSE_RESPONSE_DATA', { + message: `Unsupported response parser '${parseAs}'` + }); + } + } catch (error) { + if (error instanceof FetchError) { + throw error; + } + throw new FetchError('#ERR_PARSE_RESPONSE_DATA', { + message: `Failed to parse response as '${parseAs}'`, + cause: error + }); + } +} + +function isEmptyResponse(response: Response, method: TRequestMethod): boolean { + const contentLength = response.headers.get('Content-Length')?.trim(); + const transferEncoding = response.headers.get('Transfer-Encoding')?.toLowerCase(); + + const hasNoContentStatus = response.status === 204 || response.status === 205; + const hasNoBodyMethod = method === 'HEAD'; + const hasExplicitEmptyBody = contentLength === '0' && !transferEncoding?.includes('chunked'); + return hasNoContentStatus || hasNoBodyMethod || hasExplicitEmptyBody; +} diff --git a/packages/feature-fetch/src/errors/FetchError.test.ts b/packages/feature-fetch/src/errors/FetchError.test.ts new file mode 100644 index 00000000..c9b15af0 --- /dev/null +++ b/packages/feature-fetch/src/errors/FetchError.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { FetchError, mapErrorToFetchError } from './FetchError'; + +describe('FetchError module', () => { + describe('FetchError class', () => { + it('should format explicit messages with the error code', () => { + // Prepare + const error = new FetchError('#ERR_EXISTING', { + message: 'Something failed' + }); + + // Assert + expect(error.name).toBe('FetchError'); + expect(error.code).toBe('#ERR_EXISTING'); + expect(error.message).toBe('[#ERR_EXISTING] Something failed'); + }); + + it('should use the cause message when no message is provided', () => { + // Prepare + const cause = new Error('failed'); + + // Act + const error = new FetchError('#ERR_EXISTING', { + cause + }); + + // Assert + expect(error.message).toBe('[#ERR_EXISTING] failed'); + expect(error.cause).toBe(cause); + }); + + it('should include a human fallback when only a code is provided', () => { + // Act + const error = new FetchError('#ERR_EXISTING'); + + // Assert + expect(error.message).toBe('[#ERR_EXISTING] Feature fetch failed'); + }); + }); + + describe('mapErrorToFetchError function', () => { + it('should return existing fetch errors unchanged', () => { + // Prepare + const error = new FetchError('#ERR_EXISTING'); + + // Act + const result = mapErrorToFetchError(error, '#ERR_SERIALIZE_BODY'); + + // Assert + expect(result).toBe(error); + }); + + it('should wrap errors with context and cause details', () => { + // Prepare + const cause = new Error('Converting circular structure to JSON'); + + // Act + const result = mapErrorToFetchError( + cause, + '#ERR_SERIALIZE_BODY', + 'Failed to serialize request body' + ); + + // Assert + expect(result).toBeInstanceOf(FetchError); + expect(result.code).toBe('#ERR_SERIALIZE_BODY'); + expect(result.message).toBe( + '[#ERR_SERIALIZE_BODY] Failed to serialize request body: Converting circular structure to JSON' + ); + expect(result.cause).toBe(cause); + }); + + it('should preserve unknown thrown values as the cause', () => { + // Act + const result = mapErrorToFetchError('failed', '#ERR_SERIALIZE_BODY'); + + // Assert + expect(result).toBeInstanceOf(FetchError); + expect(result.message).toBe('[#ERR_SERIALIZE_BODY] failed'); + expect(result.cause).toBe('failed'); + }); + }); +}); diff --git a/packages/feature-fetch/src/errors/FetchError.ts b/packages/feature-fetch/src/errors/FetchError.ts new file mode 100644 index 00000000..af70a813 --- /dev/null +++ b/packages/feature-fetch/src/errors/FetchError.ts @@ -0,0 +1,61 @@ +// Note: Import directly to avoid circular dependencies +import { getCauseMessage } from '../lib/get-cause-message'; + +/** Base error for feature-fetch client, middleware, parsing, and serialization failures. */ +export class FetchError extends Error { + public readonly code: TFetchErrorCode; + + constructor(code: TFetchErrorCode, options: TFetchErrorOptions = {}) { + const { message, cause } = options; + super(formatFetchErrorMessage(code, message, cause), { cause }); + this.name = new.target.name; + this.code = code; + + // Note: captureStackTrace is V8-only, so keep it optional for browser runtimes + ( + Error as ErrorConstructor & { + captureStackTrace?: (targetObject: object, constructorOpt?: object) => void; + } + ).captureStackTrace?.(this, new.target); + } +} + +export interface TFetchErrorOptions { + message?: string; + cause?: unknown; +} + +/** Feature-fetch error code format. */ +export type TFetchErrorCode = `#ERR_${string}`; + +function formatFetchErrorMessage( + code: TFetchErrorCode, + message: string | undefined, + cause: unknown +): string { + const baseMessage = 'Feature fetch failed'; + const detail = message ?? getCauseMessage(cause) ?? baseMessage; + return `[${code}] ${detail}`; +} + +/** Maps an unknown thrown value to `FetchError` unless it already is one. */ +export function mapErrorToFetchError( + error: unknown, + code: TFetchErrorCode, + message?: string +): FetchError { + if (error instanceof FetchError) { + return error; + } + + const causeMessage = getCauseMessage(error); + const mappedMessage = + message != null && causeMessage != null && causeMessage !== message + ? `${message}: ${causeMessage}` + : message; + + return new FetchError(code, { + message: mappedMessage, + cause: error + }); +} diff --git a/packages/feature-fetch/src/errors/HttpError.test.ts b/packages/feature-fetch/src/errors/HttpError.test.ts new file mode 100644 index 00000000..4ad7ca05 --- /dev/null +++ b/packages/feature-fetch/src/errors/HttpError.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from 'vitest'; +import { hasStatusCode, HttpError, mapResponseToHttpError } from './HttpError'; + +describe('HttpError module', () => { + describe('HttpError class', () => { + it('should expose response status details', () => { + // Prepare + const response = new Response('', { + status: 404, + statusText: 'Not Found' + }); + + // Act + const error = new HttpError(response, { + data: { + message: 'Item not found' + } + }); + + // Assert + expect(error.name).toBe('HttpError'); + expect(error.code).toBe('#ERR_HTTP_STATUS'); + expect(error.status).toBe(404); + expect(error.statusText).toBe('Not Found'); + expect(error.response).toBe(response); + expect(error.data).toEqual({ + message: 'Item not found' + }); + expect(error.message).toBe( + '[#ERR_HTTP_STATUS] HTTP request failed with status 404 Not Found' + ); + }); + + it('should use the cause message when no message is provided', () => { + // Prepare + const response = new Response('', { + status: 502, + statusText: 'Bad Gateway' + }); + const cause = new Error('Body stream already read'); + + // Act + const error = new HttpError(response, { + cause + }); + + // Assert + expect(error.message).toBe( + '[#ERR_HTTP_STATUS] HTTP request failed with status 502 Bad Gateway: Body stream already read' + ); + expect(error.cause).toBe(cause); + }); + }); + + describe('hasStatusCode function', () => { + it('should match HttpError status codes', () => { + // Prepare + const error = new HttpError(new Response('', { status: 404 })); + + // Act + const result = hasStatusCode(error, 404); + + // Assert + expect(result).toBe(true); + }); + + it('should match status-like objects', () => { + // Act + const result = hasStatusCode({ status: 429 }, 429); + + // Assert + expect(result).toBe(true); + }); + + it('should return false for errors without the status code', () => { + // Act + const result = hasStatusCode(new Error('failed'), 500); + + // Assert + expect(result).toBe(false); + }); + }); + + describe('mapResponseToHttpError function', () => { + it('should extract error details from JSON responses', async () => { + // Prepare + const response = Response.json( + { + code: '#ERR_NOT_FOUND', + message: 'Item not found' + }, + { + status: 404, + statusText: 'Not Found' + } + ); + + // Act + const error = await mapResponseToHttpError(response); + + // Assert + expect(error).toBeInstanceOf(HttpError); + expect(error.code).toBe('#ERR_HTTP_STATUS'); + expect(error.message).toBe( + '[#ERR_HTTP_STATUS] HTTP request failed with status 404 Not Found: Item not found' + ); + expect(error.data).toEqual({ + code: '#ERR_NOT_FOUND', + message: 'Item not found' + }); + }); + + it('should map empty error responses without parse failures', async () => { + // Prepare + const response = new Response('', { + status: 500, + headers: { + 'Content-Type': 'application/json' + } + }); + + // Act + const error = await mapResponseToHttpError(response); + + // Assert + expect(error).toBeInstanceOf(HttpError); + expect(error.code).toBe('#ERR_HTTP_STATUS'); + expect(error.message).toBe('[#ERR_HTTP_STATUS] HTTP request failed with status 500'); + expect(error.data).toBeUndefined(); + }); + + it('should keep invalid JSON error responses as text', async () => { + // Prepare + const response = new Response('Bad gateway', { + status: 502, + headers: { + 'Content-Type': 'application/json' + } + }); + + // Act + const error = await mapResponseToHttpError(response); + + // Assert + expect(error).toBeInstanceOf(HttpError); + expect(error.message).toBe( + '[#ERR_HTTP_STATUS] HTTP request failed with status 502: Bad gateway' + ); + expect(error.data).toBe('Bad gateway'); + }); + + it('should keep the original response body readable', async () => { + // Prepare + const response = Response.json( + { + message: 'Not found' + }, + { + status: 404 + } + ); + + // Act + const error = await mapResponseToHttpError(response); + + // Assert + expect(error.data).toEqual({ + message: 'Not found' + }); + await expect(response.json()).resolves.toEqual({ + message: 'Not found' + }); + }); + }); +}); diff --git a/packages/feature-fetch/src/errors/HttpError.ts b/packages/feature-fetch/src/errors/HttpError.ts new file mode 100644 index 00000000..e0155ee4 --- /dev/null +++ b/packages/feature-fetch/src/errors/HttpError.ts @@ -0,0 +1,134 @@ +// Note: Import directly to avoid circular dependencies +import { getCauseMessage } from '../lib/get-cause-message'; +import { FetchError, type TFetchErrorCode } from './FetchError'; + +/** Represents a completed HTTP response with a non-OK status. */ +export class HttpError extends FetchError { + public readonly status: number; + public readonly statusText: string; + public readonly response: Response; + /** Parsed error response body, when one could be read. */ + public readonly data?: GData; + + constructor(response: Response, options: THttpErrorOptions = {}) { + const { code = '#ERR_HTTP_STATUS', message, cause, data } = options; + super(code, { + message: formatHttpErrorMessage(response, message, cause), + cause + }); + this.status = response.status; + this.statusText = response.statusText; + this.response = response; + this.data = data; + } +} + +export interface THttpErrorOptions { + /** Stable feature-fetch error code. Defaults to `#ERR_HTTP_STATUS`. */ + code?: TFetchErrorCode; + message?: string; + cause?: unknown; + /** Parsed error response body. */ + data?: GData; +} + +function formatHttpErrorMessage( + response: Response, + message: string | undefined, + cause: unknown +): string { + const hasStatusText = response.statusText.length > 0; + const status = hasStatusText + ? `${response.status.toString()} ${response.statusText}` + : response.status.toString(); + const baseMessage = `HTTP request failed with status ${status}`; + const detail = message ?? getCauseMessage(cause); + return detail != null ? `${baseMessage}: ${detail}` : baseMessage; +} + +/** Checks `HttpError` instances and error-like objects with a numeric `status`. */ +export function hasStatusCode(error: unknown, statusCode: number): boolean { + if (error instanceof HttpError) { + return error.status === statusCode; + } + if (isObject(error) && typeof error['status'] === 'number') { + return error['status'] === statusCode; + } + return false; +} + +/** Maps a non-OK `Response` to an `HttpError` with parsed error data when possible. */ +export async function mapResponseToHttpError( + response: Response, + code: TFetchErrorCode = '#ERR_HTTP_STATUS' +): Promise { + try { + // Note: Read from a clone so callers can still inspect error.response + const errorText = await response.clone().text(); + if (!errorText.length) { + return new HttpError(response, { + code + }); + } + + const errorData = parseResponseErrorData(response, errorText); + const errorMessage = getErrorMessage(errorData); + + return new HttpError(response, { + code, + message: errorMessage, + data: errorData + }); + } catch (error) { + return new HttpError(response, { + code, + message: 'Failed to process error response', + cause: error + }); + } +} + +function parseResponseErrorData(response: Response, errorText: string): unknown { + const mediaType = response.headers.get('Content-Type')?.split(';')[0]?.trim().toLowerCase() ?? ''; + + const isJsonMediaType = mediaType === 'application/json' || mediaType.endsWith('+json'); + if (isJsonMediaType) { + try { + return JSON.parse(errorText); + } catch { + // Keep invalid JSON error bodies as text + } + } + + return errorText; +} + +function getErrorMessage(data: unknown): string | undefined { + if (typeof data === 'string') { + return data; + } + if (!isObject(data)) { + return undefined; + } + + return ( + getErrorMessageField(data['message']) ?? + getErrorMessageField(data['detail']) ?? + getErrorMessageField(data['title']) ?? + getErrorMessageField(data['error']) + ); +} + +function getErrorMessageField(value: unknown): string | undefined { + if (typeof value === 'string') { + return value; + } + if (value != null && typeof value !== 'object') { + return String(value); + } + return undefined; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value != null; +} diff --git a/packages/feature-fetch/src/errors/NetworkError.test.ts b/packages/feature-fetch/src/errors/NetworkError.test.ts new file mode 100644 index 00000000..31074f76 --- /dev/null +++ b/packages/feature-fetch/src/errors/NetworkError.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { FetchError } from './FetchError'; +import { mapErrorToNetworkError, NetworkError } from './NetworkError'; + +describe('NetworkError module', () => { + describe('NetworkError class', () => { + it('should describe failures without an HTTP response', () => { + // Prepare + const cause = new Error('offline'); + + // Act + const error = new NetworkError({ + cause + }); + + // Assert + expect(error).toBeInstanceOf(FetchError); + expect(error.name).toBe('NetworkError'); + expect(error.code).toBe('#ERR_NETWORK'); + expect(error.message).toBe( + '[#ERR_NETWORK] Network request failed before receiving an HTTP response: offline' + ); + expect(error.cause).toBe(cause); + }); + }); + + describe('mapErrorToNetworkError function', () => { + it('should wrap regular errors as network errors', () => { + // Prepare + const cause = new Error('offline'); + + // Act + const result = mapErrorToNetworkError(cause); + + // Assert + expect(result).toBeInstanceOf(NetworkError); + expect(result.code).toBe('#ERR_NETWORK'); + expect(result.message).toBe( + '[#ERR_NETWORK] Network request failed before receiving an HTTP response: offline' + ); + expect(result.cause).toBe(cause); + }); + + it('should return existing network errors unchanged', () => { + // Prepare + const error = new NetworkError(); + + // Act + const result = mapErrorToNetworkError(error); + + // Assert + expect(result).toBe(error); + }); + + it('should create a network error for unknown thrown values', () => { + // Act + const result = mapErrorToNetworkError('offline'); + + // Assert + expect(result).toBeInstanceOf(NetworkError); + expect(result.code).toBe('#ERR_NETWORK'); + expect(result.message).toBe( + '[#ERR_NETWORK] Network request failed before receiving an HTTP response: offline' + ); + expect(result.cause).toBe('offline'); + }); + }); +}); diff --git a/packages/feature-fetch/src/errors/NetworkError.ts b/packages/feature-fetch/src/errors/NetworkError.ts new file mode 100644 index 00000000..07830258 --- /dev/null +++ b/packages/feature-fetch/src/errors/NetworkError.ts @@ -0,0 +1,41 @@ +// Note: Import directly to avoid circular dependencies +import { getCauseMessage } from '../lib/get-cause-message'; +import { FetchError, type TFetchErrorCode } from './FetchError'; + +/** Represents a failure where fetch did not produce an HTTP response. */ +export class NetworkError extends FetchError { + constructor(options: TNetworkErrorOptions = {}) { + const { code = '#ERR_NETWORK', cause, message } = options; + super(code, { + message: formatNetworkErrorMessage(message, cause), + cause + }); + } +} + +export interface TNetworkErrorOptions { + /** Stable feature-fetch error code. Defaults to `#ERR_NETWORK`. */ + code?: TFetchErrorCode; + message?: string; + cause?: unknown; +} + +function formatNetworkErrorMessage(message: string | undefined, cause: unknown): string { + const baseMessage = 'Network request failed before receiving an HTTP response'; + const detail = message ?? getCauseMessage(cause); + return detail != null ? `${baseMessage}: ${detail}` : baseMessage; +} + +/** Maps an unknown thrown value to `NetworkError` unless it already is one. */ +export function mapErrorToNetworkError( + error: unknown, + code: TFetchErrorCode = '#ERR_NETWORK' +): NetworkError { + if (error instanceof NetworkError) { + return error; + } + return new NetworkError({ + code, + cause: error + }); +} diff --git a/packages/feature-fetch/src/exceptions/index.ts b/packages/feature-fetch/src/errors/index.ts similarity index 65% rename from packages/feature-fetch/src/exceptions/index.ts rename to packages/feature-fetch/src/errors/index.ts index 7e4d0068..df5aae3f 100644 --- a/packages/feature-fetch/src/exceptions/index.ts +++ b/packages/feature-fetch/src/errors/index.ts @@ -1,3 +1,3 @@ export * from './FetchError'; export * from './NetworkError'; -export * from './RequestError'; +export * from './HttpError'; diff --git a/packages/feature-fetch/src/exceptions/FetchError.ts b/packages/feature-fetch/src/exceptions/FetchError.ts deleted file mode 100644 index 5a7d0462..00000000 --- a/packages/feature-fetch/src/exceptions/FetchError.ts +++ /dev/null @@ -1,25 +0,0 @@ -export class FetchError extends Error { - public readonly code: TErrorCode; - public readonly throwable?: Error; - - constructor(code: TErrorCode, options: TFetchErrorOptions = {}) { - const { description = options.throwable?.message, throwable } = options; - super(description != null ? `[${code}] ${description}` : code); - this.code = code; - this.throwable = throwable; - - // https://stackoverflow.com/questions/59625425/understanding-error-capturestacktrace-and-stack-trace-persistance - ( - Error as ErrorConstructor & { - captureStackTrace?: (targetObject: object, constructorOpt?: Function) => void; - } - ).captureStackTrace?.(this, FetchError); - } -} - -interface TFetchErrorOptions { - description?: string; - throwable?: Error; -} - -export type TErrorCode = `#ERR_${string}`; diff --git a/packages/feature-fetch/src/exceptions/NetworkError.ts b/packages/feature-fetch/src/exceptions/NetworkError.ts deleted file mode 100644 index b0c4d802..00000000 --- a/packages/feature-fetch/src/exceptions/NetworkError.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { FetchError, type TErrorCode } from './FetchError'; - -export class NetworkError extends FetchError { - constructor(code: TErrorCode, options: TNetworkErrorOptions = {}) { - const { throwable, description } = options; - super(code, { - description: `Call to endpoint failed with network exception${ - description != null ? `: ${description}` : '!' - }`, - throwable - }); - } -} - -interface TNetworkErrorOptions { - description?: string; - throwable?: Error; -} diff --git a/packages/feature-fetch/src/exceptions/RequestError.ts b/packages/feature-fetch/src/exceptions/RequestError.ts deleted file mode 100644 index 24b7ac2f..00000000 --- a/packages/feature-fetch/src/exceptions/RequestError.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { FetchError, type TErrorCode } from './FetchError'; - -export class RequestError extends FetchError { - public readonly status: number; - public readonly response?: Response; - public readonly data?: GData; - - constructor(code: TErrorCode, status: number, options: TRequestErrorOptions = {}) { - const { description, response, data } = options; - super(code, { - description: `Call to endpoint failed by exception with status code ${status.toString()}${ - description != null ? `: ${description}` : '!' - }` - }); - this.status = status; - this.response = response; - this.data = data; - } -} - -interface TRequestErrorOptions { - description?: string; - data?: GData; - response?: Response; -} diff --git a/packages/feature-fetch/src/features/api.test.ts b/packages/feature-fetch/src/features/api.test.ts new file mode 100644 index 00000000..dd353dd1 --- /dev/null +++ b/packages/feature-fetch/src/features/api.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, expectTypeOf, it, vi } from 'vitest'; +import { createFetchClient } from '../create-fetch-client'; +import type { TFetchLike } from '../types'; +import { apiFeature } from './api'; + +describe('apiFeature function', () => { + describe('types', () => { + it('should infer parsed data by default', async () => { + // Prepare + const fetchLike = vi.fn(async () => { + return Response.json({ id: 'item-1' }); + }); + const client = createFetchClient({ fetch: fetchLike }).with(apiFeature()); + + // Act + const result = await client.get<{ id: string }>('/items/item-1'); + const value = result.unwrap(); + + // Assert + expect(value).toEqual({ id: 'item-1' }); + expectTypeOf(value).toEqualTypeOf<{ id: string }>(); + }); + + it('should infer parsed data when options are passed without response details', async () => { + // Prepare + const fetchLike = vi.fn(async () => { + return Response.json({ id: 'item-1' }); + }); + const client = createFetchClient({ fetch: fetchLike }).with(apiFeature()); + + // Act + const result = await client.get<{ id: string }>('/items/item-1', { + headers: { + 'x-request-id': 'request-1' + } + }); + const value = result.unwrap(); + + // Assert + expect(value).toEqual({ id: 'item-1' }); + expectTypeOf(value).toEqualTypeOf<{ id: string }>(); + }); + + it('should infer response details when requested', async () => { + // Prepare + const fetchLike = vi.fn(async () => { + return Response.json({ id: 'item-1' }); + }); + const client = createFetchClient({ fetch: fetchLike }).with(apiFeature()); + + // Act + const result = await client.get<{ id: string }>('/items/item-1', { + withResponse: true + }); + const value = result.unwrap(); + + // Assert + expect(value.data).toEqual({ id: 'item-1' }); + expect(value.response).toBeInstanceOf(Response); + expectTypeOf(value).toEqualTypeOf<{ + data: { + id: string; + }; + response: Response; + }>(); + }); + + it('should infer typed request bodies', async () => { + // Prepare + const fetchLike = vi.fn(async () => { + return Response.json({ id: 'item-1' }); + }); + const client = createFetchClient({ fetch: fetchLike }).with(apiFeature()); + + // Act + const result = await client.post<{ id: string }, { message: string }, { name: string }>( + '/items', + { + body: { name: 'Jeff' } + } + ); + const value = result.unwrap(); + + // Assert + expect(value).toEqual({ id: 'item-1' }); + expectTypeOf(value).toEqualTypeOf<{ id: string }>(); + }); + + it('should infer response details for body requests when requested', async () => { + // Prepare + const fetchLike = vi.fn(async () => { + return Response.json({ id: 'item-1' }); + }); + const client = createFetchClient({ fetch: fetchLike }).with(apiFeature()); + + // Act + const result = await client.post<{ id: string }, { message: string }, { name: string }>( + '/items', + { + body: { name: 'Jeff' }, + withResponse: true + } + ); + const value = result.unwrap(); + + // Assert + expect(value.data).toEqual({ id: 'item-1' }); + expect(value.response).toBeInstanceOf(Response); + expectTypeOf(value).toEqualTypeOf<{ + data: { + id: string; + }; + response: Response; + }>(); + }); + + it('should support generic response detail flags', async () => { + // Prepare + const fetchLike = vi.fn(async () => { + return Response.json({ id: 'item-1' }); + }); + const client = createFetchClient({ fetch: fetchLike }).with(apiFeature()); + + async function getItem(withResponse: GWithResponse) { + return client.get<{ id: string }, unknown, 'json', GWithResponse>('/items/item-1', { + withResponse + }); + } + + // Act + const dataResult = await getItem(false); + const responseResult = await getItem(true); + const dataValue = dataResult.unwrap(); + const responseValue = responseResult.unwrap(); + + // Assert + expect(dataValue).toEqual({ id: 'item-1' }); + expect(responseValue.data).toEqual({ id: 'item-1' }); + expect(responseValue.response).toBeInstanceOf(Response); + expectTypeOf(dataValue).toEqualTypeOf<{ id: string }>(); + expectTypeOf(responseValue).toEqualTypeOf<{ + data: { + id: string; + }; + response: Response; + }>(); + }); + }); + + describe('HTTP helper methods', () => { + it.each([ + ['get', 'GET', undefined, { ok: true }], + ['post', 'POST', { body: { name: 'Jeff' } }, { ok: true }], + ['put', 'PUT', { body: { name: 'Jeff' } }, { ok: true }], + ['patch', 'PATCH', { body: { name: 'Jeff' } }, { ok: true }], + ['delete', 'DELETE', { body: { name: 'Jeff' } }, { ok: true }], + ['options', 'OPTIONS', undefined, { ok: true }], + ['head', 'HEAD', undefined, undefined], + ['trace', 'TRACE', undefined, { ok: true }] + ] as const)( + 'should send %s requests with the %s method', + async (helperName, method, options, value) => { + // Prepare + const fetchLike = vi.fn(async () => { + return Response.json({ ok: true }); + }); + const client = createFetchClient({ fetch: fetchLike }).with(apiFeature()); + + // Act + // @ts-expect-error TS2349: the method table intentionally combines helpers with different option signatures + const result = await client[helperName]('/items', options); + + // Assert + const expectedInit = + options?.body == null + ? { method } + : { + body: JSON.stringify(options.body), + method + }; + expect(result.unwrap()).toEqual(value); + expect(fetchLike).toHaveBeenCalledWith('/items', expect.objectContaining(expectedInit)); + } + ); + }); +}); diff --git a/packages/feature-fetch/src/features/api.ts b/packages/feature-fetch/src/features/api.ts new file mode 100644 index 00000000..bff4e73b --- /dev/null +++ b/packages/feature-fetch/src/features/api.ts @@ -0,0 +1,164 @@ +import { defineFeature, type TFeature } from 'feature-core'; +import { mapOk, type TResult } from 'tuple-result'; +import { createFetchClient, type TCreateFetchClientOptions } from '../create-fetch-client'; +import type { + TFetchClient, + TFetchClientBase, + TFetchOptions, + TFetchOptionsWithBody, + TFetchResponseError, + TFetchResponseSuccess, + TParseAs, + TParseAsResponse, + TRequestMethod, + TUnserializedBody +} from '../types'; + +/** Creates a fetch client with REST method helpers already installed. */ +export function createApiFetchClient( + options: TCreateFetchClientOptions = {} +): TFetchClient<[TApiFeature]> { + return createFetchClient(options).with(apiFeature()); +} + +// MARK: - Feature + +/** + * Adds REST method helpers around `request()`. + * Helpers unwrap the low-level success value to `data` unless `withResponse: true` is passed. + */ +export function apiFeature(): TApiFeature { + return defineFeature({ + key: 'api', + install() { + return { + get: createApiMethod('GET') as TApiMethod, + post: createApiMethod('POST') as TApiBodyMethod, + put: createApiMethod('PUT') as TApiBodyMethod, + patch: createApiMethod('PATCH') as TApiBodyMethod, + delete: createApiMethod('DELETE') as TApiBodyMethod, + options: createApiMethod('OPTIONS') as TApiMethod, + head: createApiMethod('HEAD') as TApiMethod, + trace: createApiMethod('TRACE') as TApiMethod + }; + } + }); +} + +export type TApiFeature = TFeature<'api', TApiFeatureApi>; + +export interface TApiFeatureApi { + get: TApiMethod; + post: TApiBodyMethod; + put: TApiBodyMethod; + patch: TApiBodyMethod; + delete: TApiBodyMethod; + options: TApiMethod; + head: TApiMethod; + trace: TApiMethod; +} + +// MARK: - Method + +function createApiMethod(method: TRequestMethod) { + return async function apiMethod( + this: TFetchClientBase, + path: string, + options: TApiMethodOptions = {} + ) { + const { withResponse = false, ...fetchOptions } = options; + const requestResult = await this.request(method, path, fetchOptions); + return withResponse ? requestResult : mapOk(requestResult, ({ data }) => data); + }; +} + +type TApiMethodOptions = TFetchOptionsWithBody & { + withResponse?: boolean; +}; + +/** + * Sends a REST request without a request body. + * + * By default the success branch is parsed data. Pass `withResponse: true` to receive + * `{ data, response }` instead. + */ +export interface TApiMethod { + < + GSuccessResponseBody = unknown, + GErrorResponseBody = unknown, + GParseAs extends TParseAs = 'json' + >( + path: string, + options: TFetchOptions & { withResponse: true } + ): Promise>; + < + GSuccessResponseBody = unknown, + GErrorResponseBody = unknown, + GParseAs extends TParseAs = 'json' + >( + path: string, + options?: TFetchOptions & { withResponse?: false } + ): Promise>; + < + GSuccessResponseBody = unknown, + GErrorResponseBody = unknown, + GParseAs extends TParseAs = 'json', + GWithResponse extends boolean = boolean + >( + path: string, + options?: TFetchOptions & { withResponse?: GWithResponse } + ): Promise>; +} + +/** + * Sends a REST request that can include a request body. + * + * By default the success branch is parsed data. Pass `withResponse: true` to receive + * `{ data, response }` instead. + */ +export interface TApiBodyMethod { + < + GSuccessResponseBody = unknown, + GErrorResponseBody = unknown, + GRequestBody extends TUnserializedBody = Record, + GParseAs extends TParseAs = 'json' + >( + path: string, + options: TFetchOptionsWithBody & { withResponse: true } + ): Promise>; + < + GSuccessResponseBody = unknown, + GErrorResponseBody = unknown, + GRequestBody extends TUnserializedBody = Record, + GParseAs extends TParseAs = 'json' + >( + path: string, + options?: TFetchOptionsWithBody & { withResponse?: false } + ): Promise>; + < + GSuccessResponseBody = unknown, + GErrorResponseBody = unknown, + GRequestBody extends TUnserializedBody = Record, + GParseAs extends TParseAs = 'json', + GWithResponse extends boolean = boolean + >( + path: string, + options?: TFetchOptionsWithBody & { + withResponse?: GWithResponse; + } + ): Promise>; +} + +// MARK: - Response + +type TApiResponse< + GSuccessResponseBody = unknown, + GErrorResponseBody = unknown, + GParseAs extends TParseAs = 'json', + GWithResponse extends boolean = false +> = TResult< + GWithResponse extends true + ? TFetchResponseSuccess + : TParseAsResponse, + TFetchResponseError +>; diff --git a/packages/feature-fetch/src/features/cache.test.ts b/packages/feature-fetch/src/features/cache.test.ts new file mode 100644 index 00000000..63c682b7 --- /dev/null +++ b/packages/feature-fetch/src/features/cache.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, expectTypeOf, it, vi } from 'vitest'; +import { createFetchClient } from '../create-fetch-client'; +import type { TFetchLike } from '../types'; +import { cacheFeature, createCacheMiddleware } from './cache'; + +describe('cacheFeature function', () => { + describe('default rules', () => { + it('should cache successful GET responses by URL', async () => { + // Prepare + let requestCount = 0; + const fetchLike = vi.fn(async () => { + requestCount++; + return Response.json({ requestCount }); + }); + const client = createFetchClient({ fetch: fetchLike }).with(cacheFeature()); + + // Act + const firstResult = await client.request('GET', '/items'); + const secondResult = await client.request('GET', '/items'); + + // Assert + expect(firstResult.unwrap().data).toEqual({ requestCount: 1 }); + expect(secondResult.unwrap().data).toEqual({ requestCount: 1 }); + expect(fetchLike).toHaveBeenCalledTimes(1); + }); + + it('should return a fresh cached response for each read', async () => { + // Prepare + const fetchLike = vi.fn(async () => { + return new Response(JSON.stringify({ ok: true }), { + headers: { + 'Content-Type': 'application/json' + } + }); + }); + const client = createFetchClient({ fetch: fetchLike }).with(cacheFeature()); + + // Act + const firstResult = await client.request('GET', '/items'); + const secondResult = await client.request('GET', '/items'); + + // Assert + expect(firstResult.unwrap().data).toEqual({ ok: true }); + expect(secondResult.unwrap().data).toEqual({ ok: true }); + expect(fetchLike).toHaveBeenCalledTimes(1); + }); + + it.each([ + { + name: 'non-GET requests', + options: { + method: 'POST', + requestOptions: { body: { name: 'Jeff' } } + }, + responseHeaders: undefined + }, + { + name: 'authenticated requests', + options: { + method: 'GET', + requestOptions: { headers: { Authorization: 'Bearer token' } } + }, + responseHeaders: undefined + }, + { + name: 'cache reload requests', + options: { + method: 'GET', + requestOptions: { requestInit: { cache: 'reload' } } + }, + responseHeaders: undefined + }, + { + name: 'cache no-store requests', + options: { + method: 'GET', + requestOptions: { requestInit: { cache: 'no-store' } } + }, + responseHeaders: undefined + }, + { + name: 'private responses', + options: { + method: 'GET', + requestOptions: {} + }, + responseHeaders: { + 'Cache-Control': 'private, no-store' + } + } + ] as const)('should not cache $name by default', async ({ options, responseHeaders }) => { + // Prepare + const fetchLike = vi.fn(async () => { + return Response.json( + { ok: true }, + { + headers: responseHeaders + } + ); + }); + const client = createFetchClient({ fetch: fetchLike }).with(cacheFeature()); + + // Act + await client.request(options.method, '/items', options.requestOptions); + await client.request(options.method, '/items', options.requestOptions); + + // Assert + expect(fetchLike).toHaveBeenCalledTimes(2); + }); + }); + + describe('configuration', () => { + it('should expire cached responses after maxAgeMs', async () => { + // Prepare + vi.useFakeTimers(); + try { + vi.setSystemTime(0); + + let requestCount = 0; + const fetchLike = vi.fn(async () => { + requestCount++; + return Response.json({ requestCount }); + }); + const client = createFetchClient({ fetch: fetchLike }).with( + cacheFeature({ maxAgeMs: 100 }) + ); + + // Act + const firstResult = await client.request('GET', '/items'); + vi.setSystemTime(101); + const secondResult = await client.request('GET', '/items'); + + // Assert + expect(firstResult.unwrap().data).toEqual({ requestCount: 1 }); + expect(secondResult.unwrap().data).toEqual({ requestCount: 2 }); + expect(fetchLike).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it('should cap maxAgeMs by response Cache-Control max-age', async () => { + // Prepare + vi.useFakeTimers(); + try { + vi.setSystemTime(0); + + let requestCount = 0; + const fetchLike = vi.fn(async () => { + requestCount++; + return Response.json( + { requestCount }, + { + headers: { + 'Cache-Control': 'max-age=1' + } + } + ); + }); + const client = createFetchClient({ fetch: fetchLike }).with( + cacheFeature({ maxAgeMs: 60_000 }) + ); + + // Act + const firstResult = await client.request('GET', '/items'); + vi.setSystemTime(1001); + const secondResult = await client.request('GET', '/items'); + + // Assert + expect(firstResult.unwrap().data).toEqual({ requestCount: 1 }); + expect(secondResult.unwrap().data).toEqual({ requestCount: 2 }); + expect(fetchLike).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it('should skip storing responses with zero max-age', async () => { + // Prepare + let requestCount = 0; + const fetchLike = vi.fn(async () => { + requestCount++; + return Response.json( + { requestCount }, + { + headers: { + 'Cache-Control': 'max-age=0' + } + } + ); + }); + const client = createFetchClient({ fetch: fetchLike }).with(cacheFeature()); + + // Act + const firstResult = await client.request('GET', '/items'); + const secondResult = await client.request('GET', '/items'); + + // Assert + expect(firstResult.unwrap().data).toEqual({ requestCount: 1 }); + expect(secondResult.unwrap().data).toEqual({ requestCount: 2 }); + expect(fetchLike).toHaveBeenCalledTimes(2); + }); + + it('should use custom cache rules', async () => { + // Prepare + const fetchLike = vi.fn(async (url) => { + return Response.json( + { ok: true }, + { + headers: { + 'x-cacheable': url === '/cached' ? 'true' : 'false' + } + } + ); + }); + const client = createFetchClient({ fetch: fetchLike }).with( + cacheFeature({ + getCacheKey: (url) => (url === '/cached' ? 'shared-key' : null), + shouldCache: (response) => response.headers.get('x-cacheable') === 'true' + }) + ); + + // Act + await client.request('GET', '/skipped'); + await client.request('GET', '/skipped'); + await client.request('GET', '/cached'); + await client.request('GET', '/cached'); + + // Assert + expect(fetchLike).toHaveBeenCalledTimes(3); + }); + }); + + describe('cache property', () => { + it('should expose cache control methods', async () => { + // Prepare + let requestCount = 0; + const fetchLike = vi.fn(async () => { + requestCount++; + return Response.json({ requestCount }); + }); + const client = createFetchClient({ fetch: fetchLike }).with(cacheFeature()); + + // Act + const firstResult = await client.request('GET', '/items'); + const secondResult = await client.request('GET', '/items'); + client.cache.invalidate((key) => key.includes('/items')); + const thirdResult = await client.request('GET', '/items'); + client.cache.clear(); + const fourthResult = await client.request('GET', '/items'); + + // Assert + expect(firstResult.unwrap().data).toEqual({ requestCount: 1 }); + expect(secondResult.unwrap().data).toEqual({ requestCount: 1 }); + expect(thirdResult.unwrap().data).toEqual({ requestCount: 2 }); + expect(fourthResult.unwrap().data).toEqual({ requestCount: 3 }); + expect(fetchLike).toHaveBeenCalledTimes(3); + expectTypeOf(client.cache.clear).toEqualTypeOf<() => void>(); + expectTypeOf(client.cache.invalidate).toEqualTypeOf< + (predicate: (key: string) => boolean) => void + >(); + }); + }); + + describe('createCacheMiddleware function', () => { + it('should create standalone cache middleware', async () => { + // Prepare + let requestCount = 0; + const fetchLike = vi.fn(async () => { + requestCount++; + return Response.json({ requestCount }); + }); + const fetchWithCache = createCacheMiddleware()(fetchLike); + + // Act + const firstResponse = await fetchWithCache('/items', { method: 'GET' }); + const secondResponse = await fetchWithCache('/items', { method: 'GET' }); + + // Assert + await expect(firstResponse.json()).resolves.toEqual({ requestCount: 1 }); + await expect(secondResponse.json()).resolves.toEqual({ requestCount: 1 }); + expect(fetchLike).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/feature-fetch/src/features/cache.ts b/packages/feature-fetch/src/features/cache.ts new file mode 100644 index 00000000..7ace4bab --- /dev/null +++ b/packages/feature-fetch/src/features/cache.ts @@ -0,0 +1,183 @@ +import { defineFeature, type TFeature } from 'feature-core'; +import { hasHeader, normalizeHeaders } from '../lib'; +import type { TFetchClientBase, TFetchLike, TFetchMiddleware } from '../types'; + +/** + * Adds an in-memory response cache middleware. + * Defaults to cacheable GET requests and skips requests with auth or cookie headers. + */ +export function cacheFeature(options: TCacheFeatureOptions = {}): TCacheFeature { + return defineFeature({ + key: 'cache', + install(fetchClient: TFetchClientBase) { + const cache = new ResponseCache(); + fetchClient._config.middleware.push(createResponseCacheMiddleware(cache, options)); + + return { + cache: { + clear() { + cache.clear(); + }, + invalidate(predicate: TCacheInvalidationPredicate) { + cache.invalidate(predicate); + } + } + }; + } + }); +} + +export type TCacheFeature = TFeature<'cache', TCacheFeatureApi>; + +export interface TCacheFeatureApi { + cache: TCacheApi; +} + +export interface TCacheApi { + /** Clears all cached responses. */ + clear(): void; + /** Deletes cached responses whose cache key matches the predicate. */ + invalidate(predicate: TCacheInvalidationPredicate): void; +} + +export type TCacheInvalidationPredicate = (key: string) => boolean; + +// MARK: - Middleware + +/** Creates a standalone cache middleware for custom client composition. */ +export function createCacheMiddleware(options: TCacheFeatureOptions = {}): TFetchMiddleware { + return createResponseCacheMiddleware(new ResponseCache(), options); +} + +function createResponseCacheMiddleware( + cache: ResponseCache, + options: TCacheFeatureOptions = {} +): TFetchMiddleware { + const { + maxAgeMs = 5 * 60 * 1000, + getCacheKey = defaultGetCacheKey, + shouldCache = defaultShouldCache + } = options; + + return (next: TFetchLike) => + async (url, requestInit): Promise => { + const cacheKey = getCacheKey(url, requestInit); + if (cacheKey != null) { + const cachedResponse = cache.get(cacheKey); + if (cachedResponse != null) { + return cachedResponse; + } + } + + const response = await next(url, requestInit); + if (cacheKey != null && shouldCache(response)) { + const cacheMaxAgeMs = resolveCacheMaxAgeMs(response, maxAgeMs); + if (cacheMaxAgeMs > 0) { + cache.set(cacheKey, response, cacheMaxAgeMs); + } + } + + return response; + }; +} + +export interface TCacheFeatureOptions { + /** Maximum age in milliseconds. Defaults to 5 minutes. */ + maxAgeMs?: number; + /** Returns the cache key for a request, or `null` to skip caching. Defaults to unauthenticated GET URLs. */ + getCacheKey?: TGetCacheKey; + /** Returns whether a response should be cached. Defaults to OK responses without private cache directives. */ + shouldCache?: TShouldCache; +} + +/** Returns a cache key for a request, or `null` to skip caching. */ +export type TGetCacheKey = (url: URL | string, init?: RequestInit) => string | null; + +/** Returns whether a response should be cached. */ +export type TShouldCache = (response: Response) => boolean; + +const defaultGetCacheKey: TGetCacheKey = (url, init) => { + const method = init?.method?.toUpperCase() ?? 'GET'; + if (method !== 'GET') { + return null; + } + + if (init?.cache === 'no-store' || init?.cache === 'reload') { + return null; + } + + const headers = normalizeHeaders(init?.headers); + // Note: URL-only cache keys cannot safely separate user-specific responses + if (hasHeader(headers, 'Authorization') || hasHeader(headers, 'Cookie')) { + return null; + } + + return `${method}:${url.toString()}`; +}; + +const defaultShouldCache: TShouldCache = (response) => { + const cacheControl = response.headers.get('Cache-Control')?.toLowerCase() ?? ''; + const hasBlockedCacheDirective = + cacheControl.includes('no-store') || + cacheControl.includes('no-cache') || + cacheControl.includes('private'); + return response.ok && !hasBlockedCacheDirective && !response.headers.has('Set-Cookie'); +}; + +function resolveCacheMaxAgeMs(response: Response, maxAgeMs: number): number { + const responseMaxAgeMs = getCacheControlMaxAgeMs(response.headers.get('Cache-Control')); + if (responseMaxAgeMs == null) { + return maxAgeMs; + } + + return Math.min(maxAgeMs, responseMaxAgeMs); +} + +function getCacheControlMaxAgeMs(cacheControl: string | null): number | null { + const maxAge = cacheControl?.match(/(?:^|,)\s*max-age=(\d+)\s*(?:,|$)/i)?.[1]; + if (maxAge == null) { + return null; + } + + return Number(maxAge) * 1000; +} + +// MARK: - Response Cache + +class ResponseCache { + private readonly entries = new Map(); + + set(key: string, response: Response, maxAgeMs: number): void { + this.entries.set(key, { + response: response.clone(), + expiresAt: Date.now() + maxAgeMs + }); + } + + get(key: string): Response | null { + const entry = this.entries.get(key); + if (entry != null && entry.expiresAt > Date.now()) { + return entry.response.clone(); + } + + this.entries.delete(key); + return null; + } + + clear(): void { + this.entries.clear(); + } + + invalidate(predicate: TCacheInvalidationPredicate): void { + for (const key of this.entries.keys()) { + if (predicate(key)) { + this.entries.delete(key); + } + } + } +} + +interface TCacheEntry { + response: Response; + expiresAt: number; +} diff --git a/packages/feature-fetch/src/features/delay.test.ts b/packages/feature-fetch/src/features/delay.test.ts new file mode 100644 index 00000000..fded80c6 --- /dev/null +++ b/packages/feature-fetch/src/features/delay.test.ts @@ -0,0 +1,114 @@ +import { unwrapErr } from 'tuple-result'; +import { describe, expect, it, vi } from 'vitest'; +import { createFetchClient } from '../create-fetch-client'; +import { NetworkError } from '../errors'; +import type { TFetchLike } from '../types'; +import { createDelayMiddleware, delayFeature } from './delay'; + +describe('delayFeature function', () => { + describe('request delay', () => { + it('should wait before forwarding the request to fetch', async () => { + // Prepare + vi.useFakeTimers(); + try { + const fetchLike = vi.fn(async () => { + return Response.json({ ok: true }); + }); + const client = createFetchClient({ fetch: fetchLike }).with(delayFeature(1000)); + + // Act + const resultPromise = client.request('GET', '/items'); + await vi.advanceTimersByTimeAsync(999); + + // Assert + expect(fetchLike).not.toHaveBeenCalled(); + + // Act + await vi.advanceTimersByTimeAsync(1); + const result = await resultPromise; + + // Assert + expect(result.unwrap().data).toEqual({ ok: true }); + expect(fetchLike).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it('should skip the timer for non-positive delays', async () => { + // Prepare + vi.useFakeTimers(); + try { + const fetchLike = vi.fn(async () => { + return Response.json({ ok: true }); + }); + const client = createFetchClient({ fetch: fetchLike }).with(delayFeature(0)); + + // Act + const result = await client.request('GET', '/items'); + + // Assert + expect(result.unwrap().data).toEqual({ ok: true }); + expect(fetchLike).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it('should stop waiting when the request signal aborts', async () => { + // Prepare + vi.useFakeTimers(); + try { + const controller = new AbortController(); + const fetchLike = vi.fn(async () => { + return Response.json({ ok: true }); + }); + const client = createFetchClient({ fetch: fetchLike }).with(delayFeature(1000)); + + // Act + const resultPromise = client.request('GET', '/items', { + signal: controller.signal + }); + await vi.advanceTimersByTimeAsync(100); + controller.abort(new Error('Cancelled')); + const result = await resultPromise; + + // Assert + expect(unwrapErr(result)).toBeInstanceOf(NetworkError); + expect(fetchLike).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + }); + + describe('createDelayMiddleware function', () => { + it('should create standalone delay middleware', async () => { + // Prepare + vi.useFakeTimers(); + try { + const fetchLike = vi.fn(async () => { + return Response.json({ ok: true }); + }); + const fetchWithDelay = createDelayMiddleware(1000)(fetchLike); + + // Act + const responsePromise = fetchWithDelay('/items', { method: 'GET' }); + await vi.advanceTimersByTimeAsync(999); + + // Assert + expect(fetchLike).not.toHaveBeenCalled(); + + // Act + await vi.advanceTimersByTimeAsync(1); + const response = await responsePromise; + + // Assert + await expect(response.json()).resolves.toEqual({ ok: true }); + expect(fetchLike).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + }); +}); diff --git a/packages/feature-fetch/src/features/delay.ts b/packages/feature-fetch/src/features/delay.ts new file mode 100644 index 00000000..3ae0e27c --- /dev/null +++ b/packages/feature-fetch/src/features/delay.ts @@ -0,0 +1,26 @@ +import { defineFeature, type TFeature } from 'feature-core'; +import { sleep } from '../lib'; +import type { TFetchClientBase, TFetchLike, TFetchMiddleware } from '../types'; + +/** Adds a request middleware that waits before forwarding each request. */ +export function delayFeature(delayMs: number): TDelayFeature { + return defineFeature({ + key: 'delay', + install(fetchClient: TFetchClientBase) { + fetchClient._config.middleware.push(createDelayMiddleware(delayMs)); + + return {}; + } + }); +} + +export type TDelayFeature = TFeature<'delay', object>; + +/** Creates a standalone delay middleware for custom client composition. */ +export function createDelayMiddleware(delayMs: number): TFetchMiddleware { + return (next: TFetchLike) => + async (url, requestInit): Promise => { + await sleep(delayMs, requestInit?.signal); + return next(url, requestInit); + }; +} diff --git a/packages/feature-fetch/src/features/graphql/GraphQLError.test.ts b/packages/feature-fetch/src/features/graphql/GraphQLError.test.ts new file mode 100644 index 00000000..5df2ac25 --- /dev/null +++ b/packages/feature-fetch/src/features/graphql/GraphQLError.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { GraphQLError } from './GraphQLError'; + +describe('GraphQLError class', () => { + it('should format one GraphQL error message', () => { + // Prepare + const response = new Response(); + + // Act + const error = new GraphQLError([{ message: 'User not found' }], { + response + }); + + // Assert + expect(error.name).toBe('GraphQLError'); + expect(error.code).toBe('#ERR_GRAPHQL_OPERATION'); + expect(error.message).toBe('[#ERR_GRAPHQL_OPERATION] GraphQL operation failed: User not found'); + expect(error.response).toBe(response); + }); + + it('should format multiple GraphQL error messages', () => { + // Prepare + const errors = [ + { + message: 'User not found' + }, + { + message: 'Missing permission' + } + ]; + + // Act + const error = new GraphQLError(errors, { + response: new Response() + }); + + // Assert + expect(error.message).toBe( + '[#ERR_GRAPHQL_OPERATION] GraphQL operation failed with 2 errors: User not found, Missing permission' + ); + }); + + it('should keep GraphQL response details', () => { + // Prepare + const response = new Response(); + const errors = [ + { + message: 'User not found' + } + ]; + + // Act + const error = new GraphQLError(errors, { + data: { + user: null + }, + extensions: { + requestId: 'request-1' + }, + response + }); + + // Assert + expect(error.errors).toBe(errors); + expect(error.data).toEqual({ + user: null + }); + expect(error.extensions).toEqual({ + requestId: 'request-1' + }); + expect(error.response).toBe(response); + }); +}); diff --git a/packages/feature-fetch/src/features/graphql/GraphQLError.ts b/packages/feature-fetch/src/features/graphql/GraphQLError.ts new file mode 100644 index 00000000..359b2164 --- /dev/null +++ b/packages/feature-fetch/src/features/graphql/GraphQLError.ts @@ -0,0 +1,47 @@ +import { FetchError, type TFetchErrorCode } from '../../errors'; +import type { TGraphQLError } from './graphql'; + +/** Represents a GraphQL response whose `errors` array is not empty. */ +export class GraphQLError extends FetchError { + /** GraphQL errors returned by the server. */ + public readonly errors: TGraphQLError[]; + public readonly response: Response; + /** Partial GraphQL data returned with the errors, when provided. */ + public readonly data?: GData | null; + /** GraphQL response extensions returned with the errors, when provided. */ + public readonly extensions?: Record; + + constructor(errors: TGraphQLError[], options: TGraphQLErrorOptions) { + const { code = '#ERR_GRAPHQL_OPERATION', response, data, extensions } = options; + super(code, { + message: formatGraphQLErrorMessage(errors) + }); + this.errors = errors; + this.response = response; + this.data = data; + this.extensions = extensions; + } +} + +export interface TGraphQLErrorOptions { + /** Stable feature-fetch error code. Defaults to `#ERR_GRAPHQL_OPERATION`. */ + code?: TFetchErrorCode; + response: Response; + /** Partial GraphQL data returned with the errors, when provided. */ + data?: GData | null; + /** GraphQL response extensions returned with the errors, when provided. */ + extensions?: Record; +} + +function formatGraphQLErrorMessage(errors: TGraphQLError[]): string { + if (!errors.length) { + return 'GraphQL operation failed'; + } + + const errorMessages = errors.map((error) => error.message).join(', '); + if (errors.length === 1) { + return `GraphQL operation failed: ${errorMessages}`; + } + + return `GraphQL operation failed with ${errors.length} errors: ${errorMessages}`; +} diff --git a/packages/feature-fetch/src/features/graphql/get-operation-string.test.ts b/packages/feature-fetch/src/features/graphql/get-operation-string.test.ts new file mode 100644 index 00000000..5272db26 --- /dev/null +++ b/packages/feature-fetch/src/features/graphql/get-operation-string.test.ts @@ -0,0 +1,65 @@ +import { parse, type DocumentNode } from '@0no-co/graphql.web'; +import { unwrapErr } from 'tuple-result'; +import { describe, expect, it } from 'vitest'; +import { getOperationString } from './get-operation-string'; + +describe('getOperationString function', () => { + it('should return string operations unchanged', async () => { + // Act + const result = await getOperationString('query Viewer { viewer { id } }'); + + // Assert + expect(result.unwrap()).toBe('query Viewer { viewer { id } }'); + }); + + it('should return the original source body from parsed documents', async () => { + // Prepare + const document = { + loc: { + source: { + body: 'query Viewer { viewer { id } }' + } + } + } as unknown as DocumentNode; + + // Act + const result = await getOperationString(document); + + // Assert + expect(result.unwrap()).toBe('query Viewer { viewer { id } }'); + }); + + it('should print parsed documents without source bodies', async () => { + // Prepare + const document = parse('query Viewer { viewer { id } }', { noLocation: true }); + + // Act + const result = await getOperationString(document); + + // Assert + expect(result.unwrap()).toBe(`query Viewer { + viewer { + id + } +}`); + }); + + it('should map document printing failures to an error result', async () => { + // Prepare + const document = { + kind: 'Document', + definitions: [ + { + kind: 'Unknown' + } + ] + } as unknown as DocumentNode; + + // Act + const result = await getOperationString(document); + + // Assert + expect(result.isErr()).toBe(true); + expect(unwrapErr(result).code).toBe('#ERR_GRAPHQL_PRINT'); + }); +}); diff --git a/packages/feature-fetch/src/features/graphql/get-operation-string.ts b/packages/feature-fetch/src/features/graphql/get-operation-string.ts new file mode 100644 index 00000000..5ba21a25 --- /dev/null +++ b/packages/feature-fetch/src/features/graphql/get-operation-string.ts @@ -0,0 +1,27 @@ +import { Err, Ok, type TResult } from 'tuple-result'; +import { mapErrorToFetchError, type FetchError } from '../../errors'; +import type { TGraphQLDocumentInput } from './graphql'; + +/** Returns an operation string from a GraphQL string or document input. */ +export async function getOperationString< + GResult extends object = object, + GVariables extends object = Record +>(document: TGraphQLDocumentInput): Promise> { + if (typeof document === 'string') { + return Ok(document); + } + + if (document.loc?.source.body != null) { + return Ok(document.loc.source.body); + } + + try { + // Note: Load the printer only for documents that do not carry their original source + const { print } = await import('@0no-co/graphql.web'); + return Ok(print(document)); + } catch (error) { + return Err( + mapErrorToFetchError(error, '#ERR_GRAPHQL_PRINT', 'Failed to print GraphQL document') + ); + } +} diff --git a/packages/feature-fetch/src/features/graphql/gql.test.ts b/packages/feature-fetch/src/features/graphql/gql.test.ts new file mode 100644 index 00000000..14fd683a --- /dev/null +++ b/packages/feature-fetch/src/features/graphql/gql.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { gql } from './gql'; + +describe('gql function', () => { + it('should return template literals with interpolated values', () => { + // Prepare + const fieldName = 'id'; + + // Act + const result = gql` + query Viewer { + viewer { + ${fieldName} + } + } + `; + + // Assert + expect(result).toBe(` + query Viewer { + viewer { + id + } + } + `); + }); + + it('should return an empty string for empty template literals', () => { + // Act + const result = gql``; + + // Assert + expect(result).toBe(''); + }); + + it('should keep escape sequences raw', () => { + // Act + const result = gql`query Viewer { viewer(name: "Benno\nBuilder") { id } }`; + + // Assert + expect(result).toBe('query Viewer { viewer(name: "Benno\\nBuilder") { id } }'); + }); +}); diff --git a/packages/feature-fetch/src/features/graphql/gql.ts b/packages/feature-fetch/src/features/graphql/gql.ts new file mode 100644 index 00000000..ded5381f --- /dev/null +++ b/packages/feature-fetch/src/features/graphql/gql.ts @@ -0,0 +1,20 @@ +/** + * Interpolates a GraphQL template literal into a query string. + * + * The helper does not parse or cache the document. Interpolated values are stringified, + * so compose string fragments rather than `DocumentNode` objects. + * + * @see https://github.com/apollographql/graphql-tag/blob/main/src/index.ts + */ +export function gql(literals: TemplateStringsArray, ...args: unknown[]): string { + let result = ''; + + for (let i = 0; i < literals.raw.length; i++) { + result += literals.raw[i]; + if (i < args.length) { + result += String(args[i]); + } + } + + return result; +} diff --git a/packages/feature-fetch/src/features/graphql/graphql.test-d.ts b/packages/feature-fetch/src/features/graphql/graphql.test-d.ts new file mode 100644 index 00000000..71600aa4 --- /dev/null +++ b/packages/feature-fetch/src/features/graphql/graphql.test-d.ts @@ -0,0 +1,181 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import { + createGraphQLFetchClient, + type TGraphQLOperationOptions, + type TTypedDocumentNode +} from './graphql'; + +describe('graphqlFeature function', () => { + describe('variables', () => { + it('should require variables from typed documents', () => { + const client = createGraphQLFetchClient(); + + void client.query(documentWithRequiredVariables, { + variables: { + id: 'user-1' + } + }); + void client.mutate(documentWithRequiredVariables, { + variables: { + id: 'user-1' + } + }); + void client.queryRaw(documentWithRequiredVariables, { + variables: { + id: 'user-1' + } + }); + void client.mutateRaw(documentWithRequiredVariables, { + variables: { + id: 'user-1' + } + }); + + // @ts-expect-error variables are required by this typed document. + void client.query(documentWithRequiredVariables); + }); + + it('should reject invalid variables from typed documents', () => { + const client = createGraphQLFetchClient(); + + void client.query(documentWithRequiredVariables, { + // @ts-expect-error id is required inside variables. + variables: {} + }); + + void client.query(documentWithRequiredVariables, { + variables: { + // @ts-expect-error id must be a string. + id: 1 + } + }); + }); + + it('should keep optional variables optional', () => { + const client = createGraphQLFetchClient(); + + void client.query(documentWithOptionalVariables); + void client.query(documentWithOptionalVariables, { + variables: {} + }); + void client.query(documentWithOptionalVariables, { + variables: { + id: 'user-1' + } + }); + }); + + it('should keep string operations flexible unless variables are typed manually', () => { + const client = createGraphQLFetchClient(); + + void client.query<{ viewer: { id: string } }>('query Viewer { viewer { id } }'); + void client.query<{ user: { id: string } }, { id: string }>( + 'query GetUser($id: ID!) { user(id: $id) { id } }', + { + variables: { + id: 'user-1' + } + } + ); + + // @ts-expect-error variables are required when the caller provides a variables type. + void client.query<{ user: { id: string } }, { id: string }>( + 'query GetUser($id: ID!) { user(id: $id) { id } }' + ); + }); + }); + + describe('operation options', () => { + it('should type variables from the options generic', () => { + expectTypeOf['variables']>().toEqualTypeOf<{ + id: string; + }>(); + + // @ts-expect-error variables are required by the options type. + const options: TGraphQLOperationOptions<{ id: string }> = {}; + void options; + }); + }); + + describe('response inference', () => { + it('should infer operation data from typed documents', async () => { + const client = createGraphQLFetchClient(); + + const result = await client.query(documentWithRequiredVariables, { + variables: { + id: 'user-1' + } + }); + if (result.isOk()) { + expectTypeOf(result.value).toEqualTypeOf<{ + user: { + id: string; + }; + }>(); + } + }); + + it('should infer response details when requested', async () => { + const client = createGraphQLFetchClient(); + + const result = await client.query(documentWithRequiredVariables, { + variables: { + id: 'user-1' + }, + withResponse: true + }); + if (result.isOk()) { + expectTypeOf(result.value.data).toEqualTypeOf<{ + user: { + id: string; + }; + }>(); + expectTypeOf(result.value.extensions).toEqualTypeOf | undefined>(); + expectTypeOf(result.value.response).toEqualTypeOf(); + } + }); + + it('should infer raw GraphQL response envelopes', async () => { + const client = createGraphQLFetchClient(); + + const result = await client.queryRaw(documentWithRequiredVariables, { + variables: { + id: 'user-1' + } + }); + if (result.isOk()) { + expectTypeOf(result.value.data).toEqualTypeOf< + | { + user: { + id: string; + }; + } + | null + | undefined + >(); + } + }); + }); +}); + +declare const documentWithRequiredVariables: TTypedDocumentNode< + { + user: { + id: string; + }; + }, + { + id: string; + } +>; + +declare const documentWithOptionalVariables: TTypedDocumentNode< + { + user: { + id: string; + }; + }, + { + id?: string; + } +>; diff --git a/packages/feature-fetch/src/features/graphql/graphql.test.ts b/packages/feature-fetch/src/features/graphql/graphql.test.ts new file mode 100644 index 00000000..3e2e40a5 --- /dev/null +++ b/packages/feature-fetch/src/features/graphql/graphql.test.ts @@ -0,0 +1,258 @@ +import { parse } from '@0no-co/graphql.web'; +import { unwrapErr } from 'tuple-result'; +import { describe, expect, it, vi } from 'vitest'; +import type { TFetchLike } from '../../types'; +import { createGraphQLFetchClient, type TTypedDocumentNode } from './graphql'; +import { GraphQLError } from './GraphQLError'; + +describe('graphqlFeature function', () => { + describe('query method', () => { + it('should post GraphQL queries and return operation data', async () => { + // Prepare + const { client, fetchLike, getRequestBody } = createTestGraphQLClient({ + data: { + user: { + id: 'user-1' + } + } + }); + + // Act + const result = await client.query<{ user: { id: string } }, { id: string }>( + 'query GetUser($id: ID!) { user(id: $id) { id } }', + { + variables: { + id: 'user-1' + } + } + ); + + // Assert + expect(result.unwrap()).toEqual({ + user: { + id: 'user-1' + } + }); + expect(getRequestBody()).toEqual({ + query: 'query GetUser($id: ID!) { user(id: $id) { id } }', + variables: { + id: 'user-1' + } + }); + expect(fetchLike).toHaveBeenCalledWith( + 'https://api.example.com/graphql', + expect.objectContaining({ + method: 'POST' + }) + ); + }); + + it('should post typed document inputs', async () => { + // Prepare + const { client } = createTestGraphQLClient({ + data: { + user: { + id: 'user-1' + } + } + }); + const document = parse(` + query GetUser($id: ID!) { + user(id: $id) { + id + } + } + `) as TTypedDocumentNode<{ user: { id: string } }, { id: string }>; + + // Act + const result = await client.query(document, { + variables: { + id: 'user-1' + } + }); + + // Assert + expect(result.unwrap()).toEqual({ + user: { + id: 'user-1' + } + }); + }); + + it('should map GraphQL errors to GraphQLError', async () => { + // Prepare + const { client } = createTestGraphQLClient({ + data: null, + errors: [ + { + message: 'User not found' + } + ] + }); + + // Act + const result = await client.query<{ user: { id: string } }>('query GetUser { user { id } }'); + + // Assert + expect(result.isErr()).toBe(true); + expect(unwrapErr(result)).toBeInstanceOf(GraphQLError); + }); + + it('should omit variables when none are passed', async () => { + // Prepare + const { client, getRequestBody } = createTestGraphQLClient({ + data: { + viewer: { + id: 'user-1' + } + } + }); + + // Act + const result = await client.query<{ viewer: { id: string } }>( + 'query Viewer { viewer { id } }' + ); + + // Assert + expect(result.isOk()).toBe(true); + expect(getRequestBody()).toEqual({ + query: 'query Viewer { viewer { id } }' + }); + }); + + it('should include extensions and response when requested', async () => { + // Prepare + const { client } = createTestGraphQLClient({ + data: { + user: { + id: 'user-1' + } + }, + extensions: { + traceId: 'trace-1' + } + }); + + // Act + const result = await client.query<{ user: { id: string } }>('query GetUser { user { id } }', { + withResponse: true + }); + const value = result.unwrap(); + + // Assert + expect(value.data).toEqual({ + user: { + id: 'user-1' + } + }); + expect(value.extensions).toEqual({ + traceId: 'trace-1' + }); + expect(value.response).toBeInstanceOf(Response); + }); + }); + + describe('mutate method', () => { + it('should post GraphQL mutations with variables', async () => { + // Prepare + const { client, getRequestBody } = createTestGraphQLClient({ + data: { + updateUser: { + id: 'user-1' + } + } + }); + + // Act + const result = await client.mutate<{ updateUser: { id: string } }, { id: string }>( + 'mutation UpdateUser($id: ID!) { updateUser(id: $id) { id } }', + { + variables: { + id: 'user-1' + } + } + ); + + // Assert + expect(result.unwrap()).toEqual({ + updateUser: { + id: 'user-1' + } + }); + expect(getRequestBody()).toEqual({ + query: 'mutation UpdateUser($id: ID!) { updateUser(id: $id) { id } }', + variables: { + id: 'user-1' + } + }); + }); + }); + + describe('queryRaw method', () => { + it('should return the raw GraphQL response body', async () => { + // Prepare + const responseBody = { + data: null, + errors: [ + { + message: 'User not found' + } + ] + }; + const { client } = createTestGraphQLClient(responseBody); + + // Act + const result = await client.queryRaw<{ user: { id: string } }>( + 'query GetUser { user { id } }' + ); + + // Assert + expect(result.unwrap()).toEqual(responseBody); + }); + }); + + describe('mutateRaw method', () => { + it('should return the raw GraphQL mutation response body', async () => { + // Prepare + const responseBody = { + data: { + updateUser: { + id: 'user-1' + } + } + }; + const { client } = createTestGraphQLClient(responseBody); + + // Act + const result = await client.mutateRaw<{ updateUser: { id: string } }>( + 'mutation UpdateUser { updateUser { id } }' + ); + + // Assert + expect(result.unwrap()).toEqual(responseBody); + }); + }); +}); + +function createTestGraphQLClient(responseBody: unknown): TTestGraphQLClient { + let requestBody: unknown; + const fetchLike = vi.fn(async (_url, requestInit) => { + requestBody = JSON.parse(String(requestInit?.body)); + return Response.json(responseBody); + }); + + return { + client: createGraphQLFetchClient({ + fetch: fetchLike, + baseUrl: 'https://api.example.com/graphql' + }), + fetchLike, + getRequestBody: () => requestBody + }; +} + +interface TTestGraphQLClient { + client: ReturnType; + fetchLike: ReturnType>; + getRequestBody: () => unknown; +} diff --git a/packages/feature-fetch/src/features/graphql/graphql.ts b/packages/feature-fetch/src/features/graphql/graphql.ts new file mode 100644 index 00000000..983e5f75 --- /dev/null +++ b/packages/feature-fetch/src/features/graphql/graphql.ts @@ -0,0 +1,333 @@ +import type { DocumentNode } from '@0no-co/graphql.web'; +import { defineFeature, type TFeature } from 'feature-core'; +import { Err, Ok, type TResult } from 'tuple-result'; +import { createFetchClient, type TCreateFetchClientOptions } from '../../create-fetch-client'; +import type { FetchError } from '../../errors'; +import type { + TFetchClient, + TFetchClientBase, + TFetchOptions, + TFetchRequestResponse, + TFetchResponseError +} from '../../types'; +import { getOperationString } from './get-operation-string'; +import { GraphQLError } from './GraphQLError'; + +/** + * Creates a fetch client with GraphQL query and mutation helpers already installed. + * Set `baseUrl` to the full GraphQL endpoint URL. + */ +export function createGraphQLFetchClient( + options: TCreateFetchClientOptions = {} +): TFetchClient<[TGraphQLFeature]> { + return createFetchClient(options).with(graphqlFeature()); +} + +// MARK: - Feature + +/** + * Adds GraphQL POST helpers for queries and mutations. + * `query()` and `mutate()` return operation data by default and map GraphQL errors to `GraphQLError`. + */ +export function graphqlFeature(): TGraphQLFeature { + return defineFeature({ + key: 'graphql', + install() { + return { + query: createGraphQLOperationMethod(), + queryRaw: createGraphQLRawOperationMethod(), + mutate: createGraphQLOperationMethod(), + mutateRaw: createGraphQLRawOperationMethod() + }; + } + }); +} + +export type TGraphQLFeature = TFeature<'graphql', TGraphQLFeatureApi>; + +export interface TGraphQLFeatureApi { + /** Sends a GraphQL query and unwraps operation data on success. */ + query: TGraphQLOperationMethod; + /** Sends a GraphQL query and returns the raw GraphQL response envelope on success. */ + queryRaw: TGraphQLRawOperationMethod; + /** Sends a GraphQL mutation and unwraps operation data on success. */ + mutate: TGraphQLOperationMethod; + /** Sends a GraphQL mutation and returns the raw GraphQL response envelope on success. */ + mutateRaw: TGraphQLRawOperationMethod; +} + +// MARK: - Operation + +function createGraphQLOperationMethod(): TGraphQLOperationMethod { + return async function graphQLOperationMethod( + this: TFetchClientBase, + document: TGraphQLDocumentInput, + options: TGraphQLOperationMethodOptions = {} + ) { + const { withResponse = false, ...requestOptions } = options; + const [isGraphQLResponseOk, graphQLResponseErr, graphQLResponse] = await sendGraphQLHttpRequest( + this, + document, + requestOptions + ); + if (!isGraphQLResponseOk) { + return Err(graphQLResponseErr); + } + + const { + data: { data, errors, extensions }, + response + } = graphQLResponse; + + if (Array.isArray(errors) && errors.length > 0) { + return Err( + new GraphQLError(errors, { + data, + extensions, + response + }) + ); + } + + return Ok( + withResponse + ? { + data, + extensions, + response + } + : data + ); + } as TGraphQLOperationMethod; +} + +type TGraphQLOperationMethodOptions> = + TGraphQLOperationOptions & { + withResponse?: boolean; + }; + +function createGraphQLRawOperationMethod(): TGraphQLRawOperationMethod { + return async function graphQLRawOperationMethod( + this: TFetchClientBase, + document: TGraphQLDocumentInput, + options: TGraphQLOperationOptions = {} + ) { + const [isGraphQLResponseOk, graphQLResponseErr, graphQLResponse] = await sendGraphQLHttpRequest( + this, + document, + options + ); + if (!isGraphQLResponseOk) { + return Err(graphQLResponseErr); + } + + return Ok(graphQLResponse.data); + } as TGraphQLRawOperationMethod; +} + +/** + * Sends a GraphQL operation and unwraps successful operation data. + * + * GraphQL `errors` arrays become `GraphQLError` on the tuple-result error branch. + * Pass `withResponse: true` to receive `{ data, extensions, response }` on success. + */ +export interface TGraphQLOperationMethod { + < + GData extends object, + GVariables extends object = Record, + GErrorResponseBody = unknown + >( + document: TGraphQLDocumentInput, + ...args: TGraphQLOperationOptionsArgs< + TGraphQLOperationOptions & { withResponse: true } + > + ): Promise>; + < + GData extends object, + GVariables extends object = Record, + GErrorResponseBody = unknown + >( + document: TGraphQLDocumentInput, + ...args: TGraphQLOperationOptionsArgs< + TGraphQLOperationOptions & { withResponse?: false } + > + ): Promise>; + < + GData extends object, + GVariables extends object = Record, + GErrorResponseBody = unknown, + GWithResponse extends boolean = boolean + >( + document: TGraphQLDocumentInput, + ...args: TGraphQLOperationOptionsArgs< + TGraphQLOperationOptions & { withResponse?: GWithResponse } + > + ): Promise>; +} + +/** + * Sends a GraphQL operation and returns the raw GraphQL response envelope. + * GraphQL `errors` arrays remain on the success branch instead of becoming `GraphQLError`. + */ +export type TGraphQLRawOperationMethod = < + GData extends object, + GVariables extends object = Record, + GErrorResponseBody = unknown +>( + document: TGraphQLDocumentInput, + ...args: TGraphQLOperationOptionsArgs> +) => Promise>; + +type TGraphQLOperationOptionsArgs = + TRequiredKeys extends never ? [options?: GOptions] : [options: GOptions]; + +/** Request options for GraphQL operations. Variables are required when the document type requires them. */ +export type TGraphQLOperationOptions> = Omit< + TFetchOptions<'json'>, + 'body' | 'parseAs' +> & + TGraphQLVariablesOption; + +type TGraphQLVariablesOption = + TGraphQLHasRequiredVariables extends true + ? { variables: GVariables } + : { variables?: GVariables }; + +type TGraphQLHasRequiredVariables = + TRequiredKeys extends never ? false : true; + +type TRequiredKeys = { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- Required-key detection needs the canonical `{}` assignability check + [GKey in keyof GObject]-?: {} extends Pick ? never : GKey; +}[keyof GObject]; + +// MARK: - Response + +/** Tuple-result response returned by `query()` and `mutate()`. */ +export type TGraphQLOperationResponse< + GData, + GErrorResponseBody = unknown, + GWithResponse extends boolean = false +> = TResult< + GWithResponse extends true ? TGraphQLOperationResponseDetails : GData, + TFetchResponseError +>; + +/** Tuple-result response returned by `queryRaw()` and `mutateRaw()`. */ +export type TGraphQLRawOperationResponse = TResult< + TGraphQLResponse, + TFetchResponseError +>; + +/** Success details returned when a GraphQL operation uses `withResponse: true`. */ +export interface TGraphQLOperationResponseDetails { + data: GData; + /** GraphQL response extensions, when provided by the server. */ + extensions?: Record; + /** HTTP response used to produce the operation result. */ + response: Response; +} + +/** + * Standard GraphQL response structure. + * + * @see https://spec.graphql.org/September2025/#sec-Response + */ +export interface TGraphQLResponse { + /** GraphQL operation data. Can be null or omitted when the response contains errors. */ + data?: GData | null; + /** GraphQL execution or request errors returned by the server. */ + errors?: TGraphQLError[]; + /** Optional GraphQL extension data returned by the server. */ + extensions?: Record; +} + +/** + * Standard GraphQL error structure. + * + * @see https://spec.graphql.org/September2025/#sec-Errors + */ +export interface TGraphQLError { + /** Human-readable error message from the GraphQL server. */ + message: string; + /** Source locations related to the error. */ + locations?: Array<{ + line: number; + column: number; + }>; + /** Path in the response data where the error occurred. */ + path?: Array; + /** Server-specific GraphQL error extension data. */ + extensions?: Record; +} + +// MARK: - Document + +/** GraphQL operation input accepted by query and mutation helpers. */ +export type TGraphQLDocumentInput> = + | string + | DocumentNode + | TTypedDocumentNode; + +/** GraphQL document with result and variables types, compatible with `gql.tada` and typed-document-node. */ +export type TTypedDocumentNode< + GResult = object, + GVariables = Record +> = DocumentNode & { + /** @internal Type to support `@graphql-typed-document-node/core`. */ + __apiType?: (variables: GVariables) => GResult; + /** @internal Type to support `TypedQueryDocumentNode` from `graphql`. */ + __ensureTypesOfVariablesAndResultMatching?: (variables: GVariables) => GResult; +}; + +async function sendGraphQLHttpRequest< + GData extends object, + GVariables extends object, + GErrorResponseBody +>( + client: TFetchClientBase, + document: TGraphQLDocumentInput, + options: TGraphQLOperationOptions +): Promise, GErrorResponseBody, 'json'>> { + const { variables, ...fetchOptions } = options; + const [isRequestBodyOk, requestBodyErr, requestBody] = await createGraphQLRequestBody( + document, + variables + ); + if (!isRequestBodyOk) { + return Err(requestBodyErr); + } + + return client.request, GErrorResponseBody, 'json'>('POST', '', { + ...fetchOptions, + parseAs: 'json', + body: requestBody + }); +} + +async function createGraphQLRequestBody( + document: TGraphQLDocumentInput, + variables: GVariables | undefined +): Promise, FetchError>> { + const [isOperationStringOk, operationStringErr, operationString] = + await getOperationString(document); + if (!isOperationStringOk) { + return Err(operationStringErr); + } + + if (variables === undefined) { + return Ok({ + query: operationString + }); + } + + return Ok({ + query: operationString, + variables + }); +} + +interface TGraphQLRequestBody { + query: string; + variables?: GVariables; +} diff --git a/packages/feature-fetch/src/features/graphql/index.ts b/packages/feature-fetch/src/features/graphql/index.ts new file mode 100644 index 00000000..286702cc --- /dev/null +++ b/packages/feature-fetch/src/features/graphql/index.ts @@ -0,0 +1,4 @@ +export * from './get-operation-string'; +export * from './gql'; +export * from './graphql'; +export * from './GraphQLError'; diff --git a/packages/feature-fetch/src/features/index.ts b/packages/feature-fetch/src/features/index.ts index 21dd37ce..bab97790 100644 --- a/packages/feature-fetch/src/features/index.ts +++ b/packages/feature-fetch/src/features/index.ts @@ -1,6 +1,6 @@ -export * from './with-api'; -export * from './with-cache'; -export * from './with-delay'; -export * from './with-graphql'; -export * from './with-openapi'; -export * from './with-retry'; +export * from './api'; +export * from './cache'; +export * from './delay'; +export * from './graphql'; +export * from './openapi'; +export * from './retry'; diff --git a/packages/feature-fetch/src/features/openapi.test-d.ts b/packages/feature-fetch/src/features/openapi.test-d.ts new file mode 100644 index 00000000..113727da --- /dev/null +++ b/packages/feature-fetch/src/features/openapi.test-d.ts @@ -0,0 +1,466 @@ +import type { $Read, $Write } from 'openapi-typescript-helpers'; +import { describe, expectTypeOf, it } from 'vitest'; +import type { components, paths } from '../__tests__/resources/mock-openapi-types'; +import { createFetchClient } from '../create-fetch-client'; +import { HttpError } from '../errors'; +import type { TFetchClient } from '../types'; +import { createOpenApiFetchClient, openApiFeature, type TOpenApiFeature } from './openapi'; + +describe('openApiFeature function', () => { + describe('feature composition', () => { + it('should add OpenAPI helpers through the feature-core chain', () => { + const client = createFetchClient().with(openApiFeature()); + + expectTypeOf(client).toHaveProperty('get'); + expectTypeOf(client).toHaveProperty('post'); + expectTypeOf(client).toHaveProperty('delete'); + expectTypeOf(client._features).toEqualTypeOf(); + expectTypeOf(client).toEqualTypeOf]>>(); + }); + }); + + describe('schema paths', () => { + it('should only accept schema paths for the selected method', () => { + const client = createOpenApiFetchClient(); + + void client.post('/pet', { + body: { + name: 'Jeff', + photoUrls: [] + } + }); + void client.get('/pet/{petId}', { + pathParams: { + petId: 10 + } + }); + + // @ts-expect-error /pet does not declare a GET operation. + void client.get('/pet'); + + // @ts-expect-error OpenAPI clients only accept paths declared by the schema. + void client.get('/missing'); + }); + + it('should only accept string schema paths', () => { + const client = createOpenApiFetchClient(); + + void client.get('/items'); + + // @ts-expect-error fetch paths must be strings even if a generic path map has numeric keys. + void client.get(1); + }); + }); + + describe('request options', () => { + it('should require path params declared by the operation', () => { + const client = createOpenApiFetchClient(); + + void client.get('/pet/{petId}', { + pathParams: { + petId: 10 + } + }); + + // @ts-expect-error petId is required by the OpenAPI operation. + void client.get('/pet/{petId}'); + }); + + it('should keep optional query params optional', () => { + const client = createOpenApiFetchClient(); + + void client.get('/pet/findByStatus'); + void client.get('/pet/findByStatus', { + queryParams: { + status: 'available' + } + }); + + void client.get('/pet/findByStatus', { + // @ts-expect-error this operation does not declare path params. + pathParams: { + petId: 10 + } + }); + }); + + it('should require query params declared by the operation', () => { + const client = createOpenApiFetchClient(); + + void client.get('/search', { + queryParams: { + q: 'jeff' + } + }); + + // @ts-expect-error queryParams is required by the OpenAPI operation. + void client.get('/search'); + + void client.get('/search', { + // @ts-expect-error q is required inside queryParams. + queryParams: { + limit: 10 + } + }); + }); + + it('should require request bodies declared by the operation', () => { + const client = createOpenApiFetchClient(); + + void client.post('/pet', { + body: { + name: 'Jeff', + photoUrls: [] + } + }); + + // @ts-expect-error body is required by the OpenAPI operation. + void client.post('/pet'); + }); + + it('should allow optional request bodies declared by the operation', () => { + const client = createOpenApiFetchClient(); + + void client.post('/store/order'); + void client.post('/store/order', { + body: { + petId: 10 + } + }); + }); + + it('should require OpenAPI header params and allow additional headers', () => { + const client = createOpenApiFetchClient(); + + void client.get('/items', { + headers: { + 'X-Tenant-Id': 'tenant-1', + 'Authorization': 'Bearer token' + } + }); + + // @ts-expect-error X-Tenant-Id is required by the OpenAPI operation. + void client.get('/items'); + + void client.get('/items', { + // @ts-expect-error X-Tenant-Id is required inside top-level headers. + headers: { + Authorization: 'Bearer token' + } + }); + }); + + it('should allow native headers when the operation declares no header params', () => { + const client = createOpenApiFetchClient(); + + void client.get('/status'); + void client.get('/status', { + headers: new Headers({ + Authorization: 'Bearer token' + }) + }); + }); + + it('should allow request bodies declared by DELETE operations', () => { + const client = createOpenApiFetchClient(); + + void client.delete('/items/{itemId}', { + body: { + reason: 'duplicate' + }, + pathParams: { + itemId: 'item-1' + } + }); + }); + }); + + describe('response inference', () => { + it('should infer success data by default', async () => { + const client = createOpenApiFetchClient(); + + const result = await client.get('/pet/{petId}', { + pathParams: { + petId: 10 + } + }); + if (result.isOk()) { + expectTypeOf(result.value).toEqualTypeOf(); + } + }); + + it('should infer response details when requested', async () => { + const client = createOpenApiFetchClient(); + + const result = await client.get('/pet/{petId}', { + pathParams: { + petId: 10 + }, + withResponse: true + }); + if (result.isOk()) { + expectTypeOf(result.value).toEqualTypeOf<{ + data: components['schemas']['Pet']; + response: Response; + }>(); + } + }); + + it('should infer JSON suffix media types', async () => { + const client = createOpenApiFetchClient(); + + const result = await client.get('/problem'); + if (result.isOk()) { + expectTypeOf(result.value).toEqualTypeOf<{ ok: boolean }>(); + } + }); + + it('should infer parser return types for non-json parse modes', async () => { + const client = createOpenApiFetchClient(); + + const result = await client.get('/pet/{petId}', { + pathParams: { + petId: 10 + }, + parseAs: 'text' + }); + if (result.isOk()) { + expectTypeOf(result.value).toEqualTypeOf(); + } + }); + + it('should keep default responses out of the success branch', async () => { + const client = createOpenApiFetchClient(); + + const result = await client.post('/user', { + body: { + username: 'jeff' + } + }); + if (result.isOk()) { + expectTypeOf(result.value).toEqualTypeOf(); + } + }); + }); + + describe('read and write markers', () => { + it('should apply OpenAPI read and write markers', async () => { + const client = createOpenApiFetchClient(); + + void client.post('/users', { + body: { + username: 'jeff', + password: 'secret' + } + }); + + void client.post('/users', { + body: { + // @ts-expect-error readOnly fields cannot be sent in request bodies. + id: 'user-1', + username: 'jeff', + password: 'secret' + } + }); + + const result = await client.post('/users', { + body: { + username: 'jeff', + password: 'secret' + } + }); + if (result.isOk()) { + expectTypeOf(result.value).toEqualTypeOf<{ + id: string; + username: string; + }>(); + } + if (result.isErr() && result.error instanceof HttpError) { + const errorData: { message: string } | undefined = result.error.data; + void errorData; + } + }); + }); +}); + +interface THeaderPaths { + '/items': { + get: { + parameters: { + header: { + 'X-Tenant-Id': string; + }; + cookie?: never; + path?: never; + query?: never; + }; + requestBody?: never; + responses: { + 200: { + content: { + 'application/json': { + ok: boolean; + }; + }; + }; + }; + }; + }; +} + +interface TRequiredQueryPaths { + '/search': { + get: { + parameters: { + cookie?: never; + header?: never; + path?: never; + query: { + q: string; + limit?: number; + }; + }; + requestBody?: never; + responses: { + 200: { + content: { + 'application/json': { + ok: boolean; + }; + }; + }; + }; + }; + }; +} + +interface TNumericPathKeyPaths { + 1: { + get: { + requestBody?: never; + responses: { + 200: { + content: { + 'application/json': { + ok: boolean; + }; + }; + }; + }; + }; + }; + '/items': { + get: { + requestBody?: never; + responses: { + 200: { + content: { + 'application/json': { + ok: boolean; + }; + }; + }; + }; + }; + }; +} + +interface TReadWritePaths { + '/users': { + post: { + requestBody: { + content: { + 'application/json': { + id?: $Read; + username: string; + password: $Write; + }; + }; + }; + responses: { + 200: { + content: { + 'application/json': { + id: $Read; + username: string; + password: $Write; + }; + }; + }; + 400: { + content: { + 'application/json': { + message: string; + debug: $Write; + }; + }; + }; + }; + }; + }; +} + +interface TDeleteBodyPaths { + '/items/{itemId}': { + delete: { + parameters: { + cookie?: never; + header?: never; + path: { + itemId: string; + }; + query?: never; + }; + requestBody: { + content: { + 'application/json': { + reason: string; + }; + }; + }; + responses: { + 200: { + content: { + 'application/json': { + ok: boolean; + }; + }; + }; + }; + }; + }; +} + +interface TNoParameterPaths { + '/status': { + get: { + requestBody?: never; + responses: { + 200: { + content: { + 'application/json': { + ok: boolean; + }; + }; + }; + }; + }; + }; +} + +interface TJsonSuffixPaths { + '/problem': { + get: { + requestBody?: never; + responses: { + 200: { + content: { + 'application/problem+json': { + ok: boolean; + }; + }; + }; + }; + }; + }; +} diff --git a/packages/feature-fetch/src/features/openapi.test.ts b/packages/feature-fetch/src/features/openapi.test.ts new file mode 100644 index 00000000..c16014fb --- /dev/null +++ b/packages/feature-fetch/src/features/openapi.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { paths } from '../__tests__/resources/mock-openapi-types'; +import type { TFetchLike } from '../types'; +import { createOpenApiFetchClient } from './openapi'; + +describe('openApiFeature function', () => { + describe('HTTP helper methods', () => { + it.each([ + ['get', 'GET'], + ['post', 'POST'], + ['put', 'PUT'], + ['patch', 'PATCH'], + ['delete', 'DELETE'], + ['options', 'OPTIONS'], + ['head', 'HEAD'], + ['trace', 'TRACE'] + ] as const)('should send %s requests with the %s method', async (helperName, method) => { + // Prepare + const fetchLike = vi.fn(async () => { + return new Response(null, { + status: 204 + }); + }); + const client = createOpenApiFetchClient({ + fetch: fetchLike + }); + + // Act + // @ts-expect-error TS2349: the method table intentionally combines OpenAPI helpers with different signatures + const result = await client[helperName]('/items'); + + // Assert + expect(result.isOk()).toBe(true); + expect(fetchLike).toHaveBeenCalledWith( + '/items', + expect.objectContaining({ + method + }) + ); + }); + + it('should build URLs from typed path and query params', async () => { + // Prepare + const fetchLike = vi.fn(async () => { + return Response.json({ code: 200 }); + }); + const client = createOpenApiFetchClient({ + fetch: fetchLike + }); + + // Act + const result = await client.post('/pet/{petId}/uploadImage', { + pathParams: { + petId: 10 + }, + queryParams: { + additionalMetadata: 'front' + } + }); + + // Assert + expect(result.isOk()).toBe(true); + expect(fetchLike).toHaveBeenCalledWith( + '/pet/10/uploadImage?additionalMetadata=front', + expect.objectContaining({ + method: 'POST' + }) + ); + }); + + it('should serialize typed request bodies', async () => { + // Prepare + const fetchLike = vi.fn(async () => { + return Response.json({ id: 10, name: 'Jeff' }); + }); + const client = createOpenApiFetchClient({ + fetch: fetchLike, + baseUrl: 'https://api.example.com' + }); + + // Act + const result = await client.post('/pet', { + body: { + name: 'Jeff', + photoUrls: [] + } + }); + + // Assert + expect(result.isOk()).toBe(true); + expect(fetchLike).toHaveBeenCalledWith( + 'https://api.example.com/pet', + expect.objectContaining({ + body: JSON.stringify({ name: 'Jeff', photoUrls: [] }), + method: 'POST' + }) + ); + }); + + it('should include response details when requested', async () => { + // Prepare + const responseBody = { id: 10, name: 'Jeff', photoUrls: [] }; + const fetchLike = vi.fn(async () => { + return Response.json(responseBody); + }); + const client = createOpenApiFetchClient({ + fetch: fetchLike + }); + + // Act + const result = await client.get('/pet/{petId}', { + pathParams: { + petId: 10 + }, + withResponse: true + }); + const value = result.unwrap(); + + // Assert + expect(value.data).toEqual(responseBody); + expect(value.response).toBeInstanceOf(Response); + }); + + it('should merge OpenAPI header params with transport headers', async () => { + // Prepare + const fetchLike = vi.fn(async () => { + return Response.json({ ok: true }); + }); + const client = createOpenApiFetchClient({ + fetch: fetchLike + }); + + // Act + const result = await client.get('/items', { + headers: { + 'X-Tenant-Id': 'tenant-1', + 'Authorization': 'Bearer token' + } + }); + + // Assert + expect(result.isOk()).toBe(true); + expect(fetchLike).toHaveBeenCalledWith( + '/items', + expect.objectContaining({ + headers: expect.objectContaining({ + 'x-tenant-id': 'tenant-1', + 'authorization': 'Bearer token' + }) + }) + ); + }); + + it('should serialize DELETE request bodies declared by OpenAPI', async () => { + // Prepare + const fetchLike = vi.fn(async () => { + return Response.json({ ok: true }); + }); + const client = createOpenApiFetchClient({ + fetch: fetchLike + }); + + // Act + const result = await client.delete('/items/{itemId}', { + pathParams: { + itemId: 'item-1' + }, + body: { + reason: 'duplicate' + } + }); + + // Assert + expect(result.isOk()).toBe(true); + expect(fetchLike).toHaveBeenCalledWith( + '/items/item-1', + expect.objectContaining({ + body: JSON.stringify({ reason: 'duplicate' }), + method: 'DELETE' + }) + ); + }); + }); +}); + +type TMethodPaths = { + '/items': { + get: TEmptyOperation; + post: TEmptyOperation; + put: TEmptyOperation; + patch: TEmptyOperation; + delete: TEmptyOperation; + options: TEmptyOperation; + head: TEmptyOperation; + trace: TEmptyOperation; + }; +}; + +type TEmptyOperation = { + requestBody?: never; + responses: { + 204: { + content?: never; + }; + }; +}; + +interface THeaderPaths { + '/items': { + get: { + parameters: { + header: { + 'X-Tenant-Id': string; + }; + path?: never; + query?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + content: { + 'application/json': { + ok: boolean; + }; + }; + }; + }; + }; + }; +} + +interface TDeleteBodyPaths { + '/items/{itemId}': { + delete: { + parameters: { + path: { + itemId: string; + }; + query?: never; + header?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': { + reason: string; + }; + }; + }; + responses: { + 200: { + content: { + 'application/json': { + ok: boolean; + }; + }; + }; + }; + }; + }; +} diff --git a/packages/feature-fetch/src/features/openapi.ts b/packages/feature-fetch/src/features/openapi.ts new file mode 100644 index 00000000..1e65842f --- /dev/null +++ b/packages/feature-fetch/src/features/openapi.ts @@ -0,0 +1,286 @@ +import { defineFeature, type TFeature } from 'feature-core'; +import type { + ErrorResponse, + FilterKeys, + HttpMethod, + IsOperationRequestBodyOptional, + MediaType, + OperationRequestBodyContent, + PathsWithMethod, + Readable, + RequiredKeysOf, + ResponseObjectMap, + SuccessResponse, + Writable +} from 'openapi-typescript-helpers'; +import { mapOk, type TResult } from 'tuple-result'; +import { createFetchClient, type TCreateFetchClientOptions } from '../create-fetch-client'; +import type { + TBodySerializer, + TFetchClient, + TFetchClientBase, + TFetchHeadersInit, + TFetchHeadersInitRecord, + TFetchOptions, + TFetchOptionsWithBody, + TFetchResponseError, + TFetchResponseSuccess, + TParseAs, + TParseAsResponse, + TPathSerializer, + TQuerySerializer, + TRequestMethod +} from '../types'; + +/** + * Creates a fetch client with OpenAPI-typed REST helpers already installed. + * Pass the `paths` type generated by `openapi-typescript` as the generic argument. + */ +export function createOpenApiFetchClient( + options: TCreateFetchClientOptions = {} +): TFetchClient<[TOpenApiFeature]> { + return createFetchClient(options).with(openApiFeature()); +} + +/** + * Adds OpenAPI-typed HTTP method helpers. + * Unknown paths, unsupported methods, missing params, and invalid bodies fail at compile time. + */ +export function openApiFeature(): TOpenApiFeature { + return defineFeature>({ + key: 'openapi', + install() { + return { + get: createOpenApiMethod('GET'), + post: createOpenApiMethod('POST'), + put: createOpenApiMethod('PUT'), + patch: createOpenApiMethod('PATCH'), + delete: createOpenApiMethod('DELETE'), + options: createOpenApiMethod('OPTIONS'), + head: createOpenApiMethod('HEAD'), + trace: createOpenApiMethod('TRACE') + }; + } + }); +} + +// MARK: - Feature + +export type TOpenApiFeature = TFeature< + 'openapi', + TOpenApiFeatureApi +>; + +export interface TOpenApiFeatureApi { + get: TOpenApiMethod; + post: TOpenApiMethod; + put: TOpenApiMethod; + patch: TOpenApiMethod; + delete: TOpenApiMethod; + options: TOpenApiMethod; + head: TOpenApiMethod; + trace: TOpenApiMethod; +} + +// MARK: - Method + +function createOpenApiMethod( + method: Uppercase & TRequestMethod +): TOpenApiMethod { + return async function openApiMethod( + this: TFetchClientBase, + path: string, + options: TOpenApiMethodOptions = {} + ) { + const { withResponse = false, ...fetchOptions } = options; + const requestResult = await this.request(method, path, fetchOptions); + return withResponse ? requestResult : mapOk(requestResult, ({ data }) => data); + } as TOpenApiMethod; +} + +type TOpenApiMethodOptions = TFetchOptionsWithBody & { + withResponse?: boolean; +}; + +/** + * Sends one OpenAPI-typed request for a specific HTTP method. + * + * The path, params, headers, body, success data, and HTTP error data are inferred + * from the matching OpenAPI operation. Pass `withResponse: true` to receive + * `{ data, response }` on the success branch. + */ +export interface TOpenApiMethod { + , GParseAs extends TParseAs = 'json'>( + path: GPath, + ...args: TOpenApiMethodOptionsArgs< + TOpenApiFetchOptions, GParseAs> & { + withResponse: true; + } + > + ): Promise, GParseAs, true>>; + , GParseAs extends TParseAs = 'json'>( + path: GPath, + ...args: TOpenApiMethodOptionsArgs< + TOpenApiFetchOptions, GParseAs> & { + withResponse?: false; + } + > + ): Promise, GParseAs>>; + < + GPath extends TOpenApiMethodPath, + GParseAs extends TParseAs = 'json', + GWithResponse extends boolean = boolean + >( + path: GPath, + ...args: TOpenApiMethodOptionsArgs< + TOpenApiFetchOptions, GParseAs> & { + withResponse?: GWithResponse; + } + > + ): Promise< + TOpenApiFetchResponse, GParseAs, GWithResponse> + >; +} + +// Note: Wrap PathsWithMethod because it preserves number keys from arbitrary path maps, but fetch paths are strings +type TOpenApiMethodPath = Extract< + PathsWithMethod, + string +>; + +type TOpenApiPathOperation< + GPaths extends object, + GMethod extends HttpMethod, + GPath extends TOpenApiMethodPath +> = FilterKeys; + +type TOpenApiMethodOptionsArgs = + RequiredKeysOf extends never ? [options?: GOptions] : [options: GOptions]; + +/** Request options inferred from one OpenAPI operation. */ +export type TOpenApiFetchOptions = Omit< + TFetchOptions, + 'bodySerializer' | 'headers' | 'pathParams' | 'pathSerializer' | 'queryParams' | 'querySerializer' +> & { + pathSerializer?: TPathSerializer>; + querySerializer?: TQuerySerializer>; + bodySerializer?: TBodySerializer>; +} & TOpenApiPathParamsOption & + TOpenApiQueryParamsOption & + TOpenApiHeadersOption & + TOpenApiRequestBodyOption; + +// MARK: - Response + +/** Tuple-result response inferred from one OpenAPI operation. */ +export type TOpenApiFetchResponse< + GOperation, + GParseAs extends TParseAs = 'json', + GWithResponse extends boolean = false +> = TResult< + GWithResponse extends true + ? TFetchResponseSuccess, GParseAs> + : TParseAsResponse>, + TFetchResponseError> +>; + +// Note: Readable removes OpenAPI writeOnly fields from response bodies +type TOpenApiSuccessResponse = Readable< + SuccessResponse, TOpenApiResponseMediaType> +>; + +// Note: Readable removes OpenAPI writeOnly fields from error response bodies +type TOpenApiErrorResponse = Readable< + ErrorResponse, TOpenApiResponseMediaType> +>; + +// Note: Wrap ResponseObjectMap because it falls back to unknown, but SuccessResponse and ErrorResponse require a record +type TOpenApiResponseMap = + ResponseObjectMap extends Record + ? ResponseObjectMap + : Record; + +type TOpenApiResponseMediaType = GParseAs extends 'json' + ? TOpenApiJsonMediaType + : MediaType; + +type TOpenApiJsonMediaType = `${string}/json` | `${string}/${string}+json`; + +// MARK: - Request Body + +// Note: Writable removes OpenAPI readOnly fields from request bodies +type TOpenApiRequestBody = Writable>; + +type TOpenApiRequestBodyOption = [TOpenApiRequestBody] extends [never] + ? { body?: never } + : IsOperationRequestBodyOptional extends true + ? { body?: TOpenApiRequestBody } + : { body: TOpenApiRequestBody }; + +// MARK: - Path Parameters + +type TOpenApiPathParamsOption = [TOpenApiPathParams] extends [never] + ? { pathParams?: never } + : TOpenApiHasRequiredPathParams extends true + ? { pathParams: TOpenApiPathParams } + : { pathParams?: TOpenApiPathParams }; + +type TOpenApiPathParams = GOperation extends { + parameters: { path?: infer GPathParams }; +} + ? Extract, Record> + : never; + +type TOpenApiHasRequiredPathParams = GOperation extends { + parameters: { path: unknown }; +} + ? true + : false; + +type TOpenApiPathSerializerParams = [TOpenApiPathParams] extends [never] + ? Record + : TOpenApiPathParams; + +// MARK: - Query Parameters + +type TOpenApiQueryParamsOption = [TOpenApiQueryParams] extends [never] + ? { queryParams?: never } + : TOpenApiHasRequiredQueryParams extends true + ? { queryParams: TOpenApiQueryParams } + : { queryParams?: TOpenApiQueryParams }; + +type TOpenApiQueryParams = GOperation extends { + parameters: { query?: infer GQueryParams }; +} + ? Extract, Record> + : never; + +type TOpenApiHasRequiredQueryParams = GOperation extends { + parameters: { query: unknown }; +} + ? true + : false; + +type TOpenApiQuerySerializerParams = [TOpenApiQueryParams] extends [never] + ? Record + : TOpenApiQueryParams; + +// MARK: - Header Parameters + +type TOpenApiHeadersOption = [TOpenApiHeaderParams] extends [never] + ? { headers?: TFetchHeadersInit } + : TOpenApiHasRequiredHeaderParams extends true + ? { headers: TOpenApiHeaderParams & TFetchHeadersInitRecord } + : { headers?: TOpenApiHeaderParams & TFetchHeadersInitRecord }; + +type TOpenApiHeaderParams = GOperation extends { + parameters: { header?: infer GHeaderParams }; +} + ? Extract, Record> + : never; + +type TOpenApiHasRequiredHeaderParams = GOperation extends { + parameters: { header: unknown }; +} + ? true + : false; diff --git a/packages/feature-fetch/src/features/retry.test.ts b/packages/feature-fetch/src/features/retry.test.ts new file mode 100644 index 00000000..ab77072c --- /dev/null +++ b/packages/feature-fetch/src/features/retry.test.ts @@ -0,0 +1,346 @@ +import { unwrapErr } from 'tuple-result'; +import { describe, expect, it, vi } from 'vitest'; +import { createFetchClient } from '../create-fetch-client'; +import { HttpError, NetworkError } from '../errors'; +import type { TFetchLike } from '../types'; +import { createRetryMiddleware, retryFeature } from './retry'; + +describe('retryFeature function', () => { + describe('network errors', () => { + it('should retry thrown network errors', async () => { + // Prepare + let requestCount = 0; + const fetchLike = vi.fn(async () => { + requestCount++; + if (requestCount < 3) { + throw new Error('offline'); + } + + return Response.json({ ok: true }); + }); + const client = createFetchClient({ fetch: fetchLike }).with( + retryFeature({ + maxRetries: 3, + networkError: { baseDelayMs: 0 } + }) + ); + + // Act + const result = await client.request('GET', '/items'); + + // Assert + expect(result.unwrap().data).toEqual({ ok: true }); + expect(fetchLike).toHaveBeenCalledTimes(3); + }); + + it('should stop retrying after max retries', async () => { + // Prepare + const fetchLike = vi.fn(async () => { + throw new Error('offline'); + }); + const client = createFetchClient({ fetch: fetchLike }).with( + retryFeature({ + maxRetries: 1, + networkError: { baseDelayMs: 0 } + }) + ); + + // Act + const result = await client.request('GET', '/items'); + + // Assert + expect(unwrapErr(result)).toBeInstanceOf(NetworkError); + expect(fetchLike).toHaveBeenCalledTimes(2); + }); + }); + + describe('HTTP responses', () => { + it('should retry HTTP 429 responses', async () => { + // Prepare + let requestCount = 0; + const fetchLike = vi.fn(async () => { + requestCount++; + if (requestCount < 3) { + return Response.json({ message: 'Rate limited' }, { status: 429 }); + } + + return Response.json({ ok: true }); + }); + const client = createFetchClient({ fetch: fetchLike }).with( + retryFeature({ + maxRetries: 3 + }) + ); + + // Act + const result = await client.request('GET', '/items'); + + // Assert + expect(result.unwrap().data).toEqual({ ok: true }); + expect(fetchLike).toHaveBeenCalledTimes(3); + }); + + it('should retry custom response statuses', async () => { + // Prepare + let requestCount = 0; + const fetchLike = vi.fn(async () => { + requestCount++; + if (requestCount === 1) { + return Response.json({ message: 'Service unavailable' }, { status: 503 }); + } + + return Response.json({ ok: true }); + }); + const client = createFetchClient({ fetch: fetchLike }).with( + retryFeature({ + maxRetries: 1, + shouldRetryResponse: (response) => response.status === 503 + }) + ); + + // Act + const result = await client.request('GET', '/items'); + + // Assert + expect(result.unwrap().data).toEqual({ ok: true }); + expect(fetchLike).toHaveBeenCalledTimes(2); + }); + + it('should cancel retry response bodies before the next attempt', async () => { + // Prepare + const cancel = vi.fn(); + let requestCount = 0; + const fetchLike = vi.fn(async () => { + requestCount++; + if (requestCount === 1) { + return new Response( + new ReadableStream({ + cancel + }), + { status: 429 } + ); + } + + return Response.json({ ok: true }); + }); + const client = createFetchClient({ fetch: fetchLike }).with( + retryFeature({ + maxRetries: 1 + }) + ); + + // Act + const result = await client.request('GET', '/items'); + + // Assert + expect(result.unwrap().data).toEqual({ ok: true }); + expect(cancel).toHaveBeenCalledTimes(1); + expect(fetchLike).toHaveBeenCalledTimes(2); + }); + + it('should not retry other HTTP errors by default', async () => { + // Prepare + const fetchLike = vi.fn(async () => { + return Response.json({ message: 'Internal Server Error' }, { status: 500 }); + }); + const client = createFetchClient({ fetch: fetchLike }).with( + retryFeature({ + maxRetries: 3 + }) + ); + + // Act + const result = await client.request('GET', '/items'); + + // Assert + expect(unwrapErr(result)).toBeInstanceOf(HttpError); + expect(fetchLike).toHaveBeenCalledTimes(1); + }); + }); + + describe('retry timing', () => { + it('should delay network retries with exponential backoff', async () => { + // Prepare + vi.useFakeTimers(); + try { + let requestCount = 0; + const fetchLike = vi.fn(async () => { + requestCount++; + if (requestCount === 1) { + throw new Error('offline'); + } + + return Response.json({ ok: true }); + }); + const client = createFetchClient({ fetch: fetchLike }).with( + retryFeature({ + maxRetries: 1, + networkError: { baseDelayMs: 1000 } + }) + ); + + // Act + const resultPromise = client.request('GET', '/items'); + await vi.advanceTimersByTimeAsync(999); + + // Assert + expect(fetchLike).toHaveBeenCalledTimes(1); + + // Act + await vi.advanceTimersByTimeAsync(1); + const result = await resultPromise; + + // Assert + expect(result.unwrap().data).toEqual({ ok: true }); + expect(fetchLike).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it('should cap network retry delays', async () => { + // Prepare + vi.useFakeTimers(); + try { + let requestCount = 0; + const fetchLike = vi.fn(async () => { + requestCount++; + if (requestCount === 1) { + throw new Error('offline'); + } + + return Response.json({ ok: true }); + }); + const client = createFetchClient({ fetch: fetchLike }).with( + retryFeature({ + maxRetries: 1, + networkError: { + baseDelayMs: 1000, + maxDelayMs: 250 + } + }) + ); + + // Act + const resultPromise = client.request('GET', '/items'); + await vi.advanceTimersByTimeAsync(249); + + // Assert + expect(fetchLike).toHaveBeenCalledTimes(1); + + // Act + await vi.advanceTimersByTimeAsync(1); + const result = await resultPromise; + + // Assert + expect(result.unwrap().data).toEqual({ ok: true }); + expect(fetchLike).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it('should use Retry-After delay for HTTP 429 responses', async () => { + // Prepare + vi.useFakeTimers(); + try { + let requestCount = 0; + const fetchLike = vi.fn(async () => { + requestCount++; + if (requestCount === 1) { + return Response.json( + { message: 'Rate limited' }, + { + headers: { + 'Retry-After': '1' + }, + status: 429 + } + ); + } + + return Response.json({ ok: true }); + }); + const client = createFetchClient({ fetch: fetchLike }).with( + retryFeature({ + maxRetries: 1 + }) + ); + + // Act + const resultPromise = client.request('GET', '/items'); + await vi.advanceTimersByTimeAsync(999); + + // Assert + expect(fetchLike).toHaveBeenCalledTimes(1); + + // Act + await vi.advanceTimersByTimeAsync(1); + const result = await resultPromise; + + // Assert + expect(result.unwrap().data).toEqual({ ok: true }); + expect(fetchLike).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it('should stop waiting when the request signal aborts', async () => { + // Prepare + vi.useFakeTimers(); + try { + const controller = new AbortController(); + const fetchLike = vi.fn(async () => { + throw new Error('offline'); + }); + const client = createFetchClient({ fetch: fetchLike }).with( + retryFeature({ + maxRetries: 3, + networkError: { baseDelayMs: 1000 } + }) + ); + + // Act + const resultPromise = client.request('GET', '/items', { + signal: controller.signal + }); + await vi.advanceTimersByTimeAsync(100); + controller.abort(new Error('Cancelled')); + const result = await resultPromise; + + // Assert + expect(unwrapErr(result)).toBeInstanceOf(NetworkError); + expect(fetchLike).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + }); + + describe('createRetryMiddleware function', () => { + it('should create standalone retry middleware', async () => { + // Prepare + let requestCount = 0; + const fetchLike = vi.fn(async () => { + requestCount++; + if (requestCount === 1) { + throw new Error('offline'); + } + + return Response.json({ ok: true }); + }); + const fetchWithRetry = createRetryMiddleware({ + maxRetries: 1, + networkError: { baseDelayMs: 0 } + })(fetchLike); + + // Act + const response = await fetchWithRetry('/items', { method: 'GET' }); + + // Assert + await expect(response.json()).resolves.toEqual({ ok: true }); + expect(fetchLike).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/feature-fetch/src/features/retry.ts b/packages/feature-fetch/src/features/retry.ts new file mode 100644 index 00000000..2db2b935 --- /dev/null +++ b/packages/feature-fetch/src/features/retry.ts @@ -0,0 +1,170 @@ +import { defineFeature, type TFeature } from 'feature-core'; +import { getAbortReason, sleep } from '../lib'; +import type { TFetchClientBase, TFetchLike, TFetchMiddleware } from '../types'; + +/** + * Adds retry middleware for network errors and retryable HTTP responses. + * Network errors use exponential backoff. HTTP responses retry only when `shouldRetryResponse` returns true. + */ +export function retryFeature(options: TRetryFeatureOptions = {}): TRetryFeature { + return defineFeature({ + key: 'retry', + install(fetchClient: TFetchClientBase) { + fetchClient._config.middleware.push(createRetryMiddleware(options)); + + return {}; + } + }); +} + +export type TRetryFeature = TFeature<'retry', object>; + +/** Creates a standalone retry middleware for custom client composition. */ +export function createRetryMiddleware(options: TRetryFeatureOptions = {}): TFetchMiddleware { + const { + maxRetries = 3, + networkError: { baseDelayMs = 1000, maxDelayMs = 30_000 } = {}, + shouldRetryResponse = defaultShouldRetryResponse + } = options; + return (next: TFetchLike) => + async (url, requestInit): Promise => { + return fetchWithRetries(url, requestInit, { + fetchLike: next, + maxRetries, + networkError: { + baseDelayMs, + maxDelayMs + }, + shouldRetryResponse + }); + }; +} + +export interface TRetryFeatureOptions { + /** Number of retries after the initial request. Defaults to `3`. */ + maxRetries?: number; + /** Network-error exponential backoff options. */ + networkError?: TRetryNetworkErrorOptions; + /** Response retry predicate. Defaults to HTTP 429 responses. */ + shouldRetryResponse?: TShouldRetryResponse; +} + +export interface TRetryNetworkErrorOptions { + /** Base exponential-backoff delay in milliseconds. Defaults to `1000`. */ + baseDelayMs?: number; + /** Maximum exponential-backoff delay in milliseconds. Defaults to `30000`. */ + maxDelayMs?: number; +} + +/** Returns whether an HTTP response should be retried. `attemptIndex` is zero for the initial request. */ +export type TShouldRetryResponse = (response: Response, attemptIndex: number) => boolean; + +async function fetchWithRetries( + url: URL | string, + requestInit: RequestInit | undefined, + config: TFetchWithRetriesConfig +): Promise { + const { fetchLike, maxRetries, networkError, shouldRetryResponse } = config; + + for (let attemptIndex = 0; ; attemptIndex++) { + if (requestInit?.signal?.aborted === true) { + throw getAbortReason(requestInit.signal); + } + + let response: Response; + try { + response = await fetchLike(url, requestInit); + } catch (error) { + const canRetry = attemptIndex < maxRetries; + if (!canRetry) { + throw error; + } + + await sleep(calculateNetworkErrorDelayMs(attemptIndex, networkError), requestInit?.signal); + continue; + } + + const canRetry = attemptIndex < maxRetries; + const canRetryResponse = canRetry && shouldRetryResponse(response, attemptIndex); + if (!canRetryResponse) { + return response; + } + + const responseRetryDelayMs = calculateResponseRetryDelayMs(response); + await cancelResponseBody(response); + await sleep(responseRetryDelayMs, requestInit?.signal); + } +} + +interface TFetchWithRetriesConfig { + fetchLike: TFetchLike; + maxRetries: number; + networkError: TResolvedRetryNetworkErrorConfig; + shouldRetryResponse: TShouldRetryResponse; +} + +interface TResolvedRetryNetworkErrorConfig { + baseDelayMs: number; + maxDelayMs: number; +} + +function calculateNetworkErrorDelayMs( + attemptIndex: number, + networkError: TResolvedRetryNetworkErrorConfig +): number { + const baseDelayMs = Math.max(0, networkError.baseDelayMs); + const maxDelayMs = Math.max(0, networkError.maxDelayMs); + const delayMs = Math.pow(2, attemptIndex) * baseDelayMs; + return Math.min(delayMs, maxDelayMs); +} + +function calculateResponseRetryDelayMs(response: Response): number { + const retryAfterDelayMs = getRetryAfterDelayMs(response.headers.get('Retry-After')); + if (retryAfterDelayMs != null) { + return retryAfterDelayMs; + } + + const rateLimitRemaining = response.headers.get('x-rate-limit-remaining'); + if (rateLimitRemaining !== '0') { + return 0; + } + + const rateLimitReset = Number(response.headers.get('x-rate-limit-reset')); + if (!Number.isFinite(rateLimitReset)) { + return 0; + } + + // Note: x-rate-limit-reset is commonly a Unix timestamp in seconds, but the header is non-standard + return Math.max(0, rateLimitReset * 1000 - Date.now()); +} + +function getRetryAfterDelayMs(retryAfter: string | null): number | null { + const value = retryAfter?.trim(); + if (!value) { + return null; + } + + const seconds = Number(value); + if (Number.isFinite(seconds) && seconds >= 0) { + return seconds * 1000; + } + + const retryAtMs = Date.parse(value); + if (!Number.isFinite(retryAtMs)) { + return null; + } + + return Math.max(0, retryAtMs - Date.now()); +} + +function defaultShouldRetryResponse(response: Response): boolean { + return response.status === 429; +} + +async function cancelResponseBody(response: Response): Promise { + try { + await response.body?.cancel(); + } catch { + // Note: Body cleanup should not hide the original retry decision + } +} diff --git a/packages/feature-fetch/src/features/with-api/create-api-fetch-client.ts b/packages/feature-fetch/src/features/with-api/create-api-fetch-client.ts deleted file mode 100644 index eaac2ee2..00000000 --- a/packages/feature-fetch/src/features/with-api/create-api-fetch-client.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createFetchClient } from '../../create-fetch-client'; -import type { TApiFeature, TFetchClient, TFetchClientOptions } from '../../types'; -import { withApi } from './with-api'; - -export function createApiFetchClient( - options: TFetchClientOptions = {} -): TFetchClient<[TApiFeature]> { - return withApi(createFetchClient(options)); -} diff --git a/packages/feature-fetch/src/features/with-api/index.ts b/packages/feature-fetch/src/features/with-api/index.ts deleted file mode 100644 index 1aa02baa..00000000 --- a/packages/feature-fetch/src/features/with-api/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './create-api-fetch-client'; -export * from './with-api'; diff --git a/packages/feature-fetch/src/features/with-api/with-api.test.ts b/packages/feature-fetch/src/features/with-api/with-api.test.ts deleted file mode 100644 index 6bdb5fd7..00000000 --- a/packages/feature-fetch/src/features/with-api/with-api.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { http, HttpResponse } from 'msw'; -import { setupServer } from 'msw/node'; -import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; -import { createFetchClient } from '../../create-fetch-client'; -import { withApi } from './with-api'; - -const server = setupServer(); - -const BASE_URL = 'https://api.example.com'; - -describe('withApi function', () => { - beforeAll(() => { - server.listen(); - }); - afterEach(() => { - server.resetHandlers(); - }); - afterAll(() => { - server.close(); - }); - - it('should make a GET request successfully', async () => { - server.use( - http.get(new URL('/test', BASE_URL).toString(), () => { - return HttpResponse.json({ message: 'Success' }, { status: 200 }); - }) - ); - - const client = withApi(createFetchClient({ prefixUrl: BASE_URL })); - const result = await client.get('/test'); - - expect(result.isOk()).toBe(true); - expect(result.unwrap().data).toEqual({ message: 'Success' }); - }); - - it('should make a POST request successfully', async () => { - server.use( - http.post(new URL('/test', BASE_URL).toString(), async ({ request }) => { - const body = await request.json(); - return HttpResponse.json({ message: 'Created', received: body }, { status: 201 }); - }) - ); - - const client = withApi(createFetchClient({ prefixUrl: BASE_URL })); - const result = await client.post('/test', { name: 'John' }); - - expect(result.isOk()).toBe(true); - expect(result.unwrap().data).toEqual({ message: 'Created', received: { name: 'John' } }); - }); - - it('should make a PUT request successfully', async () => { - server.use( - http.put(new URL('/test', BASE_URL).toString(), async ({ request }) => { - const body = await request.json(); - return HttpResponse.json({ message: 'Updated', received: body }, { status: 200 }); - }) - ); - - const client = withApi(createFetchClient({ prefixUrl: BASE_URL })); - const result = await client.put('/test', { name: 'John Updated' }); - - expect(result.isOk()).toBe(true); - expect(result.unwrap().data).toEqual({ - message: 'Updated', - received: { name: 'John Updated' } - }); - }); - - it('should make a PATCH request successfully', async () => { - server.use( - http.patch(new URL('/test', BASE_URL).toString(), async ({ request }) => { - const body = await request.json(); - return HttpResponse.json({ message: 'Patched', received: body }, { status: 200 }); - }) - ); - - const client = withApi(createFetchClient({ prefixUrl: BASE_URL })); - const result = await client.patch('/test', { name: 'John Patched' }); - - expect(result.isOk()).toBe(true); - expect(result.unwrap().data).toEqual({ - message: 'Patched', - received: { name: 'John Patched' } - }); - }); - - it('should make a DELETE request successfully', async () => { - server.use( - http.delete(new URL('/test', BASE_URL).toString(), () => { - return HttpResponse.json({ message: 'Deleted' }, { status: 200 }); - }) - ); - - const client = withApi(createFetchClient({ prefixUrl: BASE_URL })); - const result = await client.del('/test'); - - expect(result.isOk()).toBe(true); - expect(result.unwrap().data).toEqual({ message: 'Deleted' }); - }); -}); diff --git a/packages/feature-fetch/src/features/with-api/with-api.ts b/packages/feature-fetch/src/features/with-api/with-api.ts deleted file mode 100644 index 27ad4af1..00000000 --- a/packages/feature-fetch/src/features/with-api/with-api.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { TEnforceFeatureConstraint, TFeatureDefinition } from '@blgc/types/features'; -import type { TApiFeature, TFetchClient } from '../../types'; - -export function withApi( - baseFetchClient: TEnforceFeatureConstraint, TFetchClient, []> -): TFetchClient<[TApiFeature, ...GFeatures]> { - const apiFeature: TApiFeature['api'] = { - get(this: TFetchClient<[]>, path, options = {}) { - return this._baseFetch(path, 'GET', options); - }, - post(this: TFetchClient<[]>, path, body, options = {}) { - return this._baseFetch(path, 'POST', { ...options, body }); - }, - put(this: TFetchClient<[]>, path, body, options = {}) { - return this._baseFetch(path, 'PUT', { ...options, body }); - }, - patch(this: TFetchClient<[]>, path, body, options = {}) { - return this._baseFetch(path, 'PATCH', { ...options, body }); - }, - del(this: TFetchClient<[]>, path, options = {}) { - return this._baseFetch(path, 'DELETE', options); - } - }; - - // Extend the base fetch client with the api feature - const extendedFetchClient = Object.assign(baseFetchClient, apiFeature) as TFetchClient< - [TApiFeature] - >; - extendedFetchClient._features.push('api'); - - return extendedFetchClient as unknown as TFetchClient<[TApiFeature, ...GFeatures]>; -} diff --git a/packages/feature-fetch/src/features/with-cache/Cache.ts b/packages/feature-fetch/src/features/with-cache/Cache.ts deleted file mode 100644 index a0a3c35b..00000000 --- a/packages/feature-fetch/src/features/with-cache/Cache.ts +++ /dev/null @@ -1,41 +0,0 @@ -export class Cache { - private readonly store = new Map(); - - public set(key: GKey, response: Response, maxAge = 5 * 60 * 1000): void { - this.store.set(key, { - response: response.clone(), - timestamp: Date.now() + maxAge - }); - } - - public get(key: GKey): Response | null { - const entry = this.store.get(key); - if (entry && entry.timestamp > Date.now()) { - return entry.response.clone(); - } - this.store.delete(key); - return null; - } - - public invalidate(predicate: (key: GKey) => boolean): void { - for (const key of this.store.keys()) { - if (predicate(key)) { - this.store.delete(key); - } - } - } -} - -export interface TCacheOptions { - maxAge?: number; - getCacheKey?: TGetCacheKey; - shouldCache?: TShouldCache; -} - -export type TGetCacheKey = (url: URL | string, init?: RequestInit) => string | null; -export type TShouldCache = (response: Response) => boolean; - -export interface TCacheEntry { - response: Response; - timestamp: number; -} diff --git a/packages/feature-fetch/src/features/with-cache/index.ts b/packages/feature-fetch/src/features/with-cache/index.ts deleted file mode 100644 index 7a36408b..00000000 --- a/packages/feature-fetch/src/features/with-cache/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './Cache'; -export * from './with-cache'; -export * from './with-graphql-cache'; diff --git a/packages/feature-fetch/src/features/with-cache/with-cache.ts b/packages/feature-fetch/src/features/with-cache/with-cache.ts deleted file mode 100644 index 72319df1..00000000 --- a/packages/feature-fetch/src/features/with-cache/with-cache.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { TEnforceFeatureConstraint, TFeatureDefinition } from '@blgc/types/features'; -import { TCacheFeature, type TFetchClient, type TRequestMiddleware } from '../../types'; -import { Cache, type TCacheOptions, type TGetCacheKey, type TShouldCache } from './Cache'; - -export function withCache( - baseFetchClient: TEnforceFeatureConstraint, TFetchClient, []>, - options: TCacheOptions = {} -): TFetchClient<[TCacheFeature, ...GFeatures]> { - (baseFetchClient as TFetchClient<[TCacheFeature]>)._features.push('cache'); - baseFetchClient._config.requestMiddlewares.push(createCacheMiddleware(options)); - return baseFetchClient as TFetchClient<[TCacheFeature, ...GFeatures]>; -} - -function createCacheMiddleware(options: TCacheOptions = {}): TRequestMiddleware { - const { - maxAge = 5 * 60 * 1000, // 5min - getCacheKey = defaultGetCacheKey, - shouldCache = defaultShouldCache - } = options; - const cache = new Cache(); - - return (next) => async (url, init) => { - const cacheKey = getCacheKey(url, init); - if (cacheKey != null) { - const cachedResponse = cache.get(cacheKey); - if (cachedResponse != null) { - return cachedResponse; - } - } - - const response = await next(url, init); - if (cacheKey != null && shouldCache(response)) { - cache.set(cacheKey, response, maxAge); - } - - return response; - }; -} - -const defaultGetCacheKey: TGetCacheKey = (url, init) => { - if (init?.method !== 'GET') { - return null; - } - return `${init.method}:${url.toString()}`; -}; - -const defaultShouldCache: TShouldCache = (response) => { - return response.ok && response.status < 400; -}; diff --git a/packages/feature-fetch/src/features/with-cache/with-graphql-cache.ts b/packages/feature-fetch/src/features/with-cache/with-graphql-cache.ts deleted file mode 100644 index 04cf5102..00000000 --- a/packages/feature-fetch/src/features/with-cache/with-graphql-cache.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { TEnforceFeatureConstraint, TFeatureDefinition } from '@blgc/types/features'; -import { isFetchClientWithFeatures } from '../../helper'; -import { - TGraphQLCacheFeature, - TGraphQLFeature, - type TFetchClient, - type TRequestMiddleware -} from '../../types'; -import { Cache } from './Cache'; - -export function withGraphQLCache( - baseFetchClient: TEnforceFeatureConstraint< - TFetchClient, - TFetchClient, - ['graphql'] - > -): TFetchClient<[TGraphQLCacheFeature, ...GFeatures]> { - if (!isFetchClientWithFeatures<[TGraphQLFeature]>(baseFetchClient, ['graphql'])) { - throw Error('FetchClient must have "graphql" feature to use withGraphQLCache'); - } - - (baseFetchClient as TFetchClient<[TGraphQLFeature, TGraphQLCacheFeature]>)._features.push( - 'graphqlCache' - ); - baseFetchClient._config.requestMiddlewares.push(createGraphQLCacheMiddleware()); - - return baseFetchClient as TFetchClient<[TGraphQLCacheFeature, ...GFeatures]>; -} - -function createGraphQLCacheMiddleware(): TRequestMiddleware { - const graphqlCache = new Cache(); - - return (next) => async (url, init) => { - if (init?.method !== 'POST' || init.body == null) { - return next(url, init); - } - - let body: { query?: string; variables?: Record }; - try { - body = JSON.parse(init.body as string); - } catch { - return next(url, init); - } - - if (body.query == null || body.variables == null) { - return next(url, init); - } - - const cacheKey: TCacheKey = body as TCacheKey; - - if (body.query.trim().startsWith('mutation')) { - const response = await next(url, init); - const result = (await response.clone().json()) as TGraphQLResponse; - - // Invalidate cache based on affected types - if (result.data != null) { - const typenames = collectTypenames(result.data); - graphqlCache.invalidate((key) => { - return typenames.some((typename) => key.query.includes(typename)); - }); - } - - return response; - } - - const cachedResponse = graphqlCache.get(cacheKey); - if (cachedResponse != null) { - return cachedResponse; - } - - const response = await next(url, init); - graphqlCache.set(cacheKey, response); - - return response; - }; -} - -function collectTypenames(data: Record): string[] { - const typenames: string[] = []; - - function traverse(obj: unknown): void { - if (Array.isArray(obj)) { - obj.forEach(traverse); - } else if (obj != null && typeof obj === 'object') { - if ('__typename' in obj && typeof obj.__typename === 'string') { - typenames.push(obj.__typename); - } - Object.values(obj).forEach(traverse); - } - } - - traverse(data); - return typenames; -} - -interface TGraphQLResponse { - data?: Record; - errors?: { message: string }[]; -} - -interface TCacheKey { - query: string; - variables: Record; -} diff --git a/packages/feature-fetch/src/features/with-delay.test.ts b/packages/feature-fetch/src/features/with-delay.test.ts deleted file mode 100644 index d55d521b..00000000 --- a/packages/feature-fetch/src/features/with-delay.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { http, HttpResponse } from 'msw'; -import { setupServer } from 'msw/node'; -import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; -import { createFetchClient } from '../create-fetch-client'; -import { withDelay } from './with-delay'; - -const server = setupServer(); - -const BASE_URL = 'https://api.example.com'; - -describe('withDelay function', () => { - beforeAll(() => { - server.listen(); - }); - afterEach(() => { - server.resetHandlers(); - }); - afterAll(() => { - server.close(); - }); - - it('should delay the request by the specified amount of time', async () => { - const delayInMs = 1000; // 1 second delay - - server.use( - http.get(new URL('/test', BASE_URL).toString(), () => { - return HttpResponse.json({ message: 'Success' }, { status: 200 }); - }) - ); - - const startTime = Date.now(); - const client = withDelay(createFetchClient({ prefixUrl: BASE_URL }), delayInMs); - const result = await client._baseFetch('/test', 'GET', {}); - const endTime = Date.now(); - - expect(result.isOk()).toBe(true); - expect(result.unwrap().data).toEqual({ message: 'Success' }); - expect(endTime - startTime).toBeGreaterThanOrEqual(delayInMs); - }); - - it('should not delay the request if delayInMs is zero', async () => { - const delayInMs = 0; // No delay - - server.use( - http.get(new URL('/test', BASE_URL).toString(), () => { - return HttpResponse.json({ message: 'Success' }, { status: 200 }); - }) - ); - - const startTime = Date.now(); - const client = withDelay(createFetchClient({ prefixUrl: BASE_URL }), delayInMs); - const result = await client._baseFetch('/test', 'GET', {}); - const endTime = Date.now(); - - expect(result.isOk()).toBe(true); - expect(result.unwrap().data).toEqual({ message: 'Success' }); - expect(endTime - startTime).toBeLessThan(500); - }); -}); diff --git a/packages/feature-fetch/src/features/with-delay.ts b/packages/feature-fetch/src/features/with-delay.ts deleted file mode 100644 index cce68ec2..00000000 --- a/packages/feature-fetch/src/features/with-delay.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { TEnforceFeatureConstraint, TFeatureDefinition } from '@blgc/types/features'; -import { sleep } from '@blgc/utils'; -import type { TDelayFeature, TFetchClient, TFetchLike, TRequestMiddleware } from '../types'; - -export function withDelay( - baseFetchClient: TEnforceFeatureConstraint, TFetchClient, []>, - delayInMs: number -): TFetchClient<[TDelayFeature, ...GFeatures]> { - (baseFetchClient as TFetchClient<[TDelayFeature]>)._features.push('delay'); - - baseFetchClient._config.requestMiddlewares.push(createDelayMiddleware(delayInMs)); - - return baseFetchClient as TFetchClient<[TDelayFeature, ...GFeatures]>; -} - -export function createDelayMiddleware(delayInMs: number): TRequestMiddleware { - return (next: TFetchLike) => - async (url, requestInit): Promise => { - await sleep(delayInMs); - return next(url, requestInit); - }; -} diff --git a/packages/feature-fetch/src/features/with-graphql/GraphQLError.ts b/packages/feature-fetch/src/features/with-graphql/GraphQLError.ts deleted file mode 100644 index 92ed8eba..00000000 --- a/packages/feature-fetch/src/features/with-graphql/GraphQLError.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { FetchError, type TErrorCode } from '../../exceptions/FetchError'; -import type { TGraphQLError } from '../../types'; - -export class GraphQLError extends FetchError { - public readonly errors: TGraphQLError[]; - public readonly response?: Response; - public readonly data?: GData; - public readonly extensions?: Record; - - constructor(code: TErrorCode, options: TGraphQLErrorOptions) { - const { errors, response, data, extensions } = options; - super(code, { - description: `GraphQL query failed with ${errors.length} error(s): ${errors - .map((error) => error.message) - .join(', ')}` - }); - this.errors = errors; - this.response = response; - this.data = data; - this.extensions = extensions; - } -} - -interface TGraphQLErrorOptions { - errors: TGraphQLError[]; - data?: GData; - extensions?: Record; - response?: Response; -} diff --git a/packages/feature-fetch/src/features/with-graphql/create-graphql-fetch-client.ts b/packages/feature-fetch/src/features/with-graphql/create-graphql-fetch-client.ts deleted file mode 100644 index 323768fb..00000000 --- a/packages/feature-fetch/src/features/with-graphql/create-graphql-fetch-client.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createFetchClient } from '../../create-fetch-client'; -import type { TFetchClient, TFetchClientOptions, TGraphQLFeature } from '../../types'; -import { withGraphQL } from './with-graphql'; - -export function createGraphQLFetchClient( - options: TFetchClientOptions = {} -): TFetchClient<[TGraphQLFeature]> { - return withGraphQL(createFetchClient(options)); -} diff --git a/packages/feature-fetch/src/features/with-graphql/get-query-string.ts b/packages/feature-fetch/src/features/with-graphql/get-query-string.ts deleted file mode 100644 index 5ef3f9a0..00000000 --- a/packages/feature-fetch/src/features/with-graphql/get-query-string.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Ok, type TResult } from 'tuple-result'; -import { type FetchError } from '../../exceptions'; -import { type TDocumentInput } from '../../types'; - -export async function getQueryString< - GResult extends Record, - GVariables extends Record ->(document: TDocumentInput): Promise> { - if (typeof document === 'string') { - return Ok(document); - } - - if (document.loc?.source.body != null) { - return Ok(document.loc.source.body); - } - - const { print } = await import('@0no-co/graphql.web'); - return Ok(print(document)); -} diff --git a/packages/feature-fetch/src/features/with-graphql/gql.test.ts b/packages/feature-fetch/src/features/with-graphql/gql.test.ts deleted file mode 100644 index ccf8f683..00000000 --- a/packages/feature-fetch/src/features/with-graphql/gql.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { gql } from './gql'; - -describe('gql function', () => { - it('should return an empty string when given an empty template literal', () => { - const result = gql``; - expect(result).toBe(''); - }); - - it('should return the string as-is when given a single string', () => { - const result = gql` - query { - user { - name - } - } - `; - expect(result).toBe(` - query { - user { - name - } - } - `); - }); - - it('should interpolate a single value correctly', () => { - const name = 'John'; - const result = gql`query { user(name: "${name}") { id } }`; - expect(result).toBe('query { user(name: "John") { id } }'); - }); - - it('should interpolate multiple values correctly', () => { - const name = 'John'; - const age = 30; - const result = gql` - query { - user(name: "${name}", age: ${age}) { - id - email - } - } - `; - expect(result).toBe(` - query { - user(name: "John", age: 30) { - id - email - } - } - `); - }); - - it('should handle different types of interpolated values', () => { - const id = 123; - const active = true; - const tags = ['user', 'admin']; - const result = gql` - mutation { - updateUser(id: ${id}, active: ${active}, tags: ${JSON.stringify(tags)}) { - success - } - } - `; - expect(result).toBe(` - mutation { - updateUser(id: 123, active: true, tags: ["user","admin"]) { - success - } - } - `); - }); - - it('should handle empty interpolated values', () => { - const emptyString = ''; - const result = gql`query { user(name: "${emptyString}") { id } }`; - expect(result).toBe('query { user(name: "") { id } }'); - }); -}); diff --git a/packages/feature-fetch/src/features/with-graphql/gql.ts b/packages/feature-fetch/src/features/with-graphql/gql.ts deleted file mode 100644 index 480e9aab..00000000 --- a/packages/feature-fetch/src/features/with-graphql/gql.ts +++ /dev/null @@ -1,16 +0,0 @@ -// https://github.com/apollographql/graphql-tag/blob/main/src/index.ts -export function gql(literals: TemplateStringsArray, ...args: unknown[]): string { - // If a single string is passed, return it as is - if (literals.raw.length === 1) { - return literals.raw[0] ?? ''; - } - - // Otherwise, interleave the strings with the interpolated values - return literals.raw.reduce((result, string, i) => { - result += string; - if (i < args.length) { - result += String(args[i]); - } - return result; - }, ''); -} diff --git a/packages/feature-fetch/src/features/with-graphql/index.ts b/packages/feature-fetch/src/features/with-graphql/index.ts deleted file mode 100644 index 9ec8286c..00000000 --- a/packages/feature-fetch/src/features/with-graphql/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './create-graphql-fetch-client'; -export * from './get-query-string'; -export * from './gql'; -export * from './GraphQLError'; -export * from './with-graphql'; diff --git a/packages/feature-fetch/src/features/with-graphql/with-graphql.ts b/packages/feature-fetch/src/features/with-graphql/with-graphql.ts deleted file mode 100644 index dc40dec1..00000000 --- a/packages/feature-fetch/src/features/with-graphql/with-graphql.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { TEnforceFeatureConstraint, TFeatureDefinition } from '@blgc/types/features'; -import { Err, Ok } from 'tuple-result'; -import type { - TDocumentInput, - TFetchClient, - TGraphQLFeature, - TGraphQLFetchResponse, - TGraphQLQueryOptions, - TGraphQLResponse -} from '../../types'; -import { getQueryString } from './get-query-string'; -import { GraphQLError } from './GraphQLError'; - -export function withGraphQL( - baseFetchClient: TEnforceFeatureConstraint, TFetchClient, []> -): TFetchClient<[TGraphQLFeature, ...GFeatures]> { - const graphqlFeature: TGraphQLFeature['api'] = { - async query< - GSuccessResponseBody extends Record, - GVariables extends Record, - GErrorResponseBody = unknown - >( - this: TFetchClient<[]>, - query: TDocumentInput, - options: TGraphQLQueryOptions = {} - ): Promise> { - const maybeQueryString = await getQueryString(query); - if (maybeQueryString.isErr()) { - return Err(maybeQueryString.error); - } - - const result = await this._baseFetch< - TGraphQLResponse, - GErrorResponseBody, - 'json' - >('', 'POST', { - ...options, - parseAs: 'json', - body: { - query: maybeQueryString.value, - variables: options.variables ?? {} - } - }); - if (result.isErr()) { - return Err(result.error); - } - - const response = result.value; - const { data, errors, extensions } = response.data; - - // If there are GraphQL errors, return them even if there's partial data - if (Array.isArray(errors) && errors.length > 0) { - return Err( - new GraphQLError('#ERR_GRAPHQL_QUERY', { - errors, - data, - extensions, - response: response.response - }) - ); - } - - // For successful queries, return just the data - return Ok({ - data: data as GSuccessResponseBody, - extensions, - response: response.response - }); - }, - async queryRaw(this: TFetchClient<[]>, query, options = {}) { - const maybeQueryString = await getQueryString(query); - if (maybeQueryString.isErr()) { - return Err(maybeQueryString.error); - } - - return this._baseFetch('', 'POST', { - ...options, - parseAs: 'json', - body: { - query: maybeQueryString.value, - variables: options.variables ?? {} - } - }); - } - }; - - // Extend the base fetch client with the graphql feature - const extendedFetchClient = Object.assign(baseFetchClient, graphqlFeature) as TFetchClient< - [TGraphQLFeature] - >; - extendedFetchClient._features.push('graphql'); - - return extendedFetchClient as unknown as TFetchClient<[TGraphQLFeature, ...GFeatures]>; -} diff --git a/packages/feature-fetch/src/features/with-openapi/create-openapi-fetch-client.ts b/packages/feature-fetch/src/features/with-openapi/create-openapi-fetch-client.ts deleted file mode 100644 index 80907f26..00000000 --- a/packages/feature-fetch/src/features/with-openapi/create-openapi-fetch-client.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createFetchClient } from '../../create-fetch-client'; -import type { TFetchClient, TFetchClientOptions, TOpenApiFeature } from '../../types'; -import { withOpenApi } from './with-openapi'; - -export function createOpenApiFetchClient( - options: TFetchClientOptions = {} -): TFetchClient<[TOpenApiFeature]> { - return withOpenApi(createFetchClient(options)); -} diff --git a/packages/feature-fetch/src/features/with-openapi/index.ts b/packages/feature-fetch/src/features/with-openapi/index.ts deleted file mode 100644 index 76ac5a37..00000000 --- a/packages/feature-fetch/src/features/with-openapi/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './create-openapi-fetch-client'; -export * from './with-openapi'; diff --git a/packages/feature-fetch/src/features/with-openapi/with-openapi.ts b/packages/feature-fetch/src/features/with-openapi/with-openapi.ts deleted file mode 100644 index fd759843..00000000 --- a/packages/feature-fetch/src/features/with-openapi/with-openapi.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { TEnforceFeatureConstraint, TFeatureDefinition } from '@blgc/types/features'; -import type { TFetchClient, TOpenApiFeature } from '../../types'; - -export function withOpenApi( - baseFetchClient: TEnforceFeatureConstraint, TFetchClient, []> -): TFetchClient<[TOpenApiFeature, ...GFeatures]> { - const openApiFeature: TOpenApiFeature['api'] = { - get(this: TFetchClient<[]>, path, options) { - return this._baseFetch(path as string, 'GET', options as any); - }, - post(this: TFetchClient<[]>, path, body, options) { - return this._baseFetch(path as string, 'POST', { - ...(options as any), - body - }); - }, - put(this: TFetchClient<[]>, path, body, options) { - return this._baseFetch(path as string, 'PUT', { - ...(options as any), - body - }); - }, - patch(this: TFetchClient<[]>, path, body, options) { - return this._baseFetch(path as string, 'PATCH', { - ...(options as any), - body - }); - }, - del(this: TFetchClient<[]>, path, options) { - return this._baseFetch(path as string, 'DELETE', options as any); - } - }; - - // Extend the base fetch client with the openapi feature - const extendedFetchClient = Object.assign(baseFetchClient, openApiFeature) as TFetchClient< - [TOpenApiFeature] - >; - extendedFetchClient._features.push('openapi'); - - return extendedFetchClient as unknown as TFetchClient<[TOpenApiFeature, ...GFeatures]>; -} diff --git a/packages/feature-fetch/src/features/with-retry.test.ts b/packages/feature-fetch/src/features/with-retry.test.ts deleted file mode 100644 index a3b7d174..00000000 --- a/packages/feature-fetch/src/features/with-retry.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { http, HttpResponse } from 'msw'; -import { setupServer } from 'msw/node'; -import { unwrapErr } from 'tuple-result'; -import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; -import { createFetchClient } from '../create-fetch-client'; -import { RequestError } from '../exceptions'; -import { type TFetchLike } from '../types'; -import { withRetry } from './with-retry'; - -const server = setupServer(); - -const BASE_URL = 'https://api.example.com'; - -describe('withRetry function', () => { - beforeAll(() => { - server.listen(); - }); - afterEach(() => { - server.resetHandlers(); - }); - afterAll(() => { - server.close(); - }); - - it('should retry on network errors and eventually succeed', async () => { - let retryCount = 0; - const customFetch: TFetchLike = async (url, init) => { - retryCount++; - if (retryCount < 3) { - throw new Error('Network Error'); - } - return fetch(url, init); - }; - - server.use( - http.get(new URL('/test', BASE_URL).toString(), () => { - return HttpResponse.json({ message: 'Success' }, { status: 200 }); - }) - ); - - const client = withRetry(createFetchClient({ prefixUrl: BASE_URL, fetch: customFetch }), { - maxRetries: 3 - }); - const result = await client._baseFetch('/test', 'GET', {}); - - expect(result.isOk()).toBe(true); - expect(result.unwrap().data).toEqual({ message: 'Success' }); - expect(retryCount).toBe(3); - }, 10000); - - it('should retry on rate limit errors and eventually succeed', async () => { - let retryCount = 0; - server.use( - http.get(new URL('/test', BASE_URL).toString(), () => { - retryCount++; - if (retryCount < 3) { - return HttpResponse.json( - { message: 'Rate limit exceeded' }, - { - status: 429, - headers: { - 'x-rate-limit-reset': (Date.now() / 1000 + 1).toString() // 1 second reset time - } - } - ); - } - return HttpResponse.json({ message: 'Success' }, { status: 200 }); - }) - ); - - const client = withRetry(createFetchClient({ prefixUrl: BASE_URL }), { maxRetries: 3 }); - const result = await client._baseFetch('/test', 'GET', {}); - - expect(result.isOk()).toBe(true); - expect(result.unwrap().data).toEqual({ message: 'Success' }); - expect(retryCount).toBe(3); - }, 10000); - - it('should not rety on request exception', async () => { - server.use( - http.get(new URL('/test', BASE_URL).toString(), () => { - return HttpResponse.json({ message: 'Internal Server Error' }, { status: 500 }); - }) - ); - - const client = withRetry(createFetchClient({ prefixUrl: BASE_URL }), { maxRetries: 3 }); - const result = await client._baseFetch('/test', 'GET', {}); - - expect(result.isErr()).toBe(true); - const error = unwrapErr(result); - expect(error instanceof RequestError).toBe(true); - expect((error as RequestError).data).toEqual({ message: 'Internal Server Error' }); - }); -}); diff --git a/packages/feature-fetch/src/features/with-retry.ts b/packages/feature-fetch/src/features/with-retry.ts deleted file mode 100644 index da2c243a..00000000 --- a/packages/feature-fetch/src/features/with-retry.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { TEnforceFeatureConstraint, TFeatureDefinition } from '@blgc/types/features'; -import { sleep } from '@blgc/utils'; -import type { TFetchClient, TFetchLike, TRequestMiddleware, TRetryFeature } from '../types'; - -export function withRetry( - baseFetchClient: TEnforceFeatureConstraint, TFetchClient, []>, - options: TRetryMiddlewareOptions = {} -): TFetchClient<[TRetryFeature, ...GFeatures]> { - (baseFetchClient as TFetchClient<[TRetryFeature]>)._features.push('retry'); - - baseFetchClient._config.requestMiddlewares.push(createRetryMiddleware(options)); - - return baseFetchClient as TFetchClient<[TRetryFeature, ...GFeatures]>; -} - -export function createRetryMiddleware(options: TRetryMiddlewareOptions = {}): TRequestMiddleware { - const { maxRetries = 3 } = options; - return (next: TFetchLike) => - async (url, requestInit): Promise => { - return fetchWithRetries(url, { requestInit, maxRetries, retryCount: 0, fetchLike: next }); - }; -} - -interface TRetryMiddlewareOptions { - maxRetries?: number; -} - -async function fetchWithRetries( - url: URL | string, - config: TFetchWithRetriesConfig -): Promise { - const { requestInit, maxRetries, retryCount, fetchLike } = config; - try { - // Send request - const response = await fetchLike(url, requestInit); - - // If the rate limit error hits, retry - if (response.status === 429 && maxRetries > 0) { - await sleep(calculateRateLimitTimeout(response)); - return fetchWithRetries(url, { - fetchLike, - requestInit, - maxRetries: maxRetries - 1, - retryCount: retryCount + 1 - }); - } - - return response; - } catch (error) { - // If network error hits, retry based on exponential backoff strategy - if (maxRetries > 0) { - await sleep(calculateNetworkErrorTimeout(retryCount)); - return fetchWithRetries(url, { - fetchLike, - requestInit, - maxRetries: maxRetries - 1, - retryCount: retryCount + 1 - }); - } - - // If backoff strategy retries are exhausted, throw the network error - throw error; - } -} - -interface TFetchWithRetriesConfig { - fetchLike: TFetchLike; - requestInit?: RequestInit; - maxRetries: number; - retryCount: number; -} - -function calculateRateLimitTimeout(response: Response): number { - const rateLimitReset = Number(response.headers.get('x-rate-limit-reset')); - const rateLimitRemaining = Number(response.headers.get('x-rate-limit-remaining')); - if (rateLimitRemaining === 0) { - const timeTillReset = rateLimitReset * 1000 - Date.now(); - return timeTillReset; - } - return 0; -} - -function calculateNetworkErrorTimeout(retries: number): number { - return Math.pow(2, retries) * 1000; // Increase delay exponentially, starting with 1s -} diff --git a/packages/feature-fetch/src/helper/FetchHeaders.test.ts b/packages/feature-fetch/src/helper/FetchHeaders.test.ts deleted file mode 100644 index 9e4e0065..00000000 --- a/packages/feature-fetch/src/helper/FetchHeaders.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { FetchHeaders } from './FetchHeaders'; - -describe('FetchHeaders class', () => { - it('should initialize with no parameters correctly', () => { - const headers = new FetchHeaders(); - expect(headers).toBeInstanceOf(FetchHeaders); - }); - - it('should append and retrieve a header', () => { - const headers = new FetchHeaders(); - headers.append('Content-Type', 'application/json'); - expect(headers.get('Content-Type')).toEqual('application/json'); - }); - - it('should handle case-insensitive header names', () => { - const headers = new FetchHeaders(); - headers.set('CONTENT-TYPE', 'application/json'); - expect(headers.get('content-type')).toEqual('application/json'); - }); - - it('should overwrite existing headers with set', () => { - const headers = new FetchHeaders(); - headers.set('Accept', 'application/xml'); - headers.set('Accept', 'application/json'); - expect(headers.get('Accept')).toEqual('application/json'); - }); - - it('should return null for non-existent headers', () => { - const headers = new FetchHeaders(); - expect(headers.get('Authorization')).toBeNull(); - }); - - it('should correctly report existence of a header with has', () => { - const headers = new FetchHeaders(); - headers.set('Content-Type', 'application/json'); - expect(headers.has('Content-Type')).toBeTruthy(); - expect(headers.has('Authorization')).toBeFalsy(); - }); - - it('should delete a header', () => { - const headers = new FetchHeaders(); - headers.set('Content-Type', 'application/json'); - headers.delete('Content-Type'); - expect(headers.has('Content-Type')).toBeFalsy(); - }); - - it('should initialize with an object', () => { - const init = { 'Accept': 'application/json', 'Content-Type': 'application/json' }; - const headers = new FetchHeaders(init); - expect(headers.get('Accept')).toEqual('application/json'); - expect(headers.get('Content-Type')).toEqual('application/json'); - }); - - it('should initialize with an array of key-value pairs', () => { - const init = [ - ['Accept', 'application/json'], - ['Content-Type', 'application/json'] - ]; - const headers = new FetchHeaders(init); - expect(headers.get('Accept')).toEqual('application/json'); - expect(headers.get('Content-Type')).toEqual('application/json'); - }); - - it('should initialize with another FetchHeaders instance', () => { - const original = new FetchHeaders([['Accept', 'application/json']]); - const headers = new FetchHeaders(original); - expect(headers.get('Accept')).toEqual('application/json'); - }); - - it('should convert to HeadersInit format for single values', () => { - const headers = new FetchHeaders(); - headers.append('Accept', 'application/json'); - const headersInit = headers.toHeadersInit(); - expect(headersInit).toEqual({ accept: ['application/json'] }); - }); - - it('should handle multiple values for the same header correctly', () => { - const headers = new FetchHeaders(); - headers.append('Accept', 'application/json'); - headers.append('accept', 'text/plain'); - headers.append('Content-Type', 'application/x-www-form-urlencoded'); - const headersInit = headers.toHeadersInit(); - expect(headersInit).toEqual({ - 'accept': ['application/json', 'text/plain'], - 'content-type': ['application/x-www-form-urlencoded'] - }); - }); - - it('should iterate over entries', () => { - const headers = new FetchHeaders({ - 'Accept': 'application/json', - 'Content-Type': 'application/xml' - }); - const entries = [...headers]; - expect(entries).toEqual([ - ['accept', 'application/json'], - ['content-type', 'application/xml'] - ]); - }); - - it('should support forEach iteration', () => { - const headers = new FetchHeaders({ Accept: 'application/json' }); - let count = 0; - headers.forEach(() => count++); - expect(count).toEqual(1); - }); - - it('should getSetCookie for multiple set-cookie headers', () => { - const headers = new FetchHeaders(); - headers.append('Set-Cookie', 'id=a3fWa'); - headers.append('Set-Cookie', 'auth=token'); - expect(headers.getSetCookie()).toEqual(['id=a3fWa', 'auth=token']); - }); - - it('should merge two empty FetchHeaders instances', () => { - const headers1 = new FetchHeaders(); - const headers2 = new FetchHeaders(); - const merged = FetchHeaders.merge(headers1, headers2); - expect([...merged]).toEqual([]); - }); - - it('should merge headers with unique names correctly', () => { - const headers1 = new FetchHeaders([['Content-Type', 'application/json']]); - const headers2 = new FetchHeaders([['Accept', 'application/xml']]); - const merged = FetchHeaders.merge(headers1, headers2); - expect([...merged]).toEqual([ - ['content-type', 'application/json'], - ['accept', 'application/xml'] - ]); - }); - - it('should append values for matching header names', () => { - const headers1 = new FetchHeaders([['Accept', 'application/json']]); - const headers2 = new FetchHeaders([['Accept', 'application/xml']]); - const merged = FetchHeaders.merge(headers1, headers2); - expect([...merged.headers]).toContainEqual(['accept', ['application/json', 'application/xml']]); - }); - - it('should retain multiple values for the same header when merged', () => { - const headers1 = new FetchHeaders(); - headers1.append('Set-Cookie', 'id=a3fWa'); - const headers2 = new FetchHeaders(); - headers2.append('Set-Cookie', 'auth=token'); - const merged = FetchHeaders.merge(headers1, headers2); - expect(merged.getSetCookie()).toEqual(['id=a3fWa', 'auth=token']); - }); -}); diff --git a/packages/feature-fetch/src/helper/FetchHeaders.ts b/packages/feature-fetch/src/helper/FetchHeaders.ts deleted file mode 100644 index 815eb6ff..00000000 --- a/packages/feature-fetch/src/helper/FetchHeaders.ts +++ /dev/null @@ -1,134 +0,0 @@ -// Simplified port of Headers class because some environment (like Figma plugin) -// don't provide the Headers class. -// -// https://developer.mozilla.org/en-US/docs/Web/API/Headers/Headers -export class FetchHeaders { - private readonly _headers: Map; - - constructor(init?: RequestInit['headers'] | FetchHeaders) { - this._headers = new Map(); - - if (init instanceof FetchHeaders) { - init.headers.forEach((values, key) => { - this._headers.set(key, [...values]); - }); - } else if (Array.isArray(init)) { - init.forEach(([key, value]) => { - if (key != null && value != null) { - this.append(key, value); - } - }); - } else if (init != null) { - Object.entries(init).forEach(([key, values]) => { - if (Array.isArray(values)) { - values.forEach((value) => { - this.append(key, value); - }); - } else { - this.set(key, values); - } - }); - } - } - - private static formatName(name: string): string { - return name.toLowerCase().trim(); - } - - private static formatValue(value: string): string[] { - return value.split(',').map((v) => v.trim()); - } - - private static joinValues(value: string[]): string { - return value.join(', '); - } - - public static merge(headers1?: FetchHeaders, headers2?: FetchHeaders): FetchHeaders { - const merged = new FetchHeaders(headers1); - - if (headers2 instanceof FetchHeaders) { - headers2.headers.forEach((values, key) => { - values.forEach((value) => { - merged.append(key, value); - }); - }); - } - - return merged; - } - - public get headers(): ReadonlyMap { - return new Map(this._headers); - } - - public toHeadersInit(): Record /* RequestInit['headers'] */ { - const headersInit: Record = {}; - this._headers.forEach((value, key) => { - headersInit[key] = value; - }); - return headersInit; - } - - public append(name: string, value: string): void { - const key = FetchHeaders.formatName(name); - const values = this._headers.get(key); - if (values != null) { - values.push(...FetchHeaders.formatValue(value)); - } else { - this._headers.set(key, FetchHeaders.formatValue(value)); - } - } - - public delete(name: string): void { - this._headers.delete(FetchHeaders.formatName(name)); - } - - public get(name: string): string | null { - const values = this._headers.get(FetchHeaders.formatName(name)); - if (values != null) { - return FetchHeaders.joinValues(values); - } - return null; - } - - public has(name: string): boolean { - return this._headers.has(FetchHeaders.formatName(name)); - } - - public set(name: string, value: string): void { - this._headers.set(FetchHeaders.formatName(name), FetchHeaders.formatValue(value)); - } - - public getSetCookie(): string[] { - return this._headers.get('set-cookie') ?? []; - } - - public forEach( - callbackfn: (value: string, name: string, iterable: FetchHeaders) => void, - thisArg?: any - ): void { - this._headers.forEach((values, name) => { - callbackfn.call(thisArg, FetchHeaders.joinValues(values), name, this); - }); - } - - public keys(): Iterator { - return this._headers.keys(); - } - - public *values(): Iterator { - for (const values of this._headers.values()) { - yield FetchHeaders.joinValues(values); - } - } - - public *entries(): Iterator<[string, string]> { - for (const [name, values] of this._headers.entries()) { - yield [name, FetchHeaders.joinValues(values)]; - } - } - - public [Symbol.iterator](): Iterator<[string, string]> { - return this.entries(); - } -} diff --git a/packages/feature-fetch/src/helper/build-url.ts b/packages/feature-fetch/src/helper/build-url.ts deleted file mode 100644 index d48b5d2c..00000000 --- a/packages/feature-fetch/src/helper/build-url.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { TPathParams, TPathSerializer, TQueryParams, TQuerySerializer } from '../types'; - -export function buildUrl(prefixUrl: string, options: TBuildUrlConfig): string { - const { path = '', pathParams = {}, queryParams = {}, pathSerializer, querySerializer } = options; - const url = - prefixUrl.length > 0 ? `${removeTrailingSlash(prefixUrl)}/${removeLeadingSlash(path)}` : path; - const urlWithPathParams = pathSerializer(url, pathParams); - return appendQueryParams(urlWithPathParams, querySerializer, queryParams); -} - -interface TBuildUrlConfig { - path?: string; - pathParams?: TPathParams; - queryParams?: TQueryParams; - querySerializer: TQuerySerializer; - pathSerializer: TPathSerializer; -} - -function appendQueryParams( - path: string, - querySerializer: TQuerySerializer, - queryParams: Record -): string { - if (Object.keys(queryParams).length > 0) { - const queryString = querySerializer(queryParams); - return `${path}?${removeLeadingQuestionmark(queryString)}`; - } - return path; -} - -function removeTrailingSlash(baseUrl: string): string { - return baseUrl.replace(/\/$/, ''); -} - -function removeLeadingSlash(url: string): string { - return url.replace(/^\//, ''); -} - -function removeLeadingQuestionmark(url: string): string { - return url.replace(/^\?/, ''); -} diff --git a/packages/feature-fetch/src/helper/index.ts b/packages/feature-fetch/src/helper/index.ts deleted file mode 100644 index ed2c42c4..00000000 --- a/packages/feature-fetch/src/helper/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from './build-url'; -export * from './FetchHeaders'; -export * from './is-fetch-client-with-features'; -export * from './is-status-code'; -export * from './mapper'; -export * from './serializer'; diff --git a/packages/feature-fetch/src/helper/is-fetch-client-with-features.ts b/packages/feature-fetch/src/helper/is-fetch-client-with-features.ts deleted file mode 100644 index 12a5316e..00000000 --- a/packages/feature-fetch/src/helper/is-fetch-client-with-features.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { TFeatureDefinition, TLooseFeatureNames } from '@blgc/types/features'; -import { TFetchClient } from '../types'; - -export function isFetchClientWithFeatures( - value: unknown, - features: TLooseFeatureNames[] -): value is TFetchClient { - return ( - typeof value === 'object' && - value != null && - '_features' in value && - Array.isArray(value._features) && - features.every((feature) => (value._features as string[]).includes(feature)) - ); -} diff --git a/packages/feature-fetch/src/helper/is-status-code.ts b/packages/feature-fetch/src/helper/is-status-code.ts deleted file mode 100644 index fdb369cf..00000000 --- a/packages/feature-fetch/src/helper/is-status-code.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { RequestError } from '../exceptions'; - -export function isStatusCode(error: unknown, statusCode: number): boolean { - if (error instanceof RequestError) { - return error.status === statusCode; - } else if (typeof error === 'object' && error != null && 'status' in error) { - return error.status === statusCode; - } - return false; -} diff --git a/packages/feature-fetch/src/helper/mapper/index.ts b/packages/feature-fetch/src/helper/mapper/index.ts deleted file mode 100644 index 755303fb..00000000 --- a/packages/feature-fetch/src/helper/mapper/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './map-error-to-fetch-error'; -export * from './map-error-to-network-error'; -export * from './map-response-to-request-error'; diff --git a/packages/feature-fetch/src/helper/mapper/map-error-to-fetch-error.ts b/packages/feature-fetch/src/helper/mapper/map-error-to-fetch-error.ts deleted file mode 100644 index abd5255a..00000000 --- a/packages/feature-fetch/src/helper/mapper/map-error-to-fetch-error.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { FetchError, type TErrorCode } from '../../exceptions'; - -export function mapErrorToFetchError( - error: unknown, - errorCode: TErrorCode, - message?: string -): FetchError { - if (error instanceof FetchError) { - return error; - } else if (error instanceof Error) { - return new FetchError(errorCode, { - description: message ?? error.message, - throwable: error - }); - } - return new FetchError(errorCode); -} diff --git a/packages/feature-fetch/src/helper/mapper/map-error-to-network-error.ts b/packages/feature-fetch/src/helper/mapper/map-error-to-network-error.ts deleted file mode 100644 index dc281e9e..00000000 --- a/packages/feature-fetch/src/helper/mapper/map-error-to-network-error.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { NetworkError, type TErrorCode } from '../../exceptions'; - -export function mapErrorToNetworkError( - error: unknown, - errorCode: TErrorCode = '#ERR_NETWORK' -): NetworkError { - if (error instanceof Error) { - return new NetworkError(errorCode, { - throwable: error, - description: error.message - }); - } - return new NetworkError(errorCode); -} diff --git a/packages/feature-fetch/src/helper/mapper/map-response-to-request-error.ts b/packages/feature-fetch/src/helper/mapper/map-response-to-request-error.ts deleted file mode 100644 index 6bcc2dc7..00000000 --- a/packages/feature-fetch/src/helper/mapper/map-response-to-request-error.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { isObject } from '@blgc/utils'; -import { RequestError, type TErrorCode } from '../../exceptions'; - -export async function mapResponseToRequestError( - response: Response, - defaultErrorCode: TErrorCode = '#ERR_UNKOWN' -): Promise { - try { - const contentType = response.headers.get('Content-Type'); - - let errorData: unknown; - let errorCode: TErrorCode; - let errorDescription: string | undefined; - if (contentType?.includes('application/json')) { - errorData = await response.json(); - errorCode = getErrorCode(errorData) ?? defaultErrorCode; - errorDescription = getErrorDescription(errorData) ?? undefined; - } else { - errorData = await response.text(); - errorCode = defaultErrorCode; - errorDescription = errorData as string; - } - - return new RequestError(errorCode, response.status, { - description: errorDescription, - data: errorData, - response - }); - } catch (error) { - return new RequestError(defaultErrorCode, response.status, { - description: 'Error processing response', - data: error, - response - }); - } -} - -// Helper function to extract error description from various possible fields -function getErrorDescription(data: unknown): string | null { - if (isObject(data)) { - const message = getObjectString(data['message']); - const detail = getObjectString(data['detail']); - const title = getObjectString(data['title']); - const error = getObjectString(data['error']); - return message ?? detail ?? title ?? error ?? null; - } - return null; -} - -// Helper function to extract error code from various possible fields -function getErrorCode(data: unknown): TErrorCode | null { - if (isObject(data)) { - const errorCode = getErrorCodeValue(data['error_code']); - const code = getErrorCodeValue(data['code']); - const nestedError = getErrorCode(data['error']); - return errorCode ?? code ?? nestedError ?? null; - } - return null; -} - -function getObjectString(value: unknown): string | null { - if (typeof value === 'string') { - return value; - } - if (value != null && typeof value !== 'object') { - return String(value); - } - return null; -} - -function getErrorCodeValue(value: unknown): TErrorCode | null { - return typeof value === 'string' ? (value as TErrorCode) : null; -} diff --git a/packages/feature-fetch/src/helper/serializer/index.ts b/packages/feature-fetch/src/helper/serializer/index.ts deleted file mode 100644 index 24fd9b55..00000000 --- a/packages/feature-fetch/src/helper/serializer/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './serialize-body'; -export * from './serialize-params'; diff --git a/packages/feature-fetch/src/helper/serializer/serialize-body/index.ts b/packages/feature-fetch/src/helper/serializer/serialize-body/index.ts deleted file mode 100644 index 6a24b36a..00000000 --- a/packages/feature-fetch/src/helper/serializer/serialize-body/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './serialize-body'; diff --git a/packages/feature-fetch/src/helper/serializer/serialize-body/serialize-body-to-json.ts b/packages/feature-fetch/src/helper/serializer/serialize-body/serialize-body-to-json.ts deleted file mode 100644 index a601282d..00000000 --- a/packages/feature-fetch/src/helper/serializer/serialize-body/serialize-body-to-json.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { FetchError } from '../../../exceptions'; - -export function serializeBodyToJson(body: unknown): string { - try { - return JSON.stringify(body); - } catch (error) { - throw new FetchError('#ERR_SERIZALIZE_BODY', { - description: 'Failed to serialize body to JSON string!', - throwable: error instanceof Error ? error : undefined - }); - } -} diff --git a/packages/feature-fetch/src/helper/serializer/serialize-body/serialize-body.test.ts b/packages/feature-fetch/src/helper/serializer/serialize-body/serialize-body.test.ts deleted file mode 100644 index cb1af539..00000000 --- a/packages/feature-fetch/src/helper/serializer/serialize-body/serialize-body.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { serializeBody } from './serialize-body'; - -describe('serializeBody function', () => { - it('should return FormData instance as is', () => { - // Prepare - const formData = new FormData(); - formData.append('key', 'value'); - - // Act - const result = serializeBody(formData); - - // Assert - expect(result).toBe(formData); - }); - - it('should serialize object to JSON string when content type is application/json', () => { - // Prepare - const body = { key: 'value' }; - const contentType = 'application/json'; - - // Act - const result = serializeBody(body, contentType); - - // Assert - expect(result).toBe(JSON.stringify(body)); - }); - - it('should serialize object to JSON string when content type is application/json with charset', () => { - // Prepare - const body = { key: 'value' }; - const contentType = 'application/json; charset=utf-8'; - - // Act - const result = serializeBody(body, contentType); - - // Assert - expect(result).toBe(JSON.stringify(body)); - }); - - it('should return body as is when no content type is provided', () => { - // Prepare - const body = 'plain text body'; - - // Act - const result = serializeBody(body); - - // Assert - expect(result).toBe(body); - }); - - it('should return body as is for non-JSON content types', () => { - // Prepare - const body = 'plain text body'; - const contentType = 'text/plain'; - - // Act - const result = serializeBody(body, contentType); - - // Assert - expect(result).toBe(body); - }); -}); diff --git a/packages/feature-fetch/src/helper/serializer/serialize-body/serialize-body.ts b/packages/feature-fetch/src/helper/serializer/serialize-body/serialize-body.ts deleted file mode 100644 index b3389059..00000000 --- a/packages/feature-fetch/src/helper/serializer/serialize-body/serialize-body.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { type TSerializedBody } from '../../../types'; -import { serializeBodyToJson } from './serialize-body-to-json'; - -export function serializeBody(body: GBody, contentType?: string): TSerializedBody { - if (typeof FormData !== 'undefined' && body instanceof FormData) { - return body; - } else if (contentType?.startsWith('application/json')) { - return serializeBodyToJson(body); - } - return body as RequestInit['body']; -} diff --git a/packages/feature-fetch/src/helper/serializer/serialize-params/index.ts b/packages/feature-fetch/src/helper/serializer/serialize-params/index.ts deleted file mode 100644 index eab9afa9..00000000 --- a/packages/feature-fetch/src/helper/serializer/serialize-params/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './serialize-path-params'; -export * from './serialize-query-params'; diff --git a/packages/feature-fetch/src/helper/serializer/serialize-params/maybe-encode.ts b/packages/feature-fetch/src/helper/serializer/serialize-params/maybe-encode.ts deleted file mode 100644 index 38a90dc2..00000000 --- a/packages/feature-fetch/src/helper/serializer/serialize-params/maybe-encode.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function maybeEncode(value: string | number | boolean, encode = true): string { - return encode ? encodeURIComponent(value) : value.toString(); -} diff --git a/packages/feature-fetch/src/helper/serializer/serialize-params/serialize-array-param.ts b/packages/feature-fetch/src/helper/serializer/serialize-params/serialize-array-param.ts deleted file mode 100644 index afba9b01..00000000 --- a/packages/feature-fetch/src/helper/serializer/serialize-params/serialize-array-param.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { maybeEncode } from './maybe-encode'; -import { serializePrimitiveParam } from './serialize-primitive-param'; - -export function serializeArrayParam( - name: string, - value: unknown, - options: TSerializeArrayParamsOptions -): string { - if (!Array.isArray(value)) { - return ''; - } - - const { style = 'deepObject', explode = true, allowReserved = false } = options; - - if (!explode) { - let joiner; - switch (style) { - case 'form': - joiner = ','; - break; - case 'spaceDelimited': - joiner = '%20'; - break; - case 'pipeDelimited': - joiner = '|'; - break; - default: - joiner = ','; - } - const final = value.map((v) => maybeEncode(v, allowReserved)).join(joiner); - - switch (options.style) { - case 'simple': - return final; - case 'label': - return `.${final}`; - case 'matrix': - return `;${name}=${final}`; - default: - return `${name}=${final}`; - } - } - - let joiner; - switch (style) { - case 'simple': - joiner = ','; - break; - case 'label': - joiner = '.'; - break; - case 'matrix': - joiner = ';'; - break; - default: - joiner = '&'; - } - const final = value - .map((v) => { - switch (style) { - case 'simple': - case 'label': - return maybeEncode(v, allowReserved); - default: - return serializePrimitiveParam(name, v, allowReserved); - } - }) - .join(joiner); - - switch (style) { - case 'label': - case 'matrix': - return `${joiner}${final}`; - default: - return final; - } -} - -export interface TSerializeArrayParamsOptions { - /** - * Defines how multiple values are delimited. - * Possible styles depend on the parameter location – path, query, header or cookie. - * - * @see https://swagger.io/docs/specification/serialization/#query - */ - style?: 'simple' | 'label' | 'matrix' | 'form' | 'spaceDelimited' | 'pipeDelimited'; - /** - * Specifies whether arrays and objects should generate separate parameters - * for each array item or object property. - * - * @see https://swagger.io/docs/specification/serialization/#query - */ - explode?: boolean; - /** - * Specifies whether the reserved characters - * `:/?#[]@!$&'()*+,;=` in parameter values are allowed to be sent as they - * are, or should be percent-encoded. By default, allowReserved is `false`, - * and reserved characters are percent-encoded. - * - * @see https://swagger.io/docs/specification/serialization/#query - */ - allowReserved?: boolean; -} diff --git a/packages/feature-fetch/src/helper/serializer/serialize-params/serialize-object-param.ts b/packages/feature-fetch/src/helper/serializer/serialize-params/serialize-object-param.ts deleted file mode 100644 index 1a3969bd..00000000 --- a/packages/feature-fetch/src/helper/serializer/serialize-params/serialize-object-param.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { maybeEncode } from './maybe-encode'; -import { serializePrimitiveParam } from './serialize-primitive-param'; - -export function serializeObjectParam( - name: string, - value: unknown, - options: TSerializeObjectParamsOptions -): string { - if (typeof value !== 'object' || value == null) { - return ''; - } - - const { style = 'deepObject', explode = true, allowReserved = false } = options; - let joiner; - switch (style) { - case 'simple': - joiner = ','; - break; - case 'label': - joiner = '.'; - break; - case 'matrix': - joiner = ';'; - break; - default: - joiner = '&'; - } - - // Explode - if (!explode && style !== 'deepObject') { - const final = Object.entries(value) - .reduce((acc, [k, v]) => { - acc.push(k, maybeEncode(v, allowReserved)); - return acc; - }, []) - .join(','); - - switch (style) { - case 'form': - return `${name}=${final}`; - case 'label': - return `.${final}`; - case 'matrix': - return `;${name}=${final}`; - default: - return final; - } - } - - const final = Object.entries(value) - .reduce((acc, [k, v]) => { - const finalName = style === 'deepObject' ? `${name}[${k}]` : k; - acc.push(serializePrimitiveParam(finalName, v, allowReserved)); - return acc; - }, []) - .join(joiner); - - switch (style) { - case 'label': - case 'matrix': - return `${joiner}${final}`; - default: - return final; - } -} - -export interface TSerializeObjectParamsOptions { - /** - * Defines how multiple values are delimited. - * Possible styles depend on the parameter location – path, query, header or cookie. - * - * @see https://swagger.io/docs/specification/serialization/#query - */ - style?: 'simple' | 'label' | 'matrix' | 'form' | 'deepObject'; - /** - * Specifies whether arrays and objects should generate separate parameters - * for each array item or object property. - * - * @see https://swagger.io/docs/specification/serialization/#query - */ - explode?: boolean; - /** - * Specifies whether the reserved characters - * `:/?#[]@!$&'()*+,;=` in parameter values are allowed to be sent as they - * are, or should be percent-encoded. By default, allowReserved is `false`, - * and reserved characters are percent-encoded. - * - * @see https://swagger.io/docs/specification/serialization/#query - */ - allowReserved?: boolean; -} diff --git a/packages/feature-fetch/src/helper/serializer/serialize-params/serialize-path-params.test.ts b/packages/feature-fetch/src/helper/serializer/serialize-params/serialize-path-params.test.ts deleted file mode 100644 index 6cbba08a..00000000 --- a/packages/feature-fetch/src/helper/serializer/serialize-params/serialize-path-params.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { serializePathParams } from './serialize-path-params'; - -describe('serializePathParams function', () => { - // Simple style tests - it('[simple, false] should serialize a primitive value', () => { - const result = serializePathParams('/users/{id}', { id: 5 }); - expect(result).toEqual('/users/5'); - }); - - it('[simple, false] should serialize an array', () => { - const result = serializePathParams('/users/{id}', { id: [3, 4, 5] }); - expect(result).toEqual('/users/3,4,5'); - }); - - it('[simple, false] should serialize an object', () => { - const result = serializePathParams('/users/{id}', { id: { role: 'admin', firstName: 'Alex' } }); - expect(result).toEqual('/users/role,admin,firstName,Alex'); - }); - - // Simple style with explode - it('[simple, true] should serialize a primitive value', () => { - const result = serializePathParams('/users/{id*}', { id: 5 }); - expect(result).toEqual('/users/5'); - }); - - it('[simple, true] should serialize an array', () => { - const result = serializePathParams('/users/{id*}', { id: [3, 4, 5] }); - expect(result).toEqual('/users/3,4,5'); - }); - - it('[simple, true] should serialize an object', () => { - const result = serializePathParams('/users/{id*}', { - id: { role: 'admin', firstName: 'Alex' } - }); - expect(result).toEqual('/users/role=admin,firstName=Alex'); - }); - - // Label style tests - it('[label, false] should serialize a primitive value', () => { - const result = serializePathParams('/users/{.id}', { id: 5 }); - expect(result).toEqual('/users/.5'); - }); - - it('[label, false] should serialize an array', () => { - const result = serializePathParams('/users/{.id}', { id: [3, 4, 5] }); - expect(result).toEqual('/users/.3,4,5'); - }); - - it('[label, false] should serialize an object', () => { - const result = serializePathParams('/users/{.id}', { - id: { role: 'admin', firstName: 'Alex' } - }); - expect(result).toEqual('/users/.role,admin,firstName,Alex'); - }); - - // Label style with explode - it('[label, true] should serialize a primitive value', () => { - const result = serializePathParams('/users/{.id*}', { id: 5 }); - expect(result).toEqual('/users/.5'); - }); - - it('[label, true] should serialize an array', () => { - const result = serializePathParams('/users/{.id*}', { id: [3, 4, 5] }); - expect(result).toEqual('/users/.3.4.5'); - }); - - it('[label, true] should serialize an object', () => { - const result = serializePathParams('/users/{.id*}', { - id: { role: 'admin', firstName: 'Alex' } - }); - expect(result).toEqual('/users/.role=admin.firstName=Alex'); - }); - - // Matrix style tests - it('[matrix, false] should serialize a primitive value', () => { - const result = serializePathParams('/users/{;id}', { id: 5 }); - expect(result).toEqual('/users/;id=5'); - }); - - it('[matrix, false] should serialize an array', () => { - const result = serializePathParams('/users/{;id}', { id: [3, 4, 5] }); - expect(result).toEqual('/users/;id=3,4,5'); - }); - - it('[matrix, false] should serialize an object', () => { - const result = serializePathParams('/users/{;id}', { - id: { role: 'admin', firstName: 'Alex' } - }); - expect(result).toEqual('/users/;id=role,admin,firstName,Alex'); - }); - - // Matrix style with explode - it('[matrix, true] should serialize a primitive value', () => { - const result = serializePathParams('/users/{;id*}', { id: 5 }); - expect(result).toEqual('/users/;id=5'); - }); - - it('[matrix, true] should serialize an array', () => { - const result = serializePathParams('/users/{;id*}', { id: [3, 4, 5] }); - expect(result).toEqual('/users/;id=3;id=4;id=5'); - }); - - it('[matrix, true] should serialize an object', () => { - const result = serializePathParams('/users/{;id*}', { - id: { role: 'admin', firstName: 'Alex' } - }); - expect(result).toEqual('/users/;role=admin;firstName=Alex'); - }); -}); diff --git a/packages/feature-fetch/src/helper/serializer/serialize-params/serialize-path-params.ts b/packages/feature-fetch/src/helper/serializer/serialize-params/serialize-path-params.ts deleted file mode 100644 index 56e79345..00000000 --- a/packages/feature-fetch/src/helper/serializer/serialize-params/serialize-path-params.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { serializeArrayParam } from './serialize-array-param'; -import { serializeObjectParam } from './serialize-object-param'; -import { serializePrimitiveParam } from './serialize-primitive-param'; - -const PATH_PARAM_REGEX = /\{[^{}]+\}/g; - -export function serializePathParams(path: string, pathParams: Record): string { - return path.replace(PATH_PARAM_REGEX, (match) => { - let name = match.substring(1, match.length - 1); - let explode = false; - let style: TPathParamStyle = 'simple'; - - if (name.endsWith('*')) { - explode = true; - name = name.substring(0, name.length - 1); - } - if (name.startsWith('.')) { - style = 'label'; - name = name.substring(1); - } else if (name.startsWith(';')) { - style = 'matrix'; - name = name.substring(1); - } - - const value = pathParams[name]; - if (Array.isArray(value)) { - return serializeArrayParam(name, value, { style, explode }); - } else if (typeof value === 'object') { - return serializeObjectParam(name, value, { style, explode }); - } else if (style === 'matrix') { - return `;${serializePrimitiveParam(name, value)}`; - } else if (style === 'label') { - return `.${value as string}`; - } - return value as string; - }); -} - -type TPathParamStyle = 'simple' | 'label' | 'matrix'; diff --git a/packages/feature-fetch/src/helper/serializer/serialize-params/serialize-primitive-param.ts b/packages/feature-fetch/src/helper/serializer/serialize-params/serialize-primitive-param.ts deleted file mode 100644 index 90c9919e..00000000 --- a/packages/feature-fetch/src/helper/serializer/serialize-params/serialize-primitive-param.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { maybeEncode } from './maybe-encode'; - -export function serializePrimitiveParam( - name: string, - value: unknown, - allowReserved = true -): string { - if (value == null) { - return ''; - } else if (typeof value === 'boolean' || typeof value === 'string' || typeof value === 'number') { - return `${name}=${maybeEncode(value, allowReserved)}`; - } - - throw new Error( - 'Deeply-nested arrays and objects are not supported! Provide your own `querySerializer()`.' - ); -} diff --git a/packages/feature-fetch/src/helper/serializer/serialize-params/serialize-query-params.test.ts b/packages/feature-fetch/src/helper/serializer/serialize-params/serialize-query-params.test.ts deleted file mode 100644 index eb8feaeb..00000000 --- a/packages/feature-fetch/src/helper/serializer/serialize-params/serialize-query-params.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { serializeQueryParams } from './serialize-query-params'; - -describe('serializeQueryParams function', () => { - // Default form style with explode - it('[form, true] should serialize a primitive value', () => { - const result = serializeQueryParams( - { id: 5 }, - { array: { style: 'form', explode: true }, object: { style: 'form', explode: true } } - ); - expect(result).toEqual('id=5'); - }); - - it('[form, true] should serialize an array', () => { - const result = serializeQueryParams( - { id: [3, 4, 5] }, - { array: { style: 'form', explode: true }, object: { style: 'form', explode: true } } - ); - expect(result).toEqual('id=3&id=4&id=5'); - }); - - it('[form, true] should serialize an object', () => { - const result = serializeQueryParams( - { id: { role: 'admin', firstName: 'Alex' } }, - { array: { style: 'form', explode: true }, object: { style: 'form', explode: true } } - ); - expect(result).toEqual('role=admin&firstName=Alex'); - }); - - // Form style without explode - it('[form, false] should serialize a primitive value', () => { - const result = serializeQueryParams( - { id: 5 }, - { array: { style: 'form', explode: false }, object: { style: 'form', explode: false } } - ); - expect(result).toEqual('id=5'); - }); - - it('[form, false] should serialize an array', () => { - const result = serializeQueryParams( - { id: [3, 4, 5] }, - { array: { style: 'form', explode: false }, object: { style: 'form', explode: false } } - ); - expect(result).toEqual('id=3,4,5'); - }); - - it('[form, false] should serialize an object', () => { - const result = serializeQueryParams( - { id: { role: 'admin', firstName: 'Alex' } }, - { array: { style: 'form', explode: false }, object: { style: 'form', explode: false } } - ); - expect(result).toEqual('id=role,admin,firstName,Alex'); - }); - - // SpaceDelimited style - it('[spaceDelimited, true] should serialize an array', () => { - const result = serializeQueryParams( - { id: [3, 4, 5] }, - { array: { style: 'spaceDelimited', explode: true } } - ); - expect(result).toEqual('id=3&id=4&id=5'); - }); - - it('[spaceDelimited, false] should serialize an array', () => { - const result = serializeQueryParams( - { id: [3, 4, 5] }, - { array: { style: 'spaceDelimited', explode: false } } - ); - expect(result).toEqual('id=3%204%205'); - }); - - // PipeDelimited style - it('[pipeDelimited, true] should serialize an array', () => { - const result = serializeQueryParams( - { id: [3, 4, 5] }, - { array: { style: 'pipeDelimited', explode: true } } - ); - expect(result).toEqual('id=3&id=4&id=5'); - }); - - it('[pipeDelimited, false] should serialize an array', () => { - const result = serializeQueryParams( - { id: [3, 4, 5] }, - { array: { style: 'pipeDelimited', explode: false } } - ); - expect(result).toEqual('id=3|4|5'); - }); - - // DeepObject style - it('[deepObject, true] should serialize an object', () => { - const result = serializeQueryParams( - { id: { role: 'admin', firstName: 'Alex' } }, - { object: { style: 'deepObject', explode: true } } - ); - expect(result).toEqual('id[role]=admin&id[firstName]=Alex'); - }); -}); diff --git a/packages/feature-fetch/src/helper/serializer/serialize-params/serialize-query-params.ts b/packages/feature-fetch/src/helper/serializer/serialize-params/serialize-query-params.ts deleted file mode 100644 index 9d324f0d..00000000 --- a/packages/feature-fetch/src/helper/serializer/serialize-params/serialize-query-params.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { serializeArrayParam } from './serialize-array-param'; -import { serializeObjectParam } from './serialize-object-param'; -import { serializePrimitiveParam } from './serialize-primitive-param'; - -// https://swagger.io/docs/specification/serialization/#query -export interface TSerializeQueryParamsOptions { - array?: { - style?: 'form' | 'spaceDelimited' | 'pipeDelimited'; - /** - * Specifies whether arrays and objects should generate separate parameters - * for each array item or object property. - * - * @see https://swagger.io/docs/specification/serialization/#query - */ - explode?: boolean; - }; - object?: { - style?: 'form' | 'deepObject'; - /** - * Specifies whether arrays and objects should generate separate parameters - * for each array item or object property. - * - * @see https://swagger.io/docs/specification/serialization/#query - */ - explode?: boolean; - }; - /** - * Specifies whether the reserved characters - * `:/?#[]@!$&'()*+,;=` in parameter values are allowed to be sent as they - * are, or should be percent-encoded. By default, allowReserved is `false`, - * and reserved characters are percent-encoded. - * - * @see https://swagger.io/docs/specification/serialization/#query - */ - allowReserved?: boolean; -} - -export function serializeQueryParams( - queryParams: Record, - options: TSerializeQueryParamsOptions = {} -): string { - const { object: objectOptions = {}, array: arrayOptions = {}, allowReserved } = options; - const search: string[] = []; - - for (const name in queryParams) { - const value = queryParams[name]; - if (value == null) { - continue; - } - - if (Array.isArray(value)) { - const { style = 'form', explode = true } = arrayOptions; - search.push( - serializeArrayParam(name, value, { - style, - explode, - allowReserved - }) - ); - } else if (typeof value === 'object') { - const { style = 'deepObject', explode = true } = objectOptions; - search.push( - serializeObjectParam(name, value as Record, { - style, - explode, - allowReserved - }) - ); - } else { - search.push(serializePrimitiveParam(name, value, options.allowReserved)); - } - } - return search.join('&'); -} diff --git a/packages/feature-fetch/src/index.ts b/packages/feature-fetch/src/index.ts index f5ce0a6a..677be5db 100644 --- a/packages/feature-fetch/src/index.ts +++ b/packages/feature-fetch/src/index.ts @@ -1,7 +1,7 @@ export * from './create-fetch-client'; -export * from './exceptions'; +export * from './errors'; export * from './features'; -export * from './helper'; +export * from './lib'; export * from './types'; // Re-export tuple-result for convenience diff --git a/packages/feature-fetch/src/lib/build-url.test.ts b/packages/feature-fetch/src/lib/build-url.test.ts new file mode 100644 index 00000000..f4dfb07d --- /dev/null +++ b/packages/feature-fetch/src/lib/build-url.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { buildUrl } from './build-url'; +import { serializePathParams, serializeQueryParams } from './serialize-params'; + +describe('buildUrl function', () => { + it('should join base URL and path', () => { + const result = buildUrl('https://api.example.com/', { + path: '/items', + pathSerializer: serializePathParams, + querySerializer: serializeQueryParams + }); + + expect(result).toBe('https://api.example.com/items'); + }); + + it('should let absolute paths override the base URL', () => { + const result = buildUrl('https://api.example.com', { + path: 'https://assets.example.com/image.png', + pathSerializer: serializePathParams, + querySerializer: serializeQueryParams + }); + + expect(result).toBe('https://assets.example.com/image.png'); + }); + + it('should append query params to absolute paths', () => { + const result = buildUrl('https://api.example.com', { + path: 'https://assets.example.com/image.png', + queryParams: { + width: 400 + }, + pathSerializer: serializePathParams, + querySerializer: serializeQueryParams + }); + + expect(result).toBe('https://assets.example.com/image.png?width=400'); + }); + + it('should use the base URL as-is when path is empty', () => { + const result = buildUrl('https://api.example.com/graphql', { + pathSerializer: serializePathParams, + querySerializer: serializeQueryParams + }); + + expect(result).toBe('https://api.example.com/graphql'); + }); + + it('should serialize path params after joining base URL and path', () => { + const result = buildUrl('https://api.example.com', { + path: '/items/{itemId}', + pathParams: { + itemId: 'item 1' + }, + pathSerializer: serializePathParams, + querySerializer: serializeQueryParams + }); + + expect(result).toBe('https://api.example.com/items/item%201'); + }); + + it('should append query params to a path without existing search params', () => { + const result = buildUrl('/items', { + queryParams: { + page: 2 + }, + pathSerializer: serializePathParams, + querySerializer: serializeQueryParams + }); + + expect(result).toBe('/items?page=2'); + }); + + it('should append query params to a path with existing search params', () => { + const result = buildUrl('/items?sort=name', { + queryParams: { + page: 2 + }, + pathSerializer: serializePathParams, + querySerializer: serializeQueryParams + }); + + expect(result).toBe('/items?sort=name&page=2'); + }); + + it('should remove a leading question mark from serialized query params', () => { + const result = buildUrl('/items', { + queryParams: { + page: 2 + }, + pathSerializer: serializePathParams, + querySerializer: () => '?page=2' + }); + + expect(result).toBe('/items?page=2'); + }); + + it('should skip query params that serialize to an empty string', () => { + const result = buildUrl('/items?sort=name', { + queryParams: { + page: 2 + }, + pathSerializer: serializePathParams, + querySerializer: () => '' + }); + + expect(result).toBe('/items?sort=name'); + }); +}); diff --git a/packages/feature-fetch/src/lib/build-url.ts b/packages/feature-fetch/src/lib/build-url.ts new file mode 100644 index 00000000..55d86e49 --- /dev/null +++ b/packages/feature-fetch/src/lib/build-url.ts @@ -0,0 +1,55 @@ +import type { TPathParams, TPathSerializer, TQueryParams, TQuerySerializer } from '../types'; + +/** Builds a request URL from a base URL, path params, and query params. Absolute paths ignore the base URL. */ +export function buildUrl(baseUrl: string, config: TBuildUrlConfig): string { + const { path = '', pathParams = {}, queryParams = {}, pathSerializer, querySerializer } = config; + const url = joinUrl(baseUrl, path); + const urlWithPathParams = pathSerializer(url, pathParams); + const queryString = removeLeadingQuestionMark(querySerializer(queryParams)); + return appendQueryString(urlWithPathParams, queryString); +} + +interface TBuildUrlConfig { + path?: string; + pathParams?: TPathParams; + queryParams?: TQueryParams; + querySerializer: TQuerySerializer; + pathSerializer: TPathSerializer; +} + +function joinUrl(baseUrl: string, path: string): string { + if (!baseUrl.length || isAbsoluteUrl(path)) { + return path; + } + if (!path.length) { + return removeTrailingSlash(baseUrl); + } + + return `${removeTrailingSlash(baseUrl)}/${removeLeadingSlash(path)}`; +} + +function isAbsoluteUrl(url: string): boolean { + // Match URL schemes like http:, data:, and blob: without validating the full URL + return /^[a-z][a-z\d+\-.]*:/i.test(url); +} + +function removeTrailingSlash(baseUrl: string): string { + return baseUrl.replace(/\/$/, ''); +} + +function removeLeadingSlash(url: string): string { + return url.replace(/^\//, ''); +} + +function appendQueryString(url: string, queryString: string): string { + if (!queryString.length) { + return url; + } + + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}${queryString}`; +} + +function removeLeadingQuestionMark(url: string): string { + return url.replace(/^\?/, ''); +} diff --git a/packages/feature-fetch/src/lib/get-cause-message.ts b/packages/feature-fetch/src/lib/get-cause-message.ts new file mode 100644 index 00000000..cfbc77f0 --- /dev/null +++ b/packages/feature-fetch/src/lib/get-cause-message.ts @@ -0,0 +1,9 @@ +export function getCauseMessage(cause: unknown): string | undefined { + if (cause instanceof Error) { + return cause.message; + } + if (typeof cause === 'string') { + return cause; + } + return undefined; +} diff --git a/packages/feature-fetch/src/lib/headers.test.ts b/packages/feature-fetch/src/lib/headers.test.ts new file mode 100644 index 00000000..d224fc5a --- /dev/null +++ b/packages/feature-fetch/src/lib/headers.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from 'vitest'; +import { + deleteHeader, + getHeader, + hasHeader, + mergeHeaders, + normalizeHeaders, + setHeader +} from './headers'; + +describe('headers module', () => { + describe('normalizeHeaders function', () => { + it('should normalize record names and primitive values', () => { + const headers = normalizeHeaders({ + ' Content-Type ': ' application/json ', + 'X-Enabled': true, + 'X-Retry': 2 + }); + + expect(headers).toEqual({ + 'content-type': 'application/json', + 'x-enabled': 'true', + 'x-retry': '2' + }); + }); + + it('should ignore nullish and empty array record values', () => { + const headers = normalizeHeaders({ + 'Accept': [], + 'Authorization': undefined, + 'X-Trace-Id': null + }); + + expect(headers).toEqual({}); + }); + + it('should join record array values', () => { + const headers = normalizeHeaders({ + Accept: ['application/json', 'text/plain'] + }); + + expect(headers).toEqual({ + accept: 'application/json, text/plain' + }); + }); + + it('should append repeated tuple values', () => { + const headers = normalizeHeaders([ + ['Accept', 'application/json'], + ['accept', 'text/plain'] + ]); + + expect(headers).toEqual({ + accept: 'application/json, text/plain' + }); + }); + + it('should support header names matching object prototype keys', () => { + const headers = normalizeHeaders([['toString', 'custom']]); + + expect(getHeader(headers, 'toString')).toBe('custom'); + }); + + it('should read native Headers inputs', () => { + const headers = normalizeHeaders( + new Headers({ + 'Accept': 'application/json', + 'X-Trace-Id': 'trace-1' + }) + ); + + expect(headers).toEqual({ + 'accept': 'application/json', + 'x-trace-id': 'trace-1' + }); + }); + + it('should preserve commas inside a single value', () => { + const headers = normalizeHeaders({ + 'Content-Disposition': 'attachment; filename="a,b.txt"' + }); + + expect(headers).toEqual({ + 'content-disposition': 'attachment; filename="a,b.txt"' + }); + }); + }); + + describe('mergeHeaders function', () => { + it('should let later record values replace earlier values', () => { + const headers = mergeHeaders( + { + Authorization: 'Bearer default' + }, + { + Authorization: 'Bearer request' + } + ); + + expect(headers).toEqual({ + authorization: 'Bearer request' + }); + }); + + it('should let later native Headers values replace earlier native Headers values', () => { + const headers = mergeHeaders( + new Headers({ + Accept: 'application/json', + Authorization: 'Bearer default' + }), + new Headers({ + Authorization: 'Bearer request' + }) + ); + + expect(headers).toEqual({ + accept: 'application/json', + authorization: 'Bearer request' + }); + }); + + it('should let null remove earlier values', () => { + const headers = mergeHeaders( + { + Authorization: 'Bearer default' + }, + { + Authorization: null + } + ); + + expect(headers).toEqual({}); + }); + + it('should keep independent values from every input', () => { + const headers = mergeHeaders( + { + Accept: 'application/json' + }, + new Headers({ + Authorization: 'Bearer request' + }), + [['X-Trace-Id', 'trace-1']] + ); + + expect(headers).toEqual({ + 'accept': 'application/json', + 'authorization': 'Bearer request', + 'x-trace-id': 'trace-1' + }); + }); + }); + + describe('setHeader function', () => { + it('should set one normalized header', () => { + const headers = normalizeHeaders(); + + setHeader(headers, 'Content-Type', 'application/json'); + + expect(headers).toEqual({ + 'content-type': 'application/json' + }); + }); + }); + + describe('getHeader function', () => { + it('should get one header case-insensitively', () => { + const headers = normalizeHeaders({ + 'Content-Type': 'application/json' + }); + + expect(getHeader(headers, 'CONTENT-TYPE')).toBe('application/json'); + }); + + it('should return null when the header is missing', () => { + const headers = normalizeHeaders(); + + expect(getHeader(headers, 'Content-Type')).toBeNull(); + }); + + it('should ignore inherited object keys', () => { + const headers = normalizeHeaders(); + + expect(getHeader(headers, 'toString')).toBeNull(); + }); + }); + + describe('hasHeader function', () => { + it('should check one header case-insensitively', () => { + const headers = normalizeHeaders({ + 'Content-Type': 'application/json' + }); + + expect(hasHeader(headers, 'CONTENT-TYPE')).toBe(true); + }); + + it('should return false when the header is missing', () => { + const headers = normalizeHeaders(); + + expect(hasHeader(headers, 'Content-Type')).toBe(false); + }); + + it('should ignore inherited object keys', () => { + const headers = normalizeHeaders(); + + expect(hasHeader(headers, 'toString')).toBe(false); + }); + }); + + describe('deleteHeader function', () => { + it('should delete one header case-insensitively', () => { + const headers = normalizeHeaders({ + 'Content-Type': 'application/json' + }); + + deleteHeader(headers, 'content-type'); + + expect(headers).toEqual({}); + }); + }); +}); diff --git a/packages/feature-fetch/src/lib/headers.ts b/packages/feature-fetch/src/lib/headers.ts new file mode 100644 index 00000000..7125cbc5 --- /dev/null +++ b/packages/feature-fetch/src/lib/headers.ts @@ -0,0 +1,119 @@ +import type { TFetchHeaderPrimitive, TFetchHeadersInit, TResolvedFetchHeaders } from '../types'; + +/** Converts one header input into the normalized record shape. */ +export function normalizeHeaders(headersInit?: TFetchHeadersInit): TResolvedFetchHeaders { + // Note: Header names are dynamic, so avoid Object.prototype keys like toString + const headers = Object.create(null) as TResolvedFetchHeaders; + applyHeaders(headers, headersInit); + return headers; +} + +/** + * Merges header inputs from left to right. + * Later values override, tuple repeats append, record `null` removes, and `undefined` is ignored. + */ +export function mergeHeaders( + ...headersList: Array +): TResolvedFetchHeaders { + // Note: Header names are dynamic, so avoid Object.prototype keys like toString + const headers = Object.create(null) as TResolvedFetchHeaders; + + for (const headersInit of headersList) { + applyHeaders(headers, headersInit); + } + + return headers; +} + +/** Returns a normalized header value, or null when the header is absent. */ +export function getHeader(headers: TResolvedFetchHeaders, name: string): string | null { + const key = normalizeHeaderName(name); + return Object.hasOwn(headers, key) ? (headers[key] ?? null) : null; +} + +/** Returns whether a normalized header record contains the given header name. */ +export function hasHeader(headers: TResolvedFetchHeaders, name: string): boolean { + return Object.hasOwn(headers, normalizeHeaderName(name)); +} + +/** Sets a header value after normalizing its name and value. */ +export function setHeader( + headers: TResolvedFetchHeaders, + name: string, + value: TFetchHeaderPrimitive +): void { + headers[normalizeHeaderName(name)] = normalizeHeaderValue(value); +} + +/** Removes a header after normalizing its name. */ +export function deleteHeader(headers: TResolvedFetchHeaders, name: string): void { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Header names are normalized dynamic keys + delete headers[normalizeHeaderName(name)]; +} + +function applyHeaders(headers: TResolvedFetchHeaders, headersInit?: TFetchHeadersInit): void { + if (headersInit == null) { + return; + } + + // Tuple inputs append repeated names, matching Headers constructor behavior + if (Array.isArray(headersInit)) { + for (const [name, value] of headersInit) { + const key = normalizeHeaderName(name); + const normalizedValue = normalizeHeaderValue(value); + headers[key] = headers[key] == null ? normalizedValue : `${headers[key]}, ${normalizedValue}`; + } + return; + } + + // Headers-like inputs are already flattened by their own implementation + if (isHeadersLike(headersInit)) { + headersInit.forEach((value, name) => { + setHeader(headers, name, value); + }); + return; + } + + // Record inputs replace values and use null as an explicit delete signal + for (const [name, value] of Object.entries(headersInit)) { + if (value === null) { + deleteHeader(headers, name); + continue; + } + + if (value === undefined) { + continue; + } + + if (Array.isArray(value)) { + if (value.length > 0) { + setHeader(headers, name, value.map(normalizeHeaderValue).join(', ')); + } + continue; + } + + setHeader(headers, name, value); + } +} + +function normalizeHeaderName(name: string): string { + return name.toLowerCase().trim(); +} + +function normalizeHeaderValue(value: TFetchHeaderPrimitive): string { + return String(value).trim(); +} + +// Note: Avoids instanceof Headers so compatible inputs work when native Headers is unavailable +function isHeadersLike(value: unknown): value is THeadersLike { + return ( + typeof value === 'object' && + value != null && + 'forEach' in value && + typeof value.forEach === 'function' + ); +} + +interface THeadersLike { + forEach(callback: (value: string, key: string) => void): void; +} diff --git a/packages/feature-fetch/src/lib/index.ts b/packages/feature-fetch/src/lib/index.ts new file mode 100644 index 00000000..777c2580 --- /dev/null +++ b/packages/feature-fetch/src/lib/index.ts @@ -0,0 +1,7 @@ +export * from './build-url'; +export * from './get-cause-message'; +export * from './headers'; +export * from './native-body'; +export * from './serialize-body'; +export * from './serialize-params'; +export * from './sleep'; diff --git a/packages/feature-fetch/src/lib/native-body.test.ts b/packages/feature-fetch/src/lib/native-body.test.ts new file mode 100644 index 00000000..ff7cca2d --- /dev/null +++ b/packages/feature-fetch/src/lib/native-body.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { isFormData, isNativeBody, isUrlSearchParams } from './native-body'; + +describe('native-body module', () => { + describe('isNativeBody function', () => { + it('should return true for native fetch body values', () => { + expect(isNativeBody('plain text')).toBe(true); + expect(isNativeBody(new Blob(['content']))).toBe(true); + expect(isNativeBody(new FormData())).toBe(true); + expect(isNativeBody(new ArrayBuffer(1))).toBe(true); + expect(isNativeBody(new Uint8Array([1]))).toBe(true); + expect(isNativeBody(new ReadableStream())).toBe(true); + expect(isNativeBody(new URLSearchParams({ search: 'weather' }))).toBe(true); + }); + + it('should return false for plain objects and nullish values', () => { + expect(isNativeBody({ key: 'value' })).toBe(false); + expect(isNativeBody(null)).toBe(false); + expect(isNativeBody(undefined)).toBe(false); + }); + }); + + describe('isFormData function', () => { + it('should return true for FormData values', () => { + expect(isFormData(new FormData())).toBe(true); + }); + + it('should return false for non-FormData values', () => { + expect(isFormData({ key: 'value' })).toBe(false); + }); + }); + + describe('isUrlSearchParams function', () => { + it('should return true for URLSearchParams values', () => { + expect(isUrlSearchParams(new URLSearchParams({ search: 'weather' }))).toBe(true); + }); + + it('should return false for non-URLSearchParams values', () => { + expect(isUrlSearchParams({ search: 'weather' })).toBe(false); + }); + }); +}); diff --git a/packages/feature-fetch/src/lib/native-body.ts b/packages/feature-fetch/src/lib/native-body.ts new file mode 100644 index 00000000..f40aaebd --- /dev/null +++ b/packages/feature-fetch/src/lib/native-body.ts @@ -0,0 +1,36 @@ +/** Returns whether a value can be passed to fetch as a native request body. */ +export function isNativeBody(body: unknown): body is NonNullable { + return ( + typeof body === 'string' || + isFormData(body) || + isBlob(body) || + isArrayBuffer(body) || + isArrayBufferView(body) || + isReadableStream(body) || + isUrlSearchParams(body) + ); +} + +export function isFormData(body: unknown): body is FormData { + return typeof FormData !== 'undefined' && body instanceof FormData; +} + +export function isUrlSearchParams(body: unknown): body is URLSearchParams { + return typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams; +} + +function isBlob(body: unknown): body is Blob { + return typeof Blob !== 'undefined' && body instanceof Blob; +} + +function isArrayBuffer(body: unknown): body is ArrayBuffer { + return typeof ArrayBuffer !== 'undefined' && body instanceof ArrayBuffer; +} + +function isArrayBufferView(body: unknown): body is ArrayBufferView { + return typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView(body); +} + +function isReadableStream(body: unknown): body is ReadableStream { + return typeof ReadableStream !== 'undefined' && body instanceof ReadableStream; +} diff --git a/packages/feature-fetch/src/lib/serialize-body.test.ts b/packages/feature-fetch/src/lib/serialize-body.test.ts new file mode 100644 index 00000000..e8ef265b --- /dev/null +++ b/packages/feature-fetch/src/lib/serialize-body.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { serializeBody } from './serialize-body'; + +describe('serializeBody function', () => { + describe('JSON bodies', () => { + it('should serialize JSON content types case-insensitively', () => { + const body = { key: 'value' }; + + const result = serializeBody(body, 'Application/JSON; charset=utf-8'); + + expect(result).toBe(JSON.stringify(body)); + }); + + it('should serialize JSON subtype content types', () => { + const body = { message: 'Not found' }; + + const result = serializeBody(body, 'application/problem+json'); + + expect(result).toBe(JSON.stringify(body)); + }); + + it('should leave native body init values unchanged', () => { + const body = JSON.stringify({ key: 'value' }); + + const result = serializeBody(body, 'application/json'); + + expect(result).toBe(body); + }); + }); + + describe('form URL encoded bodies', () => { + it('should serialize records', () => { + const result = serializeBody( + { + search: 'weather map', + empty: null, + limit: 10 + }, + 'Application/X-WWW-Form-Urlencoded; charset=utf-8' + ); + + expect(result).toBe('search=weather+map&limit=10'); + }); + + it('should leave native body init values unchanged', () => { + const body = new Blob(['content']); + + const result = serializeBody(body, 'application/x-www-form-urlencoded'); + + expect(result).toBe(body); + }); + }); + + describe('native bodies', () => { + it('should leave FormData bodies unchanged', () => { + const formData = new FormData(); + formData.append('key', 'value'); + + const result = serializeBody(formData, 'application/json'); + + expect(result).toBe(formData); + }); + + it('should leave non-JSON bodies unchanged', () => { + const body = 'plain text'; + + const result = serializeBody(body, 'text/plain'); + + expect(result).toBe(body); + }); + }); +}); diff --git a/packages/feature-fetch/src/lib/serialize-body.ts b/packages/feature-fetch/src/lib/serialize-body.ts new file mode 100644 index 00000000..d852ff62 --- /dev/null +++ b/packages/feature-fetch/src/lib/serialize-body.ts @@ -0,0 +1,47 @@ +import { type TSerializedBody } from '../types'; +import { isFormData, isNativeBody, isUrlSearchParams } from './native-body'; + +/** Serializes request bodies for JSON and form URL encoded content types. */ +export function serializeBody(body: GBody, contentType?: string): TSerializedBody { + // Note: FormData must stay intact so fetch can add the multipart boundary later + if (isFormData(body)) { + return body; + } + + const mediaType = contentType?.split(';')[0]?.trim().toLowerCase() ?? ''; + + if (mediaType === 'application/x-www-form-urlencoded') { + return serializeFormUrlEncodedBody(body); + } + + const isJsonMediaType = mediaType === 'application/json' || mediaType.endsWith('+json'); + // Note: Native BodyInit values are already serialized and should not be JSON-stringified + const shouldSerializeJson = isJsonMediaType && !isNativeBody(body); + if (shouldSerializeJson) { + return JSON.stringify(body); + } + + return body as TSerializedBody; +} + +function serializeFormUrlEncodedBody(body: unknown): TSerializedBody { + // Note: In runtimes without URLSearchParams, leave the body untouched instead of guessing + if (typeof URLSearchParams === 'undefined') { + return body as TSerializedBody; + } + if (typeof body === 'string' || isUrlSearchParams(body)) { + return new URLSearchParams(body).toString(); + } + if (isNativeBody(body)) { + return body; + } + const isFormRecord = typeof body === 'object' && body != null && !Array.isArray(body); + if (isFormRecord) { + const entries = Object.entries(body) + .filter(([, value]) => value != null) + .map(([name, value]) => [name, String(value)]); + return new URLSearchParams(entries).toString(); + } + + return body as TSerializedBody; +} diff --git a/packages/feature-fetch/src/lib/serialize-params.test.ts b/packages/feature-fetch/src/lib/serialize-params.test.ts new file mode 100644 index 00000000..16d0d0a6 --- /dev/null +++ b/packages/feature-fetch/src/lib/serialize-params.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest'; +import { FetchError } from '../errors'; +import { serializePathParams, serializeQueryParams } from './serialize-params'; + +describe('serialize-params module', () => { + describe('serializePathParams function', () => { + it('should replace simple path params and encode primitive values', () => { + const result = serializePathParams('/users/{userId}/posts/{postId}', { + postId: 'post 1', + userId: 5 + }); + + expect(result).toBe('/users/5/posts/post%201'); + }); + + it('should keep missing path params unresolved', () => { + const result = serializePathParams('/users/{userId}'); + + expect(result).toBe('/users/{userId}'); + }); + + it('should serialize array path params with the simple default', () => { + const result = serializePathParams('/items/{ids}', { + ids: [1, 2, 3] + }); + + expect(result).toBe('/items/1,2,3'); + }); + + it('should serialize exploded object path params with simple style', () => { + const result = serializePathParams('/items/{filter*}', { + filter: { + role: 'admin', + status: 'active' + } + }); + + expect(result).toBe('/items/role=admin,status=active'); + }); + + it('should serialize label-style array path params', () => { + const result = serializePathParams('/items/{.ids*}', { + ids: [1, 2] + }); + + expect(result).toBe('/items/.1.2'); + }); + + it('should serialize matrix-style object path params', () => { + const result = serializePathParams('/items/{;filter*}', { + filter: { + role: 'admin' + } + }); + + expect(result).toBe('/items/;role=admin'); + }); + + it('should reject unsupported path params', () => { + expect(() => { + serializePathParams('/items/{filter}', { + filter: new URLSearchParams({ role: 'admin' }) + }); + }).toThrow(FetchError); + }); + }); + + describe('serializeQueryParams function', () => { + it('should serialize query params with OpenAPI defaults', () => { + const result = serializeQueryParams({ + search: 'weather map', + tags: ['daily', 'forecast'], + filter: { + unit: 'celsius' + } + }); + + expect(result).toBe('search=weather%20map&tags=daily&tags=forecast&filter[unit]=celsius'); + }); + + it('should reject Date query params', () => { + expect(() => { + serializeQueryParams({ + createdAt: new Date('2026-05-24T10:20:30.000Z') + }); + }).toThrow(FetchError); + }); + + it('should skip nullish values and empty arrays', () => { + const result = serializeQueryParams({ + page: 2, + search: null, + tags: [], + unit: undefined + }); + + expect(result).toBe('page=2'); + }); + + it('should return an empty query string when query params are omitted', () => { + const result = serializeQueryParams(); + + expect(result).toBe(''); + }); + + it('should skip nullish array items and object properties', () => { + const result = serializeQueryParams({ + filter: { + status: undefined, + unit: 'celsius' + }, + tags: ['daily', null, 'forecast'] + }); + + expect(result).toBe('filter[unit]=celsius&tags=daily&tags=forecast'); + }); + + it('should serialize pipe-delimited query arrays', () => { + const result = serializeQueryParams( + { + tags: ['daily', 'forecast'] + }, + { + array: { + style: 'pipeDelimited', + explode: false + } + } + ); + + expect(result).toBe('tags=daily|forecast'); + }); + + it('should serialize form-style query objects', () => { + const result = serializeQueryParams( + { + filter: { + role: 'admin', + status: 'active' + } + }, + { + object: { + style: 'form', + explode: false + } + } + ); + + expect(result).toBe('filter=role,admin,status,active'); + }); + + it('should preserve reserved characters when allowReserved is true', () => { + const result = serializeQueryParams( + { + redirect: '/posts/1?tab=comments' + }, + { + allowReserved: true + } + ); + + expect(result).toBe('redirect=/posts/1?tab=comments'); + }); + + it('should reject nested object query params', () => { + expect(() => { + serializeQueryParams({ + filter: { + owner: { + id: 'user-1' + } + } + }); + }).toThrow(FetchError); + }); + }); +}); diff --git a/packages/feature-fetch/src/lib/serialize-params.ts b/packages/feature-fetch/src/lib/serialize-params.ts new file mode 100644 index 00000000..21d4c982 --- /dev/null +++ b/packages/feature-fetch/src/lib/serialize-params.ts @@ -0,0 +1,301 @@ +import { FetchError } from '../errors'; + +/** + * Replaces OpenAPI-style path placeholders with shallow param values, leaves missing values unresolved, and rejects unsupported values. + * + * @see https://swagger.io/docs/specification/v3_0/serialization/#path-parameters + */ +export function serializePathParams( + pathTemplate: string, + pathParams: Record = {} +): string { + return pathTemplate.replace(pathParamPlaceholderRegex, (pathParamPlaceholder) => { + const pathParam = parsePathParamPlaceholder(pathParamPlaceholder); + const value = pathParams[pathParam.name]; + if (value == null) { + return pathParamPlaceholder; + } + + if (Array.isArray(value)) { + return serializeArrayParam(pathParam.name, value, pathParam); + } + + if (isPlainParamRecord(value)) { + return serializeObjectParam(pathParam.name, value, pathParam); + } + + if (isParamPrimitive(value)) { + if (pathParam.style === 'matrix') { + return `;${serializeNamedParam(pathParam.name, value)}`; + } + if (pathParam.style === 'label') { + return `.${serializeParamValue(value)}`; + } + return serializeParamValue(value); + } + + throw new FetchError('#ERR_SERIALIZE_PARAMS', { + message: `Path param "${pathParam.name}" is not serializable.` + }); + }); +} + +const pathParamPlaceholderRegex = /\{[^{}]+\}/g; + +function parsePathParamPlaceholder(placeholder: string): TParsedPathParam { + let paramName = placeholder.substring(1, placeholder.length - 1); + let explode = false; + let style: TPathParamStyle = 'simple'; + + if (paramName.endsWith('*')) { + explode = true; + paramName = paramName.substring(0, paramName.length - 1); + } + if (paramName.startsWith('.')) { + style = 'label'; + paramName = paramName.substring(1); + } else if (paramName.startsWith(';')) { + style = 'matrix'; + paramName = paramName.substring(1); + } + + return { + explode, + name: paramName, + style + }; +} + +interface TParsedPathParam { + name: string; + style: TPathParamStyle; + explode: boolean; +} + +type TPathParamStyle = 'simple' | 'label' | 'matrix'; + +/** + * Serializes shallow query params with OpenAPI defaults, skips nullish values, and rejects unsupported values. + * + * @see https://swagger.io/docs/specification/v3_0/serialization/#query-parameters + */ +export function serializeQueryParams( + queryParams: Record = {}, + options: TSerializeQueryParamsOptions = {} +): string { + const { object: objectOptions = {}, array: arrayOptions = {}, allowReserved = false } = options; + const serializedParams: string[] = []; + + for (const [name, value] of Object.entries(queryParams)) { + if (value == null) { + continue; + } + + let serializedParam: string; + if (Array.isArray(value)) { + serializedParam = serializeArrayParam(name, value, { + style: arrayOptions.style ?? 'form', + explode: arrayOptions.explode ?? true, + allowReserved + }); + } else if (isPlainParamRecord(value)) { + serializedParam = serializeObjectParam(name, value, { + style: objectOptions.style ?? 'deepObject', + explode: objectOptions.explode ?? true, + allowReserved + }); + } else { + serializedParam = serializeNamedParam(name, value, allowReserved); + } + + if (serializedParam.length > 0) { + serializedParams.push(serializedParam); + } + } + + return serializedParams.join('&'); +} + +export interface TSerializeQueryParamsOptions { + /** Array serialization options. Defaults to form style with explode enabled. */ + array?: TSerializeQueryArrayOptions; + /** Object serialization options. Defaults to deepObject style with explode enabled. */ + object?: TSerializeQueryObjectOptions; + /** Preserves reserved URL characters when true. */ + allowReserved?: boolean; +} + +interface TSerializeQueryArrayOptions { + style?: 'form' | 'spaceDelimited' | 'pipeDelimited'; + explode?: boolean; +} + +interface TSerializeQueryObjectOptions { + style?: 'form' | 'deepObject'; + explode?: boolean; +} + +function serializeArrayParam( + name: string, + value: unknown[], + config: TSerializeArrayParamConfig +): string { + const { style, explode, allowReserved = false } = config; + const items = value.filter((item) => item != null); + if (!items.length) { + return ''; + } + + if (!explode) { + const delimiter = getUnexplodedArrayDelimiter(style); + const serializedValue = items + .map((item) => serializeParamValue(item, allowReserved)) + .join(delimiter); + + switch (style) { + case 'simple': + return serializedValue; + case 'label': + return `.${serializedValue}`; + case 'matrix': + return `;${name}=${serializedValue}`; + default: + return `${name}=${serializedValue}`; + } + } + + const delimiter = getExplodedDelimiter(style); + const serializedValue = items + .map((item) => { + if (style === 'simple' || style === 'label') { + return serializeParamValue(item, allowReserved); + } + + return serializeNamedParam(name, item, allowReserved); + }) + .join(delimiter); + + if (style === 'label' || style === 'matrix') { + return `${delimiter}${serializedValue}`; + } + + return serializedValue; +} + +interface TSerializeArrayParamConfig { + style: TArrayParamStyle; + explode: boolean; + allowReserved?: boolean; +} + +type TArrayParamStyle = 'simple' | 'label' | 'matrix' | 'form' | 'spaceDelimited' | 'pipeDelimited'; + +function getUnexplodedArrayDelimiter(style: TArrayParamStyle): string { + switch (style) { + case 'spaceDelimited': + return '%20'; + case 'pipeDelimited': + return '|'; + default: + return ','; + } +} + +function serializeObjectParam( + name: string, + value: Record, + config: TSerializeObjectParamConfig +): string { + const { style, explode, allowReserved = false } = config; + const entries = Object.entries(value).filter(([, propertyValue]) => propertyValue != null); + if (!entries.length) { + return ''; + } + + if (!explode && style !== 'deepObject') { + const serializedValue = entries + .flatMap(([propertyName, propertyValue]) => [ + propertyName, + serializeParamValue(propertyValue, allowReserved) + ]) + .join(','); + + switch (style) { + case 'form': + return `${name}=${serializedValue}`; + case 'label': + return `.${serializedValue}`; + case 'matrix': + return `;${name}=${serializedValue}`; + default: + return serializedValue; + } + } + + const delimiter = getExplodedDelimiter(style); + const serializedValue = entries + .map(([propertyName, propertyValue]) => { + const serializedName = style === 'deepObject' ? `${name}[${propertyName}]` : propertyName; + return serializeNamedParam(serializedName, propertyValue, allowReserved); + }) + .join(delimiter); + + if (style === 'label' || style === 'matrix') { + return `${delimiter}${serializedValue}`; + } + + return serializedValue; +} + +interface TSerializeObjectParamConfig { + style: TObjectParamStyle; + explode: boolean; + allowReserved?: boolean; +} + +type TObjectParamStyle = 'simple' | 'label' | 'matrix' | 'form' | 'deepObject'; + +function getExplodedDelimiter(style: TArrayParamStyle | TObjectParamStyle): string { + switch (style) { + case 'simple': + return ','; + case 'label': + return '.'; + case 'matrix': + return ';'; + default: + return '&'; + } +} + +function serializeNamedParam(name: string, value: unknown, allowReserved = false): string { + return `${name}=${serializeParamValue(value, allowReserved)}`; +} + +function serializeParamValue(value: unknown, allowReserved = false): string { + if (!isParamPrimitive(value)) { + throw new FetchError('#ERR_SERIALIZE_PARAMS', { + message: + 'Only string, number, boolean, arrays of primitives, and plain objects with primitive values are supported by the default param serializers.' + }); + } + + const serializedValue = String(value); + return allowReserved ? serializedValue : encodeURIComponent(serializedValue); +} + +function isParamPrimitive(value: unknown): value is TParamPrimitive { + return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'; +} + +type TParamPrimitive = string | number | boolean; + +function isPlainParamRecord(value: unknown): value is Record { + if (typeof value !== 'object' || value == null || Array.isArray(value)) { + return false; + } + + // Note: Avoids serializing Date and class instances as empty OpenAPI objects + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype == null; +} diff --git a/packages/feature-fetch/src/lib/sleep.test.ts b/packages/feature-fetch/src/lib/sleep.test.ts new file mode 100644 index 00000000..941cbfe2 --- /dev/null +++ b/packages/feature-fetch/src/lib/sleep.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest'; +import { sleep } from './sleep'; + +describe('sleep function', () => { + it('should resolve after the delay', async () => { + // Prepare + vi.useFakeTimers(); + try { + const onResolve = vi.fn(); + + // Act + const sleepPromise = sleep(1000).then(onResolve); + await vi.advanceTimersByTimeAsync(999); + + // Assert + expect(onResolve).not.toHaveBeenCalled(); + + // Act + await vi.advanceTimersByTimeAsync(1); + await sleepPromise; + + // Assert + expect(onResolve).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it('should resolve immediately for non-positive delays', async () => { + await expect(sleep(0)).resolves.toBeUndefined(); + await expect(sleep(-1)).resolves.toBeUndefined(); + }); + + it('should reject when the signal is already aborted', async () => { + // Prepare + const controller = new AbortController(); + const reason = new Error('Cancelled'); + controller.abort(reason); + + // Act / Assert + await expect(sleep(1000, controller.signal)).rejects.toBe(reason); + }); + + it('should stop waiting when the signal aborts', async () => { + // Prepare + vi.useFakeTimers(); + try { + const controller = new AbortController(); + const reason = new Error('Cancelled'); + + // Act + const sleepPromise = sleep(1000, controller.signal); + await vi.advanceTimersByTimeAsync(100); + controller.abort(reason); + + // Assert + await expect(sleepPromise).rejects.toBe(reason); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/feature-fetch/src/lib/sleep.ts b/packages/feature-fetch/src/lib/sleep.ts new file mode 100644 index 00000000..2562d569 --- /dev/null +++ b/packages/feature-fetch/src/lib/sleep.ts @@ -0,0 +1,37 @@ +/** Resolves after the given delay, or rejects with the abort reason when the signal aborts. */ +export function sleep(ms: number, signal?: AbortSignal | null): Promise { + if (ms <= 0) { + return Promise.resolve(); + } + + if (signal == null) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + } + + if (signal.aborted) { + return Promise.reject(getAbortReason(signal)); + } + + // Note: Capture the narrowed signal for callbacks that run after this scope + const abortSignal = signal; + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + abortSignal.removeEventListener('abort', abort); + resolve(); + }, ms); + + function abort(): void { + clearTimeout(timeout); + reject(getAbortReason(abortSignal)); + } + + abortSignal.addEventListener('abort', abort, { once: true }); + }); +} + +/** Returns the abort reason, or a default abort error when the runtime did not provide one. */ +export function getAbortReason(signal: AbortSignal): unknown { + return signal.reason ?? new Error('The operation was aborted'); +} diff --git a/packages/feature-fetch/src/types.ts b/packages/feature-fetch/src/types.ts new file mode 100644 index 00000000..07d50727 --- /dev/null +++ b/packages/feature-fetch/src/types.ts @@ -0,0 +1,256 @@ +import type { TAnyFeature, TFeatureHost } from 'feature-core'; +import type { TResult } from 'tuple-result'; +import type { FetchError, HttpError, NetworkError } from './errors'; + +// MARK: - Client + +/** Represents a fetch client returned by `createFetchClient()` and extended through `.with()`. */ +export type TFetchClient = TFeatureHost< + TFetchClientBase, + GFeatures +>; + +/** + * Core fetch client API used by feature installers. + * Use this type when a feature only needs the base request method and configuration. + */ +export interface TFetchClientBase { + /** @internal */ + _config: TFetchClientConfig; + /** @internal */ + _fetchLike: TFetchLike; + /** Sends one request and returns parsed data plus the raw response on the success branch. */ + request: TFetchRequest; +} + +export type TFetchLike = (url: URL | string, init?: RequestInit) => Promise; + +export interface TFetchClientConfig { + baseUrl: string; + pathSerializer: TPathSerializer; + querySerializer: TQuerySerializer; + bodySerializer: TBodySerializer; + requestInit: TFetchRequestInit; + headers: TResolvedFetchHeaders; + prepareRequest: TPrepareRequestHook[]; + middleware: TFetchMiddleware[]; + prepareResponse: TPrepareResponseHook[]; +} + +/** + * Hook that can mutate structured request inputs before URL and body serialization. + * Throwing from this hook returns a `FetchError` on the tuple-result error branch. + */ +export type TPrepareRequestHook = (cx: TPrepareRequestContext) => void | Promise; + +/** Mutable request context passed to `prepareRequest` hooks before URL/body creation. */ +export interface TPrepareRequestContext { + /** HTTP method before final uppercasing. */ + method: TRequestMethod; + /** Base URL used by `buildUrl()`. */ + baseUrl: string; + /** Request-scoped metadata available to prepare hooks and feature wrappers. */ + meta: TFetchRequestMeta; + /** Normalized mutable headers. Header names are lowercased. */ + headers: TResolvedFetchHeaders; + /** Request body before serialization. */ + body: TUnserializedBody | undefined; + /** Request path or absolute URL before path params and query params are applied. */ + path: string; + /** Mutable path params used by the active path serializer. */ + pathParams: TPathParams; + /** Mutable query params used by the active query serializer. */ + queryParams: TQueryParams; + /** Native fetch init options except `body`, `method`, and `headers`. */ + requestInit: TFetchRequestInit; +} + +/** Open request metadata bag used by hooks and features. Extend this interface for app-specific hints. */ +export interface TFetchRequestMeta { + [key: string]: unknown; +} + +/** + * Hook that can inspect or replace the raw response before the client parses it. + * Replace `cx.response` after reading a body so later parsing still has a readable response. + */ +export type TPrepareResponseHook = (cx: TPrepareResponseContext) => void | Promise; + +/** + * Mutable response context passed to `prepareResponse` hooks before response parsing. + * Replace `response` when reading its body so the client can still parse the final response. + */ +export interface TPrepareResponseContext { + response: Response; + request: TPreparedRequest; +} + +/** Final request snapshot passed to `prepareResponse` hooks. */ +export type TPreparedRequest = Omit & { + /** Fully built URL after base URL, path params, and query params are applied. */ + url: string; + /** Native fetch init with resolved headers, method, body, and signal. */ + requestInit: TRequestInitWithResolvedHeaders; +}; + +/** Native fetch init with feature-fetch's normalized header record. */ +export type TRequestInitWithResolvedHeaders = Omit & { + headers: TResolvedFetchHeaders; +}; + +// MARK: - Request + +/** + * Sends one HTTP request and returns a tuple result. + * + * The success branch is `{ data, response }`. The error branch is `NetworkError`, + * `HttpError`, or `FetchError`. + */ +export interface TFetchRequest { + < + GSuccessResponseBody = unknown, + GErrorResponseBody = unknown, + GParseAs extends TParseAs = 'json' + >( + method: TRequestMethod, + path: string, + options?: TFetchOptionsWithBody + ): Promise>; +} + +/** Response parser names supported by `parseAs`. */ +export type TParseAs = keyof TBodyType; + +export type TRequestMethod = NonNullable; + +/** Request options for methods that can send a request body. */ +export type TFetchOptionsWithBody< + GBody extends TUnserializedBody = TUnserializedBody, + GParseAs extends TParseAs = TParseAs +> = { + /** Request body before serialization. Objects and JSON primitives are serialized by the active body serializer. */ + body?: GBody; +} & TFetchOptions; + +/** Defines request body input accepted before the active body serializer runs. */ +export type TUnserializedBody = TSerializedBody | object | number | boolean; + +/** Request-scoped options accepted by `request()` and installed method features. */ +export interface TFetchOptions { + /** Response parser. Defaults to `json`. */ + parseAs?: GParseAs; + /** Headers merged after client headers. `null` removes an earlier header and `undefined` is ignored. */ + headers?: TFetchHeadersInit; + /** Base URL override for this request. Absolute request paths ignore the base URL. */ + baseUrl?: string; + /** Native fetch init options except `body`, `method`, and `headers`. Use top-level `signal` for cancellation. */ + requestInit?: TFetchRequestInit; + /** Abort signal for this request. Overrides `requestInit.signal` when not `undefined`; use `null` to clear it. */ + signal?: AbortSignal | null; + /** Request-scoped metadata available to prepare hooks and feature wrappers. */ + meta?: TFetchRequestMeta; + /** Request-scoped middleware appended after client middleware. */ + middleware?: TFetchMiddleware[]; + /** Values used by the active path serializer. */ + pathParams?: TPathParams; + /** Values used by the active query serializer. */ + queryParams?: TQueryParams; + /** Path serializer override for this request. */ + pathSerializer?: TPathSerializer; + /** Query serializer override for this request. */ + querySerializer?: TQuerySerializer; + /** Body serializer override for this request. */ + bodySerializer?: TBodySerializer; +} + +export type TFetchRequestInit = Omit; + +/** Values used to replace `{param}` placeholders in a request path. */ +export type TPathParams = Record; +/** Values serialized into the request query string. */ +export type TQueryParams = Record; + +/** + * Middleware wrapper around the final fetch call. + * Use middleware for transport behavior such as retry, cache, tracing, or timing. + */ +export type TFetchMiddleware = (next: TFetchLike) => TFetchLike; + +// MARK: - Headers + +/** Defines header input accepted by client and request options. */ +export type TFetchHeadersInit = NonNullable | TFetchHeadersInitRecord; +/** Header record that also supports primitive arrays, `null` deletes, and ignored `undefined` values. */ +export type TFetchHeadersInitRecord = Record; +export type TFetchHeaderInitValue = + | TFetchHeaderPrimitive + | TFetchHeaderPrimitive[] + | null + | undefined; +export type TFetchHeaderPrimitive = string | number | boolean; + +/** Normalized header record used internally. Header names are lowercased. */ +export type TResolvedFetchHeaders = Record; + +// MARK: - Serializers + +/** Serializes request path params into a path string. */ +export type TPathSerializer = Record> = + (path: string, pathParams: GPathParams) => string; + +/** Serializes request query params without a leading question mark. */ +export type TQuerySerializer< + GQueryParams extends Record = Record +> = (queryParams: GQueryParams) => string; + +/** Serializes a request body before it is passed to fetch. */ +export type TBodySerializer = ( + body: GBody, + contentType?: string +) => GResult; + +export type TSerializedBody = RequestInit['body']; + +// MARK: - Response + +/** Represents the tuple result returned by the low-level request method. */ +export type TFetchRequestResponse< + GSuccessResponseBody = unknown, + GErrorResponseBody = unknown, + GParseAs extends TParseAs = 'json' +> = TResult< + TFetchResponseSuccess, + TFetchResponseError +>; + +/** Error union returned by feature-fetch request methods. */ +export type TFetchResponseError = + | NetworkError + | HttpError + | FetchError; + +/** Success value returned by the low-level `request()` method. */ +export interface TFetchResponseSuccess< + GSuccessResponseBody = unknown, + GParseAs extends TParseAs = 'json' +> { + /** Parsed response body. */ + data: TParseAsResponse; + /** Response used to produce `data`. Its body is already consumed unless `parseAs` is `stream`. */ + response: Response; +} + +/** Maps a parser name to the corresponding success data type. */ +export type TParseAsResponse< + GParseAs extends TParseAs, + GJson = unknown +> = TBodyType[GParseAs]; + +/** Response body types returned by each parser mode. */ +export interface TBodyType { + json: GJson; + text: Awaited>; + blob: Awaited>; + arrayBuffer: Awaited>; + stream: Response['body']; +} diff --git a/packages/feature-fetch/src/types/features/api.ts b/packages/feature-fetch/src/types/features/api.ts deleted file mode 100644 index a0af7700..00000000 --- a/packages/feature-fetch/src/types/features/api.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { type TParseAs } from '../fetch'; -import type { TFetchOptions, TFetchResponse, TUnserializedBody } from '../fetch-client'; - -export interface TApiFeature { - key: 'api'; - api: { - get: TApiGet; - put: TApiPut; - post: TApiPost; - patch: TApiPatch; - del: TApiDelete; - }; -} - -export type TApiGet = < - GSucessResponseBody = unknown, - GErrorResponseBody = unknown, - GParseAs extends TParseAs = 'json' ->( - path: string, - options?: TFetchOptions -) => Promise>; - -export type TApiPost = < - GSuccessResponseBody = unknown, - GErrorResponseBody = unknown, - GRequestBody extends TUnserializedBody = Record, - GParseAs extends TParseAs = 'json' ->( - path: string, - body: GRequestBody, - options?: TFetchOptions -) => Promise>; - -export type TApiPut = < - GSuccessResponseBody = unknown, - GErrorResponseBody = unknown, - GRequestBody extends TUnserializedBody = Record, - GParseAs extends TParseAs = 'json' ->( - path: string, - body: GRequestBody, - options?: TFetchOptions -) => Promise>; - -export type TApiPatch = < - GSuccessResponseBody = unknown, - GErrorResponseBody = unknown, - GRequestBody extends TUnserializedBody = Record, - GParseAs extends TParseAs = 'json' ->( - path: string, - body: GRequestBody, - options?: TFetchOptions -) => Promise>; - -export type TApiDelete = < - GSuccessResponseBody = unknown, - GErrorResponseBody = unknown, - GParseAs extends TParseAs = 'json' ->( - path: string, - options?: TFetchOptions -) => Promise>; diff --git a/packages/feature-fetch/src/types/features/graphql.ts b/packages/feature-fetch/src/types/features/graphql.ts deleted file mode 100644 index 5ba4916c..00000000 --- a/packages/feature-fetch/src/types/features/graphql.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { type DocumentNode } from '@0no-co/graphql.web'; -import { type TResult } from 'tuple-result'; -import type { TFetchOptions, TFetchResponse, TFetchResponseError } from '../fetch-client'; - -export interface TGraphQLFeature { - key: 'graphql'; - api: { - query: TGraphQLQuery; - queryRaw: TGraphQLQueryRaw; - }; -} - -export type TGraphQLQuery = < - GSuccessResponseBody extends Record, - GVariables extends Record, - GErrorResponseBody = unknown ->( - query: TDocumentInput, - options: TGraphQLQueryOptions -) => Promise>; - -export type TGraphQLQueryRaw = < - GSucessResponseBody extends Record, - GVariables extends Record, - GErrorResponseBody = unknown ->( - query: TDocumentInput, - options: TGraphQLQueryOptions -) => Promise, GErrorResponseBody, 'json'>>; - -export interface TGraphQLQueryOptions> extends Omit< - TFetchOptions<'json'>, - 'parseAs' -> { - variables?: GVariables; -} - -export type TGraphQLFetchResponse = TResult< - TGraphQLFetchResponseSuccess, - TFetchResponseError ->; - -export interface TGraphQLFetchResponseSuccess { - data: GSuccessResponseBody; - extensions?: Record; - response: Response; -} - -/** - * Standard GraphQL response structure - * @see https://spec.graphql.org/October2021/#sec-Response - */ -export interface TGraphQLResponse { - data?: GData | null; - errors?: TGraphQLError[]; - extensions?: Record; -} - -/** - * Standard GraphQL error structure - * @see https://spec.graphql.org/October2021/#sec-Errors - */ -export interface TGraphQLError { - message: string; - locations?: Array<{ - line: number; - column: number; - }>; - path?: Array; - extensions?: Record; -} - -/** - * Any GraphQL `DocumentNode` or query string input. - */ -// Based on: https://github.com/urql-graphql/urql/blob/main/packages/core/src/types.ts -export type TDocumentInput, GVariables = Record> = - | string - | DocumentNode - | TTypedDocumentNode; - -/** - * A GraphQL `DocumentNode` with attached generics for its result data and variables. - * - * @remarks - * A GraphQL {@link DocumentNode} defines both the variables it accepts on request and the `data` - * shape it delivers on a response in the GraphQL query language. - * - * To bridge the gap to TypeScript, tools may be used to generate TypeScript types that define the shape - * of `data` and `variables` ahead of time. These types are then attached to GraphQL documents using this - * `TypedDocumentNode` type. - * - * @privateRemarks - * For compatibility reasons this type has been copied and internalized from: - * https://github.com/dotansimha/graphql-typed-document-node/blob/3711b12/packages/core/src/index.ts#L3-L10 - */ -// Based on: https://github.com/urql-graphql/urql/blob/main/packages/core/src/types.ts -export type TTypedDocumentNode< - GResult = Record, - GVariables = Record -> = DocumentNode & { - /** Type to support `@graphql-typed-document-node/core` - * @internal - */ - __apiType?: (variables: GVariables) => GResult; - /** Type to support `TypedQueryDocumentNode` from `graphql` - * @internal - */ - __ensureTypesOfVariablesAndResultMatching?: (variables: GVariables) => GResult; -}; diff --git a/packages/feature-fetch/src/types/features/index.ts b/packages/feature-fetch/src/types/features/index.ts deleted file mode 100644 index d74e88ce..00000000 --- a/packages/feature-fetch/src/types/features/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -export * from './api'; -export * from './graphql'; -export * from './openapi'; - -export interface TRetryFeature { - key: 'retry'; - api: {}; -} - -export interface TDelayFeature { - key: 'delay'; - api: {}; -} - -export interface TCacheFeature { - key: 'cache'; - api: {}; -} - -export interface TGraphQLCacheFeature { - key: 'graphqlCache'; - api: {}; -} diff --git a/packages/feature-fetch/src/types/features/openapi.ts b/packages/feature-fetch/src/types/features/openapi.ts deleted file mode 100644 index e2833828..00000000 --- a/packages/feature-fetch/src/types/features/openapi.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { type TMediaType } from '@blgc/types/api'; -import type { - TOperationErrorResponseContent, - TOperationPathParams, - TOperationQueryParams, - TOperationSuccessResponseContent, - TPathsWithMethod, - TRequestBody -} from '@blgc/types/openapi'; -import { type TFilterKeys } from '@blgc/types/utils'; -import { type TParseAs } from '../fetch'; -import type { - TBodySerializer, - TFetchOptions, - TFetchResponse, - TPathSerializer, - TQuerySerializer -} from '../fetch-client'; - -export interface TOpenApiFeature { - key: 'openapi'; - api: { - get: TOpenApiGet; - put: TOpenApiPut; - post: TOpenApiPost; - patch: TOpenApiPatch; - del: TOpenApiDelete; - }; -} - -export type TOpenApiGet = < - GGetPaths extends TPathsWithMethod, - GPathOperation extends TFilterKeys, - GParseAs extends TParseAs = 'json' ->( - path: GGetPaths | (string & Record), // https://github.com/microsoft/TypeScript/issues/29729 - options?: TOpenApiFetchOptions -) => Promise>; - -export type TOpenApiPost = < - GPostPaths extends TPathsWithMethod, - GPathOperation extends TFilterKeys, - GParseAs extends TParseAs = 'json' ->( - path: GPostPaths | (string & Record), // https://github.com/microsoft/TypeScript/issues/29729 - body: TRequestBody< - 'post' extends keyof GPaths[GPostPaths] ? GPaths[GPostPaths]['post'] : unknown - > extends never - ? null - : TRequestBody<'post' extends keyof GPaths[GPostPaths] ? GPaths[GPostPaths]['post'] : unknown>, - options?: TOpenApiFetchOptions -) => Promise>; - -export type TOpenApiPut = < - GPutPaths extends TPathsWithMethod, - GPathOperation extends TFilterKeys, - GParseAs extends TParseAs = 'json' ->( - path: GPutPaths | (string & Record), // https://github.com/microsoft/TypeScript/issues/29729 - body: TRequestBody< - 'put' extends keyof GPaths[GPutPaths] ? GPaths[GPutPaths]['put'] : unknown - > extends never - ? null - : TRequestBody<'put' extends keyof GPaths[GPutPaths] ? GPaths[GPutPaths]['put'] : unknown>, - options?: TOpenApiFetchOptions -) => Promise>; - -export type TOpenApiPatch = < - GPatchPaths extends TPathsWithMethod, - GPathOperation extends TFilterKeys, - GParseAs extends TParseAs = 'json' ->( - path: GPatchPaths | (string & Record), // https://github.com/microsoft/TypeScript/issues/29729 - body: TRequestBody< - 'patch' extends keyof GPaths[GPatchPaths] ? GPaths[GPatchPaths]['patch'] : unknown - > extends never - ? null - : TRequestBody< - 'patch' extends keyof GPaths[GPatchPaths] ? GPaths[GPatchPaths]['patch'] : unknown - >, - options?: TOpenApiFetchOptions -) => Promise>; - -export type TOpenApiDelete = < - GDeletePaths extends TPathsWithMethod, - GPathOperation extends TFilterKeys, - GParseAs extends TParseAs = 'json' ->( - path: GDeletePaths | (string & Record), // https://github.com/microsoft/TypeScript/issues/29729 - options?: TOpenApiFetchOptions -) => Promise>; - -// ============================================================================= -// Fetch Response -// ============================================================================= - -export type TOpenApiFetchResponse = TFetchResponse< - TOperationSuccessResponseContent< - GPathOperation, - GParseAs extends 'json' ? 'application/json' : TMediaType - >, - TOperationErrorResponseContent< - GPathOperation, - GParseAs extends 'json' ? 'application/json' : TMediaType - >, - GParseAs ->; - -// ============================================================================= -// Fetch Options -// ============================================================================= - -export type TOpenApiFetchOptions = { - pathSerializer?: TPathSerializer< - TOperationPathParams extends never - ? Record - : TOperationPathParams - >; - querySerializer?: TQuerySerializer< - TOperationQueryParams extends never - ? Record - : TOperationQueryParams - >; - bodySerializer?: TBodySerializer< - TRequestBody extends never ? unknown : TRequestBody - >; -} & Omit< - TFetchOptions, - 'pathSerializer' | 'querySerializer' | 'bodySerializer' | 'pathParams' | 'queryParams' -> & - TOpenApiQueryParamsFetchOptions & - TOpenApiPathParamsFetchOptions; - -export type TOpenApiQueryParamsFetchOptions = - undefined extends TOperationQueryParams // If the queryParams can be undefined/optional - ? { queryParams?: TOperationQueryParams } - : TOperationQueryParams extends never - ? { queryParams?: Record } - : { queryParams: TOperationQueryParams }; - -export type TOpenApiPathParamsFetchOptions = - undefined extends TOperationPathParams // If the pathParams can be undefined/optional - ? { pathParams?: TOperationPathParams } - : TOperationPathParams extends never - ? { pathParams?: Record } - : { pathParams: TOperationPathParams }; diff --git a/packages/feature-fetch/src/types/fetch-client.ts b/packages/feature-fetch/src/types/fetch-client.ts deleted file mode 100644 index bc4fca89..00000000 --- a/packages/feature-fetch/src/types/fetch-client.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { TWithFeatures, type TFeatureDefinition } from '@blgc/types/features'; -import { type TResult } from 'tuple-result'; -import type { FetchError, NetworkError, RequestError } from '../exceptions'; -import type { FetchHeaders } from '../helper'; -import { - type TParseAs, - type TParseAsResponse, - type TRequestInitWithHeadersObject, - type TRequestMethod -} from './fetch'; - -export type TFetchClient = TWithFeatures< - { - _config: TFetchClientConfig; - _fetchLike: TFetchLike; - _baseFetch: < - GSuccessResponseBody = unknown, - GErrorResponseBody = unknown, - GParseAs extends TParseAs = 'json' - >( - path: string, - method: TRequestMethod, - options: TFetchOptionsWithBody - ) => Promise>; - }, - GFeatures ->; - -// ============================================================================= -// Fetch Client Options & Config -// ============================================================================= - -export type TFetchLike = (url: URL | string, init?: RequestInit) => ReturnType; - -export interface TFetchClientConfig { - prefixUrl: string; - pathSerializer: TPathSerializer; - querySerializer: TQuerySerializer; - bodySerializer: TBodySerializer; - fetchProps: Omit; - headers: FetchHeaders; - beforeRequestMiddlewares: TBeforeRequestMiddleware[]; - requestMiddlewares: TRequestMiddleware[]; -} - -export type TFetchClientOptions = Partial> & { - headers?: RequestInit['headers'] | FetchHeaders; - fetch?: TFetchLike; -}; - -// ============================================================================ -// Serializer Methods -// ============================================================================ - -export type TPathSerializer = Record> = - (path: string, params: GPathParams) => string; - -export type TQuerySerializer< - GQueryParams extends Record = Record -> = (params: GQueryParams) => string; - -export type TBodySerializer = ( - body: GBody, - contentType?: string -) => GResult; - -// ============================================================================ -// Middleware -// ============================================================================ - -export type TRequestMiddleware = (next: TFetchLike) => TFetchLike; - -export type TBeforeRequestMiddleware = (data: TBeforeRequestMiddlewareData) => void | Promise; - -export interface TBeforeRequestMiddlewareData { - path: string; - props: unknown; - requestInit: TRequestInitWithHeadersObject; - pathParams: TPathParams; - queryParams: TQueryParams; -} - -export type TPathParams = Record; -export type TQueryParams = Record; - -export type TSerializedBody = RequestInit['body']; -export type TUnserializedBody = TSerializedBody | Record; - -// ============================================================================= -// Fetch Options -// ============================================================================= - -export interface TFetchOptions { - parseAs?: GParseAs | TParseAs; // '| TParseAs' to fix VsCode autocomplete - headers?: RequestInit['headers']; - prefixUrl?: string; - fetchProps?: Omit; - middlewareProps?: unknown; - requestMiddlewares?: TRequestMiddleware[]; - pathParams?: TPathParams; - queryParams?: TQueryParams; - pathSerializer?: TPathSerializer; - querySerializer?: TQuerySerializer; - bodySerializer?: TBodySerializer; -} - -export type TFetchOptionsWithBody = { - body?: TUnserializedBody; // TODO: Only if POST or PUT -} & TFetchOptions; - -// ============================================================================= -// Fetch Response -// ============================================================================= - -export interface TFetchResponseSuccess { - data: TParseAsResponse; - response: Response; -} - -export type TFetchResponseError = - | NetworkError - | RequestError - | FetchError; - -export type TFetchResponse< - GSuccessResponseBody, - GErrorResponseBody, - GParseAs extends TParseAs -> = TResult< - TFetchResponseSuccess, - TFetchResponseError ->; diff --git a/packages/feature-fetch/src/types/fetch.ts b/packages/feature-fetch/src/types/fetch.ts deleted file mode 100644 index 21dea5f9..00000000 --- a/packages/feature-fetch/src/types/fetch.ts +++ /dev/null @@ -1,21 +0,0 @@ -export type THeadersInit = NonNullable; -export type TRequestMethod = NonNullable; - -export interface TBodyType { - json: GJson; - text: Awaited>; - blob: Awaited>; - arrayBuffer: Awaited>; - stream: Response['body']; -} - -export type TParseAs = keyof TBodyType; - -export type TParseAsResponse< - GParseAs extends TParseAs, - GJson = unknown -> = TBodyType[GParseAs]; - -export type TRequestInitWithHeadersObject = Omit & { - headers: Record; -}; diff --git a/packages/feature-fetch/src/types/index.ts b/packages/feature-fetch/src/types/index.ts deleted file mode 100644 index 68d3669a..00000000 --- a/packages/feature-fetch/src/types/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './features'; -export * from './fetch'; -export * from './fetch-client'; diff --git a/packages/feature-fetch/tsconfig.json b/packages/feature-fetch/tsconfig.json index 71fff84f..c18f5253 100644 --- a/packages/feature-fetch/tsconfig.json +++ b/packages/feature-fetch/tsconfig.json @@ -6,5 +6,5 @@ "declarationDir": "./dist/types" }, "include": ["src"], - "exclude": ["**/__tests__/*", "**/*.test.ts"] + "exclude": ["**/__tests__/**", "**/*.test.ts", "**/*.test-d.ts"] } diff --git a/packages/feature-form/README.md b/packages/feature-form/README.md index a0a236c6..2326c257 100644 --- a/packages/feature-form/README.md +++ b/packages/feature-form/README.md @@ -17,98 +17,411 @@

-> Status: Experimental +`feature-form` is framework-agnostic form state built from reactive fields. It uses Standard Schema validators directly, lets each field choose when validation runs, and keeps submit handling on the form object. -`feature-form` is a straightforward, typesafe, and feature-based form library. +- Validate with Zod, Valibot, ArkType, or any [Standard Schema](https://github.com/standard-schema/standard-schema) validator without resolver packages +- Tune validation per field: stay quiet while typing, validate on blur or submit, then revalidate on change +- Subscribe only to the state a view renders: value, status, touched, submitted, dirty +- Add typed behavior with `.with()` features, including built-in dirty tracking -- **Lightweight & Tree Shakable**: Function-based and modular design -- **Fast**: Optimized for speed and efficiency, ensuring smooth user experience -- **Modular & Extendable**: Easily extendable with features -- **Typesafe**: Build with TypeScript for strong type safety -- **Standalone**: Zero external dependencies, ensuring ease of use in various environments +```ts +import { createForm, dirtyFeature } from 'feature-form'; +import * as z from 'zod'; -### 📚 Examples +const $form = createForm({ + fields: { + email: { + defaultValue: '', + validator: z.string().email(), + validateOn: ['blur', 'submit'], // no errors while typing + revalidateOn: ['change', 'submit'] // revalidate after first submit + }, + password: { + defaultValue: '', + validator: z.string().min(8), + validateOn: ['touched', 'submit'], // first blur, subsequent changes, and submit + revalidateOn: ['change', 'submit'] + } + }, + onValidSubmit: (data) => saveAccount(data) +}).with(dirtyFeature()); -- [ReactJs Basic](https://github.com/builder-group/community/tree/develop/examples/feature-form/react/basic) ([Code Sandbox](https://codesandbox.io/p/sandbox/basic-c4gd3t)) +const unbind = $form.fields.email.status.listen(({ value }) => { + if (value.type === 'invalid') showError(value.errors[0].message); + if (value.type === 'valid') clearError(); +}); -### 🌟 Motivation +$form.fields.email.set('not-an-email'); +$form.fields.email.blur(); // validates email and notifies the status listener -Create a typesafe, straightforward, and lightweight form library designed to be modular and extendable with features. +await $form.submit(); // runs every validator and calls onValidSubmit when valid +$form.isDirty.get(); // true if any field differs from its default value +unbind(); +``` -### ⚖️ Alternatives +## Install -- [react-hook-form](https://github.com/react-hook-form/react-hook-form) +```bash +npm install feature-form +``` -## 📖 Usage +## Usage -```tsx +Fields are reactive states. Subscribe to status changes to wire validation feedback directly into your UI: + +```ts import { createForm } from 'feature-form'; -import { useForm } from 'feature-react/form'; -import * as v from 'valibot'; -import { vValidator } from 'validation-adapters/valibot'; -import { zValidator } from 'validation-adapters/zod'; import * as z from 'zod'; -interface TFormData { - name: string; - email: string; -} +const $form = createForm({ + fields: { + email: { defaultValue: '', validator: z.string().email() } + }, + onValidSubmit: (data) => save(data) +}); + +$form.fields.email.status.listen(({ value }) => { + if (value.type === 'invalid') { + console.log(value.errors[0].message); + } +}); -const $form = createForm({ +$form.fields.email.set('not-an-email'); // set() alone does not validate (validateOn defaults to ['submit']) +await $form.submit(); // triggers validation, listener fires with error +``` + +Control when validation fires with per-field triggers. Keep errors quiet before the user has finished, then switch to immediate feedback once they have tried to submit: + +```ts +const $form = createForm({ fields: { - name: { - validator: zValidator(z.string().min(2).max(10)), - defaultValue: '' - }, email: { - validator: vValidator(v.pipe(v.string(), v.email())), - defaultValue: '' + defaultValue: '', + validator: z.string().email(), + validateOn: ['blur', 'submit'], // quiet while typing, fires on blur + revalidateOn: ['change', 'submit'] // immediate feedback after first submit } + } +}); +``` + +## Form + +### `createForm(config)` + +Creates a form and returns it as a feature host. Each key in `fields` becomes a reactive `TFormField` with validation, blur tracking, and status. + +```ts +const $form = createForm({ + fields: { + age: { defaultValue: 0 }, + username: { + defaultValue: '', + validator: z.string().min(3), + validateOn: ['submit', 'blur'], + revalidateOn: ['submit', 'change', 'blur'] + } + } +}); +``` + +| Option | Default | Description | +| ------------------ | ---------------------- | -------------------------------------------------------------------------------------------------------- | +| `fields` | required | Field configs or pre-built fields keyed by form data property. | +| `validator` | none | Form-level validator for cross-field constraints. | +| `validateOn` | `['submit']` | Default triggers for the form validator and fields that do not override them. | +| `revalidateOn` | `['submit', 'change']` | Default revalidation triggers for the form validator and fields that do not override them. | +| `collectErrorMode` | `'firstError'` | Default Standard Schema error collection mode for the form validator and fields that do not override it. | +| `onValidSubmit` | none | Called on every valid submit. Per-call overrides can be passed to `submit()`. | +| `onInvalidSubmit` | none | Called on every invalid submit. Per-call overrides can be passed to `submit()`. | + +**Field config options** + +| Option | Default | Description | +| ------------------ | ---------------------- | -------------------------------------------------------------------------------- | +| `defaultValue` | required | Initial value and reset target. | +| `validator` | none | Field-level validator. | +| `validateOn` | `['submit']` | Triggers that run the validator before the first submit. | +| `revalidateOn` | `['submit', 'change']` | Triggers that run the validator after the first submit. | +| `collectErrorMode` | `'firstError'` | `'firstError'` keeps the first Standard Schema issue; `'all'` keeps every issue. | + +### `submit()` / `validate()` / `reset()` + +```ts +const isValid = await $form.submit(); + +await $form.submit({ + onValidSubmit: (data) => save(data), + onInvalidSubmit: (errors) => showErrors(errors), + updateDefaultValues: true, // treat submitted values as new reset baseline + context: { event } // passed through to onValidSubmit / onInvalidSubmit callbacks +}); + +const unbind = $form.onValidSubmit((data) => save(data)); +unbind(); + +const isValid = await $form.validate(); // runs all validators without submitting + +$form.reset(); // resets values, validation status, isTouched, and isSubmitted +``` + +`submit()` runs validators configured for the submit trigger. All matching field validators and the form validator run together. No failing validator prevents the others from completing, so submit gives you a complete error picture. Returns `true` if the form was valid, `false` otherwise. Persistent callbacks registered via `onValidSubmit()` / `onInvalidSubmit()` and per-call options passed to `submit()` both run in parallel. + +`validate()` runs all validators the same way but has no submit side effects: it updates validation state, but does not set `isSubmitted`, does not fire `onValidSubmit` or `onInvalidSubmit`, and does not update default values. + +`reset()` restores all fields to their `defaultValue` and clears `status`, `isTouched`, and `isSubmitted` on both the form and every field. Any in-flight async validation is cancelled so stale results cannot update field status. + +### `getData()` / `getValidData()` / `getErrors()` + +```ts +const data = $form.getData(); // current field values, regardless of validity +const data = $form.getValidData(); // current field values, or null if form status is not 'valid' + +const errors = $form.getErrors(); +errors.fields; // invalid fields and form-level errors whose path points at a field +errors.form; // pathless or unknown-path form-level errors +``` + +### `fields` / `getField(key)` + +```ts +$form.fields.name; // TFormField +$form.getField('name'); // same, useful when the key is dynamic +``` + +### Reactive states + +| State | Type | Description | +| -------------- | ------------------- | ------------------------------------------------------------ | +| `status` | `TValidationStatus` | Aggregate form status: `unvalidated`, `valid`, or `invalid`. | +| `isValidating` | `TState` | True while the latest validation run is pending. | +| `isSubmitted` | `TState` | True after the first submit attempt. | +| `isSubmitting` | `TState` | True while `submit()` is in progress. | + +### Validation triggers + +`validateOn` controls which events run the validator before the first submit. `revalidateOn` controls the same after the first submit. + +| Trigger | When it fires | +| ----------- | ------------------------------------------------------------------------------------------------------------------- | +| `'submit'` | On `submit()`. | +| `'blur'` | On every `blur()`. | +| `'change'` | On every value change via `set()`. | +| `'touched'` | On the first `blur()`, and on every subsequent `set()` once the field has been touched. Only valid in `validateOn`. | + +The `'touched'` trigger covers the "validate once the user has interacted" pattern: no validation fires until the first blur, then validation follows every change from that point on. In `revalidateOn` use `'blur'` instead. + +```ts +// validate on blur before submit, revalidate on every change after +const $form = createForm({ + fields: { + email: { + defaultValue: '', + validator: z.string().email(), + validateOn: ['blur', 'submit'], + revalidateOn: ['change', 'submit'] + } + } +}); +``` + +### Form-level validator + +Use `validator` for cross-field constraints. `validateOn`, `revalidateOn`, and `collectErrorMode` are shared defaults for the form validator and field validators unless a field overrides them. + +The library routes form-level validator errors by path. An issue that points at a field appears in `getErrors().fields` and in the matching field's `status`, with the field key stripped from the path. Pathless issues and paths with no matching field appear in `getErrors().form`. + +```ts +const $form = createForm({ + fields: { + password: { defaultValue: '' }, + confirm: { defaultValue: '' } }, - onValidSubmit: (data) => console.log('ValidSubmit', data), - onInvalidSubmit: (errors) => console.log('InvalidSubmit', errors) + validator: z + .object({ password: z.string(), confirm: z.string() }) + .refine((d) => d.password === d.confirm, { + message: 'Passwords do not match', + path: ['confirm'] + }), + validateOn: ['submit'], + revalidateOn: ['change', 'submit'] }); +``` + +### Validation status -export const MyFormComponent: React.FC = () => { - const { handleSubmit, register, status } = useForm($form); +Both `form.status` and `field.status` are discriminated unions: - return ( -
-
- - - {status('name').error && {status('name').error}} -
-
- - - {status('email').error && {status('email').error}} -
- -
- ); -}; +```ts +const status = $form.fields.email.status.get(); + +if (status.type === 'invalid') { + status.errors; // readonly TValidationError[] + status.errors[0].message; // string + status.errors[0].path; // validator path, e.g. ['address', 'city'] +} ``` -### Validators ([`validation-adapters`](https://github.com/builder-group/community/tree/develop/packages/validation-adapters)) +| `type` | Meaning | +| --------------- | ----------------------------------------------- | +| `'unvalidated'` | No validator has run yet. | +| `'valid'` | Last run passed. | +| `'invalid'` | Last run failed; `errors` contains the details. | -`feature-form` supports various validators such as [Zod](https://github.com/colinhacks/zod), [Yup](https://github.com/jquense/yup), [Valibot](https://github.com/fabian-hiller/valibot) and more. +### Validators + +Any [Standard Schema](https://github.com/standard-schema/standard-schema) compatible validator works directly without an adapter. ```ts import * as v from 'valibot'; -import { vValidator } from 'validation-adapters/valibot'; -import { zValidator } from 'validation-adapters/zod'; import * as z from 'zod'; -const zodNameValidator = zValidator( - z - .string() - .min(2) - .max(10) - .regex(/^([^0-9]*)$/) -); +const zodValidator = z.string().min(2).max(50); +const valibotValidator = v.pipe(v.string(), v.minLength(2), v.maxLength(50)); +``` + +For custom validators, implement the `StandardSchemaV1` interface from [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema). + +Validators run for validation only. If a schema transforms or coerces output values, the parsed output does not write back into the field state. + +## Field + +Each entry in `form.fields` is a `TFormField`: a full `feature-state` state with form-specific methods added. + +### `createFormField(defaultValue, options?)` + +Creates a field independently of any form. Use this for a shared search input or a field conditionally composed into different forms. Pass the resulting `TFormField` directly into the `createForm` fields config. + +```ts +import { createFormField } from 'feature-form'; + +const $name = createFormField('', { + key: 'name', + validator: z.string().min(2), + validateOn: ['blur'], + revalidateOn: ['change'] +}); + +$name.set('Alice'); +await $name.validate(); +``` + +### `set()` / `get()` / `value` + +```ts +$form.fields.name.set('Alice'); +$form.fields.name.get(); // 'Alice' +$form.fields.name.value; // 'Alice' +$form.fields.name.value = 'Bob'; // same as set('Bob') +``` + +### `blur()` / `validate()` / `reset()` -const valibotNameValidator = vValidator( - v.pipe(v.string(), v.minLength(2), v.maxLength(10), v.regex(/^([^0-9]*)$/)) -); +```ts +$form.fields.name.blur(); // marks touched, runs blur/touched validators +await $form.fields.name.validate(); // runs the field validator, returns true if valid +$form.fields.name.reset(); // resets value, touched, submitted, and status ``` + +### `onBlur(callback)` + +```ts +const unbind = $form.fields.name.onBlur(({ wasTouched }) => { + if (!wasTouched) { + // first time this field was blurred + } +}); + +unbind(); +``` + +### Reactive states + +| State | Type | Description | +| -------------- | ------------------- | ------------------------------------------------------------------------------------ | +| `status` | `TValidationStatus` | Field display status, including field validator errors and routed form-level errors. | +| `isTouched` | `TState` | True after the field has been blurred at least once. | +| `isSubmitted` | `TState` | True after the form has been submitted. | +| `isValidating` | `TState` | True while the field validator is running. | + +### `defaultValue` / `key` + +```ts +$form.fields.name.defaultValue; // the value used when reset() is called +$form.fields.name.key; // 'name', used in validation error paths +``` + +## Built-in Features + +Features are installed via `.with()` and extend the form with new methods. + +### `dirtyFeature()` + +Adds `isDirty`, `dirtyFields`, and `resetDirty()`. Tracks whether any field value has changed from its default value using deep structural equality, not reference equality. + +```ts +import { dirtyFeature } from 'feature-form'; + +const $form = createForm({ + fields: { + name: { defaultValue: 'Alice' }, + email: { defaultValue: 'alice@example.com' } + } +}).with(dirtyFeature()); + +$form.fields.name.set('Bob'); + +$form.isDirty.get(); // true +$form.dirtyFields.get(); // { name: true, email: false } + +$form.resetDirty(); // updates each field's defaultValue to its current value +$form.isDirty.get(); // false +``` + +`isDirty` and `dirtyFields` are reactive states. `resetDirty()` makes the current values the new baseline without clearing them. When `submit({ updateDefaultValues: true })` succeeds, dirty state clears automatically. + +## Extending with Features + +Forms are `feature-core` feature hosts. Add behavior with `.with(yourFeature())`. See the [feature-core README](https://github.com/builder-group/community/tree/develop/packages/feature-core) for a full guide on `defineFeature()`, dependency declaration, and the feature model. + +## Examples + +- [React Basic](https://github.com/builder-group/community/tree/develop/examples/feature-form/react/basic) ([CodeSandbox](https://codesandbox.io/p/sandbox/basic-c4gd3t)) + +## FAQ + +### How does it compare to react-hook-form, Formik, and TanStack Form? + +`feature-form` puts the form object outside the UI framework. That makes it closer to a reactive model than a React hook. Use it when you want one form core that can be reused across React, Vue, Svelte, tests, and plain JavaScript. + +- [react-hook-form](https://github.com/react-hook-form/react-hook-form): strong React-first uncontrolled form library with validation resolvers +- [Formik](https://formik.org): mature controlled React form library +- [TanStack Form](https://tanstack.com/form): framework-agnostic form library with official framework adapters + +### Does it work outside React? + +Yes. The form and field objects are plain JavaScript. Fields are reactive states from `feature-state`, which has no framework dependency. Subscribe with `.listen()` in Vue, Svelte, vanilla JS, or any runtime. The [feature-react](https://github.com/builder-group/community/tree/develop/packages/feature-react) package provides React hooks if you want them. + +### Why separate `validateOn` and `revalidateOn`? + +Before the first submit, aggressive validation (e.g. `'change'`) can feel intrusive because the user has not finished yet. After submit they expect immediate feedback as they correct errors. Keeping the phases separate lets you configure each independently without a single `mode` flag that tries to cover both. + +### Does it support async validators? + +Yes. Any Standard Schema validator can be async. `submit()` and `validate()` are both async and await all validators. The `isValidating` state on both the form and each field reflects whether a run is in progress. In-flight async runs are cancelled (by run ID) when `reset()` is called, so stale results never overwrite reset state. + +### Do all field validators run on submit, or does it stop at the first error? + +All validators configured for the submit trigger run together. No failing validator skips the others, so submit always gives a complete picture of every validation error. + +### What does `getErrors()` return before any validation has run? + +Only fields with `'invalid'` status or form-level path errors appear in `errors.fields`. Unvalidated fields are omitted. `errors.form` contains only pathless form-level errors. + +### Can I register multiple `onValidSubmit` callbacks? + +Yes. Callbacks registered via `form.onValidSubmit(callback)` are additive. On submit, all persistent callbacks and any per-call callback passed to `submit({ onValidSubmit })` run in parallel. There is no guaranteed order between them. + +### When should I use `createFormField` instead of defining fields inside `createForm`? + +Use `createFormField` when a field needs to exist independently of any specific form: a shared search input, or a field conditionally composed into different forms. Pass the resulting `TFormField` directly into the `createForm` fields config. diff --git a/packages/feature-form/package.json b/packages/feature-form/package.json index b72039eb..280a8b30 100644 --- a/packages/feature-form/package.json +++ b/packages/feature-form/package.json @@ -1,9 +1,24 @@ { "name": "feature-form", - "version": "0.0.60", + "version": "0.1.0-beta.1", "private": false, - "description": "Straightforward, typesafe, and feature-based form library for ReactJs", - "keywords": [], + "description": "Framework-agnostic form state with reactive fields and per-field validation timing.", + "keywords": [ + "form", + "form-state", + "form-validation", + "validation", + "validation-timing", + "standard-schema", + "zod", + "valibot", + "arktype", + "reactive", + "reactive-form", + "dirty-state", + "framework-agnostic", + "typescript" + ], "homepage": "https://builder.group/?utm_source=package-json", "bugs": { "url": "https://github.com/builder-group/community/issues" @@ -35,10 +50,9 @@ "update:latest": "pnpm update --latest" }, "dependencies": { - "@blgc/types": "workspace:*", - "@blgc/utils": "workspace:*", - "feature-state": "workspace:*", - "validation-adapter": "workspace:*" + "@standard-schema/spec": "^1.1.0", + "feature-core": "workspace:*", + "feature-state": "workspace:*" }, "devDependencies": { "@blgc/config": "workspace:*", diff --git a/packages/feature-form/src/create-form-field.test.ts b/packages/feature-form/src/create-form-field.test.ts new file mode 100644 index 00000000..eeb4dd84 --- /dev/null +++ b/packages/feature-form/src/create-form-field.test.ts @@ -0,0 +1,193 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import { describe, expect, expectTypeOf, it } from 'vitest'; +import { createFormField, isFormField } from './create-form-field'; +import type { TFormField } from './types'; + +describe('createFormField function', () => { + describe('types', () => { + it('should infer field state and callback types', () => { + const field = createFormField('Alice', { + key: 'name', + validator: createStandardSchema((value) => ({ value })) + }); + + expectTypeOf(field).toEqualTypeOf>(); + expectTypeOf(field.get()).toEqualTypeOf(); + expectTypeOf(field.defaultValue).toEqualTypeOf(); + expectTypeOf(field.validate()).toEqualTypeOf>(); + expectTypeOf(field.onBlur) + .parameter(0) + .toEqualTypeOf<(context: { wasTouched: boolean }) => void>(); + }); + }); + + describe('metadata', () => { + it('should create a form field state with field metadata', () => { + // Prepare + const field = createFormField('Alice', { key: 'name' }); + + // Act + field.set('Bob'); + + // Assert + expect(isFormField(field)).toBe(true); + expect(field.key).toBe('name'); + expect(field.defaultValue).toBe('Alice'); + expect(field.get()).toBe('Bob'); + expect(field.status.get()).toEqual({ type: 'valid' }); + }); + }); + + describe('validate method', () => { + it('should validate the current value', async () => { + // Prepare + const field = createFormField('', { + key: 'name', + validator: createStandardSchema((value) => + value.length > 0 ? { value } : { issues: [{ message: 'Required' }] } + ) + }); + + // Act + const isValid = await field.validate(); + + // Assert + expect(isValid).toBe(false); + expect(field.status.get()).toEqual({ + type: 'invalid', + errors: [{ message: 'Required', path: undefined }] + }); + }); + + it('should validate on change when configured', async () => { + // Prepare + const field = createFormField('Alice', { + key: 'name', + validateOn: ['change'], + validator: createStandardSchema((value) => + value.length > 0 ? { value } : { issues: [{ message: 'Required' }] } + ) + }); + + // Act + field.set(''); + await waitForQueuedValidation(); + + // Assert + expect(field.status.get()).toEqual({ + type: 'invalid', + errors: [{ message: 'Required', path: undefined }] + }); + }); + + it('should not notify status listeners when validation returns the same status', async () => { + // Prepare + const field = createFormField('', { + key: 'name', + validateOn: ['change'], + validator: createStandardSchema(() => ({ issues: [{ message: 'Required' }] })) + }); + await field.validate(); + let statusChangeCount = 0; + field.status.listen(() => { + statusChangeCount++; + }); + + // Act + field.set('A'); + await waitForQueuedValidation(); + + // Assert + expect(statusChangeCount).toBe(0); + }); + + it('should validate on first blur when touched is configured', async () => { + // Prepare + let validationCount = 0; + const field = createFormField('', { + key: 'name', + validateOn: ['touched'], + validator: createStandardSchema((value) => { + validationCount++; + return value.length > 0 ? { value } : { issues: [{ message: 'Required' }] }; + }) + }); + + // Act + field.blur(); + await waitForQueuedValidation(); + field.blur(); + await waitForQueuedValidation(); + + // Assert + expect(field.isTouched.get()).toBe(true); + expect(validationCount).toBe(1); + expect(field.status.get()).toEqual({ + type: 'invalid', + errors: [{ message: 'Required', path: undefined }] + }); + }); + }); + + describe('blur method', () => { + it('should notify blur callbacks with the previous touched state', () => { + // Prepare + const field = createFormField('', { key: 'name' }); + const wasTouchedValues: boolean[] = []; + field.onBlur(({ wasTouched }) => { + wasTouchedValues.push(wasTouched); + }); + + // Act + field.blur(); + field.blur(); + + // Assert + expect(wasTouchedValues).toEqual([false, true]); + }); + }); + + describe('reset method', () => { + it('should reset value and lifecycle state', async () => { + // Prepare + const field = createFormField('', { + key: 'name', + validateOn: ['blur'], + validator: createStandardSchema(() => ({ issues: [{ message: 'Required' }] })) + }); + field.set('Bob'); + field.blur(); + await waitForQueuedValidation(); + + // Act + field.reset(); + + // Assert + expect(field.get()).toBe(''); + expect(field.isTouched.get()).toBe(false); + expect(field.isSubmitted.get()).toBe(false); + expect(field.isValidating.get()).toBe(false); + expect(field.status.get()).toEqual({ type: 'unvalidated' }); + }); + }); +}); + +function createStandardSchema( + validate: (value: GValue) => StandardSchemaV1.Result +): StandardSchemaV1 { + return { + '~standard': { + version: 1, + vendor: 'feature-form-test', + validate(value) { + return validate(value as GValue); + } + } + }; +} + +// Listener-triggered validation is fire-and-forget and crosses two async function continuations +async function waitForQueuedValidation(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} diff --git a/packages/feature-form/src/create-form-field.ts b/packages/feature-form/src/create-form-field.ts new file mode 100644 index 00000000..8a5f93bd --- /dev/null +++ b/packages/feature-form/src/create-form-field.ts @@ -0,0 +1,220 @@ +import { defineFeature, hasFeature } from 'feature-core'; +import { createState, isEqualFeature, type TStateBase } from 'feature-state'; +import { areValidationStatusesEqual, deepCopy, validateStandardSchema } from './lib'; +import { + type TFormField, + type TFormFieldFeature, + type TFormFieldValidation, + type TFormFieldValidationConfig, + type TFormFieldValidator, + type TValidationStatusValue +} from './types'; + +/** + * Creates a standalone reactive form field. + * + * Use this when a field should exist independently of a specific form, for example a shared + * search input or a field composed into different forms. Pass the result directly into + * `createForm`'s `fields` config. + */ +export function createFormField( + defaultValue: GValue, + config: TCreateFormFieldConfig +): TFormField { + const { + key, + validator, + validateOn = ['submit'], + revalidateOn = ['submit', 'change'], + collectErrorMode = 'firstError' + } = config; + const formField = createState(defaultValue).with( + formFieldFeature({ + key, + validation: + validator == null + ? undefined + : { + validator, + config: { + validateOn, + revalidateOn, + collectErrorMode + } + } + }) + ); + + registerFormFieldListeners(formField); + + return formField; +} + +export interface TCreateFormFieldConfig extends Partial { + /** Stable field key used in validation contexts and form error paths. */ + key: string; + /** Optional field-level Standard Schema validator. */ + validator?: TFormFieldValidator; +} + +export function isFormField(value: unknown): value is TFormField { + return hasFeature>(value, 'form-field'); +} + +function formFieldFeature( + config: TFormFieldFeatureConfig +): TFormFieldFeature { + const { key, validation } = config; + + return defineFeature>({ + key: 'form-field', + install(state: TStateBase) { + // Note: No validator means there is no pending validation work + const initialFieldValidatorStatus: TValidationStatusValue = + validation == null ? { type: 'valid' } : { type: 'unvalidated' }; + + return { + _validation: validation, + _validationRunId: 0, + _fieldValidatorStatus: initialFieldValidatorStatus, + _formValidatorErrors: [], + _callbacks: { + blur: [] + }, + key, + defaultValue: deepCopy(state._v), + isTouched: createState(false), + isSubmitted: createState(false), + isValidating: createState(false), + status: createState(initialFieldValidatorStatus).with( + isEqualFeature(areValidationStatusesEqual) + ), + _applyFormValidatorErrors(this: TFormField, errors) { + this._formValidatorErrors = errors; + this.status.set(getFormFieldStatus(this)); + }, + async validate(this: TFormField) { + if (this._validation == null) { + this._fieldValidatorStatus = { type: 'valid' }; + this.status.set(getFormFieldStatus(this)); + return this.status.get().type === 'valid'; + } + + const validationRunId = ++this._validationRunId; + let status: TValidationStatusValue = { type: 'valid' }; + + this.isValidating.set(true); + try { + status = await validateStandardSchema( + this._validation.validator, + this.get(), + this._validation.config.collectErrorMode + ); + } catch (error) { + status = { + type: 'invalid', + errors: [ + { + message: error instanceof Error ? error.message : String(error) + } + ] + }; + } finally { + if (validationRunId === this._validationRunId) { + this.isValidating.set(false); + } + } + + if (validationRunId !== this._validationRunId) { + return this.status.get().type === 'valid'; + } + + this._fieldValidatorStatus = status; + this.status.set(getFormFieldStatus(this)); + return this.status.get().type === 'valid'; + }, + onBlur(this: TFormField, callback) { + this._callbacks.blur.push(callback); + + return () => { + const index = this._callbacks.blur.indexOf(callback); + if (index !== -1) { + this._callbacks.blur.splice(index, 1); + } + }; + }, + blur(this: TFormField) { + const wasTouched = this.isTouched.get(); + // Note: 'touched' validates on the first blur only; 'blur' validates on every blur + const shouldValidateOnBlur = + this._validation != null && + (this.isSubmitted.get() + ? this._validation.config.revalidateOn.includes('blur') + : this._validation.config.validateOn.includes('blur') || + (this._validation.config.validateOn.includes('touched') && !wasTouched)); + if (shouldValidateOnBlur) { + void this.validate(); + } + + this.isTouched.set(true); + for (const callback of this._callbacks.blur) { + callback({ wasTouched }); + } + }, + reset(this: TFormField) { + this.set(deepCopy(this.defaultValue), { + listenerContext: { source: formFieldResetSourceKey } + }); + this._validationRunId++; + this.isTouched.set(false); + this.isSubmitted.set(false); + this.isValidating.set(false); + this._formValidatorErrors = []; + this._fieldValidatorStatus = + this._validation == null ? { type: 'valid' } : { type: 'unvalidated' }; + this.status.set(getFormFieldStatus(this)); + } + }; + } + }); +} + +/** Identifies listener events caused by field reset so validation can ignore reset changes. */ +export const formFieldResetSourceKey = 'formFieldReset'; + +interface TFormFieldFeatureConfig { + key: string; + validation?: TFormFieldValidation; +} + +function registerFormFieldListeners(formField: TFormField): void { + formField.listen(({ source }) => { + if (source === formFieldResetSourceKey) { + return; + } + + // Note: 'touched' validates pre-submit changes after the field has been blurred once + const shouldValidateOnChange = + formField._validation != null && + (formField.isSubmitted.get() + ? formField._validation.config.revalidateOn.includes('change') + : formField._validation.config.validateOn.includes('change') || + (formField._validation.config.validateOn.includes('touched') && + formField.isTouched.get())); + if (shouldValidateOnChange) { + void formField.validate(); + } + }); +} + +function getFormFieldStatus(formField: TFormField): TValidationStatusValue { + const errors = + formField._fieldValidatorStatus.type === 'invalid' + ? [...formField._fieldValidatorStatus.errors, ...formField._formValidatorErrors] + : formField._formValidatorErrors; + if (errors.length > 0) { + return { type: 'invalid', errors }; + } + + return formField._fieldValidatorStatus; +} diff --git a/packages/feature-form/src/create-form.test.ts b/packages/feature-form/src/create-form.test.ts index 180929cc..7d4c26fd 100644 --- a/packages/feature-form/src/create-form.test.ts +++ b/packages/feature-form/src/create-form.test.ts @@ -1,47 +1,514 @@ -import { TState, withUndo } from 'feature-state'; -import { createValidator } from 'validation-adapter'; -import { describe, expect, it } from 'vitest'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import { describe, expect, expectTypeOf, it } from 'vitest'; import { createForm } from './create-form'; -import { fromValidator } from './helper'; -import { TFormFieldStateFeature } from './types'; +import { createFormField } from './create-form-field'; +import type { TForm, TFormField } from './types'; describe('createForm function', () => { - it('should have correct types', async () => { - const $form = createForm({ - fields: { - item5: fromValidator( - createValidator([ - { - key: 'length', - validate: (cx) => { - if (typeof cx.value === 'string' && cx.value.length < 5) { - cx.registerError({ code: 'LENGTH' }); + describe('types', () => { + it('should infer form data from field defaults', () => { + const form = createForm({ + fields: { + name: { defaultValue: 'Alice' }, + age: { defaultValue: 42 } + } + }); + + expectTypeOf(form).toExtend>(); + expectTypeOf(form.fields.name).toExtend>(); + expectTypeOf(form.fields.age).toExtend>(); + expectTypeOf(form.getData()).toEqualTypeOf>(); + expectTypeOf(form.getValidData()).toEqualTypeOf | null>(); + }); + + it('should infer form data from existing form fields', () => { + const form = createForm({ + fields: { + name: createFormField('Alice', { key: 'name' }), + age: createFormField(42, { key: 'age' }) + } + }); + + expectTypeOf(form).toExtend>(); + expectTypeOf(form.fields.name).toExtend>(); + expectTypeOf(form.fields.age).toExtend>(); + expectTypeOf(form.getData()).toEqualTypeOf>(); + }); + }); + + describe('fields', () => { + it('should create form fields and return current data', () => { + // Prepare + const form = createForm({ + fields: { + name: { defaultValue: 'Alice' }, + email: { defaultValue: 'alice@example.com' } + } + }); + + // Act + form.getField('name').set('Bob'); + + // Assert + expect(form.fields.name).toBe(form.getField('name')); + expect(form.getData()).toEqual({ + name: 'Bob', + email: 'alice@example.com' + }); + expect(form.getValidData()).toEqual({ + name: 'Bob', + email: 'alice@example.com' + }); + expect(form.status.get()).toEqual({ type: 'valid' }); + }); + }); + + describe('submit method', () => { + it('should submit valid data to valid submit callbacks', async () => { + // Prepare + const submittedData: Array> = []; + const form = createForm({ + fields: { + name: { + defaultValue: '', + validator: createRequiredStringSchema() + }, + email: { defaultValue: 'alice@example.com' } + }, + onValidSubmit(data) { + submittedData.push(data); + } + }); + form.fields.name.set('Bob'); + + // Act + const isValid = await form.submit(); + + // Assert + expect(isValid).toBe(true); + expect(submittedData).toEqual([{ name: 'Bob', email: 'alice@example.com' }]); + expect(form.isSubmitted.get()).toBe(true); + }); + + it('should update default values when configured', async () => { + // Prepare + const form = createForm({ + fields: { + name: { defaultValue: 'Alice' }, + email: { defaultValue: 'alice@example.com' } + } + }); + form.fields.name.set('Bob'); + + // Act + const isValid = await form.submit({ updateDefaultValues: true }); + form.fields.name.set('Charlie'); + form.reset(); + + // Assert + expect(isValid).toBe(true); + expect(form.fields.name.get()).toBe('Bob'); + expect(form.isSubmitted.get()).toBe(false); + }); + + it('should submit invalid field errors with local and aggregate paths', async () => { + // Prepare + interface TScheduleFormData { + conditions: Array<{ timeOfDayMs?: number }>; + } + const invalidSubmissions: unknown[] = []; + const form = createForm({ + fields: { + conditions: { + defaultValue: [{ timeOfDayMs: undefined }], + validator: createStandardSchema>(() => ({ + issues: [ + { + message: 'Required', + path: [0, 'timeOfDayMs'] } - } - } - ]), - { - defaultValue: '' + ] + })) } - ) - } + }, + onInvalidSubmit(errors) { + invalidSubmissions.push(errors); + } + }); + + // Act + const isValid = await form.submit(); + + // Assert + expect(isValid).toBe(false); + expect(form.getErrors().fields.conditions).toEqual([ + { message: 'Required', path: [0, 'timeOfDayMs'] } + ]); + expect(form.status.get()).toEqual({ + type: 'invalid', + errors: [{ message: 'Required', path: [0, 'timeOfDayMs'] }] + }); + expect(invalidSubmissions).toHaveLength(1); + expect(form.isSubmitted.get()).toBe(true); + }); + + it('should respect submit validation triggers', async () => { + // Prepare + let validationCount = 0; + const form = createForm({ + fields: { + name: { + defaultValue: '', + validateOn: ['blur'], + revalidateOn: ['blur'], + validator: createStandardSchema((value) => { + validationCount++; + return value.length > 0 ? { value } : { issues: [{ message: 'Required' }] }; + }) + }, + email: { defaultValue: 'alice@example.com' } + } + }); + + // Act + const isValid = await form.submit(); + + // Assert + expect(isValid).toBe(false); + expect(validationCount).toBe(0); + expect(form.status.get()).toEqual({ type: 'unvalidated' }); + }); + + it('should revalidate on submit by default', async () => { + // Prepare + const form = createForm({ + fields: { + name: { + defaultValue: 'Alice', + validator: createRequiredStringSchema() + }, + email: { defaultValue: 'alice@example.com' } + } + }); + await form.submit(); + form.fields.name.set(''); + + // Act + const isValid = await form.submit(); + + // Assert + expect(isValid).toBe(false); + expect(form.getErrors().fields.name).toEqual([{ message: 'Required', path: undefined }]); + }); + }); + + describe('validation', () => { + describe('field error routing', () => { + it('should route form-level field errors to matching fields', async () => { + // Prepare + const form = createForm({ + fields: { + password: { defaultValue: 'secret' }, + confirm: { defaultValue: 'different' } + }, + validator: createStandardSchema((value) => + value.password === value.confirm + ? { value } + : { issues: [{ message: 'Passwords do not match', path: ['confirm'] }] } + ) + }); + + // Act + const isValid = await form.validate(); + + // Assert + expect(isValid).toBe(false); + expect(form.getErrors()).toEqual({ + fields: { + confirm: [{ message: 'Passwords do not match', path: undefined }] + }, + form: [] + }); + }); + + it('should clear routed form-level field errors after the form validator passes', async () => { + // Prepare + const form = createForm({ + fields: { + password: { defaultValue: 'secret' }, + confirm: { defaultValue: 'different' } + }, + validator: createStandardSchema((value) => + value.password === value.confirm + ? { value } + : { issues: [{ message: 'Passwords do not match', path: ['confirm'] }] } + ) + }); + await form.validate(); + + // Act + form.fields.confirm.set('secret'); + const isValid = await form.validate(); + + // Assert + expect(isValid).toBe(true); + expect(form.getErrors()).toEqual({ fields: {}, form: [] }); + }); + + it('should keep field validator errors when routed form-level errors clear', async () => { + // Prepare + const form = createForm({ + fields: { + name: { + defaultValue: '', + validator: createRequiredStringSchema('Field required') + }, + email: { defaultValue: 'invalid@example.com' } + }, + validator: createStandardSchema((value) => + value.email === 'alice@example.com' + ? { value } + : { issues: [{ message: 'Form email error', path: ['name'] }] } + ) + }); + await form.validate(); + + // Act + form.fields.email.set('alice@example.com'); + await form.validate(); + + // Assert + expect(form.fields.name.status.get()).toEqual({ + type: 'invalid', + errors: [{ message: 'Field required', path: undefined }] + }); + }); + + it('should keep pathless and unknown form-level errors on the form', async () => { + // Prepare + const form = createForm({ + fields: { + name: { defaultValue: '' }, + email: { defaultValue: 'alice@example.com' } + }, + collectErrorMode: 'all', + validator: createStandardSchema(() => ({ + issues: [ + { message: 'General form error' }, + { message: 'Unknown field error', path: ['unknown'] } + ] + })) + }); + + // Act + await form.validate(); + + // Assert + expect(form.getErrors()).toEqual({ + fields: {}, + form: [ + { message: 'General form error', path: undefined }, + { message: 'Unknown field error', path: ['unknown'] } + ] + }); + }); + }); + + describe('validation triggers', () => { + it('should validate form-level constraints on field change when configured', async () => { + // Prepare + const form = createForm({ + fields: { + name: { defaultValue: '' }, + email: { defaultValue: 'alice@example.com' } + }, + validateOn: ['change'], + validator: createStandardSchema((value) => + value.name.length > 0 + ? { value } + : { issues: [{ message: 'Name required', path: ['name'] }] } + ) + }); + + // Act + form.fields.name.set('Bob'); + await waitForQueuedValidation(); + form.fields.name.set(''); + await waitForQueuedValidation(); + + // Assert + expect(form.status.get()).toEqual({ + type: 'invalid', + errors: [{ message: 'Name required', path: undefined }] + }); + }); + + it('should revalidate touched form-level constraints on field change', async () => { + // Prepare + const form = createForm({ + fields: { + name: { defaultValue: '' }, + email: { defaultValue: 'alice@example.com' } + }, + validateOn: ['touched'], + validator: createStandardSchema((value) => + value.name.length > 0 + ? { value } + : { issues: [{ message: 'Name required', path: ['name'] }] } + ) + }); + + // Act + form.fields.name.blur(); + await waitForQueuedValidation(); + form.fields.name.set('Alice'); + await waitForQueuedValidation(); + + // Assert + expect(form.status.get()).toEqual({ type: 'valid' }); + expect(form.getErrors()).toEqual({ fields: {}, form: [] }); + }); + + it('should apply form validation defaults to generated fields', async () => { + // Prepare + const form = createForm({ + fields: { + name: { + defaultValue: '', + validator: createRequiredStringSchema() + }, + email: { defaultValue: 'alice@example.com' } + }, + validateOn: ['blur'], + revalidateOn: ['submit', 'change'] + }); + + // Act + form.fields.name.blur(); + await waitForQueuedValidation(); + + // Assert + expect(form.getErrors().fields.name).toEqual([{ message: 'Required', path: undefined }]); + }); }); - const item5 = $form.getField('item5'); - item5.set('jeff'); + describe('status notifications', () => { + it('should not notify status listeners when form revalidation returns the same status', async () => { + // Prepare + const form = createForm({ + fields: { + name: { defaultValue: 'Alice' }, + email: { defaultValue: 'alice@example.com' } + }, + validator: createStandardSchema((value) => ({ value })) + }); + await form.submit(); + let statusChangeCount = 0; + form.status.listen(() => { + statusChangeCount++; + }); + + // Act + form.fields.name.set('Bob'); + await waitForQueuedValidation(); + + // Assert + expect(form.status.get()).toEqual({ type: 'valid' }); + expect(statusChangeCount).toBe(0); + }); + }); + }); + + describe('callbacks', () => { + it('should register and unregister submit callbacks', async () => { + // Prepare + const submittedData: Array> = []; + const form = createForm({ + fields: { + name: { defaultValue: 'Alice' }, + email: { defaultValue: 'alice@example.com' } + } + }); + const unbind = form.onValidSubmit((data) => { + submittedData.push(data); + }); + + // Act + await form.submit(); + unbind(); + form.fields.name.set('Bob'); + await form.submit(); + + // Assert + expect(submittedData).toEqual([{ name: 'Alice', email: 'alice@example.com' }]); + }); + }); - await $form.submit(); - $form.reset(); + describe('reset method', () => { + it('should reset field values and validation status', async () => { + // Prepare + const form = createForm({ + fields: { + name: { + defaultValue: '', + validator: createRequiredStringSchema() + }, + email: { defaultValue: 'alice@example.com' } + } + }); + form.fields.name.set('Bob'); + await form.validate(); - // TODO: Should be extendable with Undo feature, - // but seems to only work if State definition is top level - const $item5: TState]> = - $form.getField('item5'); - $item5.isSubmitted; - const $item5Undo = withUndo($item5); - $item5Undo.undo(); - $item5Undo.isSubmitted; + // Act + form.reset(); - expect($form).not.toBeNull(); + // Assert + expect(form.getData()).toEqual({ + name: '', + email: 'alice@example.com' + }); + expect(form.status.get()).toEqual({ type: 'unvalidated' }); + expect(form.getErrors()).toEqual({ fields: {}, form: [] }); + }); }); }); + +interface TUserFormData { + name: string; + email: string; +} + +interface TPasswordFormData { + password: string; + confirm: string; +} + +function createRequiredStringSchema(message = 'Required'): StandardSchemaV1 { + return createStandardSchema((value) => + value.length > 0 ? { value } : { issues: [{ message }] } + ); +} + +function createStandardSchema( + validate: (value: GValue) => StandardSchemaV1.Result +): StandardSchemaV1 { + return { + '~standard': { + version: 1, + vendor: 'feature-form-test', + validate(value) { + return validate(value as GValue); + } + } + }; +} + +// Listener-triggered validation is fire-and-forget and crosses async function continuations +async function waitForQueuedValidation(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} diff --git a/packages/feature-form/src/create-form.ts b/packages/feature-form/src/create-form.ts index 32422d58..b43aea3d 100644 --- a/packages/feature-form/src/create-form.ts +++ b/packages/feature-form/src/create-form.ts @@ -1,267 +1,506 @@ -import { type TEntries } from '@blgc/types/utils'; -import { bitwiseFlag, deepCopy, withNew, type BitwiseFlag } from '@blgc/utils'; -import { createState } from 'feature-state'; -import { TCollectErrorMode } from 'validation-adapter'; -import { createFormField, isFormField } from './form-field'; +import { createFeatureHost } from 'feature-core'; +import { createState, isEqualFeature } from 'feature-state'; +import { createFormField, formFieldResetSourceKey, isFormField } from './create-form-field'; +import { areValidationStatusesEqual, deepCopy, validateStandardSchema } from './lib'; import { - FormFieldReValidateMode, - FormFieldValidateMode, - TFormField, type TForm, - type TFormConfig, + type TFormBase, type TFormData, + type TFormField, + type TFormFieldErrors, + type TFormFieldKey, type TFormFields, - type TFormFieldStateConfig, + type TFormFieldValidationConfig, type TFormFieldValidator, - type TInvalidFormFieldError, - type TInvalidFormFieldErrors, - type TInvalidSubmitCallback, - type TSubmitCallbackResponse, - type TValidSubmitCallback + type TFormInvalidSubmitCallback, + type TFormStatus, + type TFormSubmitContext, + type TFormValidationConfig, + type TFormValidator, + type TFormValidSubmitCallback, + type TValidationError, + type TValidationStatusValue } from './types'; +/** + * Creates a reactive form. + * + * Each field is a reactive state with its own `status`, `isTouched`, and `isSubmitted` states. + * Field configs in `fields` are upgraded to reactive form fields automatically. + * Validators must implement the Standard Schema interface (Zod, Valibot, or custom). + * Extend the form with features using `.with(feature())`. + */ +export function createForm( + config: TCreateFormConfig> & { fields: GFields } +): TForm, []>; +/** Creates a form from field configs or existing form fields. */ +export function createForm( + config: TCreateFormConfig +): TForm; export function createForm( config: TCreateFormConfig ): TForm { const { fields, + validator, + validateOn = ['submit'] as const, + revalidateOn = ['submit', 'change'] as const, collectErrorMode = 'firstError', - disabled = false, - validateMode = bitwiseFlag(FormFieldValidateMode.OnSubmit), - reValidateMode = bitwiseFlag(FormFieldReValidateMode.OnBlur, FormFieldReValidateMode.OnSubmit), - onValidSubmit, onInvalidSubmit, - notifyOnStatusChange = true + onValidSubmit } = config; + // Note: No validator means there is no pending validation work + const initialFormValidatorStatus: TValidationStatusValue = + validator == null ? { type: 'valid' } : { type: 'unvalidated' }; - return withNew>({ - _features: [], - _config: { - disabled + const form = createFeatureHost>({ + _validation: + validator == null + ? undefined + : { + validator, + config: { + validateOn, + revalidateOn, + collectErrorMode + } + }, + _validationRunId: 0, + _formValidatorStatus: initialFormValidatorStatus, + _callbacks: { + invalidSubmit: onInvalidSubmit == null ? [] : [onInvalidSubmit], + validSubmit: onValidSubmit == null ? [] : [onValidSubmit] }, - _validSubmitCallbacks: onValidSubmit != null ? [onValidSubmit] : [], - _invalidSubmitCallbacks: onInvalidSubmit != null ? [onInvalidSubmit] : [], - fields: Object.fromEntries( - Object.entries(fields).map( - ([fieldKey, field]: [ - string, - TCreateFormConfigFormField | TFormField - ]) => [ - fieldKey, - isFormField(field) - ? field - : createFormField(field.defaultValue, { - key: fieldKey, - validator: field.validator, - collectErrorMode: field.collectErrorMode ?? collectErrorMode, - validateMode: field.validateMode ?? validateMode, - reValidateMode: field.reValidateMode ?? reValidateMode, - editable: field.editable ?? true, - notifyOnStatusChange - }) - ] - ) - ) as TFormFields, - isValid: createState(false), + status: createState(initialFormValidatorStatus).with( + isEqualFeature(areValidationStatusesEqual) + ), isValidating: createState(false), isSubmitted: createState(false), isSubmitting: createState(false), - _new(this: TForm) { - // Revalidate form on status change - for (const field of Object.values(this.fields)) { - field.status.listen( - async () => { - await this._revalidate(true); - }, - { key: 'form_revalidate' } - ); - } - }, - async _revalidate(this: TForm, cached = false) { - const formFields = Object.values(this.fields) as TFormFields[keyof GFormData][]; + fields: Object.fromEntries( + ( + Object.entries(fields) as Array< + [ + TFormFieldKey, + TCreateFormConfigFormField]> | TFormField + ] + > + ).map(([fieldKey, field]) => [ + fieldKey, + isFormField(field) + ? field + : createFormField(field.defaultValue, { + key: fieldKey, + validator: field.validator, + collectErrorMode: field.collectErrorMode ?? collectErrorMode, + revalidateOn: field.revalidateOn ?? revalidateOn, + validateOn: field.validateOn ?? validateOn + }) + ]) + ) as TFormFields, + onValidSubmit(this: TForm, callback) { + this._callbacks.validSubmit.push(callback); - if (!cached) { - this.isValidating.set(true); - await Promise.all(formFields.map((formField) => formField.validate())); - this.isValidating.set(false); - } + return () => { + const index = this._callbacks.validSubmit.indexOf(callback); + if (index !== -1) { + this._callbacks.validSubmit.splice(index, 1); + } + }; + }, + onInvalidSubmit(this: TForm, callback) { + this._callbacks.invalidSubmit.push(callback); - this.isValid.set(formFields.every((formField) => formField.isValid())); - return this.isValid.get(); + return () => { + const index = this._callbacks.invalidSubmit.indexOf(callback); + if (index !== -1) { + this._callbacks.invalidSubmit.splice(index, 1); + } + }; }, async submit(this: TForm, options = {}) { - const { - context, - assignToInitial = false, - onInvalidSubmit: _onInvalidSubmit, - onValidSubmit: _onValidSubmit, - postSubmitCallback - } = options; this.isSubmitting.set(true); + try { + await revalidateForm(this, { + shouldValidateField: (formField) => + formField._validation != null && + (formField.isSubmitted.get() + ? formField._validation.config.revalidateOn.includes('submit') + : formField._validation.config.validateOn.includes('submit')), + shouldValidateForm: + this._validation != null && + (this.isSubmitted.get() + ? this._validation.config.revalidateOn.includes('submit') + : this._validation.config.validateOn.includes('submit')) + }); - // Validate form fields - const validationPromises: Promise[] = []; - for (const formField of Object.values( - this.fields - ) as TFormFields[keyof GFormData][]) { - formField.isSubmitting.set(true); - if ( - (formField.isSubmitted.get() && - formField._config.reValidateMode.has(FormFieldReValidateMode.OnSubmit)) || - (!formField.isSubmitted.get() && - formField._config.validateMode.has(FormFieldValidateMode.OnSubmit)) - ) { - validationPromises.push(formField.validate()); - } - } - await Promise.all(validationPromises); - - // Note: We can't rely on the form field status listener to revalidate the form on time - // since the state queue is processed asynchronously - this._revalidate(true); - - // Execute submit callbacks - const data = this.getValidData(); - const submitCallbackPromises: TSubmitCallbackResponse[] = []; - if (data != null) { - for (const callback of this._validSubmitCallbacks) { - submitCallbackPromises.push(callback(data, context)); - } - if (typeof _onValidSubmit === 'function') { - submitCallbackPromises.push(_onValidSubmit(data, context)); - } - } else { - const errors = this.getErrors(); - for (const callback of this._invalidSubmitCallbacks) { - submitCallbackPromises.push(callback(errors, context)); - } - if (typeof _onInvalidSubmit === 'function') { - submitCallbackPromises.push(_onInvalidSubmit(errors, context)); + for (const formField of getFormFields(this.fields)) { + formField.isSubmitted.set(true); } - } + this.isSubmitted.set(true); - let submitCallbackData: Record | null = null; - if (postSubmitCallback != null) { - submitCallbackData = (await Promise.all(submitCallbackPromises)).reduce((acc, result) => { - if (result != null && typeof result === 'object') { - return { ...acc, ...result }; + const data = this.getValidData(); + if (data != null) { + if (options.updateDefaultValues) { + for (const [fieldKey, formField] of getFormFieldEntries(this.fields)) { + formField.defaultValue = deepCopy(data[fieldKey]); + } } - return acc; - }, {}) as Record; - } else { - await Promise.all(submitCallbackPromises); - } - // Update form field states - for (const [fieldKey, formField] of Object.entries(this.fields) as TEntries< - TFormFields - >) { - if (data != null && Object.prototype.hasOwnProperty.call(data, fieldKey)) { - if (assignToInitial) { - formField._intialValue = deepCopy(data[fieldKey]); - } + await runSubmitCallbacks( + options.onValidSubmit != null + ? [...this._callbacks.validSubmit, options.onValidSubmit] + : this._callbacks.validSubmit, + data, + options.context + ); + return true; } - formField.isSubmitted.set(true); - formField.isSubmitting.set(false); - } - - this.isSubmitted.set(true); - this.isSubmitting.set(false); - postSubmitCallback?.(this, submitCallbackData ?? {}); - return this.isValid.get(); + const errors = this.getErrors(); + await runSubmitCallbacks( + options.onInvalidSubmit != null + ? [...this._callbacks.invalidSubmit, options.onInvalidSubmit] + : this._callbacks.invalidSubmit, + errors, + options.context + ); + return false; + } finally { + this.isSubmitting.set(false); + } }, async validate(this: TForm) { - return this._revalidate(false); + return revalidateForm(this, { + shouldValidateField: (formField) => formField._validation != null, + shouldValidateForm: this._validation != null + }); + }, + // Note: reset clears flags and invalidates validation runs, but does not cancel submit callbacks already in progress + reset(this: TForm) { + for (const formField of getFormFields(this.fields)) { + formField.reset(); + } + this._validationRunId++; + applyFormValidatorStatus( + this, + this._validation == null ? { type: 'valid' } : { type: 'unvalidated' } + ); + this.status.set(getFormStatus(this)); + this.isValidating.set(false); + this.isSubmitted.set(false); + this.isSubmitting.set(false); }, getField(this: TForm, fieldKey) { return this.fields[fieldKey]; }, + getData(this: TForm) { + return Object.fromEntries( + getFormFieldEntries(this.fields).map(([fieldKey, formField]) => [fieldKey, formField.get()]) + ) as GFormData; + }, getValidData(this: TForm) { - if (!this.isValid.get()) { + if (this.status.get().type !== 'valid') { return null; } - // @ts-expect-error - Filled below - const preparedData: Readonly = {}; - - for (const [fieldKey, formField] of Object.entries(this.fields) as TEntries< - TFormFields - >) { - // @ts-expect-error - GFormFields is based on GFormData and the keys should be identical - preparedData[fieldKey] = formField.get(); - } - - return preparedData; + return this.getData(); }, getErrors(this: TForm) { - const errors: TInvalidFormFieldErrors = {}; - - for (const [fieldKey, formField] of Object.entries(this.fields) as TEntries< - TFormFields - >) { - switch (formField.status._v.type) { - case 'INVALID': - errors[fieldKey] = formField.status._v.errors; - break; - case 'UNVALIDATED': - errors[fieldKey] = [ - { - code: 'unvalidated', - message: `${fieldKey.toString()} was not yet validated!`, - path: fieldKey - } as TInvalidFormFieldError - ]; - break; - default: - } - } - - return errors; - }, - reset(this: TForm) { - for (const formField of Object.values( - this.fields - ) as TFormFields[keyof GFormData][]) { - formField.reset(); - } - this.isSubmitted.set(false); - this._revalidate(true); + return { + fields: getFormFieldErrors(this.fields), + form: this._formValidatorStatus.type === 'invalid' ? this._formValidatorStatus.errors : [] + }; } }); + + // Sync once after fields exist because the form validator status does not include field statuses + applyFormValidatorStatus(form, form._formValidatorStatus); + form.status.set(getFormStatus(form)); + + registerFormListeners(form); + + return form; } -export interface TCreateFormConfig extends Partial { - /** - * Form fields - */ +export interface TCreateFormConfig { + /** Field configs or pre-built form fields keyed by form data property. */ fields: TCreateFormConfigFormFields; - /** - * Error collection mode. 'firstError' gathers only the first error per field, 'all' gathers all errors. - */ - collectErrorMode?: TCollectErrorMode; - /** - * Validation strategy **before** submitting. - */ - validateMode?: BitwiseFlag; - /** - * Validation strategy **after** submitting. - */ - reValidateMode?: BitwiseFlag; - /** - * Whether to notify the form field if its status has changed - */ - notifyOnStatusChange?: boolean; - - onInvalidSubmit?: TInvalidSubmitCallback; - onValidSubmit?: TValidSubmitCallback; + /** Optional form-level validator for cross-field constraints. */ + validator?: TFormValidator>; + /** Default validation triggers for the form validator and fields that do not override them. */ + validateOn?: TFormValidationConfig['validateOn']; + /** Default revalidation triggers for the form validator and fields that do not override them. */ + revalidateOn?: TFormValidationConfig['revalidateOn']; + /** Default error collection mode for the form validator and fields that do not override it. */ + collectErrorMode?: TFormValidationConfig['collectErrorMode']; + /** Called on every valid submit. Per-call overrides can be passed directly to `submit()`. */ + onValidSubmit?: TFormValidSubmitCallback; + /** Called on every invalid submit. Per-call overrides can be passed directly to `submit()`. */ + onInvalidSubmit?: TFormInvalidSubmitCallback; } +/** Maps each form data property to a form field config or an existing form field. */ export type TCreateFormConfigFormFields = { - [Key in keyof GFormData]: TCreateFormConfigFormField | TFormField; + [Key in TFormFieldKey]: + | TCreateFormConfigFormField + | TFormField; }; -export interface TCreateFormConfigFormField extends Partial { - defaultValue?: GValue; - validator?: TFormFieldValidator; +/** Infers form data from field configs and existing form fields passed to `createForm()`. */ +export type TCreateFormDataFromFields = { + [Key in Extract]: GFields[Key] extends TFormField + ? GValue + : GFields[Key] extends { defaultValue: infer GValue } + ? GValue + : never; +}; + +export type TCreateFormFieldsInput = Record< + string, + TCreateFormConfigFormField | TFormField +>; + +/** Configures one form field when `createForm()` should create the field. */ +export interface TCreateFormConfigFormField { + /** Initial value for the created field. Also restored by `reset()`. */ + defaultValue: GValue; + /** Optional field-level Standard Schema validator. */ + validator?: TFormFieldValidator>; + /** Overrides the form default for this field. */ + collectErrorMode?: TFormFieldValidationConfig['collectErrorMode']; + /** Overrides the form default for this field after first submit. */ + revalidateOn?: TFormFieldValidationConfig['revalidateOn']; + /** Overrides the form default for this field before first submit. */ + validateOn?: TFormFieldValidationConfig['validateOn']; +} + +function registerFormListeners(form: TForm): void { + for (const formField of getFormFields(form.fields)) { + formField.status.listen(() => { + form.status.set(getFormStatus(form)); + }); + + formField.onBlur(({ wasTouched }) => { + // Note: 'touched' validates the form on the first field blur; 'blur' validates on every field blur + const shouldValidateFormOnBlur = + form._validation != null && + (form.isSubmitted.get() + ? form._validation.config.revalidateOn.includes('blur') + : form._validation.config.validateOn.includes('blur') || + (!wasTouched && form._validation.config.validateOn.includes('touched'))); + if (!shouldValidateFormOnBlur) { + return; + } + + void revalidateForm(form, { + shouldValidateField: () => false, + shouldValidateForm: true + }); + }); + + formField.listen(({ source }) => { + if (source === formFieldResetSourceKey) { + return; + } + + const shouldValidateFormOnFieldChange = + form._validation != null && + (form.isSubmitted.get() + ? form._validation.config.revalidateOn.includes('change') + : form._validation.config.validateOn.includes('change') || + (form._validation.config.validateOn.includes('touched') && formField.isTouched.get())); + if (!shouldValidateFormOnFieldChange) { + return; + } + + void revalidateForm(form, { + shouldValidateField: () => false, + shouldValidateForm: true + }); + }); + } +} + +async function revalidateForm( + form: TForm, + options: TRevalidateFormOptions +): Promise { + const { shouldValidateField, shouldValidateForm } = options; + const fieldValidationPromises = getFormFields(form.fields) + .filter(shouldValidateField) + .map((field) => field.validate()); + + const shouldSetValidating = fieldValidationPromises.length > 0 || shouldValidateForm; + const validationRunId = shouldSetValidating ? ++form._validationRunId : null; + if (shouldSetValidating) { + form.isValidating.set(true); + } + + try { + await Promise.all([ + ...fieldValidationPromises, + ...(shouldValidateForm && validationRunId != null + ? [validateFormValidator(form, validationRunId)] + : []) + ]); + } finally { + if (validationRunId != null && validationRunId === form._validationRunId) { + form.isValidating.set(false); + } + } + + const status = getFormStatus(form); + form.status.set(status); + return status.type === 'valid'; +} + +interface TRevalidateFormOptions { + shouldValidateField: (field: TFormFields[TFormFieldKey]) => boolean; + shouldValidateForm: boolean; +} + +async function validateFormValidator( + form: TForm, + validationRunId: number +): Promise { + if (form._validation == null) { + applyFormValidatorStatus(form, { type: 'valid' }); + return; + } + + let status: TValidationStatusValue = { type: 'valid' }; + try { + status = await validateStandardSchema( + form._validation.validator, + form.getData(), + form._validation.config.collectErrorMode + ); + } catch (error) { + status = { + type: 'invalid', + errors: [ + { + message: error instanceof Error ? error.message : String(error), + path: ['form'] + } + ] + }; + } + + if (validationRunId !== form._validationRunId) { + return; + } + + applyFormValidatorStatus(form, status); +} + +function getFormStatus( + form: TForm +): TFormStatus['value'] { + const errors: TValidationError[] = []; + let hasUnvalidatedStatus = false; + + for (const formField of getFormFields(form.fields)) { + const status = formField.status.get(); + if (status.type === 'invalid') { + errors.push(...status.errors); + } else if (status.type === 'unvalidated') { + hasUnvalidatedStatus = true; + } + } + + if (form._formValidatorStatus.type === 'invalid') { + errors.push(...form._formValidatorStatus.errors); + } else if (form._formValidatorStatus.type === 'unvalidated') { + hasUnvalidatedStatus = true; + } + + if (errors.length > 0) { + return { type: 'invalid', errors }; + } + + return hasUnvalidatedStatus ? { type: 'unvalidated' } : { type: 'valid' }; +} + +function applyFormValidatorStatus( + form: TForm, + status: TValidationStatusValue +): void { + if (status.type !== 'invalid') { + form._formValidatorStatus = status; + for (const formField of getFormFields(form.fields)) { + formField._applyFormValidatorErrors([]); + } + return; + } + + const fieldErrors: TFormFieldErrors = {}; + const formErrors: TValidationError[] = []; + for (const error of status.errors) { + const path = error.path; + if (path == null) { + formErrors.push(error); + continue; + } + + const fieldKey = path[0]; + if (typeof fieldKey !== 'string' || !(fieldKey in form.fields)) { + formErrors.push(error); + continue; + } + + const fieldPath = path.slice(1); + fieldErrors[fieldKey as TFormFieldKey] = [ + ...(fieldErrors[fieldKey as TFormFieldKey] ?? []), + { + ...error, + path: fieldPath.length > 0 ? fieldPath : undefined + } + ]; + } + + form._formValidatorStatus = + formErrors.length > 0 ? { type: 'invalid', errors: formErrors } : { type: 'valid' }; + + for (const [fieldKey, formField] of getFormFieldEntries(form.fields)) { + formField._applyFormValidatorErrors(fieldErrors[fieldKey] ?? []); + } +} + +function getFormFieldErrors( + fields: TFormFields +): TFormFieldErrors { + const errors: TFormFieldErrors = {}; + + for (const [fieldKey, formField] of getFormFieldEntries(fields)) { + const status = formField.status.get(); + if (status.type === 'invalid') { + errors[fieldKey] = status.errors; + } + } + + return errors; +} + +async function runSubmitCallbacks( + callbacks: Array<(value: GValue, context?: TFormSubmitContext) => Promise | void>, + value: GValue, + context: TFormSubmitContext | undefined +): Promise { + await Promise.all(callbacks.map((callback) => callback(value, context))); +} + +function getFormFields( + fields: TFormFields +): Array[TFormFieldKey]> { + return Object.values(fields) as Array[TFormFieldKey]>; +} + +function getFormFieldEntries( + fields: TFormFields +): Array<[TFormFieldKey, TFormFields[TFormFieldKey]]> { + return Object.entries(fields) as Array< + [TFormFieldKey, TFormFields[TFormFieldKey]] + >; } diff --git a/packages/feature-form/src/features/dirty.test.ts b/packages/feature-form/src/features/dirty.test.ts new file mode 100644 index 00000000..19e7f9c7 --- /dev/null +++ b/packages/feature-form/src/features/dirty.test.ts @@ -0,0 +1,135 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import { describe, expect, expectTypeOf, it } from 'vitest'; +import { createForm } from '../create-form'; +import type { TForm } from '../types'; +import { dirtyFeature, type TDirtyFeature, type TDirtyFields } from './dirty'; + +describe('dirtyFeature function', () => { + describe('types', () => { + it('should infer dirty feature APIs', () => { + const form = createForm({ + fields: { + name: { defaultValue: 'Alice' }, + email: { defaultValue: 'alice@example.com' } + } + }).with(dirtyFeature()); + + expectTypeOf(form).toEqualTypeOf]>>(); + expectTypeOf(form.isDirty.get()).toEqualTypeOf(); + expectTypeOf(form.dirtyFields.get()).toEqualTypeOf>(); + expectTypeOf(form.submit).toEqualTypeOf['submit']>(); + }); + }); + + describe('dirty tracking', () => { + it('should track dirty fields', () => { + // Prepare + const form = createForm({ + fields: { + name: { defaultValue: 'Alice' }, + email: { defaultValue: 'alice@example.com' } + } + }).with(dirtyFeature()); + + // Act + form.fields.name.set('Bob'); + + // Assert + expect(form.isDirty.get()).toBe(true); + expect(form.dirtyFields.get()).toEqual({ + name: true, + email: false + }); + }); + + it('should reset dirty baseline to current values', () => { + // Prepare + const form = createForm({ + fields: { + name: { defaultValue: 'Alice' }, + email: { defaultValue: 'alice@example.com' } + } + }).with(dirtyFeature()); + form.fields.name.set('Bob'); + + // Act + form.resetDirty(); + form.fields.name.set('Alice'); + + // Assert + expect(form.isDirty.get()).toBe(true); + expect(form.dirtyFields.get().name).toBe(true); + form.reset(); + expect(form.fields.name.get()).toBe('Bob'); + expect(form.isDirty.get()).toBe(false); + }); + + it('should clear dirty state when submit updates default values', async () => { + // Prepare + const form = createForm({ + fields: { + name: { + defaultValue: 'Alice', + validator: createStandardSchema((value) => + value.length > 0 ? { value } : { issues: [{ message: 'Required' }] } + ) + }, + email: { defaultValue: 'alice@example.com' } + } + }).with(dirtyFeature()); + form.fields.name.set('Bob'); + + // Act + const isValid = await form.submit({ updateDefaultValues: true }); + + // Assert + expect(isValid).toBe(true); + expect(form.isDirty.get()).toBe(false); + expect(form.dirtyFields.get()).toEqual({ + name: false, + email: false + }); + }); + + it('should compare plain objects structurally by default', () => { + // Prepare + const form = createForm({ + fields: { + profile: { defaultValue: { name: 'Alice' } } + } + }).with(dirtyFeature()); + + // Act + form.fields.profile.set({ name: 'Alice' }); + + // Assert + expect(form.isDirty.get()).toBe(false); + expect(form.dirtyFields.get().profile).toBe(false); + }); + }); +}); + +interface TUserFormData { + name: string; + email: string; +} + +interface TProfileFormData { + profile: { + name: string; + }; +} + +function createStandardSchema( + validate: (value: GValue) => StandardSchemaV1.Result +): StandardSchemaV1 { + return { + '~standard': { + version: 1, + vendor: 'feature-form-test', + validate(value) { + return validate(value as GValue); + } + } + }; +} diff --git a/packages/feature-form/src/features/dirty.ts b/packages/feature-form/src/features/dirty.ts new file mode 100644 index 00000000..703ef73c --- /dev/null +++ b/packages/feature-form/src/features/dirty.ts @@ -0,0 +1,163 @@ +import { defineFeature, type TFeature } from 'feature-core'; +import { createState, type TState } from 'feature-state'; +import { deepCopy } from '../lib'; +import { type TForm, type TFormData, type TFormFieldKey, type TFormFields } from '../types'; + +/** + * Adds `isDirty`, `dirtyFields`, and `resetDirty()` to a form. + * + * Tracks whether each field differs from its `defaultValue` using deep structural equality. + * `isDirty` and `dirtyFields` are reactive states. `resetDirty()` makes the current values + * the new baseline without clearing them. Dirty state clears automatically when + * `submit({ updateDefaultValues: true })` succeeds. + */ +export function dirtyFeature( + config: TDirtyFeatureConfig = {} +): TDirtyFeature { + const { isEqual = isDeepEqual } = config; + + return defineFeature>({ + key: 'dirty', + overrides: ['submit'], + install(form: TForm) { + const dirtyFields = createState>(getDirtyFields(form, isEqual)); + const isDirty = createState(isFormDirty(dirtyFields.get())); + const updateDirtyState = (): void => { + const nextDirtyFields = getDirtyFields(form, isEqual); + if (!areDirtyFieldsEqual(dirtyFields.get(), nextDirtyFields)) { + dirtyFields.set(nextDirtyFields); + } + isDirty.set(isFormDirty(nextDirtyFields)); + }; + const submit = form.submit.bind(form); + + for (const formField of getFormFields(form.fields)) { + formField.listen(updateDirtyState); + } + + return { + isDirty, + dirtyFields, + resetDirty() { + for (const formField of getFormFields(form.fields)) { + formField.defaultValue = deepCopy(formField.get()); + } + updateDirtyState(); + }, + async submit(options) { + try { + return await submit(options); + } finally { + updateDirtyState(); + } + } + }; + } + }); +} + +export interface TDirtyFeatureConfig { + /** Custom equality function. Defaults to deep structural equality. */ + isEqual?: TDirtyFieldComparator; +} + +/** Returns `true` when a current field value matches its default value. */ +export type TDirtyFieldComparator = (value: unknown, defaultValue: unknown) => boolean; + +export type TDirtyFeature = TFeature< + 'dirty', + { + /** True when at least one field differs from its default value. */ + isDirty: TState; + /** Field-keyed reactive map where `true` means the field differs from its default value. */ + dirtyFields: TState, []>; + /** Makes the current field values the new default values without clearing the form. */ + resetDirty(): void; + /** Updates dirty state after the original submit flow completes. */ + submit: TForm['submit']; + }, + [], + 'submit' +>; + +/** Field-keyed dirty map where `true` means the field differs from its default value. */ +export type TDirtyFields = { + [Key in TFormFieldKey]: boolean; +}; + +function getDirtyFields( + form: TForm, + isEqual: TDirtyFieldComparator +): TDirtyFields { + return Object.fromEntries( + getFormFieldEntries(form.fields).map(([fieldKey, formField]) => [ + fieldKey, + !isEqual(formField.get(), formField.defaultValue) + ]) + ) as TDirtyFields; +} + +function isFormDirty(dirtyFields: TDirtyFields): boolean { + return Object.values(dirtyFields).some((isFieldDirty) => isFieldDirty); +} + +function areDirtyFieldsEqual( + current: TDirtyFields, + next: TDirtyFields +): boolean { + for (const fieldKey of Object.keys(next) as Array>) { + if (current[fieldKey] !== next[fieldKey]) { + return false; + } + } + + return true; +} + +function isDeepEqual(value: unknown, defaultValue: unknown): boolean { + if (Object.is(value, defaultValue)) { + return true; + } + + if (Array.isArray(value) && Array.isArray(defaultValue)) { + if (value.length !== defaultValue.length) { + return false; + } + + return value.every((item, index) => isDeepEqual(item, defaultValue[index])); + } + + if (!isPlainObject(value) || !isPlainObject(defaultValue)) { + return false; + } + + const valueKeys = Object.keys(value); + const defaultValueKeys = Object.keys(defaultValue); + if (valueKeys.length !== defaultValueKeys.length) { + return false; + } + + return valueKeys.every( + (key) => key in defaultValue && isDeepEqual(value[key], defaultValue[key]) + ); +} + +function isPlainObject(value: unknown): value is Record { + return ( + typeof value === 'object' && value != null && Object.getPrototypeOf(value) === Object.prototype + ); +} + +function getFormFields( + fields: TFormFields +): Array[TFormFieldKey]> { + return Object.values(fields) as Array[TFormFieldKey]>; +} + +function getFormFieldEntries( + fields: TFormFields +): Array<[TFormFieldKey, TFormFields[TFormFieldKey]]> { + return Object.entries(fields) as Array< + [TFormFieldKey, TFormFields[TFormFieldKey]] + >; +} diff --git a/packages/feature-form/src/features/index.ts b/packages/feature-form/src/features/index.ts new file mode 100644 index 00000000..a7e9ce78 --- /dev/null +++ b/packages/feature-form/src/features/index.ts @@ -0,0 +1 @@ +export * from './dirty'; diff --git a/packages/feature-form/src/form-field/create-form-field-validation-context.ts b/packages/feature-form/src/form-field/create-form-field-validation-context.ts deleted file mode 100644 index ba2748eb..00000000 --- a/packages/feature-form/src/form-field/create-form-field-validation-context.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { type TFormField, type TFormFieldValidationContext } from '../types'; - -export function createFormFieldValidationContext( - formField: TFormField -): TFormFieldValidationContext { - return { - config: { - collectErrorMode: formField._config.collectErrorMode, - name: formField.key - }, - value: formField.get() as Readonly, - isValue(v): v is GValue { - return true; - }, - hasError() { - return formField.status._nextValue?.type === 'INVALID'; - }, - registerError(error) { - formField.status.registerNextError({ - code: error.code, - message: error.message, - path: error.path - }); - } - }; -} diff --git a/packages/feature-form/src/form-field/create-form-field.ts b/packages/feature-form/src/form-field/create-form-field.ts deleted file mode 100644 index a6bed73d..00000000 --- a/packages/feature-form/src/form-field/create-form-field.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { bitwiseFlag, deepCopy, withNew } from '@blgc/utils'; -import { createState } from 'feature-state'; -import { createValidator } from 'validation-adapter'; -import { - FormFieldReValidateMode, - FormFieldValidateMode, - type TFormField, - type TFormFieldStateConfig, - type TFormFieldStateFeature, - type TFormFieldValidator -} from '../types'; -import { createFormFieldValidationContext } from './create-form-field-validation-context'; -import { createStatus } from './create-status'; - -export function createFormField( - initialValue: GValue | undefined, - config: TCreateFormFieldConfig -): TFormField { - const { - key, - validator = createValidator([]), - editable = true, - reValidateMode = bitwiseFlag(FormFieldReValidateMode.OnBlur), - validateMode = bitwiseFlag(FormFieldValidateMode.OnSubmit), - collectErrorMode = 'firstError', - notifyOnStatusChange = true - } = config; - const baseState = createState(initialValue); - - const formFieldFeature: TFormFieldStateFeature['api'] = { - _config: { - editable, - validateMode, - reValidateMode, - collectErrorMode - }, - _intialValue: deepCopy(baseState._v), - _validator: validator, - key, - isTouched: createState(false), - isSubmitted: createState(false), - isSubmitting: createState(false), - isValidating: createState(false), - status: createStatus({ type: 'UNVALIDATED' }), - async validate(this: TFormField) { - const validationContext = createFormFieldValidationContext(this); - - this.isValidating.set(true); - await this._validator.validate(validationContext); - this.isValidating.set(false); - - // If no error was registered we assume its valid - if (this.status._nextValue == null) { - this.status.set({ type: 'VALID' }); - } else { - this.status.set(this.status._nextValue); - } - - this.status._nextValue = undefined; - - return this.status.get().type === 'VALID'; - }, - isValid(this: TFormField) { - return this.status.get().type === 'VALID'; - }, - blur(this: TFormField) { - if ( - (this.isSubmitted.get() && - this._config.reValidateMode.has(FormFieldReValidateMode.OnBlur)) || - (!this.isSubmitted.get() && - (this._config.validateMode.has(FormFieldValidateMode.OnBlur) || - (this._config.validateMode.has(FormFieldValidateMode.OnTouched) && - !this.isTouched.get()))) - ) { - void this.validate(); - } - - this.isTouched.set(true); - }, - reset(this: TFormField) { - this.set(this._intialValue, { listenerContext: { source: 'form-field_reset' } }); - this.isTouched.set(false); - this.isSubmitted.set(false); - this.isSubmitting.set(false); - this.status.set({ type: 'UNVALIDATED' }); - } - }; - - // Extend the base state with the form field feature - const formField = Object.assign(baseState, formFieldFeature) as TFormField; - formField._features.push('form-field'); - - return withNew, [boolean]>( - Object.assign(formField, { - _new(this: TFormField, notifyOnStatusChange: boolean) { - // Notify form field listeners if status has changed - if (notifyOnStatusChange) { - this.status.listen( - (data) => { - baseState._notify({ - listenerContext: { source: 'form-field_status-change', status: data.value } - }); - }, - { key: 'form-field_status-change' } - ); - } - - // Validate on change - this.listen( - async ({ source }) => { - // Skip non-value changes so they do not trigger value-change validation - if (source === 'form-field_reset' || source === 'form-field_status-change') { - return; - } - - if ( - (this.isSubmitted.get() && - this._config.reValidateMode.has(FormFieldReValidateMode.OnChange)) || - (!this.isSubmitted.get() && - this._config.validateMode.has(FormFieldValidateMode.OnChange)) || - (this._config.validateMode.has(FormFieldValidateMode.OnTouched) && - this.isTouched.get()) - ) { - await this.validate(); - } - }, - { key: 'form-field_validate' } - ); - } - }), - notifyOnStatusChange - ); -} - -export interface TCreateFormFieldConfig extends Partial { - key: string; - validator?: TFormFieldValidator; - notifyOnStatusChange?: boolean; -} diff --git a/packages/feature-form/src/form-field/create-status.ts b/packages/feature-form/src/form-field/create-status.ts deleted file mode 100644 index 2db5b17e..00000000 --- a/packages/feature-form/src/form-field/create-status.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { createState } from 'feature-state'; -import { - TFormFielStatusStateFeature, - type TFormFieldStatus, - type TFormFieldStatusValue -} from '../types'; - -export function createStatus(initialValue: TFormFieldStatusValue): TFormFieldStatus { - const baseState = createState(initialValue); - - const formFieldStatusFeature: TFormFielStatusStateFeature['api'] = { - _nextValue: undefined, - registerNextError(this: TFormFieldStatus, error) { - if (this._nextValue?.type === 'INVALID') { - this._nextValue.errors.push(error); - } else { - this._nextValue = { type: 'INVALID', errors: [error] }; - } - } - }; - - // Extend the base state with the form field status feature - const formFieldStatus = Object.assign(baseState, formFieldStatusFeature) as TFormFieldStatus; - formFieldStatus._features.push('form-field-status'); - - return formFieldStatus; -} diff --git a/packages/feature-form/src/form-field/index.ts b/packages/feature-form/src/form-field/index.ts deleted file mode 100644 index a2d93beb..00000000 --- a/packages/feature-form/src/form-field/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './create-form-field'; -export * from './create-status'; -export * from './is-form-field'; -export * from './is-form-field-status'; diff --git a/packages/feature-form/src/form-field/is-form-field-status.ts b/packages/feature-form/src/form-field/is-form-field-status.ts deleted file mode 100644 index a7b5259d..00000000 --- a/packages/feature-form/src/form-field/is-form-field-status.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { TFormFieldStatus } from '../types'; - -export function isFormFieldStatus(value: unknown): value is TFormFieldStatus { - return ( - typeof value === 'object' && - value != null && - '_features' in value && - Array.isArray(value._features) && - value._features.includes('form-field-status') - ); -} diff --git a/packages/feature-form/src/form-field/is-form-field.ts b/packages/feature-form/src/form-field/is-form-field.ts deleted file mode 100644 index 4ac3b609..00000000 --- a/packages/feature-form/src/form-field/is-form-field.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { type TFormField } from '../types'; - -export function isFormField(value: unknown): value is TFormField { - return ( - typeof value === 'object' && - value != null && - '_features' in value && - Array.isArray(value._features) && - value._features.includes('form-field') - ); -} diff --git a/packages/feature-form/src/helper/from-validator.ts b/packages/feature-form/src/helper/from-validator.ts deleted file mode 100644 index 580ca6ef..00000000 --- a/packages/feature-form/src/helper/from-validator.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { type TCreateFormConfigFormField } from '../create-form'; -import { type TFormFieldValidator } from '../types'; - -// Helper function to make type inference work -// https://github.com/microsoft/TypeScript/issues/26242 -export function fromValidator( - validator: TFormFieldValidator, - config: Omit, 'validator'> -): TCreateFormConfigFormField { - return { validator, ...config }; -} diff --git a/packages/feature-form/src/helper/has-form-changed.ts b/packages/feature-form/src/helper/has-form-changed.ts deleted file mode 100644 index 1a00d218..00000000 --- a/packages/feature-form/src/helper/has-form-changed.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { type TForm } from '../types'; - -export function hasFormChanged(form: TForm): boolean { - for (const key in form.fields) { - if (form.fields[key]?.get() !== form.fields[key]?._intialValue) { - return true; - } - } - return false; -} diff --git a/packages/feature-form/src/helper/index.ts b/packages/feature-form/src/helper/index.ts deleted file mode 100644 index b5b5b29b..00000000 --- a/packages/feature-form/src/helper/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './from-validator'; -export * from './has-form-changed'; -export * from './reset-form-submitted'; diff --git a/packages/feature-form/src/helper/reset-form-submitted.ts b/packages/feature-form/src/helper/reset-form-submitted.ts deleted file mode 100644 index 0a9d4fe5..00000000 --- a/packages/feature-form/src/helper/reset-form-submitted.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { type TForm } from '../types'; - -export function resetFormSubmitted(form: TForm): void { - form.isSubmitted.set(false); - form.isSubmitting.set(false); - for (const formField of Object.values(form.fields)) { - formField.isSubmitted.set(false); - formField.isSubmitting.set(false); - } -} diff --git a/packages/feature-form/src/index.ts b/packages/feature-form/src/index.ts index b83e307a..ae5db51e 100644 --- a/packages/feature-form/src/index.ts +++ b/packages/feature-form/src/index.ts @@ -1,6 +1,4 @@ -export { BitwiseFlag, bitwiseFlag } from '@blgc/utils'; export * from './create-form'; -export * from './form-field'; -export * from './helper'; -export * from './is-form-with-features'; +export * from './features'; +export * from './create-form-field'; export * from './types'; diff --git a/packages/feature-form/src/is-form-with-features.ts b/packages/feature-form/src/is-form-with-features.ts deleted file mode 100644 index 341d1951..00000000 --- a/packages/feature-form/src/is-form-with-features.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { TFeatureDefinition, TLooseFeatureNames } from '@blgc/types/features'; -import { TForm, TFormData } from './types'; - -export function isFormWithFeatures< - GFormData extends TFormData, - GFeatures extends TFeatureDefinition[] = [] ->(value: unknown, features: TLooseFeatureNames[]): value is TForm { - return ( - typeof value === 'object' && - value != null && - '_features' in value && - Array.isArray(value._features) && - features.every((feature) => (value._features as string[]).includes(feature)) - ); -} diff --git a/packages/feature-form/src/lib/deep-copy.test.ts b/packages/feature-form/src/lib/deep-copy.test.ts new file mode 100644 index 00000000..4a8e0339 --- /dev/null +++ b/packages/feature-form/src/lib/deep-copy.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, expectTypeOf, it } from 'vitest'; +import { deepCopy } from './deep-copy'; + +describe('deepCopy function', () => { + describe('types', () => { + it('should preserve the input value type', () => { + const value = { + name: 'Jeff', + items: [{ count: 1 }] + }; + + const copiedValue = deepCopy(value); + + expectTypeOf(copiedValue).toEqualTypeOf<{ + name: string; + items: { count: number }[]; + }>(); + }); + }); + + describe('plain values', () => { + it('should return primitive and nullish values unchanged', () => { + expect(deepCopy('value')).toBe('value'); + expect(deepCopy(42)).toBe(42); + expect(deepCopy(null)).toBe(null); + expect(deepCopy(undefined)).toBe(undefined); + }); + + it('should deeply copy plain objects and arrays', () => { + // Prepare + const value = { + name: 'Jeff', + tags: ['admin'], + meta: { + count: 1 + } + }; + + // Act + const copiedValue = deepCopy(value); + value.tags.push('editor'); + value.meta.count = 2; + + // Assert + expect(copiedValue).toEqual({ + name: 'Jeff', + tags: ['admin'], + meta: { + count: 1 + } + }); + expect(copiedValue).not.toBe(value); + expect(copiedValue.tags).not.toBe(value.tags); + expect(copiedValue.meta).not.toBe(value.meta); + }); + }); + + describe('non-plain values', () => { + it('should return non-plain objects by reference', () => { + // Prepare + const date = new Date('2026-01-01T00:00:00.000Z'); + const value = { + date, + map: new Map([['name', 'Jeff']]) + }; + + // Act + const copiedValue = deepCopy(value); + + // Assert + expect(copiedValue).not.toBe(value); + expect(copiedValue.date).toBe(date); + expect(copiedValue.map).toBe(value.map); + }); + }); +}); diff --git a/packages/feature-form/src/lib/deep-copy.ts b/packages/feature-form/src/lib/deep-copy.ts new file mode 100644 index 00000000..feb504ab --- /dev/null +++ b/packages/feature-form/src/lib/deep-copy.ts @@ -0,0 +1,22 @@ +/** Deeply copies plain objects and arrays. Non-plain objects (class instances, Date, Map, etc.) are returned by reference. */ +export function deepCopy(value: GValue): GValue { + if (typeof value !== 'object' || value == null) { + return value; + } + + if (Array.isArray(value)) { + return value.map((item) => deepCopy(item)) as GValue; + } + + // Note: Non-plain objects are returned by reference rather than mangled into plain objects + if (Object.getPrototypeOf(value) !== Object.prototype) { + return value; + } + + const copiedObject: Record = {}; + for (const key of Object.keys(value)) { + copiedObject[key] = deepCopy((value as Record)[key]); + } + + return copiedObject as GValue; +} diff --git a/packages/feature-form/src/lib/index.ts b/packages/feature-form/src/lib/index.ts new file mode 100644 index 00000000..d583db51 --- /dev/null +++ b/packages/feature-form/src/lib/index.ts @@ -0,0 +1,3 @@ +export * from './deep-copy'; +export * from './standard-schema'; +export * from './validation-status'; diff --git a/packages/feature-form/src/lib/standard-schema.test.ts b/packages/feature-form/src/lib/standard-schema.test.ts new file mode 100644 index 00000000..03191302 --- /dev/null +++ b/packages/feature-form/src/lib/standard-schema.test.ts @@ -0,0 +1,100 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import { describe, expect, it } from 'vitest'; +import { validateStandardSchema } from './standard-schema'; + +describe('validateStandardSchema function', () => { + it('should return valid status when the schema has no issues', async () => { + // Prepare + const schema = createStandardSchema(async () => ({ value: 'valid' })); + + // Act + const status = await validateStandardSchema(schema, 'valid', 'firstError'); + + // Assert + expect(status).toEqual({ type: 'valid' }); + }); + + it('should return valid status when the schema returns an empty issues array', async () => { + // Prepare + const schema = createStandardSchema(() => ({ issues: [] })); + + // Act + const status = await validateStandardSchema(schema, 'value', 'firstError'); + + // Assert + expect(status).toEqual({ type: 'valid' }); + }); + + it('should normalize Standard Schema issue paths', async () => { + // Prepare + const schema = createStandardSchema(() => ({ + issues: [ + { + message: 'Required', + path: [{ key: 'items' }, 0, 'name'] + } + ] + })); + + // Act + const status = await validateStandardSchema(schema, '', 'firstError'); + + // Assert + expect(status).toEqual({ + type: 'invalid', + errors: [{ message: 'Required', path: ['items', 0, 'name'] }] + }); + }); + + it('should keep only the first issue in first error mode', async () => { + // Prepare + const schema = createStandardSchema(() => ({ + issues: [{ message: 'First' }, { message: 'Second' }] + })); + + // Act + const status = await validateStandardSchema(schema, '', 'firstError'); + + // Assert + expect(status).toEqual({ + type: 'invalid', + errors: [{ message: 'First', path: undefined }] + }); + }); + + it('should collect all issues in all error mode', async () => { + // Prepare + const schema = createStandardSchema(() => ({ + issues: [{ message: 'First' }, { message: 'Second' }, { message: 'Third' }] + })); + + // Act + const status = await validateStandardSchema(schema, '', 'all'); + + // Assert + expect(status).toEqual({ + type: 'invalid', + errors: [ + { message: 'First', path: undefined }, + { message: 'Second', path: undefined }, + { message: 'Third', path: undefined } + ] + }); + }); +}); + +function createStandardSchema( + validate: ( + value: GValue + ) => Promise> | StandardSchemaV1.Result +): StandardSchemaV1 { + return { + '~standard': { + version: 1, + vendor: 'feature-form-test', + validate(value) { + return validate(value as GValue); + } + } + }; +} diff --git a/packages/feature-form/src/lib/standard-schema.ts b/packages/feature-form/src/lib/standard-schema.ts new file mode 100644 index 00000000..f653e5fc --- /dev/null +++ b/packages/feature-form/src/lib/standard-schema.ts @@ -0,0 +1,38 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import { + type TCollectErrorMode, + type TValidationPath, + type TValidationStatusValue +} from '../types'; + +/** Runs a Standard Schema validator and converts its issues into a validation status. */ +export async function validateStandardSchema( + schema: StandardSchemaV1, + value: GValue, + collectErrorMode: TCollectErrorMode +): Promise { + const { issues } = await schema['~standard'].validate(value); + if (!issues?.length) { + return { type: 'valid' }; + } + + const collectedIssues = collectErrorMode === 'firstError' ? issues.slice(0, 1) : issues; + return { + type: 'invalid', + errors: collectedIssues.map((issue) => ({ + message: issue.message, + path: standardSchemaPathToValidationPath(issue.path) + })) + }; +} + +function standardSchemaPathToValidationPath( + path: StandardSchemaV1.Issue['path'] +): TValidationPath | undefined { + if (!path?.length) { + return undefined; + } + return path.map((segment) => + typeof segment === 'object' && 'key' in segment ? segment.key : segment + ); +} diff --git a/packages/feature-form/src/lib/validation-status.ts b/packages/feature-form/src/lib/validation-status.ts new file mode 100644 index 00000000..22d56229 --- /dev/null +++ b/packages/feature-form/src/lib/validation-status.ts @@ -0,0 +1,47 @@ +import { type TValidationError, type TValidationPath, type TValidationStatusValue } from '../types'; + +/** Compares validation statuses so reactive status state can skip equivalent updates. */ +export function areValidationStatusesEqual( + current: TValidationStatusValue, + next: TValidationStatusValue +): boolean { + if (current.type !== next.type) { + return false; + } + + if (current.type !== 'invalid' || next.type !== 'invalid') { + return true; + } + + return areValidationErrorsEqual(current.errors, next.errors); +} + +function areValidationErrorsEqual( + current: readonly TValidationError[], + next: readonly TValidationError[] +): boolean { + if (current.length !== next.length) { + return false; + } + + return current.every( + (error, index) => + error.message === next[index]?.message && + areValidationPathsEqual(error.path, next[index]?.path) + ); +} + +function areValidationPathsEqual( + current: TValidationPath | undefined, + next: TValidationPath | undefined +): boolean { + if (current == null || next == null) { + return current == null && next == null; + } + + if (current.length !== next.length) { + return false; + } + + return current.every((segment, index) => Object.is(segment, next[index])); +} diff --git a/packages/feature-form/src/types.ts b/packages/feature-form/src/types.ts new file mode 100644 index 00000000..d9aba935 --- /dev/null +++ b/packages/feature-form/src/types.ts @@ -0,0 +1,255 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import { type TAnyFeature, type TFeature, type TFeatureHost } from 'feature-core'; +import { type TIsEqualFeature, type TState } from 'feature-state'; + +/** Form object returned by `createForm()`. */ +export type TForm = TFeatureHost< + TFormBase, + GFeatures +>; + +/** + * Core form API used by feature installers. + * Use this type when a feature only needs the base methods, + * regardless of which other features are already installed on the host. + */ +export interface TFormBase { + /** @internal */ + _validation?: TFormValidation; + /** @internal Increments to prevent stale async validation runs from committing after reset or newer validation. */ + _validationRunId: number; + /** @internal Stores form-level validator status after field-path errors have been routed to fields. */ + _formValidatorStatus: TValidationStatusValue; + /** @internal */ + _callbacks: TFormCallbacks; + /** Reactive aggregate validation status for the whole form. */ + status: TFormStatus; + /** True while any async validator is running. */ + isValidating: TState; + /** True after the first submit attempt, regardless of validity. */ + isSubmitted: TState; + /** True while the submit handler is executing. */ + isSubmitting: TState; + /** Map of field keys to their reactive `TFormField` instances. */ + fields: TFormFields; + /** Registers a callback for every valid submit. Returns an unsubscribe function. */ + onValidSubmit(callback: TFormValidSubmitCallback): () => void; + /** Registers a callback for every invalid submit. Returns an unsubscribe function. */ + onInvalidSubmit(callback: TFormInvalidSubmitCallback): () => void; + /** Runs all validators (including async), fires submit callbacks, and returns `true` if the form was valid. */ + submit(options?: TFormSubmitOptions): Promise; + /** Runs all validators and returns true if valid, false otherwise. */ + validate(): Promise; + /** Resets all fields to their default values and clears validation state. */ + reset(): void; + /** Returns the `TFormField` for the given key. */ + getField>(key: GKey): TFormFields[GKey]; + /** Returns current field values without checking validity. */ + getData(): Readonly; + /** Returns current field values when the form is valid, otherwise `null`. */ + getValidData(): Readonly | null; + /** Returns current field and form-level validation errors. */ + getErrors(): TFormErrors; +} + +export type TFormData = object; + +export type TFormFieldKey = Extract; + +/** Maps each form data property to its reactive field. */ +export type TFormFields = { + [Key in TFormFieldKey]: TFormField; +}; + +/** Aggregate validation status for the whole form, including field and form-level errors. */ +export type TFormStatus = TValidationStatus; + +export interface TFormCallbacks { + validSubmit: TFormValidSubmitCallback[]; + invalidSubmit: TFormInvalidSubmitCallback[]; +} + +export interface TFormSubmitOptions { + /** One-time valid-submit callback for this call, in addition to registered listeners. */ + onValidSubmit?: TFormValidSubmitCallback; + /** One-time invalid-submit callback for this call, in addition to registered listeners. */ + onInvalidSubmit?: TFormInvalidSubmitCallback; + /** Arbitrary data forwarded to all submit callbacks as the second argument. */ + context?: TFormSubmitContext; + /** Updates field default values after a successful submit so future resets return to submitted data. */ + updateDefaultValues?: boolean; +} + +/** Receives typed form data after a successful submit. */ +export type TFormValidSubmitCallback = ( + formData: Readonly, + context?: TFormSubmitContext +) => Promise | void; + +/** Receives collected form and field errors after an invalid submit. */ +export type TFormInvalidSubmitCallback = ( + errors: TFormErrors, + context?: TFormSubmitContext +) => Promise | void; + +/** Metadata forwarded to submit callbacks. */ +export interface TFormSubmitContext { + [key: string]: unknown; + /** The HTML submit event, if the form was submitted via a DOM form element. */ + event?: unknown; +} + +export interface TFormValidation { + validator: TFormValidator; + config: TFormValidationConfig; +} + +/** Controls form-level validation timing and error collection. */ +export interface TFormValidationConfig { + /** Validation triggers used before the first submit. */ + validateOn: readonly TValidateTrigger[]; + /** Validation triggers used after the first submit. */ + revalidateOn: readonly TRevalidateTrigger[]; + /** `'firstError'` stops after the first error; `'all'` collects every error. */ + collectErrorMode: TCollectErrorMode; +} + +/** Validates the full form data for cross-field and form-level constraints. */ +export type TFormValidator = StandardSchemaV1; + +export interface TFormErrors { + /** Field-level errors keyed by field, including form-level validator errors routed by field path. */ + fields: TFormFieldErrors; + /** Pathless form-level validator errors, plus errors whose path does not match a field. */ + form: readonly TFormError[]; +} + +export type TFormError = TValidationError; + +export type TFormFieldErrors = { + [Key in TFormFieldKey]?: readonly TFormFieldError[]; +}; + +export type TFormFieldError = TValidationError; + +// MARK: - Form Field + +/** Form field object returned by `createFormField()`. */ +export type TFormField = TState]>; + +export type TFormFieldFeature = TFeature< + 'form-field', + { + /** @internal */ + _validation?: TFormFieldValidation; + /** @internal Increments to prevent stale async validation runs from committing after reset or newer validation. */ + _validationRunId: number; + /** @internal Stores the field validator result before routed form-level errors are merged into `status`. */ + _fieldValidatorStatus: TValidationStatusValue; + /** @internal Form-level validator errors routed to this field by path. */ + _formValidatorErrors: readonly TValidationError[]; + /** @internal */ + _callbacks: TFormFieldCallbacks; + /** The field's key within the form data shape. */ + key: string; + /** The value this field resets to. Updated by `resetDirty()` or a successful `submit({ updateDefaultValues: true })`. */ + defaultValue: GValue; + /** True after the field has been blurred at least once. */ + isTouched: TState; + /** True after the standalone field or parent form has been submitted. */ + isSubmitted: TState; + /** True while an async field validator is running. */ + isValidating: TState; + /** Field display status, including this field's validator errors and routed form-level errors. */ + status: TFormFieldStatus; + /** @internal Updates routed form-level errors and syncs the public field status. */ + _applyFormValidatorErrors: (errors: readonly TValidationError[]) => void; + /** Runs the field validator and updates `status`. */ + validate: () => Promise; + /** Registers a callback for future blur events. Returns an unsubscribe function. */ + onBlur: (callback: TFormFieldBlurCallback) => () => void; + /** Marks the field as touched and runs blur validation when configured. */ + blur: () => void; + /** Resets the field to `defaultValue` and clears validation state. */ + reset: () => void; + } +>; + +export type TFormFieldStatus = TValidationStatus; + +/** Stores a field-level validator with its timing and error collection config. */ +export interface TFormFieldValidation { + validator: TFormFieldValidator; + config: TFormFieldValidationConfig; +} + +/** Controls field-level validation timing and error collection. */ +export interface TFormFieldValidationConfig { + /** Validation triggers used before the field is submitted. */ + validateOn: readonly TValidateTrigger[]; + /** Validation triggers used after the field is submitted. */ + revalidateOn: readonly TRevalidateTrigger[]; + /** `'firstError'` stops after the first error; `'all'` collects every error. */ + collectErrorMode: TCollectErrorMode; +} + +/** Validates one field value with Standard Schema. */ +export type TFormFieldValidator = StandardSchemaV1; + +export interface TFormFieldCallbacks { + blur: TFormFieldBlurCallback[]; +} + +export type TFormFieldBlurCallback = (context: TFormFieldBlurContext) => void; + +export interface TFormFieldBlurContext { + /** True if the field was already touched before this blur. */ + wasTouched: boolean; +} + +// MARK: - Validation + +/** Controls whether validation keeps the first issue or all issues returned by Standard Schema. */ +export type TCollectErrorMode = 'firstError' | 'all'; + +/** Validation event names. `touched` runs after the first blur and then on later changes. */ +export type TValidateTrigger = 'blur' | 'change' | 'submit' | 'touched'; + +/** Revalidation runs after submit, where `touched` no longer applies. */ +export type TRevalidateTrigger = Exclude; + +/** Validation status state used by forms and fields. */ +export type TValidationStatus = TState< + TValidationStatusValue, + [TIsEqualFeature] +>; + +export type TValidationStatusValue = + | TInvalidValidationStatus + | TValidValidationStatus + | TUnvalidatedValidationStatus; + +/** Validation failed with one or more errors. */ +export interface TInvalidValidationStatus { + type: 'invalid'; + errors: readonly TValidationError[]; +} + +export interface TValidationError { + /** Human-readable error description. */ + message: string; + /** Standard Schema-compatible path associated with the error. */ + path?: TValidationPath; +} + +/** Standard Schema-compatible validation path segments. */ +export type TValidationPath = readonly PropertyKey[]; + +export interface TValidValidationStatus { + type: 'valid'; +} + +/** Validation has not run yet. */ +export interface TUnvalidatedValidationStatus { + type: 'unvalidated'; +} diff --git a/packages/feature-form/src/types/features.ts b/packages/feature-form/src/types/features.ts deleted file mode 100644 index 71c74cfd..00000000 --- a/packages/feature-form/src/types/features.ts +++ /dev/null @@ -1 +0,0 @@ -// no default features diff --git a/packages/feature-form/src/types/form-field.ts b/packages/feature-form/src/types/form-field.ts deleted file mode 100644 index d12439e9..00000000 --- a/packages/feature-form/src/types/form-field.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { type BitwiseFlag } from '@blgc/utils'; -import { type TState } from 'feature-state'; -import { - type TBaseValidationContext, - type TCollectErrorMode, - type TValidator -} from 'validation-adapter'; - -export type TFormField = TState]>; - -export interface TFormFieldStateFeature { - key: 'form-field'; - api: { - _config: TFormFieldStateConfig; - _intialValue: GValue | undefined; - _validator: TFormFieldValidator; - key: string; - isTouched: TState; - isSubmitted: TState; - isSubmitting: TState; - isValidating: TState; - status: TFormFieldStatus; - validate: () => Promise; - isValid: () => boolean; - blur: () => void; - reset: () => void; - }; -} - -export interface TFormFieldStateConfig { - editable: boolean; - /** - * Validation strategy before submitting. - */ - // TODO: Is BitwiseFlag to confusing for user - validateMode: BitwiseFlag; - /** - * Validation strategy after submitting. - */ - // TODO: Is BitwiseFlag to confusing for user - reValidateMode: BitwiseFlag; - collectErrorMode: TCollectErrorMode; -} - -export enum FormFieldValidateMode { - // eslint-disable-next-line @typescript-eslint/prefer-literal-enum-member -- ok here - OnBlur = 1 << 0, // 1 - // eslint-disable-next-line @typescript-eslint/prefer-literal-enum-member -- ok here - OnChange = 1 << 1, // 2 - // eslint-disable-next-line @typescript-eslint/prefer-literal-enum-member -- ok here - OnSubmit = 1 << 2, // 4 - // eslint-disable-next-line @typescript-eslint/prefer-literal-enum-member -- ok here - OnTouched = 1 << 3 // 8 -} - -export enum FormFieldReValidateMode { - // eslint-disable-next-line @typescript-eslint/prefer-literal-enum-member -- ok here - OnBlur = 1 << 0, // 1 - // eslint-disable-next-line @typescript-eslint/prefer-literal-enum-member -- ok here - OnChange = 1 << 1, // 2 - // eslint-disable-next-line @typescript-eslint/prefer-literal-enum-member -- ok here - OnSubmit = 1 << 2 // 4 -} - -export type TFormFieldStatus = TState; - -export interface TFormFielStatusStateFeature { - key: 'form-field-status'; - api: { - _nextValue?: TFormFieldStatusValue; - registerNextError: (error: TInvalidFormFieldError) => void; - }; -} - -export type TFormFieldStatusValue = - | TInvalidFormFieldStatus - | TValidFormFieldStatus - | TUnvalidatedFormFieldStatus; - -export interface TInvalidFormFieldStatus { - type: 'INVALID'; - errors: TInvalidFormFieldError[]; -} - -export interface TValidFormFieldStatus { - type: 'VALID'; -} - -export interface TUnvalidatedFormFieldStatus { - type: 'UNVALIDATED'; -} - -export interface TInvalidFormFieldError { - code: string; - message?: string; - path?: string; -} - -export type TFormFieldValidator = TValidator>; - -export type TFormFieldValidationContext = TBaseValidationContext; diff --git a/packages/feature-form/src/types/form.ts b/packages/feature-form/src/types/form.ts deleted file mode 100644 index 2b86d3fa..00000000 --- a/packages/feature-form/src/types/form.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { TFeatureDefinition, TWithFeatures } from '@blgc/types/features'; -import { type TState } from 'feature-state'; -import { type TFormField, type TInvalidFormFieldError } from './form-field'; - -// Note: TForm is not itself a state because of type issues mainly because GFormData is the main generic, -// but the State value was TFormFields. Thus We had to check if GValue extends TFormFields, -// which was unreliable in TypeScript if deeply nested. -export type TForm< - GFormData extends TFormData, - GFeatures extends TFeatureDefinition[] -> = TWithFeatures< - { - _config: TFormConfig; - _validSubmitCallbacks: TValidSubmitCallback[]; - _invalidSubmitCallbacks: TInvalidSubmitCallback[]; - fields: TFormFields; - isValid: TState; - isValidating: TState; - isSubmitted: TState; - isSubmitting: TState; - _revalidate: (cached?: boolean) => Promise; - submit: (options?: TSubmitOptions) => Promise; - validate: () => Promise; - getField: >( - key: GKey - ) => TFormFields[GKey]; - getValidData: () => Readonly | null; - getErrors: () => TInvalidFormFieldErrors; - reset: () => void; - }, - GFeatures ->; - -export type TFormFields = { - [Key in keyof GFormData]: TFormField; -}; - -export type TFormData = Record; - -export interface TSubmitOptions< - GFormData extends TFormData, - GFeatures extends TFeatureDefinition[] -> { - onValidSubmit?: TValidSubmitCallback; - onInvalidSubmit?: TInvalidSubmitCallback; - postSubmitCallback?: TPostSubmitCallback; - context?: TSubmitContext; - assignToInitial?: boolean; -} - -export type TValidSubmitCallback = ( - formData: Readonly, - context?: TSubmitContext -) => TSubmitCallbackResponse; - -export type TInvalidSubmitCallback = ( - errors: TInvalidFormFieldErrors, - context?: TSubmitContext -) => TSubmitCallbackResponse; - -export type TSubmitCallbackResponse = Promise | void | TSubmitData; - -export type TSubmitData = Record; - -export type TPostSubmitCallback< - GFormData extends TFormData, - GFeatures extends TFeatureDefinition[] -> = (form: TForm, submitData: TSubmitData) => void; - -export interface TSubmitContext { - [key: string]: unknown; - event?: unknown; -} - -export type TInvalidFormFieldErrors = { - [Key in keyof GFormData]?: readonly TInvalidFormFieldError[]; -}; - -export interface TFormConfig { - /** - * Indicates if the form is disabled. - */ - disabled: boolean; -} diff --git a/packages/feature-form/src/types/index.ts b/packages/feature-form/src/types/index.ts deleted file mode 100644 index bbda244c..00000000 --- a/packages/feature-form/src/types/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './form'; -export * from './form-field'; diff --git a/packages/feature-logger/README.md b/packages/feature-logger/README.md index a2969aa8..e39d6967 100644 --- a/packages/feature-logger/README.md +++ b/packages/feature-logger/README.md @@ -17,79 +17,279 @@

-> Status: Experimental +`feature-logger` is a console logger you compose per instance. Keep the familiar `trace`, `debug`, `info`, `warn`, and `error` methods while each logger owns its level, formatting pipeline, and output sink. -`feature-logger` is a straightforward, typesafe, and feature-based logging library. +- Set a level once and keep call sites clean +- Add prefixes, timestamps, ids, and browser styles with `.with()` features +- Capture test output with `invokeConsole` instead of patching global `console` +- Add custom middleware or methods without changing unrelated loggers -- **Lightweight & Tree Shakable**: Function-based and modular design (< 1KB minified) -- **Fast**: Minimal code ensures high performance -- **Modular & Extendable**: Easily extendable with features like `withTimestamp()`, `withPrefix()`, .. -- **Typesafe**: Build with TypeScript for strong type safety -- **Standalone**: Zero dependencies, ensuring ease of use in various environments +```ts +import { createLogger, ELogLevel, prefixFeature, timestampPrefixFeature } from 'feature-logger'; + +const logger = createLogger({ level: ELogLevel.INFO }).with( + prefixFeature('[App]'), + timestampPrefixFeature() +); + +logger.debug('hidden'); // below INFO, not emitted +logger.info('server ready'); // emits with the app prefix and a timestamp -### 🌟 Motivation +// Test: capture output without patching console or using spies +const logs: Array<[string, unknown[]]> = []; +const testLogger = createLogger({ + invokeConsole: (data, context) => { + logs.push([context.logMethod, data]); + } +}).with(prefixFeature('[App]')); + +testLogger.warn('something happened'); +// logs = [['warn', ['[App] something happened']]] +``` -Create a typesafe, straightforward, and lightweight logging library designed to be modular and extendable with features like `withTimestamp()`, `withPrefix()`, .. +## Install -### ⚖️ Alternatives +```bash +npm install feature-logger +``` -- [winston](https://github.com/winstonjs/winston) -- [pino](https://github.com/pinojs/pino) +## Usage -## 📖 Usage +Create a logger and write messages. Set a level to suppress lower-priority output: ```ts -import { createLogger } from 'feature-logger'; +import { createLogger, ELogLevel } from 'feature-logger'; -export const logger = createLogger(); +const logger = createLogger({ level: ELogLevel.INFO }); -logger.trace("I'm a trace message!"); -logger.log("I'm a log message!"); -logger.info("I'm a info message!"); -logger.warn("I'm a warn message!"); -logger.error("I'm a error message!"); +logger.debug('hidden'); // below INFO, not emitted +logger.info('server started'); +logger.error('request failed', { status: 500 }); ``` -## 📙 Features +Add `logMethodPrefixFeature` to label each line with its log level, and `styleFeature` to apply color in browser consoles: + +```ts +import { createLogger, logMethodPrefixFeature, styleFeature } from 'feature-logger'; -### `withPrefix()` +const logger = createLogger().with(logMethodPrefixFeature(), styleFeature()); + +logger.error('connection lost'); // "Error: connection lost" in red +logger.warn('retrying'); // "Warn: retrying" in orange +``` -Adds a static prefix to all log messages, allowing for consistent and easily identifiable log entries. +Swap the console invoker to capture output in tests without patching `console`: ```ts -import { createLogger, withPrefix } from 'feature-logger'; +const logs: Array<[string, unknown[]]> = []; +const logger = createLogger({ + invokeConsole: (data, context) => { + logs.push([context.logMethod, data]); + } +}); + +logger.warn('something happened'); +// logs = [['warn', ['something happened']]] +``` + +## Logger + +### `createLogger(options?)` + +Creates a logger instance and returns it as a feature host. + +```ts +import { createLogger, ELogLevel } from 'feature-logger'; + +const logger = createLogger({ + active: true, + level: ELogLevel.INFO +}); + +logger.debug('hidden'); +logger.info('visible'); +``` + +| Option | Default | Description | +| --------------- | --------------- | ------------------------------------------------ | +| `active` | `true` | When false, all log calls are skipped. | +| `level` | `ELogLevel.ALL` | Minimum level that should be emitted. | +| `middleware` | `[]` | Logger middleware applied to every log call. | +| `invokeConsole` | `console.*` | Custom console invoker, useful for tests or I/O. | + +Custom invokers and middleware receive `(data, context)`. `data` is the array passed to the log method, and `context` contains the `logMethod`, `level`, and optional per-call middleware. + +`ELogLevel` uses ordered threshold values: `ALL = 0`, `TRACE = 100`, `DEBUG = 200`, `LOG = 300`, `INFO = 400`, `WARN = 500`, and `ERROR = 600`. + +### Log Methods -const logger = withPrefix(createLogger(), 'PREFIX'); +Each method maps to its matching `console.*` call and accepts the same arguments. -logger.log('This is a log message.'); -// Output: "PREFIX This is a log message." +```ts +logger.trace('trace'); +logger.debug('debug'); +logger.log('log'); +logger.info('info'); +logger.warn('warn'); +logger.error('error'); ``` -### `withTimestamp()` +## Built-in Features + +Features are installed via `.with()` and can add formatting, override methods, or add extra methods. + +### `prefixFeature(prefix, options?)` -Adds a timestamp to all log messages, enabling you to track when each log entry was created. +Adds a static prefix to each log call. ```ts -import { createLogger, withTimestamp } from 'feature-logger'; +const logger = createLogger().with(prefixFeature('[API]')); + +logger.log('ready'); // "[API] ready" +``` -const logger = withTimestamp(createLogger()); +`newLineBehavior` controls multiline string messages: -logger.log('This is a log message.'); -// Output: "[MM/DD/YYYY, HH:MM:SS AM/PM] This is a log message." +| Value | Description | +| ---------- | ------------------------------------------------------ | +| `'indent'` | Prefixes the first line and indents following lines. | +| `'prefix'` | Prefixes every line. | +| `'ignore'` | Treats the message as one string and prefixes it once. | + +### `timestampPrefixFeature(options?)` + +Adds the current local timestamp to each log call. + +```ts +const logger = createLogger().with( + timestampPrefixFeature({ + formatTimestamp: (date) => `[${date.toISOString()}]` + }) +); + +logger.info('ready'); ``` -### `withMethodPrefix()` +| Option | Default | Description | +| ----------------- | ------------------------- | ----------------------------------------------- | +| `formatTimestamp` | `[date.toLocaleString()]` | Formats the timestamp prefix for each log call. | -Adds the log method name as a prefix to all log messages, allowing you to easily identify the log level or method used. +### `logMethodPrefixFeature(options?)` + +Adds the console method name to each log call. ```ts -import { createLogger, withMethodPrefix } from 'feature-logger'; +const logger = createLogger().with( + logMethodPrefixFeature({ + formatLogMethod: (method) => `[${method.toUpperCase()}]` + }) +); + +logger.error('failed'); // "[ERROR] failed" +``` + +| Option | Default | Description | +| ----------------- | ------------ | -------------------------------------------- | +| `formatLogMethod` | `LogMethod:` | Formats the log method prefix for each call. | -const logger = withMethodPrefix(createLogger()); +### `styleFeature(styles?)` -logger.log('This is a log message.'); -// Output: "Log: This is a log message." +Applies browser console CSS styles to string messages. Custom styles override the defaults by log method. + +```ts +const logger = createLogger().with( + styleFeature({ + info: 'color: dodgerblue; font-weight: bold' + }) +); + +logger.info('styled'); +``` + +### `logIdFeature(options?)` + +Prefixes every log call with a generated id and makes each log method return it. + +```ts +const logger = createLogger().with(logIdFeature()); + +const id = logger.log('created'); +``` + +The existing `trace`, `debug`, `log`, `info`, `warn`, and `error` methods keep their console-like arguments, but return the generated id. + +| Option | Default | Description | +| ------------ | -------------- | ----------------------------------------- | +| `generateId` | 16-char hex id | Creates the id returned by each log call. | +| `formatId` | `[id]` | Formats the id before it is prefixed. | + +## Extending with Features + +Loggers are `feature-core` feature hosts. A custom feature can add middleware, add methods, or both. + +```ts +import { defineFeature, type TFeature } from 'feature-core'; +import type { TLoggerBase } from 'feature-logger'; + +export function labelFeature(label: string): TLabelFeature { + return defineFeature({ + key: 'label', + install(logger: TLoggerBase) { + logger._middleware.push((next) => { + return (data, context) => { + if (typeof data[0] === 'string') { + next([`${label}: ${data[0]}`, ...data.slice(1)], context); + return; + } + + next([label, ...data], context); + }; + }); + + return {}; + } + }); +} + +export type TLabelFeature = TFeature<'label', object>; +``` + +## FAQ + +### How does it compare to Winston, Pino, and debug? + +`feature-logger` keeps the console API and focuses on per-instance composition. Use it when you want formatted console output, testable invocation, and custom middleware without adopting transports, JSON logging, or a global namespace registry. + +- [winston](https://github.com/winstonjs/winston): full-featured Node.js logger with transports and structured logging +- [pino](https://github.com/pinojs/pino): high-performance JSON logger for Node.js +- [debug](https://github.com/debug-js/debug): lightweight namespace-based debug logger + +### What is the difference between `invokeConsole` and middleware? + +`invokeConsole` is the final step that writes to the console. It receives the fully processed data after all middleware has run. Middleware transforms data before it reaches `invokeConsole`. Use middleware to modify or annotate log output. Use `invokeConsole` to redirect it entirely. + +### When should I use `active: false` instead of setting a high `level`? + +Use `active: false` to silence everything without changing the level threshold. This is useful for toggling a logger on and off at runtime while preserving the configured level for when it is re-enabled. + +### Can I use this in Node.js? + +Yes. The logger calls `console.*` methods directly and works in any environment that provides a standard `console` object. `styleFeature` uses `%c` CSS directives, which Node ignores silently, so it is safe to install in shared code. + +### Can I compose multiple features? + +Yes. Call `.with()` once with multiple features or chain multiple `.with()` calls. Features are installed in order and each can add middleware, methods, or both. + +### How do I test a logger that uses features like `prefixFeature`? + +Pass a custom `invokeConsole` to `createLogger`, then apply the same features with `.with()`. The `invokeConsole` receives data after all middleware runs, so the captured output reflects the full formatting pipeline. + +```ts +const logs: string[] = []; +const logger = createLogger({ + invokeConsole: (data) => logs.push(data.join(' ')) +}).with(prefixFeature('[Auth]')); -logger.error('This is an error message.'); -// Output: "Error: This is an error message." +logger.info('token verified'); +// logs = ['[Auth] token verified'] ``` diff --git a/packages/feature-logger/package.json b/packages/feature-logger/package.json index 3cbf94ec..6e3e0c20 100644 --- a/packages/feature-logger/package.json +++ b/packages/feature-logger/package.json @@ -1,9 +1,25 @@ { "name": "feature-logger", - "version": "0.0.47", + "version": "0.1.0-beta.1", "private": false, - "description": "Straightforward, typesafe, and feature-based logging library", - "keywords": [], + "description": "Composable console logger with per-instance formatting, levels, and testable output sinks.", + "keywords": [ + "logger", + "logging", + "console", + "console-logger", + "typescript", + "composable", + "extensible", + "prefix", + "timestamp", + "middleware", + "log-level", + "testable", + "browser", + "debug", + "node" + ], "homepage": "https://builder.group/?utm_source=package-json", "bugs": { "url": "https://github.com/builder-group/community/issues" @@ -35,8 +51,7 @@ "update:latest": "pnpm update --latest" }, "dependencies": { - "@blgc/types": "workspace:*", - "@blgc/utils": "workspace:*" + "feature-core": "workspace:*" }, "devDependencies": { "@blgc/config": "workspace:*", diff --git a/packages/feature-logger/src/__tests__/mock-console.ts b/packages/feature-logger/src/__tests__/mock-console.ts index 29fe96f9..4c85f74b 100644 --- a/packages/feature-logger/src/__tests__/mock-console.ts +++ b/packages/feature-logger/src/__tests__/mock-console.ts @@ -1,21 +1,17 @@ import { vi, type MockInstance } from 'vitest'; -export function mockConsole(spyOnMethods: TConsoleMethod[], consoleSpies: TConsoleSpies) { - spyOnMethods.forEach((type) => { - consoleSpies[type] = vi.spyOn(console, type); - }); +export function mockConsole(methods: TConsoleMethod[], consoleSpies: TConsoleSpies): void { + for (const method of methods) { + consoleSpies[method] = vi.spyOn(console, method).mockImplementation(() => undefined); + } } -export function restoreConsoleMock(consoleSpies: TConsoleSpies) { - Object.values(consoleSpies).forEach((spy) => { +export function restoreConsoleMock(consoleSpies: TConsoleSpies): void { + for (const spy of Object.values(consoleSpies)) { spy.mockRestore(); - }); + } } -export type TConsoleMethod = keyof Console; +export type TConsoleMethod = 'debug' | 'trace' | 'log' | 'info' | 'warn' | 'error'; -export type TConsoleSpies = TSpies; - -export type TSpies = { - [K in T[number]]?: MockInstance; -}; +export type TConsoleSpies = Partial>; diff --git a/packages/feature-logger/src/create-logger.test.ts b/packages/feature-logger/src/create-logger.test.ts index 56c075c7..82148f72 100644 --- a/packages/feature-logger/src/create-logger.test.ts +++ b/packages/feature-logger/src/create-logger.test.ts @@ -1,111 +1,141 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; import { mockConsole, restoreConsoleMock, type TConsoleSpies } from './__tests__/mock-console'; -import { createLogger, LOG_LEVEL } from './create-logger'; -import { type TLoggerMiddleware } from './types'; +import { createLogger, ELogLevel } from './create-logger'; +import { logIdFeature, prefixFeature } from './features'; +import type { TLoggerMiddleware } from './types'; describe('createLogger function', () => { const consoleSpies: TConsoleSpies = {}; beforeEach(() => { - mockConsole(['log', 'trace', 'info', 'warn', 'error'], consoleSpies); + mockConsole(['trace', 'debug', 'log', 'info', 'warn', 'error'], consoleSpies); }); afterEach(() => { restoreConsoleMock(consoleSpies); }); - it('should log messages based on default log levels', () => { - const logger = createLogger(); + describe('types', () => { + it('should infer installed feature APIs through the feature-core chain', () => { + // Act + const logger = createLogger().with(prefixFeature('[App]'), logIdFeature()); - logger.trace('trace message'); - expect(consoleSpies.trace).toHaveBeenCalledWith('trace message'); - - logger.log('log message'); - expect(consoleSpies.log).toHaveBeenCalledWith('log message'); - - logger.info('info message'); - expect(consoleSpies.info).toHaveBeenCalledWith('info message'); - - logger.warn('warn message'); - expect(consoleSpies.warn).toHaveBeenCalledWith('warn message'); - - logger.error('error message'); - expect(consoleSpies.error).toHaveBeenCalledWith('error message'); + // Assert + expectTypeOf(logger.log).toEqualTypeOf<(...data: unknown[]) => string>(); + expectTypeOf(logger._features).toEqualTypeOf(); + }); }); - it('should not log messages below the set log level', () => { - const logger = createLogger({ level: LOG_LEVEL.WARN }); - - logger.trace('trace message'); - expect(consoleSpies.trace).not.toHaveBeenCalled(); - - logger.log('log message'); - expect(consoleSpies.log).not.toHaveBeenCalled(); - - logger.info('info message'); - expect(consoleSpies.info).not.toHaveBeenCalled(); - - logger.warn('warn message'); - expect(consoleSpies.warn).toHaveBeenCalledWith('warn message'); - - logger.error('error message'); - expect(consoleSpies.error).toHaveBeenCalledWith('error message'); + describe('log methods', () => { + it('should call console methods at the default level', () => { + // Prepare + const logger = createLogger(); + + // Act + logger.trace('trace message'); + logger.debug('debug message'); + logger.log('log message'); + logger.info('info message'); + logger.warn('warn message'); + logger.error('error message'); + + // Assert + expect(consoleSpies.trace).toHaveBeenCalledWith('trace message'); + expect(consoleSpies.debug).toHaveBeenCalledWith('debug message'); + expect(consoleSpies.log).toHaveBeenCalledWith('log message'); + expect(consoleSpies.info).toHaveBeenCalledWith('info message'); + expect(consoleSpies.warn).toHaveBeenCalledWith('warn message'); + expect(consoleSpies.error).toHaveBeenCalledWith('error message'); + }); + + it('should skip messages below the configured level', () => { + // Prepare + const logger = createLogger({ level: ELogLevel.WARN }); + + // Act + logger.info('info message'); + logger.warn('warn message'); + logger.error('error message'); + + // Assert + expect(consoleSpies.info).not.toHaveBeenCalled(); + expect(consoleSpies.warn).toHaveBeenCalledWith('warn message'); + expect(consoleSpies.error).toHaveBeenCalledWith('error message'); + }); + + it('should skip all messages when inactive', () => { + // Prepare + const logger = createLogger({ active: false }); + + // Act + logger.error('error message'); + + // Assert + expect(consoleSpies.error).not.toHaveBeenCalled(); + }); }); - it('should respect the active flag', () => { - const logger = createLogger({ active: false }); - - logger.trace('trace message'); - expect(consoleSpies.trace).not.toHaveBeenCalled(); - - logger.log('log message'); - expect(consoleSpies.log).not.toHaveBeenCalled(); - - logger.info('info message'); - expect(consoleSpies.info).not.toHaveBeenCalled(); - - logger.warn('warn message'); - expect(consoleSpies.warn).not.toHaveBeenCalled(); - - logger.error('error message'); - expect(consoleSpies.error).not.toHaveBeenCalled(); - }); - - it('should apply middlewares correctly', () => { - const middleware: TLoggerMiddleware = vi.fn((next: Function) => next) as any; - const logger = createLogger({ middlewares: [middleware] }); - - logger.log('log message'); - expect(middleware).toHaveBeenCalled(); - }); - - it('should invoke custom invokeConsole if provided', () => { - const customInvokeConsole = vi.fn(); - const logger = createLogger({ invokeConsole: customInvokeConsole }); - - logger.log('log message'); - expect(customInvokeConsole).toHaveBeenCalledWith('log', ['log message']); - }); - - it('should call the baseLog method correctly', () => { - const logger = createLogger({ level: LOG_LEVEL.LOG }); - const baseLogSpy = vi.spyOn(logger, '_baseLog' as any); - - logger.log('log message'); - expect(baseLogSpy).toHaveBeenCalledWith({ logMethod: 'log', level: LOG_LEVEL.LOG }, [ - 'log message' - ]); + describe('_baseLog method', () => { + it('should compose global and context middleware from right to left', () => { + // Prepare + const calls: string[] = []; + const globalMiddleware: TLoggerMiddleware = (next) => { + return (data, context) => { + calls.push('global'); + next([`global:${data[0]}`], context); + }; + }; + const contextMiddleware: TLoggerMiddleware = (next) => { + return (data, context) => { + calls.push('context'); + next([`context:${data[0]}`], context); + }; + }; + const invokeConsole = vi.fn(); + const logger = createLogger({ invokeConsole, middleware: [globalMiddleware] }); + + // Act + logger._baseLog(['message'], { + logMethod: 'log', + level: ELogLevel.LOG, + middleware: [contextMiddleware] + }); + + // Assert + expect(calls).toEqual(['global', 'context']); + expect(invokeConsole).toHaveBeenCalledWith( + ['context:global:message'], + expect.objectContaining({ logMethod: 'log' }) + ); + }); }); - it('should handle custom category middlewares correctly', () => { - const categoryMiddleware: TLoggerMiddleware = vi.fn((next: Function) => next) as any; - const logger = createLogger(); - (logger as any)._baseLog = vi.fn(logger._baseLog.bind(logger)); - - logger._baseLog({ logMethod: 'log', level: LOG_LEVEL.LOG, middlewares: [categoryMiddleware] }, [ - 'log message' - ]); - - expect(categoryMiddleware).toHaveBeenCalled(); + describe('invokeConsole option', () => { + it('should use a custom console invoker when provided', () => { + // Prepare + const invokeConsole = vi.fn(); + const logger = createLogger({ invokeConsole }); + + // Act + logger.log('log message'); + + // Assert + expect(invokeConsole).toHaveBeenCalledWith( + ['log message'], + expect.objectContaining({ logMethod: 'log' }) + ); + }); + + it('should forward an empty data array when called without data', () => { + // Prepare + const invokeConsole = vi.fn(); + const logger = createLogger({ invokeConsole }); + + // Act + logger.log(); + + // Assert + expect(invokeConsole).toHaveBeenCalledWith([], expect.objectContaining({ logMethod: 'log' })); + }); }); }); diff --git a/packages/feature-logger/src/create-logger.ts b/packages/feature-logger/src/create-logger.ts index 8c1f7cbd..27d797b2 100644 --- a/packages/feature-logger/src/create-logger.ts +++ b/packages/feature-logger/src/create-logger.ts @@ -1,74 +1,91 @@ -import { TLoggerMiddleware, type TInvokeConsole, type TLogger, type TLogMethod } from './types'; +import { createFeatureHost } from 'feature-core'; +import type { TInvokeConsole, TLogger, TLoggerBase, TLoggerMiddleware } from './types'; +/** + * Creates a logger with `trace`, `debug`, `log`, `info`, `warn`, and `error` methods. + * + * Set `level` to suppress output below a minimum priority. Set `active` to `false` to + * silence all output without removing the logger. Pass `invokeConsole` to redirect output + * or capture it in tests without patching `console`. + * Extend with features using `.with(feature())`. + */ export function createLogger(options: TCreateLoggerOptions = {}): TLogger<[]> { - const { active = true, level = 0, middlewares = [] } = options; + const { active = true, level = ELogLevel.ALL, middleware = [], invokeConsole } = options; - let invokeConsole: TInvokeConsole; - if (typeof options.invokeConsole === 'function') { - invokeConsole = options.invokeConsole; - } else if (typeof console === 'object') { - invokeConsole = defaultInvokeConsole; - } else { - throw Error(`Failed to invoke console object!`); - } - - return { - _features: [], + return createFeatureHost({ + _invokeConsole: resolveInvokeConsole(invokeConsole), active, level, - middlewares, - _invokeConsole: invokeConsole, - _baseLog(category, data) { - if (this.active && category.level >= this.level) { - this.middlewares - .concat(category.middlewares ?? []) - .reduceRight((acc, middleware) => middleware(acc), invokeConsole)( - category.logMethod, - data - ); + _middleware: middleware, + _baseLog(data, context) { + if (!this.active || context.level < this.level) { + return; } + + const invokeConsoleWithMiddleware = this._middleware + .concat(context.middleware ?? []) + .reduceRight((next, middleware) => middleware(next), this._invokeConsole); + invokeConsoleWithMiddleware(data, context); }, - trace(message, ...optionalParams) { - this._baseLog({ logMethod: 'trace', level: LOG_LEVEL.TRACE }, [message, ...optionalParams]); + trace(...data) { + this._baseLog(data, { logMethod: 'trace', level: ELogLevel.TRACE }); }, - debug(message, ...optionalParams) { - this._baseLog({ logMethod: 'debug', level: LOG_LEVEL.DEBUG }, [message, ...optionalParams]); + debug(...data) { + this._baseLog(data, { logMethod: 'debug', level: ELogLevel.DEBUG }); }, - log(message, ...optionalParams) { - this._baseLog({ logMethod: 'log', level: LOG_LEVEL.LOG }, [message, ...optionalParams]); + log(...data) { + this._baseLog(data, { logMethod: 'log', level: ELogLevel.LOG }); }, - info(message, ...optionalParams) { - this._baseLog({ logMethod: 'info', level: LOG_LEVEL.INFO }, [message, ...optionalParams]); + info(...data) { + this._baseLog(data, { logMethod: 'info', level: ELogLevel.INFO }); }, - warn(message, ...optionalParams) { - this._baseLog({ logMethod: 'warn', level: LOG_LEVEL.WARN }, [message, ...optionalParams]); + warn(...data) { + this._baseLog(data, { logMethod: 'warn', level: ELogLevel.WARN }); }, - error(message, ...optionalParams) { - this._baseLog({ logMethod: 'error', level: LOG_LEVEL.ERROR }, [message, ...optionalParams]); + error(...data) { + this._baseLog(data, { logMethod: 'error', level: ELogLevel.ERROR }); } - }; + }); } export interface TCreateLoggerOptions { + /** Whether the logger is active. When `false`, all log calls are silenced. Defaults to `true`. */ active?: boolean; + /** Minimum log level. Calls below this level are suppressed. Defaults to `ELogLevel.ALL`. */ level?: number; - middlewares?: TLoggerMiddleware[]; + /** Initial middleware stack. Merged with middleware added later via `.with()`. */ + middleware?: TLoggerMiddleware[]; + /** Custom console invoker. Use to redirect output or capture logs in tests. */ invokeConsole?: TInvokeConsole; } -function defaultInvokeConsole(logMethod: TLogMethod, data: unknown[]): void { - if (logMethod in console && typeof console[logMethod] === 'function') { - console[logMethod](...data); - } else { - throw Error(`Failed to invoke console.${logMethod}!`); - } +/** Numeric severity values used by logger level filtering. Higher values are more severe. */ +export enum ELogLevel { + ALL = 0, + TRACE = 100, + DEBUG = 200, + LOG = 300, + INFO = 400, + WARN = 500, + ERROR = 600 } -export enum LOG_LEVEL { - TRACE = 4, - DEBUG = 8, - LOG = 16, - INFO = 32, - WARN = 64, - ERROR = 128 +function resolveInvokeConsole(invokeConsole: TInvokeConsole | undefined): TInvokeConsole { + if (invokeConsole != null) { + return invokeConsole; + } + + if (typeof console !== 'object') { + throw new Error('Failed to resolve console object'); + } + + return (data, context) => { + const { logMethod } = context; + if (logMethod in console && typeof console[logMethod] === 'function') { + console[logMethod](...data); + return; + } + + throw new Error(`Failed to invoke console.${logMethod}`); + }; } diff --git a/packages/feature-logger/src/features/index.ts b/packages/feature-logger/src/features/index.ts index 3cd0e492..3bcf1a84 100644 --- a/packages/feature-logger/src/features/index.ts +++ b/packages/feature-logger/src/features/index.ts @@ -1,5 +1,5 @@ -export * from './with-log-id'; -export * from './with-log-method-prefix'; -export * from './with-prefix'; -export * from './with-style'; -export * from './with-timestamp-prefix'; +export * from './log-id'; +export * from './log-method-prefix'; +export * from './prefix'; +export * from './style'; +export * from './timestamp-prefix'; diff --git a/packages/feature-logger/src/features/log-id.test.ts b/packages/feature-logger/src/features/log-id.test.ts new file mode 100644 index 00000000..13e04a0a --- /dev/null +++ b/packages/feature-logger/src/features/log-id.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; +import { mockConsole, restoreConsoleMock, type TConsoleSpies } from '../__tests__/mock-console'; +import { createLogger, ELogLevel } from '../create-logger'; +import type { TLogContext } from '../types'; +import { logIdFeature, type TLogIdFeature } from './log-id'; + +describe('logIdFeature function', () => { + const consoleSpies: TConsoleSpies = {}; + + beforeEach(() => { + mockConsole(['log', 'error'], consoleSpies); + }); + + afterEach(() => { + restoreConsoleMock(consoleSpies); + }); + + describe('types', () => { + it('should override log methods with id-returning methods', () => { + // Act + const logger = createLogger().with(logIdFeature()); + + // Assert + expectTypeOf(logger.log).toEqualTypeOf<(...data: unknown[]) => string>(); + expectTypeOf(logger._baseLogWithId).toEqualTypeOf< + (data: unknown[], context: TLogContext) => string + >(); + expectTypeOf(logger._features).toEqualTypeOf(); + }); + }); + + describe('_baseLogWithId method', () => { + it('should prefix a generated id and return it', () => { + // Prepare + const logger = createLogger().with(logIdFeature({ generateId: () => 'test-id' })); + + // Act + const id = logger._baseLogWithId(['log message'], { + logMethod: 'log', + level: ELogLevel.LOG + }); + + // Assert + expect(id).toBe('test-id'); + expect(consoleSpies.log).toHaveBeenCalledWith('[test-id] log message'); + }); + }); + + describe('log methods', () => { + it('should prefix a generated id and return it', () => { + // Prepare + const generateId = vi.fn(() => 'test-id'); + const logger = createLogger().with(logIdFeature({ generateId })); + + // Act + const id = logger.log('log message', 'details'); + + // Assert + expect(id).toBe('test-id'); + expect(generateId).toHaveBeenCalledTimes(1); + expect(consoleSpies.log).toHaveBeenCalledWith('[test-id] log message', 'details'); + }); + + it('should prefix a generated id as a separate argument for non-string messages', () => { + // Prepare + const logger = createLogger().with(logIdFeature({ generateId: () => 'test-id' })); + const error = new Error('test'); + + // Act + const id = logger.error(error, { context: 'test' }); + + // Assert + expect(id).toBe('test-id'); + expect(consoleSpies.error).toHaveBeenCalledWith('[test-id]', error, { context: 'test' }); + }); + + it('should support custom id formatting', () => { + // Prepare + const logger = createLogger().with( + logIdFeature({ + generateId: () => 'test-id', + formatId: (id) => `(ID:${id})` + }) + ); + + // Act + const id = logger.log('log message'); + + // Assert + expect(id).toBe('test-id'); + expect(consoleSpies.log).toHaveBeenCalledWith('(ID:test-id) log message'); + }); + }); +}); diff --git a/packages/feature-logger/src/features/log-id.ts b/packages/feature-logger/src/features/log-id.ts new file mode 100644 index 00000000..1f9c112d --- /dev/null +++ b/packages/feature-logger/src/features/log-id.ts @@ -0,0 +1,79 @@ +import { defineFeature, type TFeature } from 'feature-core'; +import { ELogLevel } from '../create-logger'; +import type { TLogContext, TLoggerBase, TLogMethod } from '../types'; + +/** Adds id prefixes to log calls and makes each log method return the generated id. */ +export function logIdFeature(options: TLogIdFeatureOptions = {}): TLogIdFeature { + const { generateId = defaultGenerateLogId, formatId = defaultFormatLogId } = options; + + return defineFeature({ + key: 'log-id', + overrides: ['trace', 'debug', 'log', 'info', 'warn', 'error'], + install() { + return { + _baseLogWithId(this: TLoggerBase, data, context) { + const id = generateId(); + const formattedId = formatId(id, context); + if (typeof data[0] === 'string') { + data[0] = `${formattedId} ${data[0]}`; + } else { + data.unshift(formattedId); + } + + this._baseLog(data, context); + + return id; + }, + trace(this: TLoggerBase & TLogIdFeatureApi, ...data) { + return this._baseLogWithId(data, { logMethod: 'trace', level: ELogLevel.TRACE }); + }, + debug(this: TLoggerBase & TLogIdFeatureApi, ...data) { + return this._baseLogWithId(data, { logMethod: 'debug', level: ELogLevel.DEBUG }); + }, + log(this: TLoggerBase & TLogIdFeatureApi, ...data) { + return this._baseLogWithId(data, { logMethod: 'log', level: ELogLevel.LOG }); + }, + info(this: TLoggerBase & TLogIdFeatureApi, ...data) { + return this._baseLogWithId(data, { logMethod: 'info', level: ELogLevel.INFO }); + }, + warn(this: TLoggerBase & TLogIdFeatureApi, ...data) { + return this._baseLogWithId(data, { logMethod: 'warn', level: ELogLevel.WARN }); + }, + error(this: TLoggerBase & TLogIdFeatureApi, ...data) { + return this._baseLogWithId(data, { logMethod: 'error', level: ELogLevel.ERROR }); + } + }; + } + }); +} + +export type TLogIdFeature = TFeature<'log-id', TLogIdFeatureApi, [], TLogMethod>; + +/** Logger API after `logIdFeature()` makes each log method return the generated id. */ +export interface TLogIdFeatureApi { + /** @internal */ + _baseLogWithId(data: unknown[], context: TLogContext): string; + trace(...data: unknown[]): string; + debug(...data: unknown[]): string; + log(...data: unknown[]): string; + info(...data: unknown[]): string; + warn(...data: unknown[]): string; + error(...data: unknown[]): string; +} + +export interface TLogIdFeatureOptions { + /** Generates a unique id for each log call. Defaults to a 16-character hex string. */ + generateId?: () => string; + /** Formats the id before prepending it to the message. Defaults to `[id]`. */ + formatId?: (id: string, context: TLogContext) => string; +} + +function defaultFormatLogId(id: string): string { + return `[${id}]`; +} + +function defaultGenerateLogId(): string { + return 'xxxxxxxxxxxxxxxx'.replace(/x/g, () => { + return ((Math.random() * 16) | 0).toString(16); + }); +} diff --git a/packages/feature-logger/src/features/log-method-prefix.test.ts b/packages/feature-logger/src/features/log-method-prefix.test.ts new file mode 100644 index 00000000..d5df4a99 --- /dev/null +++ b/packages/feature-logger/src/features/log-method-prefix.test.ts @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mockConsole, restoreConsoleMock, type TConsoleSpies } from '../__tests__/mock-console'; +import { createLogger } from '../create-logger'; +import { logMethodPrefixFeature } from './log-method-prefix'; + +describe('logMethodPrefixFeature function', () => { + const consoleSpies: TConsoleSpies = {}; + + beforeEach(() => { + mockConsole(['log', 'error'], consoleSpies); + }); + + afterEach(() => { + restoreConsoleMock(consoleSpies); + }); + + describe('formatting', () => { + it('should prepend the log method to string messages', () => { + // Prepare + const logger = createLogger().with(logMethodPrefixFeature()); + + // Act + logger.log('log message'); + logger.error('error message'); + + // Assert + expect(consoleSpies.log).toHaveBeenCalledWith('Log: log message'); + expect(consoleSpies.error).toHaveBeenCalledWith('Error: error message'); + }); + + it('should prepend the log method as a separate argument for non-string messages', () => { + // Prepare + const logger = createLogger().with(logMethodPrefixFeature()); + const data = { count: 1 }; + + // Act + logger.log(data); + + // Assert + expect(consoleSpies.log).toHaveBeenCalledWith('Log:', data); + }); + + it('should support custom log method formatting', () => { + // Prepare + const logger = createLogger().with( + logMethodPrefixFeature({ + formatLogMethod: (logMethod) => `[${logMethod.toUpperCase()}]` + }) + ); + + // Act + logger.error('error message'); + + // Assert + expect(consoleSpies.error).toHaveBeenCalledWith('[ERROR] error message'); + }); + }); +}); diff --git a/packages/feature-logger/src/features/log-method-prefix.ts b/packages/feature-logger/src/features/log-method-prefix.ts new file mode 100644 index 00000000..f64e625d --- /dev/null +++ b/packages/feature-logger/src/features/log-method-prefix.ts @@ -0,0 +1,37 @@ +import { defineFeature, type TFeature } from 'feature-core'; +import type { TLoggerBase, TLoggerMiddleware, TLogMethod } from '../types'; +import { prefixMiddleware } from './prefix'; + +/** Adds the console method name as a capitalized prefix to every log call. */ +export function logMethodPrefixFeature( + options: TLogMethodPrefixFeatureOptions = {} +): TLogMethodPrefixFeature { + return defineFeature({ + key: 'log-method-prefix', + install(logger: TLoggerBase) { + logger._middleware.push(logMethodPrefixMiddleware(options)); + + return {}; + } + }); +} + +export type TLogMethodPrefixFeature = TFeature<'log-method-prefix', object>; + +export interface TLogMethodPrefixFeatureOptions { + /** Formats the console method before it is used as a prefix. */ + formatLogMethod?: (logMethod: TLogMethod) => string; +} + +/** Middleware that prefixes each log call with its console method name. */ +export function logMethodPrefixMiddleware( + options: TLogMethodPrefixFeatureOptions = {} +): TLoggerMiddleware { + const { formatLogMethod = defaultFormatLogMethod } = options; + + return prefixMiddleware((context) => formatLogMethod(context.logMethod)); +} + +function defaultFormatLogMethod(logMethod: TLogMethod): string { + return `${logMethod.charAt(0).toUpperCase()}${logMethod.slice(1)}:`; +} diff --git a/packages/feature-logger/src/features/prefix.test.ts b/packages/feature-logger/src/features/prefix.test.ts new file mode 100644 index 00000000..af187d15 --- /dev/null +++ b/packages/feature-logger/src/features/prefix.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mockConsole, restoreConsoleMock, type TConsoleSpies } from '../__tests__/mock-console'; +import { createLogger } from '../create-logger'; +import { prefixFeature } from './prefix'; + +describe('prefixFeature function', () => { + const consoleSpies: TConsoleSpies = {}; + + beforeEach(() => { + mockConsole(['log'], consoleSpies); + }); + + afterEach(() => { + restoreConsoleMock(consoleSpies); + }); + + describe('formatting', () => { + it('should prepend a prefix to string messages', () => { + // Prepare + const logger = createLogger().with(prefixFeature('PREFIX')); + + // Act + logger.log('log message', 'details'); + + // Assert + expect(consoleSpies.log).toHaveBeenCalledWith('PREFIX log message', 'details'); + }); + + it('should prepend a prefix as a separate argument for non-string messages', () => { + // Prepare + const logger = createLogger().with(prefixFeature('PREFIX')); + const data = { count: 1 }; + + // Act + logger.log(data); + + // Assert + expect(consoleSpies.log).toHaveBeenCalledWith('PREFIX', data); + }); + + it('should indent multiline messages by default', () => { + // Prepare + const logger = createLogger().with(prefixFeature('[Test]')); + + // Act + logger.log('first line\nsecond line\nthird line'); + + // Assert + expect(consoleSpies.log).toHaveBeenCalledWith( + '[Test] first line\n second line\n third line' + ); + }); + + it('should support prefixing every multiline row', () => { + // Prepare + const logger = createLogger().with(prefixFeature('[Test]', { newLineBehavior: 'prefix' })); + + // Act + logger.log('first line\nsecond line'); + + // Assert + expect(consoleSpies.log).toHaveBeenCalledWith('[Test] first line\n[Test] second line'); + }); + }); +}); diff --git a/packages/feature-logger/src/features/prefix.ts b/packages/feature-logger/src/features/prefix.ts new file mode 100644 index 00000000..0e4863ac --- /dev/null +++ b/packages/feature-logger/src/features/prefix.ts @@ -0,0 +1,91 @@ +import { defineFeature, type TFeature } from 'feature-core'; +import type { TLogContext, TLoggerBase, TLoggerMiddleware } from '../types'; + +/** + * Adds a static prefix to every log call. + * + * `newLineBehavior` controls multiline string messages: `'indent'` prefixes the first + * line and indents the rest (default), `'prefix'` prefixes every line, `'ignore'` + * prepends the prefix once without considering newlines. + */ +export function prefixFeature(prefix: string, options: TPrefixFeatureOptions = {}): TPrefixFeature { + return defineFeature({ + key: 'prefix', + install(logger: TLoggerBase) { + logger._middleware.push(prefixMiddleware(prefix, options)); + + return {}; + } + }); +} + +export type TPrefixFeature = TFeature<'prefix', object>; + +export interface TPrefixFeatureOptions { + /** + * Controls how multiline string messages are handled. + * `'indent'` prefixes the first line and indents following lines (default). + * `'prefix'` prefixes every line. + * `'ignore'` treats the message as one string and prefixes it once. + */ + newLineBehavior?: 'indent' | 'prefix' | 'ignore'; +} + +/** Middleware that prepends a static or context-derived prefix to log data. */ +export function prefixMiddleware( + prefix: string | TLogPrefixResolver, + options: TPrefixFeatureOptions = {} +): TLoggerMiddleware { + const { newLineBehavior = 'indent' } = options; + + return (next) => { + return (data, context) => { + const resolvedPrefix = typeof prefix === 'function' ? prefix(context) : prefix; + if (typeof data[0] !== 'string') { + data.unshift(resolvedPrefix); + next(data, context); + return; + } + + switch (newLineBehavior) { + case 'indent': + data[0] = formatIndentedPrefixMessage(resolvedPrefix, data[0]); + next(data, context); + return; + case 'prefix': + data[0] = formatPrefixedLines(resolvedPrefix, data[0]); + next(data, context); + return; + case 'ignore': + data[0] = `${resolvedPrefix} ${data[0]}`; + next(data, context); + return; + } + }; + }; +} + +/** Resolves a prefix for the current log call. */ +export type TLogPrefixResolver = (context: TLogContext) => string; + +function formatIndentedPrefixMessage(prefix: string, message: string): string { + const lines = message.split('\n'); + const indentation = ' '.repeat(prefix.length + 1); + + return lines + .map((line, index) => { + if (index === 0) { + return `${prefix} ${line}`; + } + + return `${indentation}${line}`; + }) + .join('\n'); +} + +function formatPrefixedLines(prefix: string, message: string): string { + return message + .split('\n') + .map((line) => `${prefix} ${line}`) + .join('\n'); +} diff --git a/packages/feature-logger/src/features/style.test.ts b/packages/feature-logger/src/features/style.test.ts new file mode 100644 index 00000000..c9f30a01 --- /dev/null +++ b/packages/feature-logger/src/features/style.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mockConsole, restoreConsoleMock, type TConsoleSpies } from '../__tests__/mock-console'; +import { createLogger } from '../create-logger'; +import { styleFeature } from './style'; + +describe('styleFeature function', () => { + const consoleSpies: TConsoleSpies = {}; + + beforeEach(() => { + mockConsole(['log', 'info'], consoleSpies); + }); + + afterEach(() => { + restoreConsoleMock(consoleSpies); + }); + + describe('formatting', () => { + it('should apply default console styles to string messages', () => { + // Prepare + const logger = createLogger().with(styleFeature()); + + // Act + logger.log('log message'); + logger.info('info message'); + + // Assert + expect(consoleSpies.log).toHaveBeenCalledWith('%clog message', 'color: #333'); + expect(consoleSpies.info).toHaveBeenCalledWith('%cinfo message', 'color: #0066cc'); + }); + + it('should prefer custom styles over default styles', () => { + // Prepare + const logger = createLogger().with(styleFeature({ log: 'color: red' })); + + // Act + logger.log('log message'); + + // Assert + expect(consoleSpies.log).toHaveBeenCalledWith('%clog message', 'color: red'); + }); + + it('should ignore non-string messages', () => { + // Prepare + const logger = createLogger().with(styleFeature()); + const data = { count: 1 }; + + // Act + logger.log(data); + + // Assert + expect(consoleSpies.log).toHaveBeenCalledWith(data); + }); + }); +}); diff --git a/packages/feature-logger/src/features/style.ts b/packages/feature-logger/src/features/style.ts new file mode 100644 index 00000000..20378186 --- /dev/null +++ b/packages/feature-logger/src/features/style.ts @@ -0,0 +1,48 @@ +import { defineFeature, type TFeature } from 'feature-core'; +import type { TLoggerBase, TLoggerMiddleware, TLogMethod } from '../types'; + +/** Applies browser console CSS styles to string messages based on the log method. */ +export function styleFeature(styles: TLogStyles = {}): TStyleFeature { + return defineFeature({ + key: 'style', + install(logger: TLoggerBase) { + logger._middleware.push(styleMiddleware(styles)); + + return {}; + } + }); +} + +export type TStyleFeature = TFeature<'style', object>; + +/** Browser console CSS strings keyed by log method. */ +export type TLogStyles = Partial>; + +/** Middleware that inserts `%c` CSS arguments for string messages. */ +export function styleMiddleware(styles: TLogStyles = {}): TLoggerMiddleware { + const allStyles = { ...defaultLogStyles, ...styles }; + + return (next) => { + return (data, context) => { + const style = allStyles[context.logMethod]; + if (typeof data[0] !== 'string' || style == null) { + next(data, context); + return; + } + + data[0] = `%c${data[0]}`; + data.splice(1, 0, style); + next(data, context); + }; + }; +} + +/** Default browser console CSS styles used by `styleFeature()`. */ +export const defaultLogStyles: TLogStyles = { + trace: 'color: #aaa', + debug: 'color: #888', + log: 'color: #333', + info: 'color: #0066cc', + warn: 'color: #f90', + error: 'color: #f33' +}; diff --git a/packages/feature-logger/src/features/timestamp-prefix.test.ts b/packages/feature-logger/src/features/timestamp-prefix.test.ts new file mode 100644 index 00000000..5428a445 --- /dev/null +++ b/packages/feature-logger/src/features/timestamp-prefix.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockConsole, restoreConsoleMock, type TConsoleSpies } from '../__tests__/mock-console'; +import { createLogger } from '../create-logger'; +import { timestampPrefixFeature } from './timestamp-prefix'; + +describe('timestampPrefixFeature function', () => { + const consoleSpies: TConsoleSpies = {}; + + beforeEach(() => { + mockConsole(['log'], consoleSpies); + }); + + afterEach(() => { + restoreConsoleMock(consoleSpies); + vi.restoreAllMocks(); + }); + + describe('formatting', () => { + it('should prepend the current timestamp to string messages', () => { + // Prepare + const date = new Date(1628749130000); + vi.spyOn(Date, 'now').mockReturnValue(date.getTime()); + const logger = createLogger().with(timestampPrefixFeature()); + + // Act + logger.log('log message'); + + // Assert + expect(consoleSpies.log).toHaveBeenCalledWith(`[${date.toLocaleString()}] log message`); + }); + + it('should prepend the current timestamp as a separate argument for non-string messages', () => { + // Prepare + const date = new Date(1628749130000); + vi.spyOn(Date, 'now').mockReturnValue(date.getTime()); + const logger = createLogger().with(timestampPrefixFeature()); + const data = { count: 1 }; + + // Act + logger.log(data); + + // Assert + expect(consoleSpies.log).toHaveBeenCalledWith(`[${date.toLocaleString()}]`, data); + }); + + it('should support custom timestamp formatting', () => { + // Prepare + const date = new Date(1628749130000); + vi.spyOn(Date, 'now').mockReturnValue(date.getTime()); + const logger = createLogger().with( + timestampPrefixFeature({ + formatTimestamp: (timestamp) => `[${timestamp.toISOString()}]` + }) + ); + + // Act + logger.log('log message'); + + // Assert + expect(consoleSpies.log).toHaveBeenCalledWith(`[${date.toISOString()}] log message`); + }); + }); +}); diff --git a/packages/feature-logger/src/features/timestamp-prefix.ts b/packages/feature-logger/src/features/timestamp-prefix.ts new file mode 100644 index 00000000..f068b7cf --- /dev/null +++ b/packages/feature-logger/src/features/timestamp-prefix.ts @@ -0,0 +1,37 @@ +import { defineFeature, type TFeature } from 'feature-core'; +import type { TLoggerBase, TLoggerMiddleware } from '../types'; +import { prefixMiddleware } from './prefix'; + +/** Adds the current local timestamp as a prefix to every log call. */ +export function timestampPrefixFeature( + options: TTimestampPrefixFeatureOptions = {} +): TTimestampPrefixFeature { + return defineFeature({ + key: 'timestamp-prefix', + install(logger: TLoggerBase) { + logger._middleware.push(timestampPrefixMiddleware(options)); + + return {}; + } + }); +} + +export type TTimestampPrefixFeature = TFeature<'timestamp-prefix', object>; + +export interface TTimestampPrefixFeatureOptions { + /** Formats the current date before it is used as a prefix. */ + formatTimestamp?: (date: Date) => string; +} + +/** Middleware that prefixes each log call with the current local timestamp. */ +export function timestampPrefixMiddleware( + options: TTimestampPrefixFeatureOptions = {} +): TLoggerMiddleware { + const { formatTimestamp = defaultFormatTimestamp } = options; + + return prefixMiddleware(() => formatTimestamp(new Date(Date.now()))); +} + +function defaultFormatTimestamp(date: Date): string { + return `[${date.toLocaleString()}]`; +} diff --git a/packages/feature-logger/src/features/with-log-id.test.ts b/packages/feature-logger/src/features/with-log-id.test.ts deleted file mode 100644 index 7ea7ea27..00000000 --- a/packages/feature-logger/src/features/with-log-id.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mockConsole, restoreConsoleMock, type TConsoleSpies } from '../__tests__/mock-console'; -import { createLogger } from '../create-logger'; -import { withLogId } from './with-log-id'; - -describe('withLogId function', () => { - const consoleSpies: TConsoleSpies = {}; - - beforeEach(() => { - mockConsole(['trace', 'debug', 'log', 'info', 'warn', 'error'], consoleSpies); - }); - - afterEach(() => { - restoreConsoleMock(consoleSpies); - }); - - it('should have correct types', () => { - const logger = createLogger(); - const loggerWithId = withLogId(logger); - expect(loggerWithId._features).toContain('log-id'); - }); - - it('should return an id when logging with different methods', () => { - // Prepare - const mockId = 'test-id-123'; - const mockGenerateId = vi.fn().mockReturnValue(mockId); - const logger = withLogId(createLogger(), { generateId: mockGenerateId }); - - // Act & Assert - const traceId = logger.traceWithId('trace message'); - expect(traceId).toBe(mockId); - expect(consoleSpies.trace).toHaveBeenCalledWith(`[${mockId}] trace message`); - - const debugId = logger.debugWithId('debug message'); - expect(debugId).toBe(mockId); - expect(consoleSpies.debug).toHaveBeenCalledWith(`[${mockId}] debug message`); - - const logId = logger.logWithId('log message'); - expect(logId).toBe(mockId); - expect(consoleSpies.log).toHaveBeenCalledWith(`[${mockId}] log message`); - - const infoId = logger.infoWithId('info message'); - expect(infoId).toBe(mockId); - expect(consoleSpies.info).toHaveBeenCalledWith(`[${mockId}] info message`); - - const warnId = logger.warnWithId('warn message'); - expect(warnId).toBe(mockId); - expect(consoleSpies.warn).toHaveBeenCalledWith(`[${mockId}] warn message`); - - const errorId = logger.errorWithId('error message'); - expect(errorId).toBe(mockId); - expect(consoleSpies.error).toHaveBeenCalledWith(`[${mockId}] error message`); - - expect(mockGenerateId).toHaveBeenCalledTimes(6); - }); - - it('should include the id and additional parameters in the log message', () => { - // Prepare - const mockId = 'test-id-123'; - const logger = withLogId(createLogger(), { - generateId: () => mockId - }); - - // Act & Assert - logger.logWithId('Test message', 'additional', 123); - expect(consoleSpies.log).toHaveBeenCalledWith(`[${mockId}] Test message`, 'additional', 123); - - logger.errorWithId(new Error('test'), { context: 'test' }); - expect(consoleSpies.error).toHaveBeenCalledWith(`[${mockId}]`, new Error('test'), { - context: 'test' - }); - }); - - it('should use shortId as default id generator', () => { - // Prepare - const logger = withLogId(createLogger()); - - // Act - const id = logger.logWithId('Test message'); - - // Assert - expect(typeof id).toBe('string'); - expect(id.length).toBeGreaterThan(0); - expect(consoleSpies.log).toHaveBeenCalledWith(`[${id}] Test message`); - }); - - it('should allow custom id formatting', () => { - // Prepare - const mockId = 'test-id-123'; - const logger = withLogId(createLogger(), { - generateId: () => mockId, - formatId: (id) => `(ID:${id})` - }); - - // Act - const id = logger.logWithId('Test message'); - - // Assert - expect(id).toBe(mockId); - expect(consoleSpies.log).toHaveBeenCalledWith('(ID:test-id-123) Test message'); - }); -}); diff --git a/packages/feature-logger/src/features/with-log-id.ts b/packages/feature-logger/src/features/with-log-id.ts deleted file mode 100644 index 52788d59..00000000 --- a/packages/feature-logger/src/features/with-log-id.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { TEnforceFeatureConstraint, TFeatureDefinition } from '@blgc/types/features'; -import { shortId } from '@blgc/utils'; -import { LOG_LEVEL } from '../create-logger'; -import { TLoggerCategory, TLogIdFeature, type TLogger } from '../types'; - -export function withLogId( - baseLogger: TEnforceFeatureConstraint, TLogger, []>, - options: TWithLogIdOptions = {} -): TLogger<[TLogIdFeature, ...GFeatures]> { - const { generateId = shortId, formatId = defaultFormatId } = options; - - const logIdFeature: TLogIdFeature['api'] = { - _baseLogWithId(this: TLogger<[TLogIdFeature]>, category, data) { - const id = generateId(); - if (typeof data[0] === 'string') { - data[0] = `${formatId(id, category)} ${data[0]}`; - } else { - data.unshift(formatId(id, category)); - } - this._baseLog(category, data); - return id; - }, - traceWithId(this: TLogger<[TLogIdFeature]>, message, ...optionalParams) { - return this._baseLogWithId({ logMethod: 'trace', level: LOG_LEVEL.TRACE }, [ - message, - ...optionalParams - ]); - }, - debugWithId(this: TLogger<[TLogIdFeature]>, message, ...optionalParams) { - return this._baseLogWithId({ logMethod: 'debug', level: LOG_LEVEL.DEBUG }, [ - message, - ...optionalParams - ]); - }, - logWithId(this: TLogger<[TLogIdFeature]>, message, ...optionalParams) { - return this._baseLogWithId({ logMethod: 'log', level: LOG_LEVEL.LOG }, [ - message, - ...optionalParams - ]); - }, - infoWithId(this: TLogger<[TLogIdFeature]>, message, ...optionalParams) { - return this._baseLogWithId({ logMethod: 'info', level: LOG_LEVEL.INFO }, [ - message, - ...optionalParams - ]); - }, - warnWithId(this: TLogger<[TLogIdFeature]>, message, ...optionalParams) { - return this._baseLogWithId({ logMethod: 'warn', level: LOG_LEVEL.WARN }, [ - message, - ...optionalParams - ]); - }, - errorWithId(this: TLogger<[TLogIdFeature]>, message, ...optionalParams) { - return this._baseLogWithId({ logMethod: 'error', level: LOG_LEVEL.ERROR }, [ - message, - ...optionalParams - ]); - } - }; - - // Extend the base logger with the logId feature - const extendedLogger = Object.assign(baseLogger, logIdFeature) as TLogger<[TLogIdFeature]>; - extendedLogger._features.push('log-id'); - - return extendedLogger as unknown as TLogger<[TLogIdFeature, ...GFeatures]>; -} - -export type TWithLogIdOptions = { - generateId?: () => string; - formatId?: (id: string, category: TLoggerCategory) => string; -}; - -const defaultFormatId = (id: string) => `[${id}]`; diff --git a/packages/feature-logger/src/features/with-log-method-prefix.test.ts b/packages/feature-logger/src/features/with-log-method-prefix.test.ts deleted file mode 100644 index 3416236a..00000000 --- a/packages/feature-logger/src/features/with-log-method-prefix.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { mockConsole, restoreConsoleMock, type TConsoleSpies } from '../__tests__/mock-console'; -import { createLogger } from '../create-logger'; -import { withLogMethodPrefix } from './with-log-method-prefix'; - -describe('withLogMethodPrefix function', () => { - const consoleSpies: TConsoleSpies = {}; - - beforeEach(() => { - mockConsole(['trace', 'debug', 'log', 'info', 'warn', 'error'], consoleSpies); - }); - - afterEach(() => { - restoreConsoleMock(consoleSpies); - }); - - it('should add methodPrefix middleware correctly', () => { - const logger = createLogger(); - const methodPrefixedLogger = withLogMethodPrefix(logger); - - expect(methodPrefixedLogger._features.includes('log-method-prefix')).toBe(true); - expect(methodPrefixedLogger.middlewares.length).toBe(1); - }); - - it('should prepend method prefix to log messages', () => { - const logger = createLogger(); - const methodPrefixedLogger = withLogMethodPrefix(logger); - - methodPrefixedLogger.trace('trace message'); - expect(consoleSpies.trace).toHaveBeenCalledWith('Trace: trace message'); - - methodPrefixedLogger.debug('debug message'); - expect(consoleSpies.debug).toHaveBeenCalledWith('Debug: debug message'); - - methodPrefixedLogger.log('log message'); - expect(consoleSpies.log).toHaveBeenCalledWith('Log: log message'); - - methodPrefixedLogger.info('info message'); - expect(consoleSpies.info).toHaveBeenCalledWith('Info: info message'); - - methodPrefixedLogger.warn('warn message'); - expect(consoleSpies.warn).toHaveBeenCalledWith('Warn: warn message'); - - methodPrefixedLogger.error('error message'); - expect(consoleSpies.error).toHaveBeenCalledWith('Error: error message'); - }); -}); diff --git a/packages/feature-logger/src/features/with-log-method-prefix.ts b/packages/feature-logger/src/features/with-log-method-prefix.ts deleted file mode 100644 index 38faab14..00000000 --- a/packages/feature-logger/src/features/with-log-method-prefix.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { TEnforceFeatureConstraint, TFeatureDefinition } from '@blgc/types/features'; -import { TLoggerMiddleware, TMethodPrefixFeature, type TLogger } from '../types'; - -export function withLogMethodPrefix( - baseLogger: TEnforceFeatureConstraint, TLogger, []> -): TLogger<[TMethodPrefixFeature, ...GFeatures]> { - (baseLogger as TLogger<[TMethodPrefixFeature]>)._features.push('log-method-prefix'); - - baseLogger.middlewares.push(logMethodPrefixMiddleware()); - - return baseLogger as TLogger<[TMethodPrefixFeature, ...GFeatures]>; -} - -export function logMethodPrefixMiddleware(): TLoggerMiddleware { - return (next) => { - return (logMethod, data) => { - const prefix = `${logMethod.charAt(0).toUpperCase() + logMethod.slice(1)}:`; - if (typeof data[0] === 'string') { - data[0] = `${prefix} ${data[0]}`; - } else { - data.unshift(prefix); - } - next(logMethod, data); - }; - }; -} diff --git a/packages/feature-logger/src/features/with-prefix.test.ts b/packages/feature-logger/src/features/with-prefix.test.ts deleted file mode 100644 index 65acecb2..00000000 --- a/packages/feature-logger/src/features/with-prefix.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { mockConsole, restoreConsoleMock, type TConsoleSpies } from '../__tests__/mock-console'; -import { createLogger } from '../create-logger'; -import { withPrefix } from './with-prefix'; - -describe('withPrefix function', () => { - const consoleSpies: TConsoleSpies = {}; - - beforeEach(() => { - mockConsole(['trace', 'debug', 'log', 'info', 'warn', 'error'], consoleSpies); - }); - - afterEach(() => { - restoreConsoleMock(consoleSpies); - }); - - it('should add prefix middleware correctly', () => { - const logger = createLogger(); - const prefixedLogger = withPrefix(logger, 'PREFIX'); - - expect(prefixedLogger._features.includes('prefix')).toBe(true); - expect(prefixedLogger.middlewares.length).toBe(1); - }); - - it('should prepend prefix to log messages', () => { - const logger = createLogger(); - const prefixedLogger = withPrefix(logger, 'PREFIX'); - - prefixedLogger.trace('trace message'); - expect(consoleSpies.trace).toHaveBeenCalledWith('PREFIX trace message'); - - prefixedLogger.debug('debug message'); - expect(consoleSpies.debug).toHaveBeenCalledWith('PREFIX debug message'); - - prefixedLogger.log('log message'); - expect(consoleSpies.log).toHaveBeenCalledWith('PREFIX log message'); - - prefixedLogger.info('info message'); - expect(consoleSpies.info).toHaveBeenCalledWith('PREFIX info message'); - - prefixedLogger.warn('warn message'); - expect(consoleSpies.warn).toHaveBeenCalledWith('PREFIX warn message'); - - prefixedLogger.error('error message'); - expect(consoleSpies.error).toHaveBeenCalledWith('PREFIX error message'); - }); - - describe('multi-line message handling', () => { - const message = 'first line\nsecond line\nthird line'; - - it('should indent subsequent lines by default', () => { - const logger = createLogger(); - const prefixedLogger = withPrefix(logger, '[Test]'); - prefixedLogger.info(message); - expect(consoleSpies.info).toHaveBeenCalledWith( - '[Test] first line\n second line\n third line' - ); - }); - - it('should prefix each line when newLineBehavior is prefix', () => { - const logger = createLogger(); - const prefixedLogger = withPrefix(logger, '[Test]', { newLineBehavior: 'prefix' }); - prefixedLogger.info(message); - expect(consoleSpies.info).toHaveBeenCalledWith( - '[Test] first line\n[Test] second line\n[Test] third line' - ); - }); - - it('should ignore newlines when newLineBehavior is ignore', () => { - const logger = createLogger(); - const prefixedLogger = withPrefix(logger, '[Test]', { newLineBehavior: 'ignore' }); - prefixedLogger.info(message); - expect(consoleSpies.info).toHaveBeenCalledWith('[Test] first line\nsecond line\nthird line'); - }); - }); -}); diff --git a/packages/feature-logger/src/features/with-prefix.ts b/packages/feature-logger/src/features/with-prefix.ts deleted file mode 100644 index b890149a..00000000 --- a/packages/feature-logger/src/features/with-prefix.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { TEnforceFeatureConstraint, TFeatureDefinition } from '@blgc/types/features'; -import { TLoggerMiddleware, TPrefixFeature, type TLogger } from '../types'; - -export function withPrefix( - baseLogger: TEnforceFeatureConstraint, TLogger, []>, - prefix: string, - options: TPrefixMiddlewareOptions = {} -): TLogger<[TPrefixFeature, ...GFeatures]> { - (baseLogger as TLogger<[TPrefixFeature]>)._features.push('prefix'); - - baseLogger.middlewares.push(prefixMiddleware(prefix, options)); - - return baseLogger as TLogger<[TPrefixFeature, ...GFeatures]>; -} - -export function prefixMiddleware( - prefix: string, - options: TPrefixMiddlewareOptions = {} -): TLoggerMiddleware { - const { newLineBehavior = 'indent' } = options; - - return (next) => { - return (logMethod, data) => { - if (typeof data[0] === 'string') { - const message = data[0]; - - switch (newLineBehavior) { - case 'indent': { - const lines = message.split('\n'); - const indentation = ' '.repeat(prefix.length + 1); - const formattedMessage = lines - .map((line, index) => { - if (index === 0) return `${prefix} ${line}`; - return `${indentation}${line}`; - }) - .join('\n'); - data[0] = formattedMessage; - break; - } - case 'prefix': { - const lines = message.split('\n'); - data[0] = lines.map((line) => `${prefix} ${line}`).join('\n'); - break; - } - case 'ignore': { - data[0] = `${prefix} ${message}`; - break; - } - } - } else { - data.unshift(prefix); - } - next(logMethod, data); - }; - }; -} - -export type TPrefixMiddlewareOptions = { - newLineBehavior?: 'indent' | 'prefix' | 'ignore'; -}; diff --git a/packages/feature-logger/src/features/with-style.test.ts b/packages/feature-logger/src/features/with-style.test.ts deleted file mode 100644 index c3771a4a..00000000 --- a/packages/feature-logger/src/features/with-style.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { mockConsole, restoreConsoleMock, type TConsoleSpies } from '../__tests__/mock-console'; -import { createLogger } from '../create-logger'; -import { withStyle } from './with-style'; - -describe('withStyle function', () => { - const consoleSpies: TConsoleSpies = {}; - - beforeEach(() => { - mockConsole(['trace', 'debug', 'log', 'info', 'warn', 'error'], consoleSpies); - }); - - afterEach(() => { - restoreConsoleMock(consoleSpies); - }); - - it('should add style middleware correctly', () => { - const logger = createLogger(); - const styledLogger = withStyle(logger); - - expect(styledLogger._features.includes('style')).toBe(true); - expect(styledLogger.middlewares.length).toBe(1); - }); - - it('should apply default styles to log messages', () => { - const logger = createLogger(); - const styledLogger = withStyle(logger); - - styledLogger.trace('trace message'); - expect(consoleSpies.trace).toHaveBeenCalledWith('%ctrace message', 'color: #aaa'); - - styledLogger.debug('debug message'); - expect(consoleSpies.debug).toHaveBeenCalledWith('%cdebug message', 'color: #888'); - - styledLogger.log('log message'); - expect(consoleSpies.log).toHaveBeenCalledWith('%clog message', 'color: #333'); - - styledLogger.info('info message'); - expect(consoleSpies.info).toHaveBeenCalledWith('%cinfo message', 'color: #0066cc'); - - styledLogger.warn('warn message'); - expect(consoleSpies.warn).toHaveBeenCalledWith('%cwarn message', 'color: #f90'); - - styledLogger.error('error message'); - expect(consoleSpies.error).toHaveBeenCalledWith('%cerror message', 'color: #f33'); - }); - - it('should apply custom styles when provided', () => { - const logger = createLogger(); - const customStyles = { - log: 'color: red', - info: 'color: blue' - }; - const styledLogger = withStyle(logger, customStyles); - - styledLogger.log('log message'); - expect(consoleSpies.log).toHaveBeenCalledWith('%clog message', 'color: red'); - - styledLogger.info('info message'); - expect(consoleSpies.info).toHaveBeenCalledWith('%cinfo message', 'color: blue'); - }); - - it('should handle non-string first arguments', () => { - const logger = createLogger(); - const styledLogger = withStyle(logger); - const obj = { test: 'value' }; - - styledLogger.log(obj); - expect(consoleSpies.log).toHaveBeenCalledWith(obj); - }); -}); diff --git a/packages/feature-logger/src/features/with-style.ts b/packages/feature-logger/src/features/with-style.ts deleted file mode 100644 index 7c3b559b..00000000 --- a/packages/feature-logger/src/features/with-style.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { TEnforceFeatureConstraint, TFeatureDefinition } from '@blgc/types/features'; -import { TLoggerMiddleware, TStyleFeature, type TLogger } from '../types'; - -export function withStyle( - baseLogger: TEnforceFeatureConstraint, TLogger, []>, - styles: Record = {} -): TLogger<[TStyleFeature, ...GFeatures]> { - (baseLogger as TLogger<[TStyleFeature]>)._features.push('style'); - - baseLogger.middlewares.push(styleMiddleware(styles)); - - return baseLogger as TLogger<[TStyleFeature, ...GFeatures]>; -} - -export function styleMiddleware(styles: Record = {}): TLoggerMiddleware { - const allStyles = { ...DEFAULT_STYLES, ...styles }; - return (next) => { - return (logMethod, data) => { - const style = allStyles[logMethod]; - if (typeof data[0] === 'string' && style != null) { - data[0] = `%c${data[0]}`; - data.splice(1, 0, style); - } - next(logMethod, data); - }; - }; -} - -export const DEFAULT_STYLES: Record = { - trace: 'color: #aaa', - debug: 'color: #888', - log: 'color: #333', - info: 'color: #0066cc', - warn: 'color: #f90', - error: 'color: #f33' -}; diff --git a/packages/feature-logger/src/features/with-timestamp-prefix.test.ts b/packages/feature-logger/src/features/with-timestamp-prefix.test.ts deleted file mode 100644 index cbdbceff..00000000 --- a/packages/feature-logger/src/features/with-timestamp-prefix.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mockConsole, restoreConsoleMock, type TConsoleSpies } from '../__tests__/mock-console'; -import { createLogger } from '../create-logger'; -import { withTimestamp } from './with-timestamp-prefix'; - -describe('withTimestampPrefix function', () => { - const consoleSpies: TConsoleSpies = {}; - - beforeEach(() => { - mockConsole(['trace', 'debug', 'log', 'info', 'warn', 'error'], consoleSpies); - }); - - afterEach(() => { - restoreConsoleMock(consoleSpies); - }); - - it('should add timestamp middleware correctly', () => { - const logger = createLogger(); - const timestampedLogger = withTimestamp(logger); - - expect(timestampedLogger._features.includes('timestamp-prefix')).toBe(true); - expect(timestampedLogger.middlewares.length).toBe(1); - }); - - it('should prepend timestamp to log messages', () => { - const logger = createLogger(); - const timestampedLogger = withTimestamp(logger); - const mockDate = new Date(1628749130000); // Arbitrary fixed timestamp value for testing - vi.spyOn(Date, 'now').mockReturnValue(mockDate.getTime()); - - timestampedLogger.trace('trace message'); - expect(consoleSpies.trace).toHaveBeenCalledWith(`[${mockDate.toLocaleString()}] trace message`); - - timestampedLogger.debug('debug message'); - expect(consoleSpies.debug).toHaveBeenCalledWith(`[${mockDate.toLocaleString()}] debug message`); - - timestampedLogger.log('log message'); - expect(consoleSpies.log).toHaveBeenCalledWith(`[${mockDate.toLocaleString()}] log message`); - - timestampedLogger.info('info message'); - expect(consoleSpies.info).toHaveBeenCalledWith(`[${mockDate.toLocaleString()}] info message`); - - timestampedLogger.warn('warn message'); - expect(consoleSpies.warn).toHaveBeenCalledWith(`[${mockDate.toLocaleString()}] warn message`); - - timestampedLogger.error('error message'); - expect(consoleSpies.error).toHaveBeenCalledWith(`[${mockDate.toLocaleString()}] error message`); - }); -}); diff --git a/packages/feature-logger/src/features/with-timestamp-prefix.ts b/packages/feature-logger/src/features/with-timestamp-prefix.ts deleted file mode 100644 index 00069fc3..00000000 --- a/packages/feature-logger/src/features/with-timestamp-prefix.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { TEnforceFeatureConstraint, TFeatureDefinition } from '@blgc/types/features'; -import { TLoggerMiddleware, TTimestampFeature, type TLogger } from '../types'; - -export function withTimestamp( - baseLogger: TEnforceFeatureConstraint, TLogger, []> -): TLogger<[TTimestampFeature, ...GFeatures]> { - (baseLogger as TLogger<[TTimestampFeature]>)._features.push('timestamp-prefix'); - - baseLogger.middlewares.push(timestampPrefixMiddleware()); - - return baseLogger as TLogger<[TTimestampFeature, ...GFeatures]>; -} - -export function timestampPrefixMiddleware(): TLoggerMiddleware { - return (next) => { - return (logMethod, data) => { - const timestamp = new Date(Date.now()).toLocaleString(); - if (typeof data[0] === 'string') { - data[0] = `[${timestamp}] ${data[0]}`; - } else { - data.unshift(`[${timestamp}]`); - } - next(logMethod, data); - }; - }; -} diff --git a/packages/feature-logger/src/index.ts b/packages/feature-logger/src/index.ts index 4aaeab6f..960790ce 100644 --- a/packages/feature-logger/src/index.ts +++ b/packages/feature-logger/src/index.ts @@ -1,4 +1,3 @@ export * from './create-logger'; export * from './features'; -export * from './is-logger-with-features'; export * from './types'; diff --git a/packages/feature-logger/src/is-logger-with-features.ts b/packages/feature-logger/src/is-logger-with-features.ts deleted file mode 100644 index 06e61357..00000000 --- a/packages/feature-logger/src/is-logger-with-features.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { TFeatureDefinition, TLooseFeatureNames } from '@blgc/types/features'; -import { TLogger } from './types'; - -export function isLoggerWithFeatures( - value: unknown, - features: TLooseFeatureNames[] -): value is TLogger { - return ( - typeof value === 'object' && - value != null && - '_features' in value && - Array.isArray(value._features) && - features.every((feature) => (value._features as string[]).includes(feature)) - ); -} diff --git a/packages/feature-logger/src/types.ts b/packages/feature-logger/src/types.ts new file mode 100644 index 00000000..c432bc2d --- /dev/null +++ b/packages/feature-logger/src/types.ts @@ -0,0 +1,52 @@ +import type { TAnyFeature, TFeatureHost } from 'feature-core'; + +/** Logger object returned by `createLogger()`. */ +export type TLogger = TFeatureHost; + +/** + * Core logger API used by feature installers. + * Use this type when a feature only needs the base methods, + * regardless of which other features are already installed on the host. + */ +export interface TLoggerBase { + /** @internal */ + _invokeConsole: TInvokeConsole; + /** @internal */ + _baseLog(data: unknown[], context: TLogContext): void; + /** @internal */ + _middleware: TLoggerMiddleware[]; + /** When `false`, all log calls are silenced without removing the logger. */ + active: boolean; + /** Minimum log level. Calls with a level below this value are suppressed. See `ELogLevel`. */ + level: number; + /** Emits at `ELogLevel.TRACE`. */ + trace(...data: unknown[]): void; + /** Emits at `ELogLevel.DEBUG`. */ + debug(...data: unknown[]): void; + /** Emits at `ELogLevel.LOG`. */ + log(...data: unknown[]): void; + /** Emits at `ELogLevel.INFO`. */ + info(...data: unknown[]): void; + /** Emits at `ELogLevel.WARN`. */ + warn(...data: unknown[]): void; + /** Emits at `ELogLevel.ERROR`. */ + error(...data: unknown[]): void; +} + +/** Final output sink called after logger middleware has transformed the log data. */ +export type TInvokeConsole = (data: unknown[], context: TLogContext) => void; + +/** Wraps a console invoker to transform, filter, or redirect a log call. */ +export type TLoggerMiddleware = (next: TInvokeConsole) => TInvokeConsole; + +/** Metadata passed through the logger middleware chain for one log call. */ +export interface TLogContext { + /** The console method this call maps to. */ + logMethod: TLogMethod; + /** Numeric log level for this call. Compare against `ELogLevel` values. */ + level: number; + /** Per-call middleware appended after the logger's own stack for this invocation only. */ + middleware?: TLoggerMiddleware[]; +} + +export type TLogMethod = 'debug' | 'trace' | 'log' | 'info' | 'warn' | 'error'; diff --git a/packages/feature-logger/src/types/features.ts b/packages/feature-logger/src/types/features.ts deleted file mode 100644 index e120bf5b..00000000 --- a/packages/feature-logger/src/types/features.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { TLoggerCategory } from './logger'; - -export interface TPrefixFeature { - key: 'prefix'; - api: {}; -} - -export interface TTimestampFeature { - key: 'timestamp-prefix'; - api: {}; -} - -export interface TMethodPrefixFeature { - key: 'log-method-prefix'; - api: {}; -} - -export interface TStyleFeature { - key: 'style'; - api: {}; -} - -export interface TLogIdFeature { - key: 'log-id'; - api: { - _baseLogWithId: (category: TLoggerCategory, data: unknown[]) => string; - traceWithId: (message: unknown, ...optionalParams: unknown[]) => string; - debugWithId: (message: unknown, ...optionalParams: unknown[]) => string; - logWithId: (message: unknown, ...optionalParams: unknown[]) => string; - infoWithId: (message: unknown, ...optionalParams: unknown[]) => string; - warnWithId: (message: unknown, ...optionalParams: unknown[]) => string; - errorWithId: (message: unknown, ...optionalParams: unknown[]) => string; - }; -} diff --git a/packages/feature-logger/src/types/index.ts b/packages/feature-logger/src/types/index.ts deleted file mode 100644 index d723774b..00000000 --- a/packages/feature-logger/src/types/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './features'; -export * from './logger'; diff --git a/packages/feature-logger/src/types/logger.ts b/packages/feature-logger/src/types/logger.ts deleted file mode 100644 index 96960304..00000000 --- a/packages/feature-logger/src/types/logger.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { TFeatureDefinition, TWithFeatures } from '@blgc/types/features'; - -export type TLogger = TWithFeatures< - { - _invokeConsole: TInvokeConsole; - _baseLog: (category: TLoggerCategory, data: unknown[]) => void; - active: boolean; - level: number; - middlewares: TLoggerMiddleware[]; - trace: (message?: unknown, ...optionalParams: unknown[]) => void; - debug: (message?: unknown, ...optionalParams: unknown[]) => void; - log: (message?: unknown, ...optionalParams: unknown[]) => void; - info: (message?: unknown, ...optionalParams: unknown[]) => void; - warn: (message?: unknown, ...optionalParams: unknown[]) => void; - error: (message?: unknown, ...optionalParams: unknown[]) => void; - }, - GFeatures ->; - -export type TInvokeConsole = (logMethod: TLogMethod, data: unknown[]) => void; - -export type TLoggerMiddleware = (next: TInvokeConsole) => TInvokeConsole; - -export interface TLoggerCategory { - logMethod: TLogMethod; - level: number; - middlewares?: TLoggerMiddleware[]; -} - -export type TLogMethod = 'debug' | 'trace' | 'log' | 'info' | 'warn' | 'error' | 'table'; diff --git a/packages/feature-react/README.md b/packages/feature-react/README.md index 7ee5cf0d..741bbf6a 100644 --- a/packages/feature-react/README.md +++ b/packages/feature-react/README.md @@ -10,31 +10,64 @@ NPM bundle minzipped size - NPM total downloads + NPM total downloads Join Discord

-`feature-react` is the ReactJs extension for the `feature-state` and `feature-form` library, providing hooks and features for easy state management in ReactJs. +`feature-react` connects React components to [`feature-state`](https://github.com/builder-group/community/tree/develop/packages/feature-state) and [`feature-form`](https://github.com/builder-group/community/tree/develop/packages/feature-form) objects outside the React tree. Hooks subscribe directly, so no provider is required and computed hooks re-render only when selected values change. -- **Lightweight & Tree Shakable**: Function-based and modular design (< 1KB minified) -- **Modular & Extendable**: Easily extendable with features like `withLocalStorage()`, .. -- **Seamless Integration**: Designed to work effortlessly with `feature-state` -- **Typesafe**: Build with TypeScript for strong type safety +- Use module, service, or form state directly from components +- Derive slices with `useCompute` instead of re-rendering on every source update +- Bind `feature-form` fields with focused field subscriptions +- Pass `null` for conditional subscriptions without breaking hook rules -# [`feature-state`](https://github.com/builder-group/community/tree/develop/packages/feature-state) +```tsx +import { createForm } from 'feature-form'; +import { useFormField } from 'feature-react/form'; +import { useCompute } from 'feature-react/state'; +import { createState } from 'feature-state'; +import * as z from 'zod'; -## 📖 Usage +const $tasks = createState([]); +const $profileForm = createForm({ + fields: { + email: { defaultValue: '', validator: z.string().email(), validateOn: ['blur'] } + } +}); -### `useFeatureState()` +const CompletedCount = () => { + const count = useCompute($tasks, (tasks) => tasks.filter((t) => t.done).length); + return {count} completed; +}; -A hook to bind a `feature-state` state to a React component, causing the component to re-render whenever the state changes. +const EmailField = () => { + const { input, status } = useFormField($profileForm, 'email'); + return ( + + ); +}; +``` + +## Install + +```bash +npm install feature-react +``` + +## Usage + +Use `useFeatureState` to subscribe a component to a state value and re-render when it changes. Use `useCompute` when you only care about a derived slice: the component skips re-renders unless the computed result itself changes. ```ts import { createState } from 'feature-state'; -import { useFeatureState } from 'feature-react/state'; +import { useFeatureState, useCompute } from 'feature-react/state'; const $tasks = createState([]); @@ -44,108 +77,310 @@ export const Tasks = () => { return (
    {tasks.map((task) => ( -
  • {task.title}
  • +
  • {task.title}
  • ))}
); }; ``` -## 📙 Features +Bind a form with `useForm` to get input helpers and a submit handler in one call: -### `withLocalStorage()` +```ts +import { createForm } from 'feature-form'; +import { useForm } from 'feature-react/form'; + +const $form = createForm<{ name: string; email: string }>({ + fields: { + name: { defaultValue: '' }, + email: { defaultValue: '' } + } +}); + +export const ContactForm = () => { + const { input, handleSubmit } = useForm($form); + + return ( +
+ + + +
+ ); +}; +``` + +## Hooks -Adds persistence functionality to the state, using [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage?retiredLocale=de) to save and load the state. +### `useFeatureState(state)` + +Returns the current state value and re-renders the component when the state changes. ```ts import { createState } from 'feature-state'; -import { withLocalStorage } from 'feature-react'; +import { useFeatureState } from 'feature-react/state'; -const state = withLocalStorage(createState([]), 'tasks'); +const $tasks = createState([]); -await state.persist(); +export const Tasks = () => { + const tasks = useFeatureState($tasks); -state.set([..., state.get(), { id: 1, title: 'Task 1' }]); + return ( +
    + {tasks.map((task) => ( +
  • {task.title}
  • + ))} +
+ ); +}; ``` -- **`key`**: The key used to identify the state in `localStorage`. +Passing `null` or `undefined` returns `null` without subscribing. Background updates do not trigger an immediate re-render. -### `withGlobalBind()` +### `useCompute(state, compute, deps?, isEqual?)` -Binds a value to the global scope, using [`globalThis`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/globalThis) to make the value accessible globally. +Derives a computed value from one state or a tuple of states. The component re-renders only when the computed result changes. ```ts -import { withGlobalBind } from 'feature-global'; +import { useCompute } from 'feature-react/state'; + +// Single state +const completedCount = useCompute($tasks, (tasks) => tasks.filter((t) => t.done).length); + +// Multiple states +const filtered = useCompute([$tasks, $filter], ([tasks, filter]) => + tasks.filter((t) => t.category === filter) +); +``` -// Define a value to be bound globally -const $state = createState([]); +`deps` lists any values that `compute` reads outside the subscribed state. When `deps` change, the computed value is recalculated. -// Bind the value to the global scope -withGlobalBind('_state', $state); +`isEqual` defaults to `Object.is`. Pass a custom comparator to suppress re-renders when the computed structure is equivalent but not referentially identical. Pass `false` to always re-render on any input change. -// Now `$state` is accessible globally -console.log(globalThis._state); // { /* $state */ } +`compute` and `isEqual` must stay pure because React may call them during render. + +### `useListener(state, callback)` + +Calls `callback` whenever the state changes without subscribing the component to re-renders. Use this for side effects triggered by state changes. + +```ts +import { useListener } from 'feature-react/state'; + +export const Analytics = () => { + useListener($tasks, (tasks) => { + analytics.track('tasks_changed', { count: tasks.length }); + }); + + return null; +}; ``` -- **`key`**: The key used to identify the value in the global scope. -- **`value`**: The value to be bound to the global scope. +The callback must be synchronous. It runs after every state change, including background updates. -# [`feature-form`](https://github.com/builder-group/community/tree/develop/packages/feature-form) +### `useSubscriber(state, callback)` -## 📖 Usage +Like `useListener`, but runs the callback immediately on mount with the current state value. -### `useForm()` +```ts +import { useSubscriber } from 'feature-react/state'; -A hook to manage form state and behavior in a React component, providing utilities to register form fields, handle form submission, and track field status. +useSubscriber($theme, (theme) => { + document.documentElement.setAttribute('data-theme', theme); +}); +``` + +### `useForm(form)` + +Subscribes a component to a form and re-renders when any field changes. Use this when a single component owns the whole form. ```ts -import React from 'react'; -import { useForm } from 'feature-react/form'; import { createForm } from 'feature-form'; +import { useForm } from 'feature-react/form'; interface TFormData { - name: string; - email: string; + name: string; + email: string; } const $form = createForm({ - fields: { - name: { defaultValue: '' }, - email: { defaultValue: '' } - } + fields: { + name: { defaultValue: '' }, + email: { defaultValue: '' } + } }); -export const MyFormComponent: React.FC = () => { - const { register, handleSubmit, field, status } = useForm($form); - - const onSubmit = handleSubmit({ - onValidSubmit?: (formData) => { - console.log('Form submitted successfully:', formData); - }, - onInvalidSubmit?: (errors) => { - console.error('Form submission failed:', errors); - } - }); - - return ( -
-
- - - {status('name').error && {status('name').error}} -
-
- - - {status('email').error && {status('email').error}} -
- -
- ); +export const ContactForm = () => { + const { input, handleSubmit } = useForm($form); + + return ( +
+ + + +
+ ); }; ``` -- **`register(formFieldKey, controlled?)`**: Registers a form field with the given key, optionally as a controlled component. -- **`handleSubmit(options?)`**: Returns a function to handle form submission with optional configuration for preventing default behavior and including additional data. -- **`field(formFieldKey)`**: Retrieves the form field object for the given key. -- **`status(formFieldKey)`**: Retrieves the status of the form field for the given key. +**Return value** + +| Property | Description | +| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `form` | The underlying `TForm` instance | +| `input(key, options?)` | Returns props for a native input, textarea, or select. See [input options](#input-options) | +| `handleSubmit(options?)` | Returns an event handler. Calls `event.preventDefault()` by default. Options: `onValidSubmit(data)`, `onInvalidSubmit(errors)`, `preventDefault` (default `true`) | +| `field(key)` | Returns the `TFormField` for the given key | +| `status(key)` | Returns the field's status state. Pass it to `useFeatureState` to subscribe to status changes for a specific field | + +### `useFormField(form, key, options?)` + +Subscribes to a single field's status and returns input props for uncontrolled fields by default. Use this for isolated field components or large forms where re-rendering on every keystroke is expensive. + +```ts +import { useFormField } from 'feature-react/form'; + +export const NameField = () => { + const { status, input } = useFormField($form, 'name'); + + return ( +
+ + {status.type === 'invalid' && {status.errors[0]?.message}} +
+ ); +}; +``` + +Pass `{ controlled: true }` when React should subscribe to and render the field value: + +```ts +const { value, status, input } = useFormField($form, 'name', { controlled: true }); +``` + +**Return value** + +| Property | Description | +| --------- | -------------------------------------------------------------- | +| `field` | The `TFormField` instance for the given key | +| `value` | The current field value. Only returned when `controlled: true` | +| `status` | The current validation status value | +| `input()` | Returns props for a native input, textarea, or select | + +### `getFieldInputProps(formField, options?)` + +Builds input props from a `TFormField` directly, without a hook. Use this outside components or when you already hold the field reference. + +#### Input options + +`useForm().input()` and `getFieldInputProps()` accept options per call. `useFormField()` accepts options at the hook level; the returned `input()` helper takes no arguments. + +For string-valued fields, all options are optional: + +| Option | Description | +| ------------ | ------------------------------------------------------------------------------------------------------------------ | +| `controlled` | When `true`, renders as a controlled input. On `useFormField`, pass this to the hook instead of a per-call option. | +| `format` | Maps the field value to a display string | +| `parse` | Maps the input string back to the field value | + +For non-string fields, `format` and `parse` are required. + +```ts +// useForm: options per input call +input('name'); +input('age', { format: (v) => String(v), parse: (s) => Number(s), controlled: true }); + +// useFormField: all options go to the hook; input() takes no args +const { input: ageInput } = useFormField($form, 'age', { + controlled: true, + format: (v) => String(v), + parse: (s) => Number(s) +}); +ageInput(); +``` + +## Built-in Features + +Features are installed via `.with()` and extend a state with new capabilities. + +### `localStorageFeature(key)` + +Persists state in `localStorage`. Built on top of `storageFeature` from `feature-state`. + +```ts +import { createState } from 'feature-state'; +import { localStorageFeature, useFeatureState } from 'feature-react/state'; + +const $theme = createState<'light' | 'dark'>('light').with(localStorageFeature('theme')); +await $theme.persist(); + +export const ThemeToggle = () => { + const theme = useFeatureState($theme); + return ; +}; +``` + +`persist()` loads any previously saved value. If nothing is stored it saves the current value instead, then auto-saves on every subsequent `set()`. See `storageFeature` in the [feature-state README](https://github.com/builder-group/community/tree/develop/packages/feature-state) for the full contract. + +### `globalBindFeature(key)` + +Exposes the state on `globalThis[key]` for debugging in the browser console. + +```ts +import { globalBindFeature } from 'feature-react/state'; +import { createState } from 'feature-state'; + +const $tasks = createState([]).with(globalBindFeature('_tasks')); + +// In the browser console: +// globalThis._tasks.get() +``` + +## Examples + +- [React Basic](https://github.com/builder-group/community/tree/develop/examples/feature-state/react/basic) + +## FAQ + +### How does it compare to Zustand, Jotai, and React context? + +`feature-react` is a binding layer, not a state model by itself. Use it when your state already lives in `feature-state` or your forms already live in `feature-form`, and React should subscribe to those objects without providers. + +- [zustand](https://github.com/pmndrs/zustand): store-based state with a built-in selector hook +- [jotai](https://github.com/pmndrs/jotai): atom-based state defined outside components +- [React context](https://react.dev/reference/react/createContext): built-in context API that re-renders consumers when the provided value changes + +### When should I use `useFormField` instead of `useForm`? + +Use `useFormField` when a field component should re-render only on its own status changes. With `useForm`, any field change in the form re-renders the whole component. For large forms, `useFormField` in isolated field components is significantly cheaper. + +### When should I use `status(key)` from `useForm` instead of `useFormField`? + +Use `status(key)` when you want to subscribe to a single field's status from within a component that already calls `useForm`. Pass the returned state to `useFeatureState` to get a focused subscription without adding a second `useFormField` call. + +### What is the difference between `useListener` and `useSubscriber`? + +`useListener` runs the callback only on subsequent state changes. `useSubscriber` also runs it immediately on mount with the current value. Use `useSubscriber` when the side effect must reflect the current state on first render, such as syncing a DOM attribute. + +### When should I use `useListener` instead of `useFeatureState`? + +Use `useListener` when you need to react to state changes as a side effect but the component does not render anything derived from that state. Avoids an unnecessary re-render. + +### Can I reference the latest values in a `useListener` or `useSubscriber` callback? + +Yes. Both hooks use a stable callback ref internally, so you can close over other state or props without stale value issues. The callback itself must be synchronous. + +### Can I pass `null` or `undefined` as the state argument? + +Yes. All hooks accept `null` and `undefined` without subscribing. `useFeatureState` returns `null` in that case. This makes conditional subscription safe without violating the rules of hooks. + +### Why do background updates not trigger an immediate re-render? + +States can emit updates marked as background, meaning the change should be picked up on the next render rather than forced immediately. `useFeatureState` and `useCompute` record the change so the next render reflects it, but they do not schedule an extra re-render. + +### What does passing `isEqual = false` to `useCompute` do? + +It disables the equality check entirely. The component re-renders every time any subscribed state emits a change, regardless of whether the computed value actually changed. Useful when the compute function has deliberate side effects or when you always want the freshest object reference. + +### Does `useFeatureState` re-render when I call `notify()` without replacing the value? + +Yes. `notify()` signals a change regardless of whether the value reference changed. `useFeatureState` uses `useSyncExternalStore` with a snapshot wrapper, so a `notify()` call produces a new snapshot and triggers a re-render. diff --git a/packages/feature-react/package.json b/packages/feature-react/package.json index a8a29d9c..c825879e 100644 --- a/packages/feature-react/package.json +++ b/packages/feature-react/package.json @@ -1,9 +1,23 @@ { "name": "feature-react", - "version": "0.0.68", + "version": "0.1.0-beta.1", "private": false, - "description": "ReactJs extension features for feature-state", - "keywords": [], + "description": "React hooks for provider-free feature-state and feature-form subscriptions.", + "keywords": [ + "react", + "hooks", + "react-hooks", + "state", + "state-management", + "form", + "computed", + "selector", + "provider-free", + "useSyncExternalStore", + "feature-state", + "feature-form", + "typescript" + ], "homepage": "https://builder.group/?utm_source=package-json", "bugs": { "url": "https://github.com/builder-group/community/issues" @@ -64,24 +78,34 @@ "publish:patch": "pnpm build:prod && pnpm version patch && pnpm publish --no-git-checks --access=public", "size": "size-limit --why", "start:dev": "tsc -w", - "test": "echo \"Error: no test specified\"", + "test": "vitest run", + "test:types": "vitest run --typecheck.only", "update:latest": "pnpm update --latest" }, "dependencies": { - "@blgc/types": "workspace:*", - "@blgc/utils": "workspace:*" + "feature-core": "workspace:*" }, "devDependencies": { "@blgc/config": "workspace:*", - "@types/react": "^19.2.14", + "@types/react": "^19.2.15", "feature-form": "workspace:*", "feature-state": "workspace:*", - "react": "^19.2.5", + "react": "^19.2.6", "rollup-presets": "workspace:*" }, "peerDependencies": { + "feature-form": "workspace:*", + "feature-state": "workspace:*", "react": "^18.3.1" }, + "peerDependenciesMeta": { + "feature-form": { + "optional": true + }, + "feature-state": { + "optional": true + } + }, "size-limit": [ { "path": "dist/state/esm/index.js" diff --git a/packages/feature-react/rollup.config.js b/packages/feature-react/rollup.config.js index d09fd346..245ea62c 100644 --- a/packages/feature-react/rollup.config.js +++ b/packages/feature-react/rollup.config.js @@ -3,4 +3,6 @@ const { libraryPreset } = require('rollup-presets'); /** * @type {import('rollup').RollupOptions[]} */ -module.exports = libraryPreset(); +module.exports = libraryPreset({ + crossModuleImports: true +}); diff --git a/packages/feature-react/src/form/get-field-input-props.test-d.ts b/packages/feature-react/src/form/get-field-input-props.test-d.ts new file mode 100644 index 00000000..b21d16cf --- /dev/null +++ b/packages/feature-react/src/form/get-field-input-props.test-d.ts @@ -0,0 +1,67 @@ +import { createFormField } from 'feature-form'; +import { describe, expectTypeOf, it } from 'vitest'; +import { getFieldInputProps } from './get-field-input-props'; + +describe('getFieldInputProps function', () => { + it('should infer uncontrolled field props from a string form field', () => { + const field = createFormField('Jeff', { key: 'name' }); + const props = getFieldInputProps<'name'>(field); + + expectTypeOf(props.name).toEqualTypeOf<'name'>(); + expectTypeOf(props.defaultValue).toEqualTypeOf(); + expectTypeOf(props.value).toEqualTypeOf(); + }); + + it('should infer controlled field props from a string form field', () => { + const field = createFormField('', { key: 'email' }); + const props = getFieldInputProps<'email'>(field, { controlled: true }); + + expectTypeOf(props.name).toEqualTypeOf<'email'>(); + expectTypeOf(props.value).toEqualTypeOf(); + }); + + it('should accept optional string form fields without parse and format', () => { + const field = createFormField(undefined, { key: 'name' }); + const props = getFieldInputProps<'name', string | undefined>(field); + + expectTypeOf(props.defaultValue).toEqualTypeOf(); + }); + + it('should require parse and format for non-string fields', () => { + const field = createFormField(32, { key: 'age' }); + + // @ts-expect-error non-string fields need parse and format + getFieldInputProps(field); + + getFieldInputProps<'age', number>(field, { + format: (value) => String(value), + parse: (value) => Number(value) + }); + }); + + it('should require parse and format for string union fields', () => { + const field = createFormField<'blockTargets' | 'wholeDevice'>('blockTargets', { + key: 'scope' + }); + + // @ts-expect-error string union fields need parse and format + getFieldInputProps(field); + + getFieldInputProps<'scope', 'blockTargets' | 'wholeDevice'>(field, { + format: (value) => value, + parse: (value) => (value === 'wholeDevice' ? 'wholeDevice' : 'blockTargets') + }); + }); + + it('should require parse and format for unknown fields', () => { + const field = createFormField('Jeff', { key: 'name' }); + + // @ts-expect-error unknown fields need parse and format + getFieldInputProps(field); + + getFieldInputProps<'name', unknown>(field, { + format: (value) => (typeof value === 'string' ? value : ''), + parse: (value) => value + }); + }); +}); diff --git a/packages/feature-react/src/form/get-field-input-props.test.ts b/packages/feature-react/src/form/get-field-input-props.test.ts new file mode 100644 index 00000000..1edaaf7f --- /dev/null +++ b/packages/feature-react/src/form/get-field-input-props.test.ts @@ -0,0 +1,128 @@ +import { createFormField } from 'feature-form'; +import { describe, expect, it } from 'vitest'; +import { getFieldInputProps } from './get-field-input-props'; + +describe('getFieldInputProps function', () => { + describe('value props', () => { + it('should return defaultValue for uncontrolled inputs', () => { + // Prepare + const field = createFormField('Jeff', { key: 'name' }); + + // Act + const props = getFieldInputProps<'name'>(field); + + // Assert + expect(props.name).toBe('name'); + expect(props.defaultValue).toBe('Jeff'); + expect('value' in props).toBe(false); + }); + + it('should return value for controlled inputs', () => { + // Prepare + const field = createFormField('Jeff', { key: 'name' }); + + // Act + const props = getFieldInputProps<'name'>(field, { controlled: true }); + + // Assert + expect(props.name).toBe('name'); + expect(props.value).toBe('Jeff'); + expect('defaultValue' in props).toBe(false); + }); + + it('should format non-string field values', () => { + // Prepare + const field = createFormField(32, { key: 'age' }); + + // Act + const props = getFieldInputProps<'age', number>(field, { + format: (value) => String(value), + parse: (value) => Number(value) + }); + + // Assert + expect(props.defaultValue).toBe('32'); + }); + }); + + describe('onChange handler', () => { + it('should update uncontrolled fields in the background', () => { + // Prepare + const field = createFormField('Jeff', { key: 'name' }); + const changes: unknown[] = []; + field.listen((change) => { + changes.push(change); + }); + const props = getFieldInputProps<'name'>(field); + + // Act + props.onChange(createInputChangeEvent('Ben')); + + // Assert + expect(field.get()).toBe('Ben'); + expect(changes).toEqual([ + expect.objectContaining({ + background: true, + prevValue: 'Jeff', + value: 'Ben' + }) + ]); + }); + + it('should parse and update controlled fields outside the background', () => { + // Prepare + const field = createFormField(32, { key: 'age' }); + const changes: unknown[] = []; + field.listen((change) => { + changes.push(change); + }); + const props = getFieldInputProps<'age', number>(field, { + controlled: true, + format: (value) => String(value), + parse: (value) => Number(value) + }); + + // Act + props.onChange(createInputChangeEvent('42')); + + // Assert + expect(field.get()).toBe(42); + expect(changes).toEqual([ + expect.objectContaining({ + background: false, + prevValue: 32, + value: 42 + }) + ]); + }); + }); + + describe('onBlur handler', () => { + it('should blur the field', () => { + // Prepare + const field = createFormField('', { key: 'name' }); + const wasTouchedValues: boolean[] = []; + field.onBlur(({ wasTouched }) => { + wasTouchedValues.push(wasTouched); + }); + const props = getFieldInputProps(field); + + // Act + props.onBlur({} as Parameters[0]); + + // Assert + expect(field.isTouched.get()).toBe(true); + expect(wasTouchedValues).toEqual([false]); + }); + }); +}); + +function createInputChangeEvent(value: string): TInputChangeEvent { + return { + currentTarget: { + value + } + } as TInputChangeEvent; +} + +type TInputChangeEvent = Parameters['onChange']>[0]; diff --git a/packages/feature-react/src/form/get-field-input-props.ts b/packages/feature-react/src/form/get-field-input-props.ts new file mode 100644 index 00000000..4f7ab3dd --- /dev/null +++ b/packages/feature-react/src/form/get-field-input-props.ts @@ -0,0 +1,71 @@ +import { type TFormField } from 'feature-form'; +import { type ChangeEventHandler, type FocusEventHandler } from 'react'; +import { type TIsWideString } from './types'; + +/** + * Returns `name`, `defaultValue`/`value`, `onChange`, and `onBlur` props for binding + * a native input, textarea, or select to a form field. + * + * Uncontrolled by default: `onChange` sets the field value with `background: true` so React + * does not re-render on every keystroke. Set `controlled: true` for a controlled input. + * Non-string fields require `format` (field value to display string) and `parse` (string to field value). + */ +export function getFieldInputProps( + formField: TFormField, + ...[options]: TFieldInputOptionsArgs +): TFieldInputProps { + const { controlled = false, format, parse } = options ?? {}; + const value = format == null ? (formField.get() as string | undefined) : format(formField.get()); + + return { + name: formField.key as GKey, + ...(controlled ? { value } : { defaultValue: value }), + onBlur: () => { + formField.blur(); + }, + onChange(event) { + const nextValue = + parse == null ? (event.currentTarget.value as GValue) : parse(event.currentTarget.value); + + formField.set(nextValue, { + listenerContext: { + background: !controlled + } + }); + } + }; +} + +export interface TFieldInputProps { + defaultValue?: string; + value?: string; + name: GKey; + onChange: ChangeEventHandler; + onBlur: FocusEventHandler; +} + +/** Requires `format` and `parse` for non-string field values. */ +export type TFieldInputOptionsArgs = + TIsWideString extends true + ? [options?: TFieldInputOptions] + : [options: TParsedFieldInputOptions]; + +export interface TFieldInputOptions { + /** Use a controlled input. Defaults to `false`. */ + controlled?: boolean; + /** Converts the field value to a display string. */ + format?: (value: GValue) => string | undefined; + /** Converts the input string back to the field value type. */ + parse?: (value: string) => GValue; +} + +export interface TParsedFieldInputOptions { + /** Use a controlled input. Defaults to `false`. */ + controlled?: boolean; + /** Converts the field value to a display string. */ + format: (value: GValue) => string | undefined; + /** Converts the input string back to the field value type. */ + parse: (value: string) => GValue; +} + +type TFieldInputElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; diff --git a/packages/feature-react/src/form/hooks/index.ts b/packages/feature-react/src/form/hooks/index.ts index 602759f3..76f65573 100644 --- a/packages/feature-react/src/form/hooks/index.ts +++ b/packages/feature-react/src/form/hooks/index.ts @@ -1 +1,2 @@ export * from './use-form'; +export * from './use-form-field'; diff --git a/packages/feature-react/src/form/hooks/use-form-field.test-d.ts b/packages/feature-react/src/form/hooks/use-form-field.test-d.ts new file mode 100644 index 00000000..9c88f957 --- /dev/null +++ b/packages/feature-react/src/form/hooks/use-form-field.test-d.ts @@ -0,0 +1,70 @@ +import { createForm, type TFormField, type TValidationStatusValue } from 'feature-form'; +import { describe, expectTypeOf, it } from 'vitest'; +import { useFormField } from './use-form-field'; + +const form = createForm({ + fields: { + age: { defaultValue: 32 }, + name: { defaultValue: 'Jeff' }, + scope: { defaultValue: 'blockTargets' } + } +}); + +describe('useFormField function', () => { + it('should infer field, status, and input types', () => { + const name = useFormField(form, 'name'); + const age = useFormField(form, 'age'); + + expectTypeOf(name.field).toEqualTypeOf>(); + expectTypeOf(name.status).toEqualTypeOf(); + expectTypeOf(name.input().name).toEqualTypeOf<'name'>(); + expectTypeOf(age.field).toEqualTypeOf>(); + expectTypeOf( + age.input({ + format: (value) => String(value), + parse: (value) => Number(value) + }).name + ).toEqualTypeOf<'age'>(); + + // @ts-expect-error uncontrolled fields do not expose value + expectTypeOf(name.value).toEqualTypeOf(); + }); + + it('should expose field value in controlled mode', () => { + const name = useFormField(form, 'name', { controlled: true }); + const age = useFormField(form, 'age', { controlled: true }); + + expectTypeOf(name.value).toEqualTypeOf(); + expectTypeOf(age.value).toEqualTypeOf(); + + // @ts-expect-error input options are configured at hook level + name.input({ controlled: true }); + }); + + it('should require parse and format for non-string and union fields', () => { + const age = useFormField(form, 'age'); + const scope = useFormField(form, 'scope'); + + // @ts-expect-error non-string fields need parse and format + age.input(); + + age.input({ + format: (value) => String(value), + parse: (value) => Number(value) + }); + + // @ts-expect-error string union fields need parse and format + scope.input(); + + scope.input({ + format: (value) => value, + parse: (value) => (value === 'wholeDevice' ? 'wholeDevice' : 'blockTargets') + }); + }); +}); + +interface TTestFormData { + age: number; + name: string; + scope: 'blockTargets' | 'wholeDevice'; +} diff --git a/packages/feature-react/src/form/hooks/use-form-field.ts b/packages/feature-react/src/form/hooks/use-form-field.ts new file mode 100644 index 00000000..736a9347 --- /dev/null +++ b/packages/feature-react/src/form/hooks/use-form-field.ts @@ -0,0 +1,124 @@ +import { type TAnyFeature } from 'feature-core'; +import { + type TForm, + type TFormData, + type TFormField, + type TFormFieldKey, + type TValidationStatusValue +} from 'feature-form'; +// Note: Import the hook file directly so the form entry does not pull in the state feature barrel +import { useFeatureState } from '../../state/hooks/use-feature-state'; +import { + getFieldInputProps, + type TFieldInputOptionsArgs, + type TFieldInputProps +} from '../get-field-input-props'; +import { type TIsWideString } from '../types'; + +/** + * Subscribes to a form field's status and re-renders when it changes. Uncontrolled by + * default: only re-renders on status changes, not on every keystroke. Pass + * `{ controlled: true }` to also subscribe to the field value and re-render on change. + * + * Returns `field` (the `TFormField` instance), `status()` (the current validation status), + * and `input()` (ready-to-spread input props). All options (`controlled`, `format`, `parse`) + * go to this hook; the returned `input()` takes no arguments. + */ +export function useFormField< + GFormData extends TFormData, + GFeatures extends TAnyFeature[], + GKey extends TFormFieldKey +>( + form: TForm, + key: GKey, + options: TControlledUseFormFieldOptions +): TControlledUseFormFieldResponse; +export function useFormField< + GFormData extends TFormData, + GFeatures extends TAnyFeature[], + GKey extends TFormFieldKey +>( + form: TForm, + key: GKey, + options?: TUseFormFieldOptions +): TUseFormFieldResponse; +export function useFormField< + GFormData extends TFormData, + GFeatures extends TAnyFeature[], + GKey extends TFormFieldKey +>( + form: TForm, + key: GKey, + options?: TUseFormFieldOptions | TControlledUseFormFieldOptions +): TUseFormFieldResponse | TControlledUseFormFieldResponse { + const controlled = options?.controlled ?? false; + const field = form.getField(key); + const value = useFeatureState(controlled ? field : null); + const status = useFeatureState(field.status); + + return { + field, + ...(controlled ? { value } : {}), + status, + input(...[inputOptions]: TUseFormFieldInputOptionsArgs) { + return getFieldInputProps( + field, + // Note: Input options must be re-spread as a tuple because TypeScript cannot forward + // conditional rest params directly; the conditional spread preserves the required/optional distinction. + ...((controlled || inputOptions != null + ? [{ ...inputOptions, controlled }] + : []) as TFieldInputOptionsArgs) + ); + } + }; +} + +export interface TUseFormFieldResponse< + GFormData extends TFormData, + GKey extends TFormFieldKey +> { + /** The raw `TFormField` instance for the subscribed field. */ + field: TFormField; + /** Current validation status value. */ + status: TValidationStatusValue; + /** Returns ready-to-spread input props. Takes no arguments; pass `format` and `parse` to `useFormField` instead. */ + input: (...options: TUseFormFieldInputOptionsArgs) => TFieldInputProps; +} + +/** Requires `format` and `parse` for non-string field values. */ +export type TUseFormFieldInputOptionsArgs = + TIsWideString extends true + ? [options?: TUseFormFieldInputOptions] + : [options: TUseFormFieldParsedInputOptions]; + +export interface TUseFormFieldInputOptions { + /** Converts the field value to a display string. */ + format?: (value: GValue) => string | undefined; + /** Converts the input string back to the field value type. */ + parse?: (value: string) => GValue; +} + +export interface TUseFormFieldParsedInputOptions { + /** Converts the field value to a display string. */ + format: (value: GValue) => string | undefined; + /** Converts the input string back to the field value type. */ + parse: (value: string) => GValue; +} + +export interface TControlledUseFormFieldResponse< + GFormData extends TFormData, + GKey extends TFormFieldKey +> extends TUseFormFieldResponse { + /** Current field value. Only present when `controlled: true` is passed. */ + value: GFormData[GKey]; +} + +export interface TUseFormFieldOptions { + /** `false` (default). Subscribes to status changes only; does not re-render on every keystroke. */ + controlled?: false; +} + +export interface TControlledUseFormFieldOptions { + /** Subscribes to both value and status changes. Re-renders on every keystroke. */ + controlled: true; +} diff --git a/packages/feature-react/src/form/hooks/use-form.test-d.ts b/packages/feature-react/src/form/hooks/use-form.test-d.ts new file mode 100644 index 00000000..bd244f97 --- /dev/null +++ b/packages/feature-react/src/form/hooks/use-form.test-d.ts @@ -0,0 +1,91 @@ +import { + createForm, + dirtyFeature, + type TFormField, + type TValidationStatusValue +} from 'feature-form'; +import { describe, expectTypeOf, it } from 'vitest'; +import { useForm } from './use-form'; + +describe('useForm function', () => { + it('should infer field, status, and input types from form data', () => { + const form = createForm({ + fields: { + age: { defaultValue: 32 }, + name: { defaultValue: 'Jeff' }, + nickname: { defaultValue: undefined }, + scope: { defaultValue: 'blockTargets' } + } + }); + const response = useForm(form); + + expectTypeOf(response.form).toEqualTypeOf(form); + expectTypeOf(response.field('name')).toEqualTypeOf>(); + expectTypeOf(response.field('age')).toEqualTypeOf>(); + expectTypeOf(response.status('name').get()).toEqualTypeOf(); + expectTypeOf(response.input('name').name).toEqualTypeOf<'name'>(); + expectTypeOf(response.input('nickname').name).toEqualTypeOf<'nickname'>(); + }); + + it('should require parse and format for non-string and union fields', () => { + const form = createForm({ + fields: { + age: { defaultValue: 32 }, + name: { defaultValue: 'Jeff' }, + nickname: { defaultValue: undefined }, + scope: { defaultValue: 'blockTargets' } + } + }); + const response = useForm(form); + + // @ts-expect-error non-string fields need parse and format + response.input('age'); + response.input('age', { + format: (value) => String(value), + parse: (value) => Number(value) + }); + + // @ts-expect-error string union fields need parse and format + response.input('scope'); + response.input('scope', { + format: (value) => value, + parse: (value) => (value === 'wholeDevice' ? 'wholeDevice' : 'blockTargets') + }); + }); + + it('should infer form data in submit callbacks', () => { + const form = createForm({ + fields: { + name: { defaultValue: 'Jeff' } + } + }); + const response = useForm(form); + + response.handleSubmit({ + context: { source: 'test' }, + onValidSubmit(data, context) { + expectTypeOf(data).toEqualTypeOf>(); + expectTypeOf(context?.event).toEqualTypeOf(); + } + }); + }); + + it('should preserve installed form features', () => { + const form = createForm({ + fields: { + name: { defaultValue: 'Jeff' } + } + }).with(dirtyFeature<{ name: string }>()); + const response = useForm(form); + + expectTypeOf(response.form.isDirty.get()).toEqualTypeOf(); + expectTypeOf(response.form.dirtyFields.get()).toEqualTypeOf<{ name: boolean }>(); + }); +}); + +interface TTestFormData { + age: number; + name: string; + nickname: string | undefined; + scope: 'blockTargets' | 'wholeDevice'; +} diff --git a/packages/feature-react/src/form/hooks/use-form.ts b/packages/feature-react/src/form/hooks/use-form.ts index 19894f49..36ae8bd5 100644 --- a/packages/feature-react/src/form/hooks/use-form.ts +++ b/packages/feature-react/src/form/hooks/use-form.ts @@ -1,63 +1,80 @@ -import { TFeatureDefinition } from '@blgc/types/features'; +import { type TAnyFeature } from 'feature-core'; import { type TForm, type TFormData, type TFormField, + type TFormFieldKey, type TFormFieldStatus, - type TSubmitOptions + type TFormSubmitOptions } from 'feature-form'; import React from 'react'; -import { registerFormField, type TRegisterFormFieldResponse } from '../register-form-field'; +import { + getFieldInputProps, + type TFieldInputOptionsArgs, + type TFieldInputProps +} from '../get-field-input-props'; -export function useForm( - formOrFactory: TForm | (() => TForm), - deps: React.DependencyList = [] +/** + * Subscribes a component to a form and re-renders when any field value changes. + * + * Returns `input(key)` to bind a field to a native input, `handleSubmit()` to wire a submit + * event, `field(key)` to access the raw `TFormField`, and `status(key)` to get a field's + * status state for a targeted subscription via `useFeatureState`. Use `useFormField` instead + * for isolated field components or large forms where per-keystroke re-renders are expensive. + */ +export function useForm( + form: TForm ): TUseFormResponse { - const [, forceRender] = React.useReducer((s: number) => s + 1, 0); - const form = React.useMemo( - () => (typeof formOrFactory === 'function' ? formOrFactory() : formOrFactory), - deps - ); + const [, forceRender] = React.useReducer((value: number) => value + 1, 0); + // Note: useEffect is intentional here; useForm subscribes to multiple field sources + // which makes useSyncExternalStore impractical. Post-paint timing is fine for forms. React.useEffect(() => { - const unbindCallbacks: (() => void)[] = []; + const unbinds: Array<() => void> = []; for (const formField of Object.values(form.fields) as TFormField[]) { - const unbind = formField.listen( - ({ background }) => { - if (!background) { + unbinds.push( + formField.listen(({ background }) => { + if (background !== true) { forceRender(); } - }, - { key: `use-form_${formField.key}` } + }) ); - unbindCallbacks.push(unbind); } + return () => { - unbindCallbacks.forEach((callback) => { - callback(); - }); + for (const unbind of unbinds) { + unbind(); + } }; }, [form]); return { form, - register(formFieldKey: GKey, controlled = false) { - return registerFormField(form.getField(formFieldKey), controlled); + input>( + formFieldKey: GKey, + ...[options]: TFieldInputOptionsArgs + ) { + return getFieldInputProps( + form.getField(formFieldKey), + // Note: Options must be re-spread as a tuple because TypeScript cannot forward + // conditional rest params directly; the conditional spread preserves the required/optional distinction. + ...((options == null ? [] : [options]) as TFieldInputOptionsArgs) + ); }, handleSubmit: (options = {}) => { - const { preventDefault = true, ...submitOptions } = options; + const { context, preventDefault = true, ...submitOptions } = options; return (event?: React.BaseSyntheticEvent) => { if (preventDefault) { event?.preventDefault(); } - if (submitOptions.context != null) { - submitOptions.context.event = event; - } else { - submitOptions.context = { event }; - } - - return form.submit(submitOptions); + return form.submit({ + ...submitOptions, + context: { + ...context, + event + } + }); }; }, field(formFieldKey) { @@ -69,25 +86,25 @@ export function useForm { +export interface TUseFormResponse { + /** The form instance passed to the hook. */ form: TForm; + /** Returns an event handler that calls `form.submit()`. Prevents the default browser action by default. */ handleSubmit: ( - options?: THandleSubmitOptions + options?: THandleSubmitOptions ) => (event?: React.BaseSyntheticEvent) => Promise; - register: ( + /** Returns ready-to-spread input props for a field. Calls `getFieldInputProps` under the hood. */ + input: >( formFieldKey: GKey, - controlled?: boolean - ) => TRegisterFormFieldResponse; - field: (formFieldKey: GKey) => TFormField; - status: (formFieldKey: GKey) => TFormFieldStatus; + ...options: TFieldInputOptionsArgs + ) => TFieldInputProps; + /** Returns the raw `TFormField` for the given key. */ + field: >(formFieldKey: GKey) => TFormField; + /** Returns the validation status state for the given field. Pass to `useFeatureState` for a targeted subscription. */ + status: >(formFieldKey: GKey) => TFormFieldStatus; } -interface THandleSubmitOptions< - GFormData extends TFormData, - GFeatures extends TFeatureDefinition[] -> extends TSubmitOptions { +interface THandleSubmitOptions extends TFormSubmitOptions { + /** Calls `event.preventDefault()` before submitting. Defaults to `true`. */ preventDefault?: boolean; } diff --git a/packages/feature-react/src/form/index.ts b/packages/feature-react/src/form/index.ts index 13912785..490c24f5 100644 --- a/packages/feature-react/src/form/index.ts +++ b/packages/feature-react/src/form/index.ts @@ -1,2 +1,2 @@ export * from './hooks'; -export * from './register-form-field'; +export * from './get-field-input-props'; diff --git a/packages/feature-react/src/form/register-form-field.ts b/packages/feature-react/src/form/register-form-field.ts deleted file mode 100644 index 0b666a72..00000000 --- a/packages/feature-react/src/form/register-form-field.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { hasProperty } from '@blgc/utils'; -import { type TFormField } from 'feature-form'; -import { type ChangeEventHandler, type FocusEventHandler } from 'react'; - -export function registerFormField( - formField: TFormField, - controlled?: boolean -): TRegisterFormFieldResponse { - return { - name: formField.key, - ...(controlled ? { value: formField._v } : { defaultValue: formField._intialValue }), - onBlur: () => { - formField.blur(); - }, - onChange(event) { - if (hasProperty(event.target, 'value')) { - formField.set(event.target.value as any, { - listenerContext: { - background: !controlled - } - }); - } - } - }; -} - -export interface TRegisterFormFieldResponse { - defaultValue?: GValue; - value?: GValue; - name?: GKey | string; - onChange?: ChangeEventHandler; - onBlur?: FocusEventHandler; -} diff --git a/packages/feature-react/src/form/types.ts b/packages/feature-react/src/form/types.ts new file mode 100644 index 00000000..461113bc --- /dev/null +++ b/packages/feature-react/src/form/types.ts @@ -0,0 +1,10 @@ +/** + * Distinguishes the wide string type from string literal unions. + * `'a' | 'b'` extends string, but string does not extend `'a' | 'b'`. + */ +export type TIsWideString = + Exclude extends string + ? string extends Exclude + ? true + : false + : false; diff --git a/packages/feature-react/src/state/features/global-bind.ts b/packages/feature-react/src/state/features/global-bind.ts new file mode 100644 index 00000000..b9628b63 --- /dev/null +++ b/packages/feature-react/src/state/features/global-bind.ts @@ -0,0 +1,19 @@ +import { defineFeature, type TFeature } from 'feature-core'; +import type { TStateBase } from 'feature-state'; + +/** + * Exposes a state on `globalThis` under `key` for browser console debugging. + * After installing, `globalThis[key]` holds the state object so you can call + * `get()`, `set()`, and inspect `_v` directly from the browser console. + */ +export function globalBindFeature(key: string): TGlobalBindFeature { + return defineFeature({ + key: 'global-bind', + install(state: TStateBase) { + (globalThis as Record)[key] = state; + return {}; + } + }); +} + +export type TGlobalBindFeature = TFeature<'global-bind', object>; diff --git a/packages/feature-react/src/state/features/index.ts b/packages/feature-react/src/state/features/index.ts index 80bf37ae..9240d3f2 100644 --- a/packages/feature-react/src/state/features/index.ts +++ b/packages/feature-react/src/state/features/index.ts @@ -1,2 +1,2 @@ -export * from './with-global-bind'; -export * from './with-local-storage'; +export * from './global-bind'; +export * from './local-storage'; diff --git a/packages/feature-react/src/state/features/local-storage.ts b/packages/feature-react/src/state/features/local-storage.ts new file mode 100644 index 00000000..7397a79f --- /dev/null +++ b/packages/feature-react/src/state/features/local-storage.ts @@ -0,0 +1,56 @@ +import { + missingStorageValue, + storageFeature, + type TStorageFeature, + type TStorageInterface +} from 'feature-state'; + +/** + * Adds `persist()`, `loadFromStorage()`, and `deleteFromStorage()` to a state, + * backed by browser `localStorage`. + * + * Values are serialized with `JSON.stringify` and deserialized with `JSON.parse`. + * Returns `false` silently when `localStorage` is unavailable (SSR, private mode). + * See `storageFeature` in `feature-state` for the full `persist()` contract. + */ +export function localStorageFeature( + key: string +): TStorageFeature { + return storageFeature(createLocalStorageInterface(), key); +} + +function createLocalStorageInterface(): TStorageInterface { + return { + save(key, value) { + const storage = getLocalStorage(); + if (storage == null) { + return false; + } + + storage.setItem(key, JSON.stringify(value)); + return true; + }, + load(key) { + const storage = getLocalStorage(); + const item = storage?.getItem(key); + if (item == null) { + return missingStorageValue; + } + + return JSON.parse(item) as GStorageValue; + }, + delete(key) { + const storage = getLocalStorage(); + if (storage == null) { + return false; + } + + storage.removeItem(key); + return true; + } + }; +} + +function getLocalStorage(): Storage | null { + return typeof localStorage === 'undefined' ? null : localStorage; +} diff --git a/packages/feature-react/src/state/features/with-global-bind.ts b/packages/feature-react/src/state/features/with-global-bind.ts deleted file mode 100644 index 04d63e45..00000000 --- a/packages/feature-react/src/state/features/with-global-bind.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { isObject } from '@blgc/utils'; - -// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/globalThis -export function withGlobalBind(key: string, value: T): T { - if (isObject(globalThis)) { - (globalThis as Record)[key] = value; - } - return value; -} diff --git a/packages/feature-react/src/state/features/with-local-storage.ts b/packages/feature-react/src/state/features/with-local-storage.ts deleted file mode 100644 index 45f5a562..00000000 --- a/packages/feature-react/src/state/features/with-local-storage.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { TEnforceFeatureConstraint, TFeatureDefinition } from '@blgc/types/features'; -import { - FAILED_TO_LOAD_FROM_STORAGE_IDENTIFIER, - TPersistFeature, - withStorage, - type TState, - type TStorageInterface -} from 'feature-state'; - -class LocalStorageInterface implements TStorageInterface { - save(key: string, value: GStorageValue): boolean { - localStorage.setItem(key, JSON.stringify(value)); - return true; - } - load(key: string): GStorageValue | typeof FAILED_TO_LOAD_FROM_STORAGE_IDENTIFIER { - const item = localStorage.getItem(key); - return item !== null - ? (JSON.parse(item) as GStorageValue) - : FAILED_TO_LOAD_FROM_STORAGE_IDENTIFIER; - } - delete(key: string): boolean { - localStorage.removeItem(key); - return true; - } -} - -export function withLocalStorage( - baseState: TEnforceFeatureConstraint, TState, []>, - key: string -): TState { - return withStorage(baseState, new LocalStorageInterface(), key); -} diff --git a/packages/feature-react/src/state/hooks/index.ts b/packages/feature-react/src/state/hooks/index.ts index 713f53c7..3f78280e 100644 --- a/packages/feature-react/src/state/hooks/index.ts +++ b/packages/feature-react/src/state/hooks/index.ts @@ -1,7 +1,6 @@ -export * from './use-combined-compute'; export * from './use-compute'; +export * from './use-event-callback'; export * from './use-feature-state'; -export * from './use-feature-state-with-middleware'; +export * from './use-isomorphic-layout-effect'; export * from './use-listener'; -export * from './use-selector'; export * from './use-subscriber'; diff --git a/packages/feature-react/src/state/hooks/use-combined-compute.ts b/packages/feature-react/src/state/hooks/use-combined-compute.ts deleted file mode 100644 index 0ea6ed80..00000000 --- a/packages/feature-react/src/state/hooks/use-combined-compute.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { TListenerContext, TListenerOptions, TState, TStateValue } from 'feature-state'; -import React from 'react'; - -// 1 state -export function useCombinedCompute | undefined, GComputed>( - states: readonly [S1], - compute: ( - cxs: readonly [S1 extends TState ? TListenerContext> : undefined] - ) => GComputed, - deps?: React.DependencyList, - options?: TUseCombinedComputeOptions< - S1 extends TState ? TStateValue : never, - GComputed - > -): GComputed; - -// 2 states -export function useCombinedCompute< - S1 extends TState | undefined, - S2 extends TState | undefined, - GComputed ->( - states: readonly [S1, S2], - compute: ( - cxs: readonly [ - S1 extends TState ? TListenerContext> : undefined, - S2 extends TState ? TListenerContext> : undefined - ] - ) => GComputed, - deps?: React.DependencyList, - options?: TUseCombinedComputeOptions< - | (S1 extends TState ? TStateValue : never) - | (S2 extends TState ? TStateValue : never), - GComputed - > -): GComputed; - -// 3 states -export function useCombinedCompute< - S1 extends TState | undefined, - S2 extends TState | undefined, - S3 extends TState | undefined, - GComputed ->( - states: readonly [S1, S2, S3], - compute: ( - cxs: readonly [ - S1 extends TState ? TListenerContext> : undefined, - S2 extends TState ? TListenerContext> : undefined, - S3 extends TState ? TListenerContext> : undefined - ] - ) => GComputed, - deps?: React.DependencyList, - options?: TUseCombinedComputeOptions< - | (S1 extends TState ? TStateValue : never) - | (S2 extends TState ? TStateValue : never) - | (S3 extends TState ? TStateValue : never), - GComputed - > -): GComputed; - -// 4 states -export function useCombinedCompute< - S1 extends TState | undefined, - S2 extends TState | undefined, - S3 extends TState | undefined, - S4 extends TState | undefined, - GComputed ->( - states: readonly [S1, S2, S3, S4], - compute: ( - cxs: readonly [ - S1 extends TState ? TListenerContext> : undefined, - S2 extends TState ? TListenerContext> : undefined, - S3 extends TState ? TListenerContext> : undefined, - S4 extends TState ? TListenerContext> : undefined - ] - ) => GComputed, - deps?: React.DependencyList, - options?: TUseCombinedComputeOptions< - | (S1 extends TState ? TStateValue : never) - | (S2 extends TState ? TStateValue : never) - | (S3 extends TState ? TStateValue : never) - | (S4 extends TState ? TStateValue : never), - GComputed - > -): GComputed; - -// 5 states -export function useCombinedCompute< - S1 extends TState | undefined, - S2 extends TState | undefined, - S3 extends TState | undefined, - S4 extends TState | undefined, - S5 extends TState | undefined, - GComputed ->( - states: readonly [S1, S2, S3, S4, S5], - compute: ( - cxs: readonly [ - S1 extends TState ? TListenerContext> : undefined, - S2 extends TState ? TListenerContext> : undefined, - S3 extends TState ? TListenerContext> : undefined, - S4 extends TState ? TListenerContext> : undefined, - S5 extends TState ? TListenerContext> : undefined - ] - ) => GComputed, - deps?: React.DependencyList, - options?: TUseCombinedComputeOptions< - | (S1 extends TState ? TStateValue : never) - | (S2 extends TState ? TStateValue : never) - | (S3 extends TState ? TStateValue : never) - | (S4 extends TState ? TStateValue : never) - | (S5 extends TState ? TStateValue : never), - GComputed - > -): GComputed; - -// Implementation -export function useCombinedCompute< - S1 extends TState | undefined, - S2 extends TState | undefined, - S3 extends TState | undefined, - S4 extends TState | undefined, - S5 extends TState | undefined, - GComputed ->( - states: readonly (TState | undefined)[], - compute: (cxs: any) => GComputed, - deps: React.DependencyList = [], - options: TUseCombinedComputeOptions< - | (S1 extends TState ? TStateValue : never) - | (S2 extends TState ? TStateValue : never) - | (S3 extends TState ? TStateValue : never) - | (S4 extends TState ? TStateValue : never) - | (S5 extends TState ? TStateValue : never), - GComputed - > = {} -): GComputed { - const { isEqual = Object.is, ...listenerOptions } = options; - const [, forceRender] = React.useReducer((s) => s + 1, 0); - - const currentContextsRef = React.useRef( - states.map((state) => (state != null ? { value: state._v } : undefined)) - ); - const lastComputedRef = React.useRef(compute(currentContextsRef.current)); - - React.useEffect(() => { - const updateContext = (index: number, context: any, background?: boolean) => { - currentContextsRef.current[index] = context; - const newComputed = compute(currentContextsRef.current); - - // Only trigger re-render if computed value changed and not in background - if (!background && (isEqual === false || !isEqual(newComputed, lastComputedRef.current))) { - forceRender(); - } - lastComputedRef.current = newComputed; - }; - - const unbinds = states.map((state, index) => { - if (state == null) { - return; - } - - return state.subscribe((context) => updateContext(index, context, context.background), { - key: `use-combined-compute-${index}`, - ...listenerOptions - }); - }); - - return () => unbinds.forEach((unbind) => unbind?.()); - }, [...states, ...deps]); - - return lastComputedRef.current; -} - -interface TUseCombinedComputeOptions extends TListenerOptions { - isEqual?: ((a: GComputed, b: GComputed) => boolean) | false; -} diff --git a/packages/feature-react/src/state/hooks/use-compute.test-d.ts b/packages/feature-react/src/state/hooks/use-compute.test-d.ts new file mode 100644 index 00000000..7ee55c7c --- /dev/null +++ b/packages/feature-react/src/state/hooks/use-compute.test-d.ts @@ -0,0 +1,81 @@ +import { type TFeature } from 'feature-core'; +import { createState } from 'feature-state'; +import { describe, expectTypeOf, it } from 'vitest'; +import { useCompute } from './use-compute'; + +describe('useCompute function', () => { + it('should infer the computed value type from a single state', () => { + expectTypeOf(useCompute(createState(2), (count) => count * 2)).toEqualTypeOf(); + }); + + it('should infer the computed value type from a tuple of states', () => { + const count = createState(2); + const label = createState('count'); + const value = useCompute([count, label] as const, ([countValue, labelValue]) => { + expectTypeOf(countValue).toEqualTypeOf(); + expectTypeOf(labelValue).toEqualTypeOf(); + return `${labelValue}:${countValue}`; + }); + + expectTypeOf(value).toEqualTypeOf(); + }); + + it('should handle nullable state input', () => { + const state = Math.random() > 0.5 ? createState('Jeff') : null; + + expectTypeOf(useCompute(state, (name) => name?.length ?? 0)).toEqualTypeOf(); + }); + + it('should handle nullable entries in a state tuple', () => { + const count = createState(2); + const label = Math.random() > 0.5 ? createState('count') : null; + const value = useCompute([count, label] as const, ([countValue, labelValue]) => { + expectTypeOf(labelValue).toEqualTypeOf(); + return labelValue == null ? countValue : `${labelValue}:${countValue}`; + }); + + expectTypeOf(value).toEqualTypeOf(); + }); + + it('should infer the computed value type for states with installed features', () => { + const state = createState(2).with(testFeature()); + + expectTypeOf(useCompute(state, (count) => count * 2)).toEqualTypeOf(); + }); + + it('should accept a custom equality function', () => { + const value = useCompute( + createState(2), + (count) => ({ count }), + [], + (next, current) => next.count === current.count + ); + + expectTypeOf(value).toEqualTypeOf<{ count: number }>(); + }); + + it('should accept explicit compute dependencies with custom equality', () => { + const multiplier = 2; + const value = useCompute( + createState(2), + (count) => ({ count: count * multiplier }), + [multiplier], + (next, current) => next.count === current.count + ); + + expectTypeOf(value).toEqualTypeOf<{ count: number }>(); + }); +}); + +function testFeature(): TTestFeature { + return { + key: 'test', + overrides: [], + requires: [], + install() { + return { test: () => 'test' }; + } + }; +} + +type TTestFeature = TFeature<'test', { test(): string }>; diff --git a/packages/feature-react/src/state/hooks/use-compute.ts b/packages/feature-react/src/state/hooks/use-compute.ts index 183b2610..cd2471a6 100644 --- a/packages/feature-react/src/state/hooks/use-compute.ts +++ b/packages/feature-react/src/state/hooks/use-compute.ts @@ -1,66 +1,135 @@ -import { - TListenerContext, - TNullableStateValue, - type TListenerOptions, - type TState -} from 'feature-state'; +import { type TAnyFeature } from 'feature-core'; +import type { TState } from 'feature-state'; import React from 'react'; -export function useCompute< - GState extends TState | undefined | null, - GValue extends TNullableStateValue, - GComputed ->( +/** + * Derives a computed value from one state or a tuple of states. + * + * Re-renders only when the computed result changes (`Object.is` by default). + * Pass `deps` for any values `compute` reads outside the subscribed states. + * Pass a custom `isEqual` to use structural comparison, or `false` to re-render on every + * source change regardless of the computed value. Keep `compute` and `isEqual` pure: + * React may call them outside a render. + */ +export function useCompute( state: GState, - compute: (cx: TListenerContext) => GComputed, + compute: (value: TComputeValue) => GComputed, + deps?: React.DependencyList, + isEqual?: TComputeIsEqual +): GComputed; +export function useCompute( + states: GStates, + compute: (values: TComputeValues) => GComputed, + deps?: React.DependencyList, + isEqual?: TComputeIsEqual +): GComputed; +export function useCompute( + input: TAnyComputeState | readonly TAnyComputeState[], + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- implementation accepts all public overload value shapes + compute: (value: any) => GComputed, deps: React.DependencyList = [], - options: TUseComputeOptions = {} + isEqual: TComputeIsEqual = Object.is ): GComputed { - const { isEqual = Object.is, ...listenerOptions } = options; - const [, forceRender] = React.useReducer((s: number) => s + 1, 0); + const isTupleInput = Array.isArray(input); + const inputStates = (isTupleInput ? input : [input]) as readonly TAnyComputeState[]; + const states = React.useMemo(() => [...inputStates], [isTupleInput, ...inputStates]); + // Note: depsToken is a stable object whose identity changes when deps change, used as a cache key + // to detect external dependency changes without tracking the compute function reference + const depsToken = React.useMemo(() => ({}), deps); + // Note: Wrapping computed value in a snapshot object (not returning the raw value) lets useSyncExternalStore + // detect re-renders via reference equality: returning a new object forces a re-render even + // when the raw computed value reference is unchanged (e.g. notify() called on a mutable object) + const forceSnapshotRef = React.useRef(false); + const snapshotRef = React.useRef | null>(null); + const snapshotMetaRef = React.useRef(null); - const lastComputedRef = React.useRef( - compute({ value: state == null ? null : state._v }) - ); + const getSnapshot = React.useCallback((): TComputeSnapshot => { + const snapshot = snapshotRef.current; + const meta = snapshotMetaRef.current; + const values = states.map((state) => (state == null ? null : state.get())); - React.useEffect(() => { - // If state is null/undefined, compute with null and update if needed - if (state == null) { - const newComputed = compute({ value: null as GValue }); - if (isEqual === false || !isEqual(newComputed, lastComputedRef.current)) { - forceRender(); - } - lastComputedRef.current = newComputed; - return; + const shouldReuseSnapshot = + !forceSnapshotRef.current && + snapshot != null && + meta != null && + meta.depsToken === depsToken && + meta.states === states && + meta.values.length === values.length && + values.every((value, index) => Object.is(value, meta.values[index])); + forceSnapshotRef.current = false; + if (shouldReuseSnapshot) { + return snapshot; } - // Use subscribe to ensure lastComputedRef is updated when useEffect re-runs on component re-renders, - // even if the state value hasn't changed since the last subscription - // but the state instance might have changed. - const unbind = state.subscribe( - (cx) => { - const newComputed = compute(cx); - - // Only trigger re-render if computed value changed and not in background - if ( - !cx.background && - (isEqual === false || !isEqual(newComputed, lastComputedRef.current)) - ) { - forceRender(); + const nextValue = compute(isTupleInput ? values : values[0]); + const nextMeta = { depsToken, states, values }; + if (snapshot != null && isEqual !== false && isEqual(nextValue, snapshot.value)) { + snapshotMetaRef.current = nextMeta; + return snapshot; + } + + const nextSnapshot = { value: nextValue }; + snapshotRef.current = nextSnapshot; + snapshotMetaRef.current = nextMeta; + return nextSnapshot; + }, [compute, depsToken, isEqual, isTupleInput, states]); + + const subscribe = React.useCallback( + (onStoreChange: () => void) => { + // Note: Force is set even for background updates so the next render picks up the + // change lazily without triggering an immediate re-render + function emit(background: boolean | undefined): void { + forceSnapshotRef.current = true; + if (background !== true) { + onStoreChange(); + } + } + + const subscribedStates = new Set>(); + const unbinds: Array<() => void> = []; + for (const state of states) { + if (state == null || subscribedStates.has(state)) { + continue; + } + + subscribedStates.add(state); + unbinds.push( + state.listen(({ background }) => { + emit(background); + }) + ); + } + + return () => { + for (const unbind of unbinds) { + unbind(); } - lastComputedRef.current = newComputed; - }, - { key: 'use-compute', ...listenerOptions } - ); + }; + }, + [states] + ); + + return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot).value; +} + +/** Compares next and current computed values, or disables equality checks when set to `false`. */ +export type TComputeIsEqual = ((next: GComputed, current: GComputed) => boolean) | false; + +type TComputeValues = { + readonly [GIndex in keyof GStates]: TComputeValue; +}; + +type TComputeValue = GState extends TState ? GValue : null; - return () => { - unbind(); - }; - }, [state, ...deps]); +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- used only to accept arbitrary state value types in the public input +type TAnyComputeState = TState | null | undefined; - return lastComputedRef.current; +interface TComputeSnapshot { + readonly value: GComputed; } -interface TUseComputeOptions extends TListenerOptions { - isEqual?: ((a: GComputed, b: GComputed) => boolean) | false; +interface TComputeSnapshotMeta { + depsToken: object; + states: readonly TAnyComputeState[]; + values: readonly unknown[]; } diff --git a/packages/feature-react/src/state/hooks/use-event-callback.ts b/packages/feature-react/src/state/hooks/use-event-callback.ts new file mode 100644 index 00000000..f6c38cdb --- /dev/null +++ b/packages/feature-react/src/state/hooks/use-event-callback.ts @@ -0,0 +1,20 @@ +import React from 'react'; +import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect'; + +/** + * Returns a stable callback reference that always delegates to the latest implementation. + * Prevents effect re-registration when an inline callback changes between renders. + * + * Serves the same role as `useEffectEvent` while this package supports React 18. + */ +export function useEventCallback( + callback: (...args: GArgs) => GReturn +): (...args: GArgs) => GReturn { + const callbackRef = React.useRef(callback); + + useIsomorphicLayoutEffect(() => { + callbackRef.current = callback; + }, [callback]); + + return React.useCallback((...args: GArgs) => callbackRef.current(...args), []); +} diff --git a/packages/feature-react/src/state/hooks/use-feature-state-with-middleware.ts b/packages/feature-react/src/state/hooks/use-feature-state-with-middleware.ts deleted file mode 100644 index 285309e3..00000000 --- a/packages/feature-react/src/state/hooks/use-feature-state-with-middleware.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { TFeatureDefinition } from '@blgc/types/features'; -import type { TListenerContext, TListenerOptions, TState } from 'feature-state'; -import React from 'react'; - -export function useFeatureStateWithMiddleware( - state: TState, - middleware: TFeatureStateMiddleware[] = [], - deps: React.DependencyList = [], - options: TUseFeatureStateMiddlewareOptions = {} -): Readonly { - const { ...listenerOptions } = options; - const [, forceRender] = React.useReducer((s: number) => s + 1, 0); - - React.useEffect(() => { - const unbind = state.listen( - (data) => { - const processedData = middleware.reduce((acc, middlewareFn) => middlewareFn(acc), data); - - if (!processedData.background) { - forceRender(); - } - }, - { key: 'use-feature-state-with-middleware', ...listenerOptions } - ); - - return () => { - unbind(); - }; - }, [state, ...deps]); - - return state._v; -} - -export type TFeatureStateMiddleware = ( - context: TListenerContext -) => TListenerContext; - -export interface TUseFeatureStateMiddlewareOptions extends TListenerOptions {} diff --git a/packages/feature-react/src/state/hooks/use-feature-state.test-d.ts b/packages/feature-react/src/state/hooks/use-feature-state.test-d.ts new file mode 100644 index 00000000..455fe2bd --- /dev/null +++ b/packages/feature-react/src/state/hooks/use-feature-state.test-d.ts @@ -0,0 +1,39 @@ +import { type TFeature } from 'feature-core'; +import { createState } from 'feature-state'; +import { describe, expectTypeOf, it } from 'vitest'; +import { useFeatureState } from './use-feature-state'; + +describe('useFeatureState function', () => { + it('should infer the state value type', () => { + expectTypeOf(useFeatureState(createState(0))).toEqualTypeOf(); + }); + + it('should return null for null input', () => { + expectTypeOf(useFeatureState(null)).toEqualTypeOf(); + }); + + it('should return value or null for nullable state', () => { + const state = Math.random() > 0.5 ? createState('Jeff') : null; + + expectTypeOf(useFeatureState(state)).toEqualTypeOf(); + }); + + it('should infer the value type for states with installed features', () => { + const state = createState(0).with(testFeature()); + + expectTypeOf(useFeatureState(state)).toEqualTypeOf(); + }); +}); + +function testFeature(): TTestFeature { + return { + key: 'test', + overrides: [], + requires: [], + install() { + return { test: () => 'test' }; + } + }; +} + +type TTestFeature = TFeature<'test', { test(): string }>; diff --git a/packages/feature-react/src/state/hooks/use-feature-state.ts b/packages/feature-react/src/state/hooks/use-feature-state.ts index ac90a5d8..292c5b3c 100644 --- a/packages/feature-react/src/state/hooks/use-feature-state.ts +++ b/packages/feature-react/src/state/hooks/use-feature-state.ts @@ -1,31 +1,69 @@ -import type { TNullableStateValue, TState } from 'feature-state'; +import { type TAnyFeature } from 'feature-core'; +import type { TState } from 'feature-state'; import React from 'react'; -export function useFeatureState< - GState extends TState | undefined | null, - GValue extends TNullableStateValue ->(state: GState): GValue { - const [, forceRender] = React.useReducer((s: number) => s + 1, 0); +/** + * Returns the current value of a state and re-renders the component on each change. + * + * Pass `null` or `undefined` to opt out: the hook returns `null` and registers no listener. + * Background updates mark the change for the next render without forcing an immediate re-render. + */ +export function useFeatureState( + state: GState +): TFeatureStateValue { + // Note: Wrapping value in a snapshot object (not returning the raw value) lets useSyncExternalStore + // detect re-renders via reference equality: returning a new object forces a re-render even + // when the raw value reference is unchanged (e.g. notify() called on a mutable object) + const forceSnapshotRef = React.useRef(false); + const snapshotRef = React.useRef> | null>(null); - React.useEffect(() => { - // No subscription needed for null/undefined state - if (state == null) { - return; + const getSnapshot = React.useCallback((): TFeatureStateSnapshot> => { + const snapshot = snapshotRef.current; + const value = (state == null ? null : state.get()) as TFeatureStateValue; + + const shouldReuseSnapshot = + !forceSnapshotRef.current && snapshot != null && Object.is(snapshot.value, value); + forceSnapshotRef.current = false; + if (shouldReuseSnapshot) { + return snapshot; } - const unbind = state.listen( - ({ background }) => { - if (!background) { - forceRender(); + const nextSnapshot = { value }; + snapshotRef.current = nextSnapshot; + return nextSnapshot; + }, [state]); + + const subscribe = React.useCallback( + (onStoreChange: () => void) => { + // Note: Force is set even for background updates so the next render picks up the + // change lazily without triggering an immediate re-render + function emit(background: boolean | undefined): void { + forceSnapshotRef.current = true; + if (background !== true) { + onStoreChange(); } - }, - { key: 'use-feature-state' } - ); + } - return () => { - unbind(); - }; - }, [state]); + if (state == null) { + return () => {}; + } + + return state.listen(({ background }) => { + emit(background); + }); + }, + [state] + ); + + return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot).value; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- used only to accept arbitrary state value types in the public input +type TAnyFeatureState = TState | null | undefined; + +type TFeatureStateValue = + GState extends TState ? GValue : null; - return (state == null ? null : state._v) as GValue; +interface TFeatureStateSnapshot { + readonly value: GValue; } diff --git a/packages/feature-react/src/state/hooks/use-isomorphic-layout-effect.ts b/packages/feature-react/src/state/hooks/use-isomorphic-layout-effect.ts new file mode 100644 index 00000000..9698d4de --- /dev/null +++ b/packages/feature-react/src/state/hooks/use-isomorphic-layout-effect.ts @@ -0,0 +1,5 @@ +import React from 'react'; + +/** Uses layout effect in the browser and regular effect during SSR. */ +export const useIsomorphicLayoutEffect = + typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect; diff --git a/packages/feature-react/src/state/hooks/use-listener.test-d.ts b/packages/feature-react/src/state/hooks/use-listener.test-d.ts new file mode 100644 index 00000000..d050b020 --- /dev/null +++ b/packages/feature-react/src/state/hooks/use-listener.test-d.ts @@ -0,0 +1,55 @@ +import { type TFeature } from 'feature-core'; +import { createState } from 'feature-state'; +import { describe, expectTypeOf, it } from 'vitest'; +import { useListener, type TUseListenerCallback } from './use-listener'; + +describe('useListener function', () => { + it('should infer value and prevValue types from the state', () => { + useListener(createState(0), (context) => { + expectTypeOf(context.value).toEqualTypeOf(); + expectTypeOf(context.prevValue).toEqualTypeOf(); + }); + }); + + it('should accept nullable state input', () => { + const state = Math.random() > 0.5 ? createState('Jeff') : null; + + useListener(state, (context) => { + expectTypeOf(context.value).toEqualTypeOf(); + }); + }); + + it('should accept states with installed features', () => { + const state = createState(0).with(testFeature()); + + useListener(state, (context) => { + expectTypeOf(context.value).toEqualTypeOf(); + }); + }); + + it('should accept a callback that returns a cleanup function', () => { + const callback: TUseListenerCallback = () => () => {}; + + useListener(createState(0), callback); + }); + + it('should accept an async callback', () => { + useListener(createState(0), async ({ value }) => { + expectTypeOf(value).toEqualTypeOf(); + await Promise.resolve(); + }); + }); +}); + +function testFeature(): TTestFeature { + return { + key: 'test', + overrides: [], + requires: [], + install() { + return { test: () => 'test' }; + } + }; +} + +type TTestFeature = TFeature<'test', { test(): string }>; diff --git a/packages/feature-react/src/state/hooks/use-listener.ts b/packages/feature-react/src/state/hooks/use-listener.ts index 1ed1f91d..0bda1a90 100644 --- a/packages/feature-react/src/state/hooks/use-listener.ts +++ b/packages/feature-react/src/state/hooks/use-listener.ts @@ -1,36 +1,39 @@ -import { TFeatureDefinition } from '@blgc/types/features'; -import type { TListenerContext, TListenerOptions, TState } from 'feature-state'; +import { type TAnyFeature } from 'feature-core'; +import type { TListenerContext, TState } from 'feature-state'; import React from 'react'; +import { useEventCallback } from './use-event-callback'; -export function useListener( +/** + * Registers a listener for state changes as a side effect and removes it when the component unmounts. + * + * The callback fires on every future change, matching `state.listen()`. It can return a cleanup + * function that runs before the next invocation and on unmount. Pass `null` or `undefined` + * for `state` to register no listener. + */ +export function useListener( state: TState | null | undefined, - callback: TUseListenerCallback, - deps: React.DependencyList = [], - options: TUseListenerOptions = {} + callback: TUseListenerCallback ): void { - const { ...listenerOptions } = options; + const stableCallback = useEventCallback(callback); React.useEffect(() => { let cleanup: (() => void) | undefined; - const unbind = state?.listen( - async (cx) => { - cleanup?.(); - const result = await callback(cx); - cleanup = typeof result === 'function' ? result : undefined; - }, - { key: 'use-listener', ...listenerOptions } - ); + const unbind = state?.listen((context) => { + cleanup?.(); + const result = stableCallback(context); + cleanup = typeof result === 'function' ? result : undefined; + }); return () => { cleanup?.(); unbind?.(); }; - }, [state, ...deps]); + }, [state, stableCallback]); } -export interface TUseListenerOptions extends TListenerOptions {} - +/** Receives each future listener context and may return cleanup for the next call or unmount. */ export type TUseListenerCallback = ( context: TListenerContext -) => (() => void) | Promise<() => void> | void | Promise; + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- callbacks may return nothing, async work, or a cleanup +) => (() => void) | Promise | void; diff --git a/packages/feature-react/src/state/hooks/use-selector.ts b/packages/feature-react/src/state/hooks/use-selector.ts deleted file mode 100644 index 82bcc2b8..00000000 --- a/packages/feature-react/src/state/hooks/use-selector.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { TEnforceFeatureConstraint, TFeatureDefinition } from '@blgc/types/features'; -import { getNestedProperty, type TNestedPath, type TNestedProperty } from '@blgc/utils'; -import { isStateWithFeatures, TSelectorFeature, TState } from 'feature-state'; -import React from 'react'; - -export function useSelector< - GValue, - GFeatures extends TFeatureDefinition[], - GPath extends TNestedPath ->( - state: TEnforceFeatureConstraint< - TState, - TState, - ['selector'] - >, - selectedProperty: GPath -): Readonly> { - const [, forceRender] = React.useReducer((s: number) => s + 1, 0); - - React.useEffect(() => { - let unbind: () => void | undefined; - - if (isStateWithFeatures]>(state, ['selector'])) { - unbind = state.listenToSelected( - [selectedProperty], - ({ background }) => { - if (!background) { - forceRender(); - } - }, - { key: 'use-selected' } - ); - } - - return () => { - unbind(); - }; - }, [state]); - - if (!isStateWithFeatures]>(state, ['selector'])) { - throw Error('State must have "selector" feature to use useSelector'); - } - - return getNestedProperty(state._v, selectedProperty); -} diff --git a/packages/feature-react/src/state/hooks/use-subscriber.test-d.ts b/packages/feature-react/src/state/hooks/use-subscriber.test-d.ts new file mode 100644 index 00000000..e1ca42da --- /dev/null +++ b/packages/feature-react/src/state/hooks/use-subscriber.test-d.ts @@ -0,0 +1,55 @@ +import { type TFeature } from 'feature-core'; +import { createState } from 'feature-state'; +import { describe, expectTypeOf, it } from 'vitest'; +import { useSubscriber, type TUseSubscriberCallback } from './use-subscriber'; + +describe('useSubscriber function', () => { + it('should infer value and prevValue types from the state', () => { + useSubscriber(createState(0), (context) => { + expectTypeOf(context.value).toEqualTypeOf(); + expectTypeOf(context.prevValue).toEqualTypeOf(); + }); + }); + + it('should accept nullable state input', () => { + const state = Math.random() > 0.5 ? createState('Jeff') : null; + + useSubscriber(state, (context) => { + expectTypeOf(context.value).toEqualTypeOf(); + }); + }); + + it('should accept states with installed features', () => { + const state = createState(0).with(testFeature()); + + useSubscriber(state, (context) => { + expectTypeOf(context.value).toEqualTypeOf(); + }); + }); + + it('should accept a callback that returns a cleanup function', () => { + const callback: TUseSubscriberCallback = () => () => {}; + + useSubscriber(createState(0), callback); + }); + + it('should accept an async callback', () => { + useSubscriber(createState(0), async ({ value }) => { + expectTypeOf(value).toEqualTypeOf(); + await Promise.resolve(); + }); + }); +}); + +function testFeature(): TTestFeature { + return { + key: 'test', + overrides: [], + requires: [], + install() { + return { test: () => 'test' }; + } + }; +} + +type TTestFeature = TFeature<'test', { test(): string }>; diff --git a/packages/feature-react/src/state/hooks/use-subscriber.ts b/packages/feature-react/src/state/hooks/use-subscriber.ts index e0981ea9..f5aaff85 100644 --- a/packages/feature-react/src/state/hooks/use-subscriber.ts +++ b/packages/feature-react/src/state/hooks/use-subscriber.ts @@ -1,36 +1,38 @@ -import { TFeatureDefinition } from '@blgc/types/features'; -import type { TListenerContext, TListenerOptions, TState } from 'feature-state'; +import { type TAnyFeature } from 'feature-core'; +import type { TListenerContext, TState } from 'feature-state'; import React from 'react'; +import { useEventCallback } from './use-event-callback'; -export function useSubscriber( +/** + * Calls `callback` immediately with the current state value, then on every future change, + * matching `state.subscribe()`. The callback may return a cleanup function that runs before + * the next call and on unmount. Pass `null` or `undefined` as `state` to opt out without + * conditionally calling the hook. + */ +export function useSubscriber( state: TState | null | undefined, - callback: TUseSubscriberCallback, - deps: React.DependencyList = [], - options: TUseSubscriberOptions = {} + callback: TUseSubscriberCallback ): void { - const { ...listenerOptions } = options; + const stableCallback = useEventCallback(callback); React.useEffect(() => { let cleanup: (() => void) | undefined; - const unbind = state?.subscribe( - async (cx) => { - cleanup?.(); - const result = await callback(cx); - cleanup = typeof result === 'function' ? result : undefined; - }, - { key: 'use-subscriber', ...listenerOptions } - ); + const unbind = state?.subscribe((context) => { + cleanup?.(); + const result = stableCallback(context); + cleanup = typeof result === 'function' ? result : undefined; + }); return () => { cleanup?.(); unbind?.(); }; - }, [state, ...deps]); + }, [state, stableCallback]); } -export interface TUseSubscriberOptions extends TListenerOptions {} - +/** Receives the current listener context immediately, then on each future change. */ export type TUseSubscriberCallback = ( context: TListenerContext -) => (() => void) | Promise<() => void> | void | Promise; + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- callbacks may return nothing, async work, or a cleanup +) => (() => void) | Promise | void; diff --git a/packages/feature-react/vitest.config.mjs b/packages/feature-react/vitest.config.mjs new file mode 100644 index 00000000..8482b939 --- /dev/null +++ b/packages/feature-react/vitest.config.mjs @@ -0,0 +1,4 @@ +import { nodeConfig } from '@blgc/config/vite/node'; +import { defineConfig, mergeConfig } from 'vitest/config'; + +export default mergeConfig(nodeConfig, defineConfig({})); diff --git a/packages/feature-state/README.md b/packages/feature-state/README.md index b2b4b5aa..ce26729d 100644 --- a/packages/feature-state/README.md +++ b/packages/feature-state/README.md @@ -10,223 +10,344 @@ NPM bundle minzipped size - NPM total downloads + NPM total downloads Join Discord

-`feature-state` is a straightforward, typesafe, and feature-based state management library for ReactJs. +`feature-state` is reactive state that grows by installing features. Start with one observable value, then add undo, storage, computed values, custom equality, queues, or custom capabilities with `.with()` only where you need them. -- **Lightweight & Tree Shakable**: Function-based and modular design (< 1KB minified) -- **Fast**: Minimal code ensures high performance, and state changes can be deferred in "the bucket" -- **Modular & Extendable**: Easily extendable with features like `withStorage()`, `withUndo()`, .. -- **Typesafe**: Build with TypeScript for strong type safety -- **Standalone**: Zero dependencies, ensuring ease of use in various environments +- Install capabilities per state: undo, storage, queues, and equality stay opt-in +- Use one `createState()` API in vanilla JS, React, Vue, Svelte, Node.js, and tests +- Let TypeScript track installed capabilities: `undo()` exists only after `undoFeature()` +- Build custom feature packs on the same typed host model as the built-ins -### 📚 Examples +```ts +import { createComputed, createState, undoFeature } from 'feature-state'; + +const $tasks = createState([]).with(undoFeature()); +const $openTasks = createComputed($tasks, (tasks) => tasks.filter((task) => !task.done)); -- [ReactJs Counter](https://github.com/builder-group/community/tree/develop/examples/feature-state/react/counter) ([Code Sandbox](https://codesandbox.io/p/sandbox/counter-k74k9k)) +const unlisten = $openTasks.listen(({ value }) => { + renderOpenTasks(value); +}); -### 🌟 Motivation +$tasks.set([{ id: 1, title: 'Buy milk', done: false }]); -Create a typesafe, straightforward, and lightweight state management library designed to be modular and extendable with features like `withStorage()`, `withUndo()`, .. Having previously built [AgileTs](https://agile-ts.org/), I realized the importance of simplicity and modularity. AgileTs, while powerful, became bloated and complex. Learning from that experience, I followed the KISS (Keep It Simple, Stupid) principle for `feature-state`, aiming to provide a more streamlined and efficient solution. Because no code is the best code. +$openTasks.get(); // [{ id: 1, title: 'Buy milk', done: false }] +$tasks.undo(); // back to [] +unlisten(); +``` -### ⚖️ Alternatives +## Install -- [nanostores](https://github.com/nanostores/nanostores) -- [jotai](https://github.com/pmndrs/jotai) -- [AgileTs](https://github.com/agile-ts/agile) +```bash +npm install feature-state +``` -## 📖 Usage +## Usage -`store/tasks.ts` +A state holds a single value and notifies listeners when it changes: ```ts import { createState } from 'feature-state'; -export const $tasks = createState([]); +const $count = createState(0); -export function addTask(task: Task) { - $tasks.set([...$tasks.get(), task]); -} +$count.listen(({ value, prevValue }) => { + console.log(value, prevValue); +}); + +$count.set(5); +$count.set((v) => v + 1); // updater form, value is now 6 ``` -`components/Tasks.tsx` +Extend a state with features using `.with()`. Each installed feature adds typed methods: -```tsx -import { useFeatureState } from 'feature-state-react'; -import { $tasks } from '../store/tasks'; +```ts +import { multiUndoFeature, undoFeature } from 'feature-state'; -export const Tasks = () => { - const tasks = useFeatureState($tasks); +const $count = createState(0).with(undoFeature(), multiUndoFeature()); - return ( -
    - {tasks.map((task) => ( -
  • {task.title}
  • - ))} -
- ); -}; +$count.set(1); +$count.set(2); +$count.set(3); +$count.undo(); // 2 +$count.multiUndo(2); // 0 ``` -### Atom-based +Derive a read-only value from one or more states with `createComputed`. It recomputes whenever a source state changes: + +```ts +import { createComputed, createState } from 'feature-state'; + +const $tasks = createState([]); +const $done = createComputed($tasks, (tasks) => tasks.filter((t) => t.done)); -States in `feature-state` are atom-based, meaning each state should only represent a single piece of data. They can store various types of data such as strings, numbers, arrays, or even objects. +$tasks.set([{ id: 1, title: 'Buy milk', done: true }]); +$done.get(); // [{ id: 1, title: 'Buy milk', done: true }] +``` -To create an state, use `createState(initialValue)` and pass the initial value as the first argument. +Persist state across sessions with `storageFeature`. Pass any storage adapter that implements `save`, `load`, and `delete`: ```ts -import { createState } from 'feature-state'; +import { createState, missingStorageValue, storageFeature } from 'feature-state'; -export const $temperature = createState(20); // °C +const localAdapter = { + save: (key: string, value: unknown) => { + localStorage.setItem(key, JSON.stringify(value)); + return true; + }, + load: (key: string) => { + const raw = localStorage.getItem(key); + return raw != null ? JSON.parse(raw) : missingStorageValue; + }, + delete: (key: string) => { + localStorage.removeItem(key); + return true; + } +}; + +const $tasks = createState([]).with(storageFeature(localAdapter, 'tasks')); + +await $tasks.persist(); // loads saved value on first call; auto-saves on every set() ``` -In TypeScript, you can optionally specify the value type using a type parameter. +## State + +### `createState(initialValue)` + +Creates a state container and returns it as a feature host. ```ts -export type TWeatherCondition = 'sunny' | 'cloudy' | 'rainy'; +const $count = createState(0); +const $status = createState<'idle' | 'loading' | 'error'>('idle'); +``` + +### `value` / `get()` / `set()` -export const $weatherCondition = createState('sunny'); +```ts +$count.value; // 0 +$count.get(); // 0 + +$count.set(5); +$count.set((v) => v + 1); // updater form + +$count.value = 10; // same as set(10) ``` -To get the current value of the state, use `$state.get()`. To change the value, use `$state.set(nextValue)`. +`set()` skips updating and notifying when the new value is identical to the current one (`Object.is` comparison). + +### `notify()` + +Triggers all listeners without changing the value. Use this after mutating a value in place via `_v`, or when a feature updates internal state by other means. ```ts -$temperature.set($temperature.get() + 5); +$count._v = 42; // mutate directly, no notification +$count.notify(); // notify listeners manually ``` -### Subscribing to State Changes +Pass custom metadata to every listener in the same notification: -You can subscribe to state changes using `$state.subscribe(callback)`, which works in vanilla JS. For React, special hooks like [`useFeatureState($state)`](https://github.com/builder-group/community/tree/develop/packages/feature-state-react) are available to re-render components on state changes. +```ts +$count.notify({ listenerContext: { source: 'mySync', background: true } }); +``` + +### `listen(callback)` / `subscribe(callback)` -Listener callbacks will receive the new value as the first argument. +`listen` registers a callback for future changes and returns an unsubscribe function. `subscribe` does the same but also calls the callback immediately with the current value. ```ts -const unsubscribe = $temperature.subscribe((newValue) => { - console.log(`Temperature changed to ${newValue}°C`); +const unlisten = $count.listen(({ value, prevValue, source }) => { + console.log(value, prevValue, source); }); + +unlisten(); // remove listener ``` -Unlike `$state.listen(callback)`, `$state.subscribe(callback)` immediately invokes the listener during the subscription. +Calling `unlisten()` inside the listener itself is safe. Any pending call to that callback in the current notification cycle is removed immediately. + +**Listener context** -## 📙 Features +| Field | Description | +| ------------ | ---------------------------------------------------------------------------------------- | +| `value` | The new value. | +| `prevValue` | The previous value. Undefined when `notify()` is called without a prior value. | +| `source` | What triggered the change. `'stateSet'` for `set()`. Features set their own source keys. | +| `background` | When `true`, signals that the change is a background sync and UI updates can be skipped. | -### `withStorage()` +Listeners run synchronously in registration order. Nested `set()` calls inside a listener are batched: their listeners join the current queue and run after the outermost notification finishes. -Adds persistence functionality to the state, allowing the state to be saved to and loaded from a storage medium. +### `createComputed(source, compute, options?)` + +Creates a read-only state derived from one source state: ```ts -import { createState, withStorage } from 'feature-state'; +import { createComputed, createState } from 'feature-state'; + +const $tasks = createState([]); +const $completedCount = createComputed($tasks, (tasks) => tasks.filter((task) => task.done).length); +``` + +Pass a tuple when the value depends on multiple states: + +```ts +const $filteredTasks = createComputed([$tasks, $filter] as const, ([tasks, filter]) => + tasks.filter((task) => task.category === filter) +); +``` + +Computed states expose the normal read and subscription API (`value`, `get()`, `listen()`, and `subscribe()`), but `set()` and assigning `value` throw because source states own the data. Call `destroy()` when the containing object is torn down to unsubscribe from source states. + +`isEqual` defaults to `Object.is`. Pass a custom comparator to suppress notifications when the computed structure is equivalent but not referentially identical. Pass `false` to notify on every source update. + +## Built-in Features + +Features are installed via `.with()` and extend the state with new methods. + +### `undoFeature(historyLimit?)` + +Adds `undo()`. Keeps the last 50 values by default. History is seeded with the initial value at install time. + +```ts +const $count = createState(0).with(undoFeature()); + +$count.set(1); +$count.set(2); +$count.undo(); // 1 +$count.undo(); // 0 +$count.undo(); // no-op, already at oldest +``` + +### `multiUndoFeature()` + +Adds `multiUndo(count)`. Requires `undoFeature` to be installed first. + +```ts +const $count = createState(0).with(undoFeature(), multiUndoFeature()); + +$count.set(1); +$count.set(2); +$count.set(3); +$count.multiUndo(2); // back to 1 +``` + +### `storageFeature(storage, key)` + +Adds `persist()`, `loadFromStorage()`, and `deleteFromStorage()`. The storage adapter is a plain object with `save`, `load`, and `delete` methods. + +```ts +import { missingStorageValue } from 'feature-state'; const storage = { - async save(key, value) { + save(key, value) { localStorage.setItem(key, JSON.stringify(value)); return true; }, - async load(key) { - const value = localStorage.getItem(key); - return value ? JSON.parse(value) : undefined; + load(key) { + const raw = localStorage.getItem(key); + return raw != null ? JSON.parse(raw) : missingStorageValue; }, - async delete(key) { + delete(key) { localStorage.removeItem(key); return true; } }; -const state = withStorage(createState([]), storage, 'tasks'); - -await state.persist(); +const $tasks = createState([]).with(storageFeature(storage, 'tasks')); -state.addTask({ id: 1, title: 'Task 1' }); +await $tasks.persist(); ``` -- **`storage`**: An object implementing the `StorageInterface` with methods `save`, `load`, and `delete` for handling the persistence -- **`key`**: The key used to identify the state in the storage medium +`persist()` loads any previously saved value. If nothing is stored it saves the current state instead, then auto-saves on every subsequent `set()`. Calling `persist()` more than once is safe. + +**`TStorageInterface` contract:** `load` must return `missingStorageValue` (a Symbol) when the key is absent. `null` and `undefined` are treated as valid stored values. -### `withUndo()` +### `isEqualFeature(isEqual)` -Adds undo functionality to the state, allowing the state to revert to previous values. +Overrides `set()` with a domain-specific equality check. Use it when reference equality would notify listeners even though the visible state did not change. ```ts -import { createState, withUndo } from 'feature-state'; +import { createState, isEqualFeature } from 'feature-state'; + +const $status = createState({ type: 'valid' }).with( + isEqualFeature((prevValue, nextValue) => prevValue.type === nextValue.type) +); +``` + +### `asyncQueueFeature()` -const state = withUndo(createState([]), 50); +Replaces the default sync listener queue with a microtask-based FIFO queue. Listeners still run in registration order, but after the current call stack resolves. Async listeners are awaited one by one. + +```ts +import { asyncQueueFeature } from 'feature-state'; + +const $count = createState(0).with(asyncQueueFeature()); + +$count.listen(async ({ value }) => { + await save(value); +}); -state.addTask({ id: 1, title: 'Task 1' }); -state.undo(); +await $count.notify(); // resolves when all listeners have completed ``` -- **`historyLimit`**: The maximum number of states to keep in history for undo functionality. The default is `50` +`notify()` returns the active queue flush promise. Multiple `notify()` calls before the microtask fires share the same promise. `set()` still returns `void`, so listener errors from `set()` are not awaitable through `set()` itself. -### `withMultiUndo()` +### `priorityQueueFeature()` -Adds multi-undo functionality to the state, allowing the state to revert to multiple previous values at once. +Replaces the default sync listener queue with a priority-based sync queue. Lower priority values run first. Listeners with the same priority keep registration order. ```ts -import { createState, withMultiUndo, withUndo } from 'feature-state'; +import { EListenerPriority, priorityQueueFeature } from 'feature-state'; -const state = withMultiUndo(withUndo(createState([]), 50)); +const $count = createState(0).with(priorityQueueFeature()); -state.addTask({ id: 1, title: 'Task 1' }); -state.addTask({ id: 2, title: 'Task 2' }); -state.multiUndo(2); +$count.listen(() => {}, { priority: EListenerPriority.LATE }); +$count.listen(() => {}, { priority: EListenerPriority.EARLY }); // runs first ``` -- **`count`**: The number of undo steps to perform, reverting the state back by the specified number of changes +`EListenerPriority` provides named constants: `FIRST = 0`, `EARLY = 125`, `DEFAULT = 250`, `LATE = 375`, `LAST = 500`. Any number is valid. -## ❓ FAQ +## Extending with Features -### Why can't we pass the state itself into the listener queue? +States are `feature-core` feature hosts. Add behavior with `.with(yourFeature())`. See the [feature-core README](https://github.com/builder-group/community/tree/develop/packages/feature-core) for a full guide on `defineFeature()`, dependency declaration, and the feature model. -When passing the state object directly into the listener queue, any subsequent state changes before the queue is processed will affect the state reference in the queued listeners. This means listeners would always capture the latest state value rather than the value at the time they were queued. +## Examples -For example: +- [React Basic](https://github.com/builder-group/community/tree/develop/examples/feature-state/react/basic) -```ts -const $counter = createState(0); +## FAQ -$counter.listen((context) => { - // By the time this runs, state._v might be different - // from when the listener was queued - console.log(context.state._v); -}); +### How does it compare to Nanostores, Zustand, and MobX? -$counter.set(1); // Queues listener -$counter.set(2); // Changes state before queue processes -``` +`feature-state` is centered on composable feature hosts. Use it when you want state objects that can gain typed capabilities over time without committing to proxies, decorators, or a React-specific store model. -If you want to access the state inside the listener, you can simply capture it: +- [nanostores](https://github.com/nanostores/nanostores): framework-agnostic atom-based state with framework integrations +- [zustand](https://github.com/pmndrs/zustand): store-based state management, primarily for React +- [MobX](https://mobx.js.org): reactive state via proxies and decorators, class-oriented -```ts -const $counter = createState(0); +### Why does `set()` skip notification when the value is the same? -$counter.listen(() => { - $counter.set((v) => v + 1); -}); -``` +Skipping on reference equality (`Object.is`) prevents redundant re-renders and listener calls. To force a notification without changing the value, call `notify()` directly. -While you can reference `$counter` directly in listeners, there's no guarantee about its value since it might have changed between queueing and execution of the listener. That's why using `context.value` is the safer approach. +### Why does `subscribe()` pass `prevValue` equal to `value` on the initial call? -### Why are all listeners processed asynchronously? +The initial call has no prior state, so `prevValue` is set to the current value. Listeners never receive `undefined` for `prevValue` and can be written without a null check. -All listeners are processed asynchronously through a priority queue to maintain a consistent execution order. Adding a separate sync listener queue would: +### Is it safe to unsubscribe inside a listener? -1. Disrupt the priority-based execution order -2. Add complexity to the mental model (sync vs async execution paths) -3. Make state updates less predictable +Yes. The unsubscribe function removes the callback from `_listeners` and also removes any pending call to that callback already queued in the current notification cycle. The listener will not fire again even if `notify()` is still draining. -If you need immediate listener processing, you can disable automatic queue processing and handle it manually: +### Can I combine `asyncQueueFeature` and `priorityQueueFeature`? -```ts -// Disable automatic queue processing -$counter.set(1, { processListenerQueue: false }); -$counter.set(2, { processListenerQueue: false }); +No. Both features override the same internal queue (`listen`, `subscribe`, and `notify`). Installing both means the last one installed takes effect and the first is silently ignored. Pick one. -// Process the queue when you're ready -await processStateQueue(); -``` +### When should I use `_v` directly instead of `set()`? + +Use `_v` when you need to mutate a value in place, for example pushing to an array, without going through `set()`'s reference equality check. Mutate via `_v`, then call `notify()` manually to trigger listeners. This is an escape hatch; prefer replacing the value with `set()` when possible. + +### How does `storageFeature` prevent save loops? -Alternatively, we could switch to a fully sync listener queue. However, this would compromise the benefits of asynchronous processing. +When `loadFromStorage()` calls `set()` internally it passes `source: 'loadFromStorage'` in the listener context. The auto-save listener ignores changes with that source, so loading a value does not immediately write it back to storage. diff --git a/packages/feature-state/package.json b/packages/feature-state/package.json index a35b377c..fda03a57 100644 --- a/packages/feature-state/package.json +++ b/packages/feature-state/package.json @@ -1,9 +1,24 @@ { "name": "feature-state", - "version": "0.0.66", + "version": "0.1.0-beta.1", "private": false, - "description": "Straightforward, typesafe, and feature-based state management library for ReactJs", - "keywords": [], + "description": "Reactive state with opt-in .with() features for undo, storage, computed values, and queues.", + "keywords": [ + "state", + "state-management", + "reactive", + "reactive-state", + "observable", + "computed", + "typescript", + "framework-agnostic", + "feature-composition", + "undo-redo", + "undo", + "storage", + "persistence", + "store" + ], "homepage": "https://builder.group/?utm_source=package-json", "bugs": { "url": "https://github.com/builder-group/community/issues" @@ -35,8 +50,7 @@ "update:latest": "pnpm update --latest" }, "dependencies": { - "@blgc/types": "workspace:*", - "@blgc/utils": "workspace:*" + "feature-core": "workspace:*" }, "devDependencies": { "@blgc/config": "workspace:*", diff --git a/packages/feature-state/src/FlatQueue.test.ts b/packages/feature-state/src/FlatQueue.test.ts deleted file mode 100644 index 300d9aaf..00000000 --- a/packages/feature-state/src/FlatQueue.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { FlatQueue } from './FlatQueue'; - -describe('FlatQueue class', () => { - it('should maintain a priority queue', () => { - const queue = new FlatQueue(); - queue.push('a', 4); - queue.push('b', 3); - queue.push('c', 1); - queue.push('d', 2); - queue.push('e', 5); - - expect(queue.pop()).toBe('c'); - expect(queue.pop()).toBe('d'); - expect(queue.pop()).toBe('b'); - expect(queue.pop()).toBe('a'); - expect(queue.pop()).toBe('e'); - }); - - it('should handle edge cases', () => { - const queue = new FlatQueue(); - - queue.push('a', 2); - queue.push('b', 1); - expect(queue.pop()).toBe('b'); - expect(queue.pop()).toBe('a'); - expect(queue.pop()).toBe(null); - queue.push('c', 2); - queue.push('d', 1); - expect(queue.pop()).toBe('d'); - expect(queue.pop()).toBe('c'); - expect(queue.pop()).toBe(null); - }); -}); diff --git a/packages/feature-state/src/FlatQueue.ts b/packages/feature-state/src/FlatQueue.ts deleted file mode 100644 index 8bf12780..00000000 --- a/packages/feature-state/src/FlatQueue.ts +++ /dev/null @@ -1,103 +0,0 @@ -// Based on: https://github.com/mourner/flatqueue/blob/main/index.js -// Could not use the original package because it doesn't support CommonJs. -export class FlatQueue { - private _ids: Array; - private _values: Array; - private _length: number; - - constructor() { - this._ids = []; - this._values = []; - this._length = 0; - } - - /** - * Number of items in the queue. - */ - public get length(): number { - return this._length; - } - - /** - * Adds `item` to the queue with the specified `priority`. - * - * `priority` must be a number. Items are sorted and returned from low to - * high priority. Multiple items with the same priority value can be added - * to the queue, but there is no guaranteed order between these items. - */ - public push(id: GItem, priority: number): void { - let pos = this._length++; - - while (pos > 0) { - const parent = (pos - 1) >> 1; - const parentValue = this._values[parent] as number; - if (priority >= parentValue) { - break; - } - this._ids[pos] = this._ids[parent]; - this._values[pos] = parentValue; - pos = parent; - } - - this._ids[pos] = id; - this._values[pos] = priority; - } - - /** - * Removes and returns the item from the head of this queue, which is one of - * the items with the lowest priority. If this queue is empty, returns - * `null`. - */ - public pop(): GItem | null { - if (!this._length) { - return null; - } - - const top = this._ids[0]; - this._length--; - - if (this._length > 0) { - const id = this._ids[this._length] as GItem; - const value = this._values[this._length] as number; - this._ids[0] = id; - this._values[0] = value; - - const halfLength = this._length >> 1; - let pos = 0; - - while (pos < halfLength) { - let left = (pos << 1) + 1; - const right = left + 1; - - // Initialize with left child values - let bestIndex = this._ids[left] as GItem; - let bestValue = this._values[left] as number; - - // Check if right child exists and has lower priority - const rightValue = this._values[right] as number; - - if (right < this._length && rightValue < bestValue) { - left = right; - bestIndex = this._ids[right] as GItem; - bestValue = rightValue; - } - - // If current node has lower or equal priority than best child, stop - if (bestValue >= value) { - break; - } - - // Move best child up - this._ids[pos] = bestIndex; - this._values[pos] = bestValue; - pos = left; - } - - // Place the current item at its new position - this._ids[pos] = id; - this._values[pos] = value; - } - - return top ?? null; - } -} diff --git a/packages/feature-state/src/__tests__/queue.bench.ts b/packages/feature-state/src/__tests__/queue.bench.ts index 6c806796..de0c7ef7 100644 --- a/packages/feature-state/src/__tests__/queue.bench.ts +++ b/packages/feature-state/src/__tests__/queue.bench.ts @@ -1,127 +1,112 @@ import { bench, describe } from 'vitest'; -import { FlatQueue } from '../FlatQueue'; +import { FlatQueue } from '../features'; + +const dataSets: TBenchmarkDataSet[] = [ + { name: 'Tiny data (5 items)', data: createDataSet(5) }, + { name: 'Small data (10 items)', data: createDataSet(10) }, + { name: 'Medium data (1,000 items)', data: createDataSet(1000) }, + { name: 'Large data (10,000 items)', data: createDataSet(10000) } +]; + +describe('queue benchmark', () => { + for (const { name, data } of dataSets) { + describe(name, () => { + bench('array insertion order', () => { + const queue = createArrayQueue(data); + drainArrayQueueInInsertionOrder(queue); + }); -function createDataSet(size: number): TDataSet { - return Array(size) - .fill(0) - .map((_, i) => ({ id: i, priority: Math.random() })); + bench('array priority sort', () => { + const queue = createArrayQueue(data); + drainArrayQueueByPriority(queue); + }); + + bench('FlatQueue priority heap', () => { + const queue = createFlatQueue(data); + drainFlatQueue(queue); + }); + }); + } +}); + +interface TDataItem { + id: number; + priority: number; } -function initSortedArray(data: TDataSet): TDataItem[] { - return [...data]; +type TDataSet = TDataItem[]; + +interface TBenchmarkDataSet { + name: string; + data: TDataSet; } -function initFlatQueue(data: TDataSet): FlatQueue { - const queue = new FlatQueue(); - for (let i = 0; i < data.length; i++) { - queue.push(data[i] as TDataItem, data[i]?.priority as number); +function createDataSet(size: number): TDataSet { + let seed = size; + const data: TDataSet = []; + + for (let id = 0; id < size; id++) { + seed = (seed * 1664525 + 1013904223) >>> 0; + data.push({ id, priority: seed / 0xffffffff }); } - return queue; + + return data; } -function extractFromSortedArray(data: TDataItem[]): number { - const sortedData = data.sort((a, b) => a.priority - b.priority); // Not sorting at init because thats how the Feature State queue works - let sum = 0; - for (let i = 0; i < sortedData.length; i++) { - sum += sortedData[i]?.id as number; +function createArrayQueue(data: TDataSet): TDataItem[] { + const queue: TDataItem[] = []; + + for (const item of data) { + queue.push(item); } - return sum; + + return queue; } -function extractFromFlatQueue(queue: FlatQueue, count: number): number { +function drainArrayQueueInInsertionOrder(queue: TDataItem[]): number { let sum = 0; - for (let i = 0; i < count; i++) { - sum += queue.pop()?.id as number; + + for (const item of queue) { + sum += item.id; } + return sum; } -// Create datasets of different sizes -const smallData: TDataSet = createDataSet(10); -const mediumData: TDataSet = createDataSet(1000); -const largeData: TDataSet = createDataSet(10000); - -describe('Priority Queue Benchmark', () => { - describe('Extraction Phase', () => { - describe('Small Data (10 items)', () => { - bench('Array Sort', () => { - const data = initSortedArray(smallData); - extractFromSortedArray(data); - }); - - bench('FlatQueue', () => { - const queue = initFlatQueue(smallData); - extractFromFlatQueue(queue, smallData.length); - }); - }); - - describe('Medium Data (1,000 items)', () => { - bench('Array Sort', () => { - const data = initSortedArray(mediumData); - extractFromSortedArray(data); - }); +function drainArrayQueueByPriority(queue: TDataItem[]): number { + queue.sort(comparePriority); - bench('FlatQueue', () => { - const queue = initFlatQueue(mediumData); - extractFromFlatQueue(queue, mediumData.length); - }); - }); - - describe('Large Data (10,000 items)', () => { - bench('Array Sort', () => { - const data = initSortedArray(largeData); - extractFromSortedArray(data); - }); - - bench('FlatQueue', () => { - const queue = initFlatQueue(largeData); - extractFromFlatQueue(queue, largeData.length); - }); - }); - }); + let sum = 0; + for (const item of queue) { + sum += item.id; + } - describe('Total Operation', () => { - describe('Small Data (10 items)', () => { - bench('Array Sort', () => { - const data = initSortedArray(smallData); - extractFromSortedArray(data); - }); + return sum; +} - bench('FlatQueue', () => { - const queue = initFlatQueue(smallData); - extractFromFlatQueue(queue, smallData.length); - }); - }); +function createFlatQueue(data: TDataSet): FlatQueue { + const queue = new FlatQueue(); - describe('Medium Data (1,000 items)', () => { - bench('Array Sort', () => { - const data = initSortedArray(mediumData); - extractFromSortedArray(data); - }); + for (const item of data) { + queue.push(item, item.priority); + } - bench('FlatQueue', () => { - const queue = initFlatQueue(mediumData); - extractFromFlatQueue(queue, mediumData.length); - }); - }); + return queue; +} - describe('Large Data (10,000 items)', () => { - bench('Array Sort', () => { - const data = initSortedArray(largeData); - extractFromSortedArray(data); - }); +function drainFlatQueue(queue: FlatQueue): number { + let sum = 0; - bench('FlatQueue', () => { - const queue = initFlatQueue(largeData); - extractFromFlatQueue(queue, largeData.length); - }); - }); - }); -}); + while (queue.length > 0) { + const item = queue.pop(); + if (item != null) { + sum += item.id; + } + } -interface TDataItem { - id: number; - priority: number; + return sum; } -type TDataSet = TDataItem[]; +function comparePriority(a: TDataItem, b: TDataItem): number { + return a.priority - b.priority; +} diff --git a/packages/feature-state/src/_experimental/apply-features.test.ts b/packages/feature-state/src/_experimental/apply-features.test.ts deleted file mode 100644 index 6d9c0f5b..00000000 --- a/packages/feature-state/src/_experimental/apply-features.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { TEnforceFeatureConstraint, TFeatureDefinition } from '@blgc/types/features'; -import { describe, it } from 'vitest'; -import { createState } from '../create-state'; -import { TStorageInterface } from '../features'; -import { TMultiUndoFeature, TPersistFeature, TState, TUndoFeature } from '../types'; -import { applyFeatures } from './apply-features'; - -describe('applyFeatures function', () => { - it('should have correct types', () => { - const state1 = applyFeatures(createState(0), withUndo(), withMultiUndo()); - const state2 = applyFeatures(createState(0), withUndo()); - const state3 = applyFeatures( - createState(0), - withUndo(), - withMultiUndo(), - withStorage(null as any, 'test') - ); - const state4 = withMultiUndo()(withUndo()(createState(0))); - }); -}); - -export function withMultiUndo() { - return ( - state: TEnforceFeatureConstraint, TState, ['undo']> - ): TState => { - return null as any; - }; -} - -export function withStorage(storage: TStorageInterface, key: string) { - return ( - state: TEnforceFeatureConstraint, TState, []> - ): TState => { - return null as any; - }; -} - -export function withUndo(historyLimit = 50) { - return ( - state: TEnforceFeatureConstraint, TState, []> - ): TState, ...GFeatures]> => { - return null as any; - }; -} diff --git a/packages/feature-state/src/_experimental/apply-features.ts b/packages/feature-state/src/_experimental/apply-features.ts deleted file mode 100644 index 37f4140f..00000000 --- a/packages/feature-state/src/_experimental/apply-features.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { TFeatureDefinition } from '@blgc/types/features'; -import { TState } from '../types'; - -// We tried two approaches with array parameters that didn't work due to TypeScript limitations: -// -// 1. Using array of feature functions with return type constraint: -// function applyFeatures< -// GValue, -// GInitialFeatures extends TFeatureDefinition[], -// GF0 extends TFeatureDefinition, -// GF1 extends TFeatureDefinition -// >( -// initialState: TState, -// [(state: TState) => GF0, -// (state: TState) => GF1] -// ): TState -// Issue: Generic type inference fails (see: https://stackoverflow.com/q/54955340) -// Example: TUndoFeature becomes TUndoFeature -// When we tried working around this by using TState, -// it fixed type inference issue, but at the cost of type safety at a different place: -// TypeScript could no longer detect missing feature dependencies. -// Thus e.g. applying withMultiUndo without its required withUndo feature was possible. -// -// 2. Using function type constraints: -// function applyFeatures< -// GValue, -// GInitialFeatures extends TFeatureDefinition[], -// GF0 extends (state: TState) => TFeatureDefinition, -// GF1 extends (state: TState, ...GInitialFeatures]>) => TFeatureDefinition -// >( -// initialState: TState, -// [GF0, GF1] -// ): TState, ReturnType, ...GInitialFeatures]> -// Issue: ReturnType loses generic type information (see: https://stackoverflow.com/q/64948037) -// Example: TUndoFeature becomes TUndoFeature -// -// Current working solution uses separate function parameters to preserve type inference: -// function applyFeatures( -// initialState: TState, -// f1: (state: TState) => GF0, -// f2: (state: TState) => GF1 -// ): TState - -// Overload for one feature -export function applyFeatures< - GValue, - GInitialFeatures extends TFeatureDefinition[], - GF0 extends TFeatureDefinition ->( - initialState: TState, - f1: (state: TState) => TState -): TState; - -// Overload for two features -export function applyFeatures< - GValue, - GInitialFeatures extends TFeatureDefinition[], - GF0 extends TFeatureDefinition, - GF1 extends TFeatureDefinition ->( - initialState: TState, - f1: (state: TState) => TState, - f2: ( - state: TState - ) => TState -): TState; - -// Overload for three features -export function applyFeatures< - GValue, - GInitialFeatures extends TFeatureDefinition[], - GF0 extends TFeatureDefinition, - GF1 extends TFeatureDefinition, - GF2 extends TFeatureDefinition ->( - initialState: TState, - f1: (state: TState) => TState, - f2: ( - state: TState - ) => TState, - f3: ( - state: TState - ) => TState -): TState; - -// Implementation -export function applyFeatures< - GValue, - GInitialFeatures extends TFeatureDefinition[], - GF0 extends TFeatureDefinition, - GF1 extends TFeatureDefinition, - GF2 extends TFeatureDefinition ->( - initialState: TState, - f1: (state: TState) => GF0, - f2?: (state: TState) => GF1, - f3?: (state: TState) => GF2 -): TState { - // TODO: Implement - return null as any; -} diff --git a/packages/feature-state/src/create-computed.test.ts b/packages/feature-state/src/create-computed.test.ts new file mode 100644 index 00000000..561e56fe --- /dev/null +++ b/packages/feature-state/src/create-computed.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, expectTypeOf, it, vi } from 'vitest'; +import { computedSourceKey, createComputed, type TComputedState } from './create-computed'; +import { createState } from './create-state'; +import { priorityQueueFeature, undoFeature } from './features'; + +describe('createComputed function', () => { + describe('types', () => { + it('should infer value and source types', () => { + // Act + const $a = createState(2); + const $b = createState('hello'); + const $computed = createComputed([$a, $b] as const, ([a, b]) => `${b}:${a}`); + const $doubled = createComputed(createState(2).with(undoFeature()), (count) => { + return count * 2; + }); + + // Assert + expectTypeOf($computed).toEqualTypeOf< + TComputedState + >(); + expectTypeOf($computed.get()).toEqualTypeOf(); + expectTypeOf($computed._sources).toEqualTypeOf(); + expectTypeOf($computed.destroy).toEqualTypeOf<() => void>(); + expectTypeOf($doubled.get()).toEqualTypeOf(); + }); + + it('should type set and value as read-only', () => { + // Act + const $count = createState(2); + const $doubled = createComputed([$count], ([count]) => count * 2); + const $withPriority = createComputed($count, (count) => count * 2).with( + priorityQueueFeature() + ); + + // Assert + expectTypeOf($doubled.set).parameter(0).toEqualTypeOf(); + expectTypeOf($doubled).toMatchTypeOf>(); + expectTypeOf($doubled.value).toEqualTypeOf(); + expectTypeOf($withPriority.set).parameter(0).toEqualTypeOf(); + expectTypeOf($withPriority.value).toEqualTypeOf(); + }); + }); + + describe('reactivity', () => { + it('should initialize and recompute from a single source', () => { + // Prepare + const $count = createState(2); + const $doubled = createComputed($count, (count) => count * 2); + + // Act + $count.set(5); + + // Assert + expect($doubled.get()).toBe(10); + }); + + it('should recompute from multiple sources', () => { + // Prepare + const $a = createState(1); + const $b = createState(2); + const $sum = createComputed([$a, $b] as const, ([a, b]) => a + b); + + // Act + $b.set(4); + + // Assert + expect($sum.get()).toBe(5); + }); + + it('should notify listeners with computed context', () => { + // Prepare + const $count = createState(2); + const $doubled = createComputed($count, (count) => count * 2); + const listener = vi.fn(); + $doubled.listen(listener); + + // Act + $count.set(5, { listenerContext: { background: true } }); + + // Assert + expect(listener).toHaveBeenCalledWith({ + source: computedSourceKey, + background: true, + value: 10, + prevValue: 4 + }); + }); + }); + + describe('isEqual option', () => { + it('should skip notifications when values are equal', () => { + // Prepare + const $obj = createState({ type: 'valid', count: 1 }); + const $derived = createComputed($obj, (obj) => ({ type: obj.type }), { + isEqual: (prev, next) => prev.type === next.type + }); + const listener = vi.fn(); + $derived.listen(listener); + + // Act + $obj.set({ type: 'valid', count: 2 }); + + // Assert + expect(listener).not.toHaveBeenCalled(); + }); + }); + + describe('destroy method', () => { + it('should stop recomputing', () => { + // Prepare + const $count = createState(2); + const $doubled = createComputed($count, (count) => count * 2); + + // Act + $doubled.destroy(); + $count.set(5); + + // Assert + expect($doubled.get()).toBe(4); + }); + }); + + describe('set method', () => { + it('should throw when called', () => { + // Prepare + const $count = createState(2); + const $doubled = createComputed($count, (count) => count * 2); + + // Act & Assert + expect(() => $doubled.set(10 as never)).toThrow('Cannot call set()'); + }); + }); + + describe('value property', () => { + it('should throw when assigned', () => { + // Prepare + const $count = createState(2); + const $doubled = createComputed($count, (count) => count * 2); + + // Act & Assert + expect(() => { + Object.assign($doubled, { value: 10 }); + }).toThrow('Cannot assign to value'); + }); + }); +}); diff --git a/packages/feature-state/src/create-computed.ts b/packages/feature-state/src/create-computed.ts new file mode 100644 index 00000000..123c0435 --- /dev/null +++ b/packages/feature-state/src/create-computed.ts @@ -0,0 +1,146 @@ +import { defineFeature, type TAnyFeature, type TFeature } from 'feature-core'; +import { createState } from './create-state'; +import type { TState, TStateBase, TStateValue } from './types'; + +/** + * Creates a read-only state derived from one source state or a tuple of source states. + * + * Recomputes and notifies listeners whenever a source changes. Calling `set()` or + * assigning `value` throws: update the source states instead. Call `destroy()` when + * the computed state is no longer needed to unsubscribe from its sources. + */ +export function createComputed( + source: GState, + compute: (value: TStateValue) => GValue, + options?: TCreateComputedOptions +): TComputedState; +export function createComputed( + sources: GStates, + compute: (values: TComputedValues) => GValue, + options?: TCreateComputedOptions +): TComputedState; +export function createComputed( + input: TAnyComputedSourceState | readonly TAnyComputedSourceState[], + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- implementation accepts both public overload value shapes + compute: (value: any) => GValue, + options: TCreateComputedOptions = {} +): TComputedState { + const { isEqual = Object.is } = options; + const isTupleInput = Array.isArray(input); + const sources = (isTupleInput ? [...input] : [input]) as TAnyComputedSourceState[]; + const cleanups: Array<() => void> = []; + + const computeValue = (): GValue => + isTupleInput ? compute(sources.map((s) => s.get())) : compute(sources[0]?.get()); + + const computedState = createState(computeValue()).with( + defineFeature>({ + key: 'computed', + overrides: ['value', 'set'], + install(host: Pick, '_v'>) { + return { + value: host._v, // Note: Satisfies the override check; replaced by Object.defineProperty below + _sources: sources, + destroy() { + for (const cleanup of cleanups) { + cleanup(); + } + cleanups.length = 0; + }, + set(_value: never, _options?: never) { + throw new Error( + 'Cannot call set() on a computed state. Update the source states instead.' + ); + } + }; + } + }) + ); + + // Note: feature-core installs via Object.assign() which cannot create a property setter; + // Object.defineProperty is needed to make value assignment throw. + Object.defineProperty(computedState, 'value', { + configurable: true, + enumerable: true, + get() { + return computedState._v; + }, + set() { + throw new Error( + 'Cannot assign to value on a computed state. Update the source states instead.' + ); + } + }); + + const subscribedSources = new Set(); + for (const source of sources) { + if (subscribedSources.has(source)) { + continue; + } + + subscribedSources.add(source); + cleanups.push( + source.listen(({ background }) => { + const nextValue = computeValue(); + const prevValue = computedState._v; + if (isEqual !== false && isEqual(prevValue, nextValue)) { + return; + } + // Note: _v is mutated directly because set() is overridden to throw on computed states + computedState._v = nextValue; + computedState.notify({ + listenerContext: + background === true + ? { source: computedSourceKey, background } + : { source: computedSourceKey }, + prevValue + }); + }) + ); + } + + return computedState; +} + +/** Source key set on the listener context when a computed state recomputes. */ +export const computedSourceKey = 'computed'; + +export interface TCreateComputedOptions { + /** + * Compares the current computed value with the next one. + * Pass `false` to notify on every source update. + */ + isEqual?: TComputedIsEqual; +} + +/** Compares computed values, or disables equality checks when set to `false`. */ +export type TComputedIsEqual = ((prevValue: GValue, nextValue: GValue) => boolean) | false; + +/** Read-only state derived from one or more source states. Created by `createComputed()`. */ +export type TComputedState = TState< + GValue, + [TComputedFeature] +>; + +export type TComputedFeature< + GValue, + GSources extends readonly TAnyComputedSourceState[] +> = TFeature<'computed', TComputedFeatureApi, [], 'value' | 'set'>; + +export interface TComputedFeatureApi { + /** The current computed value. Read-only: update the source states to change it. */ + readonly value: GValue; + /** @internal */ + readonly _sources: GSources; + /** Unsubscribes from all source states. Call this when the computed state is no longer needed to prevent memory leaks. */ + destroy(): void; + /** Always throws. Update the source states instead. */ + set(value: never, options?: never): void; +} + +type TComputedValues = { + readonly [GIndex in keyof GSources]: TStateValue; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- used only to accept arbitrary source state value types +type TAnyComputedSourceState = TState; diff --git a/packages/feature-state/src/create-state.test.ts b/packages/feature-state/src/create-state.test.ts index dffbc011..8c84c27f 100644 --- a/packages/feature-state/src/create-state.test.ts +++ b/packages/feature-state/src/create-state.test.ts @@ -1,151 +1,249 @@ -import { describe, expect, it, vi } from 'vitest'; -import { createState } from './create-state'; -import { withMultiUndo, withSelector, withUndo } from './features'; +import { describe, expect, expectTypeOf, it, vi } from 'vitest'; +import { createState, setSourceKey } from './create-state'; +import { multiUndoFeature, undoFeature } from './features'; describe('createState function', () => { - it('should have correct types', () => { - const state = createState('Jeff'); - const stateWithUndo = withUndo(state); - const stateWithMultiUndo = withMultiUndo(stateWithUndo); - const stateWithSelector = withSelector(stateWithMultiUndo); + describe('types', () => { + it('should infer value and feature types', () => { + // Act + const state = createState('Jeff').with(undoFeature(), multiUndoFeature()); + + // Assert + expectTypeOf(state.get()).toEqualTypeOf(); + expectTypeOf(state._v).toEqualTypeOf(); + expectTypeOf(state.value).toEqualTypeOf(); + expectTypeOf(state._history).toEqualTypeOf(); + expectTypeOf(state.multiUndo).toEqualTypeOf<(count: number) => void>(); + }); }); - it('should initialize with the provided value', () => { - // Prepare - const initialState = 10; + describe('value property', () => { + it('should initialize with the provided value', () => { + // Prepare + const initialValue = { count: 0 }; - // Act - const state = createState(initialState); + // Act + const state = createState(initialValue); - // Assert - expect(state.get()).toBe(initialState); - }); + // Assert + expect(state.get()).toBe(initialValue); + expect(state._v).toBe(initialValue); + expect(state.value).toBe(initialValue); + }); - it('should handle different types of values correctly', () => { - // Prepare - different types of values - const numberState = createState(10); - const stringState = createState('hello'); - const arrayState = createState([1, 2, 3]); - const objectState = createState({ key: 'value' }); - - // Act and Assert for number - numberState.set(20); - expect(numberState.get()).toBe(20); - numberState.set((v) => v + 10); - expect(numberState.get()).toBe(30); - - // Act and Assert for string - stringState.set('world'); - expect(stringState.get()).toBe('world'); - - // Act and Assert for array - const newArray = [4, 5, 6]; - arrayState.set(newArray); - expect(arrayState.get()).toEqual(newArray); - - // Act and Assert for object - const newObject = { key: 'newValue' }; - objectState.set(newObject); - expect(objectState.get()).toEqual(newObject); - }); + it('should update through the value setter', () => { + // Prepare + const state = createState(10); + const listener = vi.fn(); + state.listen(listener); + + // Act + state.value = 20; + + // Assert + expect(state.get()).toBe(20); + expect(listener).toHaveBeenCalledWith({ + source: setSourceKey, + value: 20, + prevValue: 10 + }); + }); - it('should update the value with set', () => { - // Prepare - const state = createState(10); + it('should expose the same object through the backing value and value getter', () => { + // Prepare + const state = createState({ count: 0 }); - // Act - state.set(20); + // Act + state.value.count = 1; - // Assert - expect(state.get()).toBe(20); + // Assert + expect(state._v.count).toBe(1); + expect(state.get().count).toBe(1); + }); }); - it('should not update the value if set with the same value', () => { - // Prepare - const initialState = 10; - const state = createState(initialState); + describe('set method', () => { + it('should update with a direct value', () => { + // Prepare + const state = createState(10); - // Act - state.set(initialState); + // Act + state.set(20); - // Assert - expect(state.get()).toBe(initialState); - }); + // Assert + expect(state.get()).toBe(20); + }); - it('should call listeners when the value is updated', async () => { - // Prepare - const state = createState(10); - const listener = vi.fn(); - state.listen(listener); + it('should update with an updater function', () => { + // Prepare + const state = createState(10); - // Act - state.set(20); + // Act + state.set((value) => value + 10); - // Assert - expect(listener).toHaveBeenCalledWith({ source: 'state_set', value: 20, prevValue: 10 }); - }); + // Assert + expect(state.get()).toBe(20); + }); + + it('should not notify listeners when the value is unchanged', () => { + // Prepare + const state = createState(10); + const listener = vi.fn(); + state.listen(listener); + + // Act + state.set(10); - it('should not call listeners when set with the same value', () => { - // Prepare - const state = createState(10); - const listener = vi.fn(); - state.listen(listener); + // Assert + expect(listener).not.toHaveBeenCalled(); + }); + + it('should compare changes with Object.is', () => { + // Prepare + const state = createState(Number.NaN); + const listener = vi.fn(); + state.listen(listener); - // Act - state.set(10); + // Act + state.set(Number.NaN); - // Assert - expect(listener).not.toHaveBeenCalled(); + // Assert + expect(listener).not.toHaveBeenCalled(); + }); }); - it('should call listeners with the correct order based on level', async () => { - // Prepare - const state = createState(10); - const firstListener = vi.fn(); - const secondListener = vi.fn(); - state.listen(firstListener); - state.listen(secondListener); - - // Act - state.set(20); - await new Promise((resolve) => { - setTimeout(resolve, 0); - }); - - // Assert - expect(firstListener).toHaveBeenCalled(); - expect(secondListener).toHaveBeenCalled(); - if (firstListener.mock.calls.length > 0 && secondListener.mock.calls.length > 0) { - expect(firstListener.mock.invocationCallOrder[0] as unknown as number).toBeLessThan( - secondListener.mock.invocationCallOrder[0] as unknown as number - ); - } + describe('notify method', () => { + it('should notify listeners with the current value', () => { + // Prepare + const state = createState(10); + const listener = vi.fn(); + state.listen(listener); + + // Act + state.notify({ listenerContext: { source: 'manual' } }); + + // Assert + expect(listener).toHaveBeenCalledWith({ + source: 'manual', + value: 10, + prevValue: undefined + }); + }); + + it('should support manual notification after mutating value in place', () => { + // Prepare + const state = createState({ count: 0 }); + const prevValue = { count: state.value.count }; + const listener = vi.fn(); + state.listen(listener); + + // Act + state.value.count = 1; + state.notify({ listenerContext: { source: 'manual' }, prevValue }); + + // Assert + expect(listener).toHaveBeenCalledWith({ + source: 'manual', + value: { count: 1 }, + prevValue + }); + }); }); - it('should remove listener when the returned unbind function is called', () => { - // Prepare - const state = createState(10); - const listener = vi.fn(); - const unbind = state.listen(listener); + describe('listen method', () => { + it('should call listeners when the value changes', () => { + // Prepare + const state = createState(10); + const listener = vi.fn(); + state.listen(listener); + + // Act + state.set(20); + + // Assert + expect(listener).toHaveBeenCalledWith({ + source: setSourceKey, + value: 20, + prevValue: 10 + }); + }); + + it('should call listeners in registration order by default', () => { + // Prepare + const state = createState(10); + const calls: string[] = []; + state.listen(() => { + calls.push('first'); + }); + state.listen(() => { + calls.push('second'); + }); + + // Act + state.set(20); + + // Assert + expect(calls).toEqual(['first', 'second']); + }); + + it('should remove a listener with the returned unbind function', () => { + // Prepare + const state = createState(10); + const listener = vi.fn(); + const unbind = state.listen(listener); - // Act - unbind(); - state.set(20); + // Act + unbind(); + state.set(20); + + // Assert + expect(listener).not.toHaveBeenCalled(); + }); - // Assert - expect(listener).not.toHaveBeenCalled(); + it('should remove queued listener calls by callback when unbound during notification', () => { + // Prepare + const state = createState(10); + let unbindSecond = () => {}; + const listener = vi.fn(); + state.listen(() => { + unbindSecond(); + }); + unbindSecond = state.listen(listener); + + // Act + state.set(20); + + // Assert + expect(listener).not.toHaveBeenCalled(); + }); }); - it('should call listeners immediately on subscribe', () => { - // Prepare - const initialState = 10; - const state = createState(initialState); - const listener = vi.fn(); + describe('subscribe method', () => { + it('should call listeners immediately with the current value', () => { + // Prepare + const state = createState(10); + const listener = vi.fn(); + + // Act + state.subscribe(listener); - // Act - state.subscribe(listener); + // Assert + expect(listener).toHaveBeenCalledWith({ value: 10, prevValue: 10 }); + }); + }); - // Assert - expect(listener).toHaveBeenCalledWith({ value: initialState, prevValue: initialState }); + describe('features', () => { + it('should compose features with the feature-core chain', () => { + // Prepare + const state = createState(0).with(undoFeature(), multiUndoFeature()); + + // Act + state.set(1); + state.multiUndo(1); + + // Assert + expect(state.get()).toBe(0); + expect(state._history).toStrictEqual([0]); + }); }); }); diff --git a/packages/feature-state/src/create-state.ts b/packages/feature-state/src/create-state.ts index b72633ce..d75782ab 100644 --- a/packages/feature-state/src/create-state.ts +++ b/packages/feature-state/src/create-state.ts @@ -1,62 +1,41 @@ -import { - createListenerQueue, - getListenerQueue, - TCreateListenerQueueOptions -} from './listener-queue'; -import type { TListener, TListenerContext, TState } from './types'; +import { createFeatureHost } from 'feature-core'; +import type { TListener, TListenerCallback, TListenerContext, TState, TStateBase } from './types'; -export const SET_SOURCE_KEY = 'state_set'; - -export function createState( - initialValue: GValue, - options: TCreateStateOptions = {} -): TState { - const { - queue: - // Default to sync queue to avoid side-effects - // https://evilmartians.com/chronicles/how-to-avoid-tricky-async-state-manager-pitfalls-react - queueConfigOrKey = 'sync' - } = options; - - let queue = getListenerQueue( - typeof queueConfigOrKey === 'string' ? queueConfigOrKey : queueConfigOrKey.key - ); - if (queue == null) { - const { key, ...queueOptions } = - typeof queueConfigOrKey === 'string' - ? { key: queueConfigOrKey, async: queueConfigOrKey === 'async' } - : queueConfigOrKey; - queue = createListenerQueue(key, queueOptions); - } - - return { - _features: [], +/** + * Creates a reactive state container. + * + * The state exposes `value`, `get()`, `set()`, `notify()`, `listen()`, and `subscribe()`. + * `set()` skips notification when the new value equals the current one (`Object.is`). + * Extend the state with features by calling `.with(feature())`. + */ +export function createState(initialValue: GValue): TState { + return createFeatureHost>({ _listeners: [], _v: initialValue, - _queue: queue, - _notify(notifyOptions = {}) { - const { processListenerQueue = true, listenerContext = {}, prevValue } = notifyOptions; + get value() { + return this._v; + }, + set value(newValue) { + this.set(newValue); + }, + notify(notifyOptions = {}) { + const { listenerContext = {}, prevValue } = notifyOptions; + // Note: Only the outermost notify drains the queue. Nested notify calls append work to the active flush. + const shouldProcessListenerQueue = !listenerQueue.length; - // Push all listeners to the state's queue for (const listener of this._listeners) { - const context: TListenerContext = Object.assign(listenerContext, { - value: this._v, - prevValue + listenerQueue.push({ + callback: listener.callback, + context: { + ...listenerContext, + value: this._v, + prevValue + } }); - if (listener.queueIf == null || listener.queueIf(context)) { - this._queue.push( - { - context, - callback: listener.callback - }, - listener.priority - ); - } } - // Process the state's queue - if (processListenerQueue) { - void this._queue.process(); + if (shouldProcessListenerQueue) { + processListenerQueue(); } }, get() { @@ -68,51 +47,76 @@ export function createState( ? (newValueOrUpdater as (value: GValue) => GValue)(this._v) : newValueOrUpdater; const prevValue = this._v; - if (prevValue !== newValue) { - const { listenerContext = {}, processListenerQueue = true } = setOptions; - listenerContext.source = listenerContext.source ?? SET_SOURCE_KEY; - this._v = newValue; - this._notify({ - listenerContext, - processListenerQueue, - prevValue - }); + if (Object.is(prevValue, newValue)) { + return; } + + const { listenerContext = {} } = setOptions; + this._v = newValue; + this.notify({ + listenerContext: { + ...listenerContext, + source: listenerContext.source ?? setSourceKey + }, + prevValue + }); }, - listen(callback, listenOptions = {}) { - const { priority = EStateListenerQueuePriority.DEFAULT, key, queueIf } = listenOptions; + listen(callback) { const listener: TListener = { - key, - priority, - callback, - queueIf + callback }; this._listeners.push(listener); - // Unbind return () => { + removeQueuedListenerCalls(callback); const index = this._listeners.indexOf(listener); if (index !== -1) { this._listeners.splice(index, 1); } }; }, - subscribe(callback, subscribeOptions) { - const unbind = this.listen(callback, subscribeOptions); + subscribe(callback) { + const unbind = this.listen(callback); + // Note: prevValue mirrors value on the initial call so listeners never receive undefined for prevValue void callback({ value: this._v, prevValue: this._v }); return unbind; } - }; + }); } -export interface TCreateStateOptions { - queue?: ({ key: string } & TCreateListenerQueueOptions) | string; +/** Source key set on the listener context when a value is changed via `set()`. */ +export const setSourceKey = 'stateSet'; + +// MARK: - Queue + +const listenerQueue: TListenerQueueItem[] = []; +let listenerQueueIndex = 0; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- the shared queue stores listener calls from states with different value types +interface TListenerQueueItem { + callback: TListenerCallback; + context: TListenerContext; +} + +function processListenerQueue(): void { + try { + for (listenerQueueIndex = 0; listenerQueueIndex < listenerQueue.length; listenerQueueIndex++) { + const item = listenerQueue[listenerQueueIndex]; + if (item != null) { + void item.callback(item.context); + } + } + } finally { + listenerQueue.length = 0; + listenerQueueIndex = 0; + } } -export enum EStateListenerQueuePriority { - FIRST = 0, - EARLY = 125, - DEFAULT = 250, - LATE = 375, - LAST = 500 +function removeQueuedListenerCalls(callback: TListener['callback']): void { + for (let i = listenerQueueIndex + 1; i < listenerQueue.length; i++) { + if (listenerQueue[i]?.callback === callback) { + listenerQueue.splice(i, 1); + i--; + } + } } diff --git a/packages/feature-state/src/features/async-queue.test.ts b/packages/feature-state/src/features/async-queue.test.ts new file mode 100644 index 00000000..90ffb923 --- /dev/null +++ b/packages/feature-state/src/features/async-queue.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, expectTypeOf, it, vi } from 'vitest'; +import { createState } from '../create-state'; +import type { TState } from '../types'; +import { + asyncQueueFeature, + type TAsyncQueueFeature, + type TAsyncQueueFeatureApi +} from './async-queue'; + +describe('asyncQueueFeature function', () => { + describe('types', () => { + it('should make notify return the async queue flush promise', () => { + // Act + const state = createState(0).with(asyncQueueFeature()); + + // Assert + expectTypeOf(state.notify).toEqualTypeOf['notify']>(); + expectTypeOf(state._features).toEqualTypeOf(); + expectTypeOf(state).toEqualTypeOf]>>(); + }); + }); + + describe('notify method', () => { + it('should defer listener calls', async () => { + // Prepare + const state = createState(0).with(asyncQueueFeature()); + const listener = vi.fn(); + state.listen(listener); + + // Act + const processPromise = state.notify({ prevValue: 0 }); + + // Assert + expect(listener).not.toHaveBeenCalled(); + + // Act + await processPromise; + + // Assert + expect(listener).toHaveBeenCalledWith({ value: 0, prevValue: 0 }); + }); + + it('should process listeners in registration order', async () => { + // Prepare + const state = createState(0).with(asyncQueueFeature()); + const calls: string[] = []; + state.listen(async () => { + await Promise.resolve(); + calls.push('first'); + }); + state.listen(() => { + calls.push('second'); + }); + + // Act + await state.notify(); + + // Assert + expect(calls).toEqual(['first', 'second']); + }); + + it('should reuse the active queue flush promise', async () => { + // Prepare + const state = createState(0).with(asyncQueueFeature()); + const deferred = createDeferred(); + const calls: string[] = []; + state.listen(async () => { + await deferred.promise; + calls.push('first'); + }); + + // Act + const firstPromise = state.notify(); + const secondPromise = state.notify({ listenerContext: { source: 'second' } }); + + // Assert + expect(secondPromise).toBe(firstPromise); + expect(calls).toEqual([]); + + // Act + deferred.resolve(); + await secondPromise; + + // Assert + expect(calls).toEqual(['first', 'first']); + }); + + it('should remove queued listener calls by callback when unbound before flushing', async () => { + // Prepare + const state = createState(0).with(asyncQueueFeature()); + const listener = vi.fn(); + const unbind = state.listen(listener); + + // Act + const processPromise = state.notify(); + unbind(); + await processPromise; + + // Assert + expect(listener).not.toHaveBeenCalled(); + }); + + it('should pass set listener context to listeners', async () => { + // Prepare + const state = createState(0).with(asyncQueueFeature()); + const listener = vi.fn(); + state.listen(listener); + + // Act + state.set(1); + await Promise.resolve(); + + // Assert + expect(listener).toHaveBeenCalledWith({ + source: 'stateSet', + value: 1, + prevValue: 0 + }); + }); + }); + + describe('subscribe method', () => { + it('should call listeners immediately with the current value', () => { + // Prepare + const state = createState(10).with(asyncQueueFeature()); + const listener = vi.fn(); + + // Act + state.subscribe(listener); + + // Assert + expect(listener).toHaveBeenCalledWith({ value: 10, prevValue: 10 }); + }); + }); +}); + +function createDeferred(): TDeferred { + let resolvePromise: (() => void) | null = null; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + if (resolvePromise == null) { + throw new Error('Deferred promise resolver was not initialized.'); + } + + return { + promise, + resolve: resolvePromise + }; +} + +interface TDeferred { + promise: Promise; + resolve(): void; +} diff --git a/packages/feature-state/src/features/async-queue.ts b/packages/feature-state/src/features/async-queue.ts new file mode 100644 index 00000000..287ec6bd --- /dev/null +++ b/packages/feature-state/src/features/async-queue.ts @@ -0,0 +1,133 @@ +import { defineFeature, type TFeature } from 'feature-core'; +import type { + TListener, + TListenerCallback, + TListenerContext, + TStateBase, + TStateNotifyOptions +} from '../types'; + +/** + * Defers listener calls to a shared microtask queue instead of running them synchronously. + * Listeners fire in FIFO order after the current call stack clears. Multiple `notify()` calls + * before the microtask fires are batched: all listeners join the same queue and the returned + * promise resolves when every enqueued call has completed. + * + * Overrides `listen`, `subscribe`, and `notify`. Do not combine with other features that + * override `notify`, as the last installed override wins and earlier ones are discarded. + */ +export function asyncQueueFeature(): TAsyncQueueFeature { + return defineFeature>({ + key: 'async-queue', + overrides: ['listen', 'subscribe', 'notify'], + install() { + return { + notify(this: TStateBase, notifyOptions = {}) { + const { listenerContext = {}, prevValue } = notifyOptions; + + for (const listener of this._listeners) { + asyncListenerQueue.push({ + callback: listener.callback, + context: { + ...listenerContext, + value: this._v, + prevValue + } + }); + } + + return scheduleAsyncListenerQueue(); + }, + listen(this: TStateBase, callback) { + const listener: TListener = { + callback + }; + this._listeners.push(listener); + + return () => { + removeQueuedAsyncListenerCalls(callback); + const index = this._listeners.indexOf(listener); + if (index !== -1) { + this._listeners.splice(index, 1); + } + }; + }, + subscribe(this: TStateBase & TAsyncQueueFeatureApi, callback) { + const unbind = this.listen(callback); + // Note: prevValue mirrors value on the initial call so listeners never receive undefined for prevValue + void callback({ value: this._v, prevValue: this._v }); + return unbind; + } + }; + } + }); +} + +export type TAsyncQueueFeature = TFeature< + 'async-queue', + TAsyncQueueFeatureApi, + [], + 'listen' | 'notify' | 'subscribe' +>; + +export interface TAsyncQueueFeatureApi { + /** Registers a callback for future changes and returns an unsubscribe function. */ + listen(callback: TListenerCallback): () => void; + /** Enqueues listeners and returns a promise that resolves when all have completed. */ + notify(options?: TStateNotifyOptions): Promise; + /** Registers a callback, calls it immediately with the current value, and returns an unsubscribe function. */ + subscribe(callback: TListenerCallback): () => void; +} + +// MARK: - Queue + +const asyncListenerQueue: TAsyncListenerQueueItem[] = []; +let asyncListenerQueueIndex = 0; +let asyncListenerQueuePromise: Promise | null = null; +let isProcessingAsyncListenerQueue = false; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- the shared queue stores listener calls from states with different value types +interface TAsyncListenerQueueItem { + callback: TListenerCallback; + context: TListenerContext; +} + +// Note: Returns the same promise for any notifications batched before the microtask fires +function scheduleAsyncListenerQueue(): Promise { + asyncListenerQueuePromise ??= Promise.resolve().then(processAsyncListenerQueue); + return asyncListenerQueuePromise; +} + +async function processAsyncListenerQueue(): Promise { + isProcessingAsyncListenerQueue = true; + + try { + for ( + asyncListenerQueueIndex = 0; + asyncListenerQueueIndex < asyncListenerQueue.length; + asyncListenerQueueIndex++ + ) { + const item = asyncListenerQueue[asyncListenerQueueIndex]; + if (item != null) { + await item.callback(item.context); + } + } + } finally { + asyncListenerQueue.length = 0; + asyncListenerQueueIndex = 0; + asyncListenerQueuePromise = null; + isProcessingAsyncListenerQueue = false; + } +} + +function removeQueuedAsyncListenerCalls(callback: TListener['callback']): void { + // Note: Unlike the sync queue, items can be pending before the microtask fires, so start from 0 when not yet processing + const startIndex = isProcessingAsyncListenerQueue ? asyncListenerQueueIndex + 1 : 0; + + for (let i = startIndex; i < asyncListenerQueue.length; i++) { + if (asyncListenerQueue[i]?.callback === callback) { + asyncListenerQueue.splice(i, 1); + i--; + } + } +} diff --git a/packages/feature-state/src/features/index.ts b/packages/feature-state/src/features/index.ts index 1a559c02..e18bddad 100644 --- a/packages/feature-state/src/features/index.ts +++ b/packages/feature-state/src/features/index.ts @@ -1,4 +1,6 @@ -export * from './with-multi-undo'; -export * from './with-selector'; -export * from './with-storage'; -export * from './with-undo'; +export * from './async-queue'; +export * from './is-equal'; +export * from './multi-undo'; +export * from './priority-queue'; +export * from './storage'; +export * from './undo'; diff --git a/packages/feature-state/src/features/is-equal.test.ts b/packages/feature-state/src/features/is-equal.test.ts new file mode 100644 index 00000000..daae9d33 --- /dev/null +++ b/packages/feature-state/src/features/is-equal.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createState, setSourceKey } from '../create-state'; +import { isEqualFeature } from './is-equal'; + +describe('isEqualFeature function', () => { + it('should skip updates when custom equality says values are equal', () => { + // Prepare + const initialValue = { status: 'valid' }; + const state = createState(initialValue).with( + isEqualFeature( + (prevValue, nextValue) => prevValue.status === nextValue.status + ) + ); + const listener = vi.fn(); + state.listen(listener); + + // Act + state.set({ status: 'valid' }); + state.set({ status: 'invalid' }); + + // Assert + expect(state.get()).toEqual({ status: 'invalid' }); + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith({ + source: setSourceKey, + value: { status: 'invalid' }, + prevValue: initialValue + }); + }); + + it('should preserve set updater and listener context behavior', () => { + // Prepare + const state = createState(10).with( + isEqualFeature((prevValue, nextValue) => prevValue === nextValue) + ); + const listener = vi.fn(); + state.listen(listener); + + // Act + state.set((value) => value + 10, { + listenerContext: { + source: 'customSet' + } + }); + + // Assert + expect(state.get()).toBe(20); + expect(listener).toHaveBeenCalledWith({ + source: 'customSet', + value: 20, + prevValue: 10 + }); + }); + + it('should expose the configured equality function on the installed state', () => { + // Prepare + const isEqual = (prevValue: number, nextValue: number) => prevValue === nextValue; + + // Act + const state = createState(0).with(isEqualFeature(isEqual)); + + // Assert + expect(state._isEqual).toBe(isEqual); + }); +}); diff --git a/packages/feature-state/src/features/is-equal.ts b/packages/feature-state/src/features/is-equal.ts new file mode 100644 index 00000000..467c278d --- /dev/null +++ b/packages/feature-state/src/features/is-equal.ts @@ -0,0 +1,61 @@ +import { defineFeature, type TFeature } from 'feature-core'; +import { setSourceKey } from '../create-state'; +import type { TStateBase, TStateSetOptions } from '../types'; + +/** + * Replaces the default `Object.is` equality check in `set()` with a custom comparator. + * Useful for value types that are structurally equal but not referentially equal, such as + * arrays or objects where re-notifying listeners on the same logical value is wasteful. + * `set()` calls `isEqual(prevValue, newValue)` and skips notification when it returns `true`. + */ +export function isEqualFeature(isEqual: TStateEquality): TIsEqualFeature { + return defineFeature>({ + key: 'is-equal', + overrides: ['set'], + install() { + return { + /** @internal */ + _isEqual: isEqual, + set( + this: TStateBase & TIsEqualFeatureApi, + newValueOrUpdater, + setOptions = {} + ) { + const newValue = + typeof newValueOrUpdater === 'function' + ? (newValueOrUpdater as (value: GValue) => GValue)(this._v) + : newValueOrUpdater; + const prevValue = this._v; + if (this._isEqual(prevValue, newValue)) { + return; + } + + const { listenerContext = {} } = setOptions; + this._v = newValue; + this.notify({ + listenerContext: { + ...listenerContext, + source: listenerContext.source ?? setSourceKey + }, + prevValue + }); + } + }; + } + }); +} + +export type TIsEqualFeature = TFeature<'is-equal', TIsEqualFeatureApi, [], 'set'>; + +export interface TIsEqualFeatureApi { + /** @internal */ + _isEqual: TStateEquality; + /** Sets the value and skips notification when the installed equality function returns `true`. */ + set( + newValueOrUpdater: GValue | ((value: GValue) => GValue), + options?: TStateSetOptions + ): void; +} + +/** Returns `true` when two state values should be treated as equal. */ +export type TStateEquality = (prevValue: GValue, nextValue: GValue) => boolean; diff --git a/packages/feature-state/src/features/multi-undo.test.ts b/packages/feature-state/src/features/multi-undo.test.ts new file mode 100644 index 00000000..a01cdb61 --- /dev/null +++ b/packages/feature-state/src/features/multi-undo.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, expectTypeOf, it } from 'vitest'; +import { createState } from '../create-state'; +import { multiUndoFeature } from './multi-undo'; +import { undoFeature } from './undo'; + +describe('multiUndoFeature function', () => { + describe('types', () => { + it('should infer multi-undo feature APIs', () => { + const state = createState('Jeff').with(undoFeature(), multiUndoFeature()); + + expectTypeOf(state.get()).toEqualTypeOf(); + expectTypeOf(state._history).toEqualTypeOf(); + expectTypeOf(state.multiUndo).toEqualTypeOf<(count: number) => void>(); + }); + }); + + describe('multiUndo method', () => { + it('should restore multiple previous values', () => { + // Prepare + const state = createState(0).with(undoFeature(), multiUndoFeature()); + + // Act + state.set(1); + state.set(2); + state.set(3); + state.multiUndo(2); + + // Assert + expect(state.get()).toBe(1); + }); + }); +}); diff --git a/packages/feature-state/src/features/multi-undo.ts b/packages/feature-state/src/features/multi-undo.ts new file mode 100644 index 00000000..68e2994e --- /dev/null +++ b/packages/feature-state/src/features/multi-undo.ts @@ -0,0 +1,33 @@ +import { defineFeature, type TFeature } from 'feature-core'; +import type { TState } from '../types'; +import type { TUndoFeature } from './undo'; + +/** + * Adds `multiUndo(count)` to a state, stepping back through multiple past values in one call. + * + * Requires `undoFeature` to be installed first. + */ +export function multiUndoFeature(): TMultiUndoFeature { + return defineFeature>({ + key: 'multi-undo', + requires: ['undo'], + install() { + return { + multiUndo(this: TState]>, count) { + for (let i = 0; i < count; i++) { + this.undo(); + } + } + }; + } + }); +} + +export type TMultiUndoFeature = TFeature< + 'multi-undo', + { + /** Steps back `count` entries in the undo history by calling `undo()` repeatedly. */ + multiUndo(count: number): void; + }, + [TUndoFeature] +>; diff --git a/packages/feature-state/src/features/priority-queue/FlatQueue.test.ts b/packages/feature-state/src/features/priority-queue/FlatQueue.test.ts new file mode 100644 index 00000000..12c1cb64 --- /dev/null +++ b/packages/feature-state/src/features/priority-queue/FlatQueue.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; +import { FlatQueue } from './FlatQueue'; + +describe('FlatQueue class', () => { + describe('pop method', () => { + it('should return null when the queue is empty', () => { + // Prepare + const queue = new FlatQueue(); + + // Act + const item = queue.pop(); + + // Assert + expect(item).toBe(null); + }); + + it('should pop items from lowest priority to highest priority', () => { + // Prepare + const queue = new FlatQueue(); + queue.push('a', 4); + queue.push('b', 3); + queue.push('c', 1); + queue.push('d', 2); + queue.push('e', 5); + + // Act + const items = [queue.pop(), queue.pop(), queue.pop(), queue.pop(), queue.pop()]; + + // Assert + expect(items).toEqual(['c', 'd', 'b', 'a', 'e']); + expect(queue.length).toBe(0); + }); + + it('should preserve insertion order for equal priorities', () => { + // Prepare + const queue = new FlatQueue(); + queue.push('first', 1); + queue.push('second', 1); + queue.push('third', 1); + + // Act + const items = [queue.pop(), queue.pop(), queue.pop()]; + + // Assert + expect(items).toEqual(['first', 'second', 'third']); + }); + + it('should support pushing again after it was drained', () => { + // Prepare + const queue = new FlatQueue(); + queue.push('a', 2); + queue.push('b', 1); + queue.pop(); + queue.pop(); + + // Act + queue.push('c', 2); + queue.push('d', 1); + const items = [queue.pop(), queue.pop(), queue.pop()]; + + // Assert + expect(items).toEqual(['d', 'c', null]); + expect(queue.length).toBe(0); + }); + }); + + describe('removeWhere method', () => { + it('should remove matching queued items', () => { + // Prepare + const queue = new FlatQueue(); + queue.push('a', 4); + queue.push('b', 3); + queue.push('c', 1); + queue.push('d', 2); + + // Act + const removedCount = queue.removeWhere((item) => item === 'b' || item === 'd'); + + // Assert + expect(removedCount).toBe(2); + expect(queue.length).toBe(2); + expect([queue.pop(), queue.pop(), queue.pop()]).toEqual(['c', 'a', null]); + }); + + it('should preserve the queue when no item matches', () => { + // Prepare + const queue = new FlatQueue(); + queue.push('a', 2); + queue.push('b', 1); + + // Act + const removedCount = queue.removeWhere((item) => item === 'c'); + + // Assert + expect(removedCount).toBe(0); + expect(queue.length).toBe(2); + expect([queue.pop(), queue.pop()]).toEqual(['b', 'a']); + }); + + it('should restore heap order after removing the current root', () => { + // Prepare + const queue = new FlatQueue(); + queue.push('a', 5); + queue.push('b', 1); + queue.push('c', 4); + queue.push('d', 2); + queue.push('e', 3); + + // Act + const removedCount = queue.removeWhere((item) => item === 'b' || item === 'd'); + + // Assert + expect(removedCount).toBe(2); + expect(queue.length).toBe(3); + expect([queue.pop(), queue.pop(), queue.pop(), queue.pop()]).toEqual(['e', 'c', 'a', null]); + }); + }); + + describe('clear method', () => { + it('should remove all queued items', () => { + // Prepare + const queue = new FlatQueue(); + queue.push('a', 2); + queue.push('b', 1); + + // Act + queue.clear(); + + // Assert + expect(queue.length).toBe(0); + expect(queue.pop()).toBe(null); + }); + }); +}); diff --git a/packages/feature-state/src/features/priority-queue/FlatQueue.ts b/packages/feature-state/src/features/priority-queue/FlatQueue.ts new file mode 100644 index 00000000..98b27f5e --- /dev/null +++ b/packages/feature-state/src/features/priority-queue/FlatQueue.ts @@ -0,0 +1,171 @@ +/** Priority queue where lower priority values are popped first and ties keep insertion order. */ +// Based on: https://github.com/mourner/flatqueue/blob/main/index.js +// Note: Kept local because the original package does not support CommonJS output +export class FlatQueue { + private _ids: Array; + private _orders: Array; + private _values: Array; + private _length: number; + private _nextOrder: number; + + constructor() { + this._ids = []; + this._orders = []; + this._values = []; + this._length = 0; + this._nextOrder = 0; + } + + public get length(): number { + return this._length; + } + + public clear(): void { + for (let i = 0; i < this._length; i++) { + this._ids[i] = undefined; + this._orders[i] = 0; + this._values[i] = 0; + } + this._length = 0; + this._nextOrder = 0; + } + + /** Adds an item. Lower priority values are popped first. Same-priority items keep insertion order. */ + public push(id: GItem, priority: number): void { + const order = this._nextOrder++; + let pos = this._length++; + + // Move parents down until the new item fits the heap order + while (pos > 0) { + const parent = (pos - 1) >> 1; + const parentValue = this._values[parent] as number; + const parentOrder = this._orders[parent] as number; + if (!isBefore(priority, order, parentValue, parentOrder)) { + break; + } + this._ids[pos] = this._ids[parent]; + this._orders[pos] = parentOrder; + this._values[pos] = parentValue; + pos = parent; + } + + this._ids[pos] = id; + this._orders[pos] = order; + this._values[pos] = priority; + } + + /** Removes the item with the lowest priority, or returns null when empty. */ + public pop(): GItem | null { + if (!this._length) { + return null; + } + + const top = this._ids[0]; + this._length--; + + if (this._length > 0) { + this._ids[0] = this._ids[this._length] as GItem; + this._orders[0] = this._orders[this._length] as number; + this._values[0] = this._values[this._length] as number; + this._ids[this._length] = undefined; + this._orders[this._length] = 0; + this._values[this._length] = 0; + this._siftDown(0); + } else { + this._ids[0] = undefined; + this._orders[0] = 0; + this._values[0] = 0; + this._nextOrder = 0; + } + + return top ?? null; + } + + /** Removes every queued item matching `predicate` and returns the removed count. */ + public removeWhere(predicate: (id: GItem) => boolean): number { + let writeIdx = 0; + let removedCount = 0; + + for (let i = 0; i < this._length; i++) { + if (predicate(this._ids[i] as GItem)) { + removedCount++; + } else { + this._ids[writeIdx] = this._ids[i]; + this._orders[writeIdx] = this._orders[i] as number; + this._values[writeIdx] = this._values[i] as number; + writeIdx++; + } + } + + if (removedCount === 0) { + return 0; + } + + // Clear vacated slots so removed items can be garbage collected + for (let i = writeIdx; i < this._length; i++) { + this._ids[i] = undefined; + this._orders[i] = 0; + this._values[i] = 0; + } + this._length = writeIdx; + if (this._length === 0) { + this._nextOrder = 0; + } + + // Restore heap order using Floyd's bottom-up heapify: O(n) vs O(n log n) for re-pushing + for (let i = (this._length >> 1) - 1; i >= 0; i--) { + this._siftDown(i); + } + + return removedCount; + } + + private _siftDown(pos: number): void { + const id = this._ids[pos] as GItem; + const value = this._values[pos] as number; + const order = this._orders[pos] as number; + const halfLength = this._length >> 1; + + // Move the item down until the heap order is restored + while (pos < halfLength) { + let bestPos = (pos << 1) + 1; + let bestId = this._ids[bestPos] as GItem; + let bestValue = this._values[bestPos] as number; + let bestOrder = this._orders[bestPos] as number; + + const right = bestPos + 1; + const rightValue = this._values[right] as number; + const rightOrder = this._orders[right] as number; + + // Prefer the child with the lower priority value, then the earlier insertion order + if (right < this._length && isBefore(rightValue, rightOrder, bestValue, bestOrder)) { + bestPos = right; + bestId = this._ids[right] as GItem; + bestValue = rightValue; + bestOrder = rightOrder; + } + + if (!isBefore(bestValue, bestOrder, value, order)) { + break; + } + + this._ids[pos] = bestId; + this._orders[pos] = bestOrder; + this._values[pos] = bestValue; + pos = bestPos; + } + + this._ids[pos] = id; + this._orders[pos] = order; + this._values[pos] = value; + } +} + +function isBefore( + priority: number, + order: number, + otherPriority: number, + otherOrder: number +): boolean { + return priority < otherPriority || (priority === otherPriority && order < otherOrder); +} diff --git a/packages/feature-state/src/features/priority-queue/index.ts b/packages/feature-state/src/features/priority-queue/index.ts new file mode 100644 index 00000000..c22322ef --- /dev/null +++ b/packages/feature-state/src/features/priority-queue/index.ts @@ -0,0 +1,2 @@ +export * from './FlatQueue'; +export * from './priority-queue'; diff --git a/packages/feature-state/src/features/priority-queue/priority-queue.test.ts b/packages/feature-state/src/features/priority-queue/priority-queue.test.ts new file mode 100644 index 00000000..623fbd67 --- /dev/null +++ b/packages/feature-state/src/features/priority-queue/priority-queue.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, expectTypeOf, it, vi } from 'vitest'; +import { createState } from '../../create-state'; +import type { TState } from '../../types'; +import { + EListenerPriority, + priorityQueueFeature, + type TPriorityQueueFeature, + type TPriorityQueueFeatureApi +} from './priority-queue'; + +describe('priorityQueueFeature function', () => { + describe('types', () => { + it('should add priority listener options to listen and subscribe', () => { + // Act + const state = createState(0).with(priorityQueueFeature()); + + // Assert + expectTypeOf(state.listen).toEqualTypeOf['listen']>(); + expectTypeOf(state.subscribe).toEqualTypeOf['subscribe']>(); + expectTypeOf(state._features).toEqualTypeOf(); + expectTypeOf(state).toEqualTypeOf]>>(); + }); + }); + + describe('listen method', () => { + it('should call listeners by priority', () => { + // Prepare + const state = createState(0).with(priorityQueueFeature()); + const calls: string[] = []; + state.listen( + () => { + calls.push('late'); + }, + { priority: EListenerPriority.LATE } + ); + state.listen( + () => { + calls.push('early'); + }, + { priority: EListenerPriority.EARLY } + ); + + // Act + state.set(1); + + // Assert + expect(calls).toEqual(['early', 'late']); + }); + + it('should pass set listener context to listeners', () => { + // Prepare + const state = createState(0).with(priorityQueueFeature()); + const listener = vi.fn(); + state.listen(listener); + + // Act + state.set(1); + + // Assert + expect(listener).toHaveBeenCalledWith({ + source: 'stateSet', + value: 1, + prevValue: 0 + }); + }); + + it('should preserve registration order for listeners with the same priority', () => { + // Prepare + const state = createState(0).with(priorityQueueFeature()); + const calls: string[] = []; + state.listen(() => { + calls.push('first'); + }); + state.listen(() => { + calls.push('second'); + }); + + // Act + state.set(1); + + // Assert + expect(calls).toEqual(['first', 'second']); + }); + + it('should remove queued listener calls by callback when unbound during notification', () => { + // Prepare + const state = createState(0).with(priorityQueueFeature()); + let unbindSecond = () => {}; + const listener = vi.fn(); + state.listen( + () => { + unbindSecond(); + }, + { priority: EListenerPriority.EARLY } + ); + unbindSecond = state.listen(listener, { priority: EListenerPriority.LATE }); + + // Act + state.set(1); + + // Assert + expect(listener).not.toHaveBeenCalled(); + }); + }); + + describe('notify method', () => { + it('should pass custom listener context to listeners', () => { + // Prepare + const state = createState(10).with(priorityQueueFeature()); + const listener = vi.fn(); + state.listen(listener); + + // Act + state.notify({ listenerContext: { source: 'manual' }, prevValue: 5 }); + + // Assert + expect(listener).toHaveBeenCalledWith({ + source: 'manual', + value: 10, + prevValue: 5 + }); + }); + }); + + describe('subscribe method', () => { + it('should call listeners immediately with the current value', () => { + // Prepare + const state = createState(10).with(priorityQueueFeature()); + const listener = vi.fn(); + + // Act + state.subscribe(listener, { priority: EListenerPriority.EARLY }); + + // Assert + expect(listener).toHaveBeenCalledWith({ value: 10, prevValue: 10 }); + }); + }); +}); diff --git a/packages/feature-state/src/features/priority-queue/priority-queue.ts b/packages/feature-state/src/features/priority-queue/priority-queue.ts new file mode 100644 index 00000000..c8d3df4f --- /dev/null +++ b/packages/feature-state/src/features/priority-queue/priority-queue.ts @@ -0,0 +1,137 @@ +import { defineFeature, type TFeature } from 'feature-core'; +import type { + TListener, + TListenerCallback, + TListenerContext, + TStateBase, + TStateNotifyOptions +} from '../../types'; +import { FlatQueue } from './FlatQueue'; + +/** + * Adds priority-based listener scheduling to a state. + * Lower priority numbers run first. Same-priority listeners keep insertion order. + */ +export function priorityQueueFeature(): TPriorityQueueFeature { + return defineFeature>({ + key: 'priority-queue', + overrides: ['listen', 'subscribe', 'notify'], + install() { + return { + notify(this: TStateBase, notifyOptions = {}) { + const { listenerContext = {}, prevValue } = notifyOptions; + + for (const listener of this._listeners) { + const priority = getListenerPriority(listener); + priorityListenerQueue.push( + { + callback: listener.callback, + context: { + ...listenerContext, + value: this._v, + prevValue + } + }, + priority + ); + } + + if (!isProcessingPriorityListenerQueue) { + processPriorityListenerQueue(); + } + }, + listen(this: TStateBase, callback, options = {}) { + const { priority = EListenerPriority.DEFAULT } = options; + const listener: TPriorityListener = { + callback, + priority + }; + this._listeners.push(listener); + + return () => { + removeQueuedPriorityListenerCalls(callback); + const index = this._listeners.indexOf(listener); + if (index !== -1) { + this._listeners.splice(index, 1); + } + }; + }, + subscribe(this: TStateBase & TPriorityQueueFeatureApi, callback, options) { + const unbind = this.listen(callback, options); + // Note: prevValue mirrors value on the initial call so listeners never receive undefined for prevValue + void callback({ value: this._v, prevValue: this._v }); + return unbind; + } + }; + } + }); +} + +export type TPriorityQueueFeature = TFeature< + 'priority-queue', + TPriorityQueueFeatureApi, + [], + 'listen' | 'notify' | 'subscribe' +>; + +export interface TPriorityQueueFeatureApi { + /** Registers a listener with an optional priority and returns an unsubscribe function. */ + listen(callback: TListenerCallback, options?: TPriorityListenerOptions): () => void; + /** Notifies listeners in priority order. */ + notify(options?: TStateNotifyOptions): void; + /** Registers a listener with an optional priority and calls it immediately with the current value. */ + subscribe(callback: TListenerCallback, options?: TPriorityListenerOptions): () => void; +} + +export interface TPriorityListenerOptions { + /** Determines execution order relative to other listeners. Lower values run first. */ + priority?: number; +} + +/** Listener priority values. Lower values are processed earlier. */ +export enum EListenerPriority { + FIRST = 0, + EARLY = 125, + DEFAULT = 250, + LATE = 375, + LAST = 500 +} + +interface TPriorityListener extends TListener { + priority: number; +} + +function getListenerPriority(listener: TListener): number { + return 'priority' in listener && typeof listener['priority'] === 'number' + ? listener['priority'] + : EListenerPriority.DEFAULT; +} + +// MARK: - Queue + +const priorityListenerQueue = new FlatQueue(); +let isProcessingPriorityListenerQueue = false; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- the shared queue stores listener calls from states with different value types +interface TPriorityListenerQueueItem { + callback: TListenerCallback; + context: TListenerContext; +} + +function processPriorityListenerQueue(): void { + isProcessingPriorityListenerQueue = true; + + try { + let item: TPriorityListenerQueueItem | null; + while ((item = priorityListenerQueue.pop()) != null) { + void item.callback(item.context); + } + } finally { + priorityListenerQueue.clear(); + isProcessingPriorityListenerQueue = false; + } +} + +function removeQueuedPriorityListenerCalls(callback: TListener['callback']): void { + priorityListenerQueue.removeWhere((item) => item.callback === callback); +} diff --git a/packages/feature-state/src/features/storage.test.ts b/packages/feature-state/src/features/storage.test.ts new file mode 100644 index 00000000..86156040 --- /dev/null +++ b/packages/feature-state/src/features/storage.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, expectTypeOf, it } from 'vitest'; +import { createState } from '../create-state'; +import type { TState } from '../types'; +import { + missingStorageValue, + storageFeature, + type TStorageFeature, + type TStorageFeatureApi, + type TStorageInterface +} from './storage'; + +describe('storageFeature function', () => { + let mockStorage: MockStorage; + + beforeEach(() => { + mockStorage = new MockStorage(); + }); + + describe('types', () => { + it('should infer storage feature APIs', () => { + const state = createState(0).with(storageFeature(mockStorage, 'testKey')); + + expectTypeOf(state.persist).toEqualTypeOf(); + expectTypeOf(state.loadFromStorage).toEqualTypeOf(); + expectTypeOf(state.deleteFromStorage).toEqualTypeOf< + TStorageFeatureApi['deleteFromStorage'] + >(); + expectTypeOf(state).toEqualTypeOf>(); + }); + }); + + describe('persist method', () => { + it('should initialize state with persisted value if available', async () => { + // Prepare + const key = 'testKey'; + const persistedValue = 42; + mockStorage.save(key, persistedValue); + const state = createState(0).with(storageFeature(mockStorage, key)); + + // Act + const result = await state.persist(); + + // Assert + expect(result).toBe(true); + expect(state.get()).toBe(persistedValue); + }); + + it('should persist state changes', async () => { + // Prepare + const key = 'testKey'; + const state = createState(10).with(storageFeature(mockStorage, key)); + await state.persist(); + + // Act + state.set(20); + const value: number = state.get(); + + // Assert + expect(value).toBe(20); + expect(mockStorage.load(key)).toBe(20); + }); + + it('should not override state when no persisted value exists', async () => { + // Prepare + const key = 'testKey'; + + // Act + const state = createState(10).with(storageFeature(mockStorage, key)); + await state.persist(); + + // Assert + expect(state.get()).toBe(10); + }); + + it('should support null and undefined as persisted values', async () => { + // Prepare + const nullKey = 'nullKey'; + const undefinedKey = 'undefinedKey'; + const nullableStorage = new MockStorage(); + nullableStorage.save(nullKey, null); + nullableStorage.save(undefinedKey, undefined); + const nullState = createState(10).with( + storageFeature(nullableStorage, nullKey) + ); + const undefinedState = createState(10).with( + storageFeature(nullableStorage, undefinedKey) + ); + + // Act + const nullResult = await nullState.persist(); + const undefinedResult = await undefinedState.persist(); + + // Assert + expect(nullResult).toBe(true); + expect(nullState.get()).toBe(null); + expect(undefinedResult).toBe(true); + expect(undefinedState.get()).toBe(undefined); + }); + }); + + describe('deleteFromStorage method', () => { + it('should delete persisted state', async () => { + // Prepare + const key = 'testKey'; + const state = createState(10).with(storageFeature(mockStorage, key)); + await state.persist(); + + // Act + const deleteResult = await state.deleteFromStorage(); + + // Assert + expect(deleteResult).toBe(true); + expect(mockStorage.load(key)).toBe(missingStorageValue); + }); + + it('should return false when no persisted value exists', async () => { + // Prepare + const key = 'nonExistentKey'; + const state = createState(10).with(storageFeature(mockStorage, key)); + + // Act + const deleteResult = await state.deleteFromStorage(); + + // Assert + expect(deleteResult).toBe(false); + }); + }); +}); + +class MockStorage implements TStorageInterface { + private store = new Map(); + + public save(key: string, value: GValue): boolean { + this.store.set(key, value); + return true; + } + + public load(key: string): GValue | typeof missingStorageValue { + return this.store.has(key) ? (this.store.get(key) as GValue) : missingStorageValue; + } + + public delete(key: string): boolean { + return this.store.delete(key); + } +} diff --git a/packages/feature-state/src/features/storage.ts b/packages/feature-state/src/features/storage.ts new file mode 100644 index 00000000..73089c43 --- /dev/null +++ b/packages/feature-state/src/features/storage.ts @@ -0,0 +1,100 @@ +import { defineFeature, type TFeature } from 'feature-core'; +import type { TState } from '../types'; + +/** + * Adds `persist()`, `loadFromStorage()`, and `deleteFromStorage()` to a state. + * + * `persist()` tries to load a previously saved value first; if none exists it + * saves the current state instead. After that, every `set()` call saves the new + * value automatically. Saves triggered by loading are skipped to prevent loops. + */ +export function storageFeature( + storage: TStorageInterface, + key: string +): TStorageFeature { + return defineFeature({ + key: 'storage', + install() { + let listening = false; + + return { + async persist(this: TState) { + let success = await this.loadFromStorage(); + if (!success) { + success = await storage.save(key, this.value as GStorageValue); + } + + // Note: Guard prevents registering a duplicate listener if persist() is called more than once + if (!listening) { + listening = true; + this.listen(async ({ value, source }) => { + if (source !== loadFromStorageSourceKey) { + await storage.save(key, value as GStorageValue); + } + }); + } + + return success; + }, + async loadFromStorage(this: TState) { + let success = false; + + const persistedValue = await storage.load(key); + if (persistedValue !== missingStorageValue) { + this.set(persistedValue, { + listenerContext: { source: loadFromStorageSourceKey } + }); + success = true; + } + + return success; + }, + async deleteFromStorage() { + return storage.delete(key); + } + }; + } + }); +} + +/** Sentinel returned by `TStorageInterface.load` when no value is stored for a key. */ +export const missingStorageValue = Symbol('missingStorageValue'); +/** Source key set on the listener context when a value is restored from storage. */ +export const loadFromStorageSourceKey = 'loadFromStorage'; + +export type TStorageFeature = TFeature<'storage', TStorageFeatureApi>; + +export interface TStorageFeatureApi { + /** + * Loads the persisted value if one exists, otherwise saves the current value. + * After the first call, every `set()` saves automatically. Returns `true` on success. + * Safe to call multiple times: registers the auto-save listener only once. + */ + persist(): Promise; + /** Loads the persisted value and updates the state. Returns `true` if a value was found. */ + loadFromStorage(): Promise; + /** Deletes the persisted value from storage. Returns `true` on success. */ + deleteFromStorage(): Promise; +} + +/** + * Minimal storage adapter required by `storageFeature`. + * `load` must return `missingStorageValue` when the key is absent. + * `null` and `undefined` are treated as stored values. + */ +export interface TStorageInterface { + /** Saves `value` under `key`. Returns `true` on success. */ + save(key: string, value: GStorageValue): Promise | boolean; + /** + * Loads the value stored under `key`. + * Returns `missingStorageValue` when the key is absent; `null` and `undefined` are valid stored values. + */ + load( + key: string + ): + | Promise + | GStorageValue + | typeof missingStorageValue; + /** Deletes the value stored under `key`. Returns `true` on success. */ + delete(key: string): Promise | boolean; +} diff --git a/packages/feature-state/src/features/undo.test.ts b/packages/feature-state/src/features/undo.test.ts new file mode 100644 index 00000000..020b35ab --- /dev/null +++ b/packages/feature-state/src/features/undo.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, expectTypeOf, it } from 'vitest'; +import { createState } from '../create-state'; +import { undoFeature } from './undo'; + +describe('undoFeature function', () => { + describe('types', () => { + it('should infer undo feature state types', () => { + const state = createState('Jeff').with(undoFeature()); + + expectTypeOf(state.get()).toEqualTypeOf(); + expectTypeOf(state._history).toEqualTypeOf(); + }); + }); + + describe('undo method', () => { + it('should restore the previous value', () => { + // Prepare + const state = createState(10).with(undoFeature()); + + // Act + state.set(20); + state.undo(); + + // Assert + expect(state.get()).toBe(10); + }); + + it('should restore multiple previous values in order', () => { + // Prepare + const state = createState('initial').with(undoFeature()); + + // Act + state.set('first'); + state.set('second'); + state.undo(); + state.undo(); + + // Assert + expect(state.get()).toBe('initial'); + }); + + it('should restore nullish values', () => { + // Prepare + const state = createState(undefined).with( + undoFeature() + ); + + // Act + state.set(null); + state.set(1); + state.undo(); + const nullValue = state.get(); + state.undo(); + const undefinedValue = state.get(); + + // Assert + expect(nullValue).toBe(null); + expect(undefinedValue).toBe(undefined); + }); + + it('should do nothing when history is empty', () => { + // Prepare + const state = createState(10).with(undoFeature()); + + // Act + state.undo(); + + // Assert + expect(state.get()).toBe(10); + }); + }); + + describe('history', () => { + it('should only record distinct consecutive values', () => { + // Prepare + const state = createState(10).with(undoFeature()); + + // Act + state.set(10); + state.set(20); + state.set(20); + state.undo(); + + // Assert + expect(state.get()).toBe(10); + }); + + it('should respect the stack size limit', () => { + // Prepare + const historyLimit = 5; + const state = createState(0).with(undoFeature(historyLimit)); + + // Act + for (let i = 1; i <= 10; i++) { + state.set(i); + } + for (let i = 0; i < historyLimit; i++) { + state.undo(); + } + + // Assert + expect(state.get()).toBe(6); + }); + }); +}); diff --git a/packages/feature-state/src/features/undo.ts b/packages/feature-state/src/features/undo.ts new file mode 100644 index 00000000..8d9cf077 --- /dev/null +++ b/packages/feature-state/src/features/undo.ts @@ -0,0 +1,50 @@ +import { defineFeature, type TFeature } from 'feature-core'; +import type { TState, TStateBase, TStateSetOptions } from '../types'; + +/** + * Adds `undo()` to a state, stepping back through past values one at a time. + * + * History is seeded with the current value when `undoFeature` is installed, so + * `undo()` is a no-op when already at the oldest recorded state. The `historyLimit` + * caps the number of entries kept; older entries are dropped as new ones arrive. + */ +export function undoFeature(historyLimit = 50): TUndoFeature { + return defineFeature>({ + key: 'undo', + install(state: TStateBase) { + const history = [state.value]; + + state.listen(({ value }) => { + if (history.length >= historyLimit) { + history.shift(); + } + + history.push(value); + }); + + return { + _history: history, + undo(this: TState]>, options) { + if (this._history.length <= 1) { + return; + } + + this._history.pop(); + const nextValue = this._history.pop(); + // Note: The length guard guarantees this pop reads a stored history entry, even when that value is undefined + this.set(nextValue as GValue, options); + } + }; + } + }); +} + +export type TUndoFeature = TFeature< + 'undo', + { + /** Steps back to the previous value. No-op when already at the oldest recorded entry. */ + undo(options?: TStateSetOptions): void; + /** @internal */ + _history: GValue[]; + } +>; diff --git a/packages/feature-state/src/features/with-multi-undo.test.ts b/packages/feature-state/src/features/with-multi-undo.test.ts deleted file mode 100644 index 3024ec69..00000000 --- a/packages/feature-state/src/features/with-multi-undo.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, it } from 'vitest'; -import { createState } from '../create-state'; -import { withMultiUndo } from './with-multi-undo'; -import { withUndo } from './with-undo'; - -describe('withMultiUndo function', () => { - it('should have correct types', () => { - const state = createState('Jeff'); - const stateWithUndo = withUndo(state); - const stateWithMultiUndo = withMultiUndo(stateWithUndo); - // const stateWithMultiUndo2 = withMultiUndo(state); - }); -}); diff --git a/packages/feature-state/src/features/with-multi-undo.ts b/packages/feature-state/src/features/with-multi-undo.ts deleted file mode 100644 index 18491a91..00000000 --- a/packages/feature-state/src/features/with-multi-undo.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { TEnforceFeatureConstraint, TFeatureDefinition } from '@blgc/types/features'; -import { isStateWithFeatures } from '../is-state-with-features'; -import type { TMultiUndoFeature, TState, TUndoFeature } from '../types'; - -export function withMultiUndo( - baseState: TEnforceFeatureConstraint< - TState, - TState, - ['undo'] - > -): TState { - if (!isStateWithFeatures]>(baseState, ['undo'])) { - throw Error('State must have "undo" feature to use withMultiUndo'); - } - - const multiUndoFeature: TMultiUndoFeature['api'] = { - multiUndo(this: TState, TMultiUndoFeature]>, count: number) { - for (let i = 0; i < count; i++) { - this.undo(); - } - } - }; - - // Extend the base state with the multiundo feature - const extendedState = Object.assign(baseState, multiUndoFeature) as TState< - GValue, - [TMultiUndoFeature] - >; - extendedState._features.push('multiundo'); - - return extendedState as unknown as TState; -} diff --git a/packages/feature-state/src/features/with-selector.test.ts b/packages/feature-state/src/features/with-selector.test.ts deleted file mode 100644 index 13a08d75..00000000 --- a/packages/feature-state/src/features/with-selector.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { createState } from '../create-state'; -import { withSelector } from './with-selector'; - -describe('withSelector function', () => { - it('should notify listener when selected path changes', () => { - // Prepare - const state = withSelector(createState({ user: { name: 'John', age: 30 } })); - const callback = vi.fn(); - - // Act - state.listenToSelected(['user.name'], callback); - state.set({ user: { name: 'Jane', age: 30 } }); - - // Assert - expect(callback).toHaveBeenCalledTimes(1); - }); - - it('should not notify listener when unrelated path changes', () => { - // Prepare - const state = withSelector(createState({ user: { name: 'John', age: 30 } })); - const callback = vi.fn(); - - // Act - state.listenToSelected(['user.name'], callback); - state.set({ user: { name: 'John', age: 31 } }); - - // Assert - expect(callback).not.toHaveBeenCalled(); - }); - - it('should notify listener when any of multiple selected paths change', () => { - // Prepare - const state = withSelector(createState({ user: { name: 'John', age: 30 } })); - const callback = vi.fn(); - - // Act - state.listenToSelected(['user.name', 'user.age'], callback); - state.set({ user: { name: 'John', age: 31 } }); - - // Assert - expect(callback).toHaveBeenCalledTimes(1); - }); - - it('should notify listener when parent of selected path changes', () => { - // Prepare - const state = withSelector(createState({ user: { name: 'John', age: 30 } })); - const callback = vi.fn(); - - // Act - state.listenToSelected(['user.name'], callback); - state._v = { user: { name: 'Jane', age: 31 } }; - state._notify({ listenerContext: { changedProperties: ['user.name'] } }); - - // Assert - expect(callback).toHaveBeenCalledTimes(1); - }); - - it('should work with function-based selectors', () => { - // Prepare - const state = withSelector(createState({ count: 0 })); - const callback = vi.fn(); - - // Act - state.listenToSelected((value) => value.count % 2, callback); - state.set({ count: 1 }); - state.set({ count: 2 }); - - // Assert - expect(callback).toHaveBeenCalledTimes(2); - }); - - it('should notify listener when prevValue is null', () => { - // Prepare - const state = withSelector(createState(null)); - const callback = vi.fn(); - - // Act - state.listenToSelected(['user.name'] as any, callback); - state.set({ user: { name: 'John' } } as any); - - // Assert - expect(callback).toHaveBeenCalledTimes(1); - }); - - it('should notify listener when changedProperties is null', () => { - // Prepare - const state = withSelector(createState({ user: { name: 'John' } })); - const callback = vi.fn(); - - // Act - state.listenToSelected(['user.name'], callback); - state._v = { user: { name: 'Jane' } }; - state._notify({ listenerContext: { changedProperties: null as any } }); - - // Assert - expect(callback).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/feature-state/src/features/with-selector.ts b/packages/feature-state/src/features/with-selector.ts deleted file mode 100644 index 910ac786..00000000 --- a/packages/feature-state/src/features/with-selector.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { TEnforceFeatureConstraint, TFeatureDefinition } from '@blgc/types/features'; -import { getNestedProperty } from '@blgc/utils'; -import { TSelectorFeature, type TState } from '../types'; - -export function withSelector( - baseState: TEnforceFeatureConstraint, TState, []> -): TState, ...GFeatures]> { - const selectorFeature: TSelectorFeature['api'] = { - listenToSelected( - this: TState]>, - queueIf, - callback, - listenOptions = {} - ) { - return this.listen(callback, { - ...listenOptions, - key: 'with-selector_selector', - queueIf: ({ value, prevValue, changedProperties }) => { - return ( - // Notify if we can't verify what changed (assume everything changed) - (prevValue == null && changedProperties == null) || - // Notify if any changed property matches or is a parent of any selected property - (changedProperties != null && - Array.isArray(changedProperties) && - Array.isArray(queueIf) && - queueIf.some((selectedProp) => - changedProperties?.some((changedProp) => - selectedProp.toString().startsWith(changedProp.toString()) - ) - )) || - // Notify if any selected property's value has changed - (prevValue != null && - ((Array.isArray(queueIf) && - queueIf.some( - (selectedProp) => - getNestedProperty(value, selectedProp) !== - getNestedProperty(prevValue as GValue, selectedProp) - )) || - (typeof queueIf === 'function' && queueIf(value) !== queueIf(prevValue)))) - ); - } - }); - } - }; - - // Extend the base state with the selector feature - const extendedState = Object.assign(baseState, selectorFeature) as TState< - GValue, - [TSelectorFeature] - >; - extendedState._features.push('selector'); - - return extendedState as unknown as TState, ...GFeatures]>; -} diff --git a/packages/feature-state/src/features/with-storage.test.ts b/packages/feature-state/src/features/with-storage.test.ts deleted file mode 100644 index 865c648d..00000000 --- a/packages/feature-state/src/features/with-storage.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { sleep } from '@blgc/utils'; -import { beforeEach, describe, expect, it } from 'vitest'; -import { createState } from '../create-state'; -import { - FAILED_TO_LOAD_FROM_STORAGE_IDENTIFIER, - withStorage, - type TStorageInterface -} from './with-storage'; - -class MockStorage implements TStorageInterface { - private store: Record = {}; - - async save(key: string, value: GValue): Promise { - this.store[key] = value; - return true; - } - - async load(key: string): Promise { - return this.store[key] ?? FAILED_TO_LOAD_FROM_STORAGE_IDENTIFIER; - } - - async delete(key: string): Promise { - if (key in this.store) { - delete this.store[key]; - return true; - } - return false; - } - - clear(): void { - this.store = {}; - } -} - -describe('withStorage function', () => { - let mockStorage: MockStorage; - - beforeEach(() => { - mockStorage = new MockStorage(); - }); - - it('should initialize state with persisted value if available', async () => { - // Prepare - const key = 'testKey'; - const persistedValue = 42; - await mockStorage.save(key, persistedValue); - const state = withStorage(createState(0), mockStorage, key); - - // Act - const result = await state.persist(); - - // Assert - expect(result).toBe(true); - expect(state.get()).toBe(persistedValue); - }); - - it('should persist state changes', async () => { - // Prepare - const key = 'testKey'; - const state = withStorage(createState(10), mockStorage, key); - await state.persist(); - - // Act - state.set(20); - await sleep(10); - - // Assert - expect(await mockStorage.load(key)).toBe(20); - }); - - it('should delete persisted state', async () => { - // Prepare - const key = 'testKey'; - const state = withStorage(createState(10), mockStorage, key); - await state.persist(); - - // Act - const deleteResult = await state.deleteFormStorage(); - - // Assert - expect(deleteResult).toBe(true); - expect(await mockStorage.load(key)).toBe(FAILED_TO_LOAD_FROM_STORAGE_IDENTIFIER); - }); - - it('should return false if deleting non-existent key', async () => { - // Prepare - const key = 'nonExistentKey'; - const state = withStorage(createState(10), mockStorage, key); - - // Act - const deleteResult = await state.deleteFormStorage(); - - // Assert - expect(deleteResult).toBe(false); - }); - - it('should not override state with null if no persisted value', async () => { - // Prepare - const key = 'testKey'; - - // Act - const state = withStorage(createState(10), mockStorage, key); - await state.persist(); - - // Assert - expect(state.get()).toBe(10); - }); -}); diff --git a/packages/feature-state/src/features/with-storage.ts b/packages/feature-state/src/features/with-storage.ts deleted file mode 100644 index 89d10c0b..00000000 --- a/packages/feature-state/src/features/with-storage.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { TEnforceFeatureConstraint, TFeatureDefinition } from '@blgc/types/features'; -import type { TPersistFeature, TState } from '../types'; - -export const FAILED_TO_LOAD_FROM_STORAGE_IDENTIFIER = null; -export const LOAD_FROM_STORAGE_SOURCE_KEY = 'loadFromStorage'; - -export interface TStorageInterface { - save: (key: string, value: GStorageValue) => Promise | boolean; - load: ( - key: string - ) => - | Promise - | GStorageValue - | typeof FAILED_TO_LOAD_FROM_STORAGE_IDENTIFIER; - delete: (key: string) => Promise | boolean; -} - -export function withStorage< - GValue, - GFeatures extends TFeatureDefinition[], - GStorageValue extends GValue = GValue ->( - baseState: TEnforceFeatureConstraint, TState, []>, - storage: TStorageInterface, - key: string -): TState { - const persistFeature: TPersistFeature['api'] = { - async persist(this: TState) { - // Load persisted value or store inital value - let success = await this.loadFormStorage(); - if (!success) { - success = await storage.save(key, this._v as GStorageValue); - } - - // Setup listener - this.listen( - async ({ value, source }) => { - if (source !== LOAD_FROM_STORAGE_SOURCE_KEY) { - await storage.save(key, value as GStorageValue); - } - }, - { key: 'with-persist' } - ); - - return success; - }, - async loadFormStorage(this: TState) { - let success = false; - - const persistedValue = await storage.load(key); - if (persistedValue !== FAILED_TO_LOAD_FROM_STORAGE_IDENTIFIER) { - this.set(persistedValue, { listenerContext: { source: LOAD_FROM_STORAGE_SOURCE_KEY } }); - success = true; - } - - return success; - }, - async deleteFormStorage() { - return storage.delete(key); - } - }; - - // Extend the base state with the persist feature - const extendedState = Object.assign(baseState, persistFeature) as TState< - GValue, - [TPersistFeature] - >; - extendedState._features.push('persist'); - - return extendedState as unknown as TState; -} diff --git a/packages/feature-state/src/features/with-undo.test.ts b/packages/feature-state/src/features/with-undo.test.ts deleted file mode 100644 index 9c9cf1b0..00000000 --- a/packages/feature-state/src/features/with-undo.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { createState } from '../create-state'; -import { withUndo } from './with-undo'; - -describe('withUndo function', () => { - it('should have correct types', () => { - const state = createState('Jeff'); - const stateWithUndo = withUndo(state); - }); - - it('should allow undoing the last set operation', () => { - // Prepare - const state = withUndo(createState(10)); - - // Act - state.set(20); - state.undo(); - - // Assert - expect(state.get()).toBe(10); - }); - - it('should handle multiple undos correctly', () => { - // Prepare - const state = withUndo(createState('initial')); - - // Act - state.set('first'); - state.set('second'); - state.undo(); - state.undo(); - - // Assert - expect(state.get()).toBe('initial'); - }); - - it('should do nothing if there is nothing to undo', () => { - // Prepare - const state = withUndo(createState(10)); - - // Act - state.undo(); - - // Assert - expect(state.get()).toBe(10); - }); - - it('should only record distinct consecutive values for undo', () => { - // Prepare - const state = withUndo(createState(10)); - - // Act - state.set(10); // Same as initial, should not be recorded - state.set(20); - state.set(20); // Duplicate, should not be recorded again - state.undo(); - - // Assert - expect(state.get()).toBe(10); - }); - - it('should respect the history stack size limit', () => { - // Prepare - const historyLimit = 5; - const state = withUndo(createState(0), historyLimit); - - // Act - for (let i = 1; i <= 10; i++) { - state.set(i); - } - for (let i = 0; i < historyLimit; i++) { - state.undo(); - } - - // Assert - expect(state.get()).toBe(6); - }); -}); diff --git a/packages/feature-state/src/features/with-undo.ts b/packages/feature-state/src/features/with-undo.ts deleted file mode 100644 index 2cb3dac5..00000000 --- a/packages/feature-state/src/features/with-undo.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { TEnforceFeatureConstraint, TFeatureDefinition } from '@blgc/types/features'; -import { withNew } from '@blgc/utils'; -import type { TState, TUndoFeature } from '../types'; - -export function withUndo( - baseState: TEnforceFeatureConstraint, TState, []>, - historyLimit = 50 -): TState, ...GFeatures]> { - const undoFeature: TUndoFeature['api'] = { - _history: [baseState._v], - undo(this: TState]>, options) { - if (this._history.length > 1) { - this._history.pop(); // Pop current value - const newValue = this._history.pop(); // Pop previous value - if (newValue != null) { - this.set(newValue, options); - } - } - } - }; - - // Extend the base state with the undo feature - const extendedState = Object.assign(baseState, undoFeature) as TState< - GValue, - [TUndoFeature] - >; - extendedState._features.push('undo'); - - return withNew]>, [number]>( - Object.assign(extendedState, { - _new(this: TState]>, historyLimit: number) { - this.listen( - ({ value }) => { - // Maintaining the history stack size - if (this._history.length >= historyLimit) { - this._history.shift(); // Remove oldest state - } - - this._history.push(value); - }, - { key: 'with-undo' } - ); - } - }), - historyLimit - ) as unknown as TState, ...GFeatures]>; -} diff --git a/packages/feature-state/src/index.ts b/packages/feature-state/src/index.ts index 746768c9..5dd3bcd5 100644 --- a/packages/feature-state/src/index.ts +++ b/packages/feature-state/src/index.ts @@ -1,5 +1,4 @@ +export * from './create-computed'; export * from './create-state'; export * from './features'; -export * from './is-state-with-features'; -export * from './listener-queue'; export * from './types'; diff --git a/packages/feature-state/src/is-state-with-features.test.ts b/packages/feature-state/src/is-state-with-features.test.ts deleted file mode 100644 index 065cdc7b..00000000 --- a/packages/feature-state/src/is-state-with-features.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { createState } from './create-state'; -import { withUndo } from './features'; -import { isStateWithFeatures } from './is-state-with-features'; - -describe('isStateWithFeatures function', () => { - it('should return true if the state has all the requested features', () => { - const state = withUndo(createState(10)); - expect(isStateWithFeatures(state, ['undo'])).toBe(true); - }); - - it('should return false if the state is missing any of the requested features', () => { - const state = createState(10); - expect(isStateWithFeatures(state, ['undo'])).toBe(false); - }); - - it('should return true for a state with only the specified features', () => { - const state = createState(10); - expect(isStateWithFeatures(state, [])).toBe(true); - }); - - it('should return true if checking for an empty feature set', () => { - const state = createState(10); - expect(isStateWithFeatures(state, [])).toBe(true); - }); -}); diff --git a/packages/feature-state/src/is-state-with-features.ts b/packages/feature-state/src/is-state-with-features.ts deleted file mode 100644 index eb389037..00000000 --- a/packages/feature-state/src/is-state-with-features.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { TFeatureDefinition, TLooseFeatureNames } from '@blgc/types/features'; -import { TState } from './types'; - -export function isStateWithFeatures( - value: unknown, - features: TLooseFeatureNames[] -): value is TState { - return ( - typeof value === 'object' && - value != null && - '_features' in value && - Array.isArray(value._features) && - features.every((feature) => (value._features as string[]).includes(feature)) - ); -} diff --git a/packages/feature-state/src/listener-queue.test.ts b/packages/feature-state/src/listener-queue.test.ts deleted file mode 100644 index cab8cc99..00000000 --- a/packages/feature-state/src/listener-queue.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createState, EStateListenerQueuePriority } from './create-state'; -import { - AsyncListenerQueue, - createListenerQueue, - getListenerQueue, - GLOBAL_LISTENER_QUEUES, - processAllListenerQueues, - processListenerQueue, - SyncListenerQueue -} from './listener-queue'; - -describe('Listener queue', () => { - beforeEach(() => { - GLOBAL_LISTENER_QUEUES.clear(); - }); - - it('should create, retrieve and handle queues correctly', () => { - // Create queues - createListenerQueue('test-sync', { async: false }); - createListenerQueue('test-async', { async: true }); - - // Direct access to global state - expect(GLOBAL_LISTENER_QUEUES.size).toBe(2); - expect(GLOBAL_LISTENER_QUEUES.has('test-sync')).toBe(true); - expect(GLOBAL_LISTENER_QUEUES.has('test-async')).toBe(true); - - // Retrieve queues - const syncQueue = getListenerQueue('test-sync'); - const asyncQueue = getListenerQueue('test-async'); - const nonExistent = getListenerQueue('non-existent'); - - // Assert - expect(syncQueue instanceof SyncListenerQueue).toBe(true); - expect(asyncQueue instanceof AsyncListenerQueue).toBe(true); - expect(nonExistent).toBeUndefined(); - - // Override queue - createListenerQueue('test-sync', { async: true }); - expect(getListenerQueue('test-sync') instanceof AsyncListenerQueue).toBe(true); - expect(GLOBAL_LISTENER_QUEUES.size).toBe(2); // Size should remain same - }); - - it('should create states with auto-created queues', () => { - // States should auto-create default queues - const defaultState = createState('default'); - const customState = createState('custom', { queue: 'my-custom-queue' }); - const syncState = createState('sync', { queue: { key: 'sync', async: false } }); - const asyncState = createState('async', { queue: { key: 'async', async: true } }); - - // Check auto-created queues - expect(GLOBAL_LISTENER_QUEUES.has('sync')).toBe(true); - expect(GLOBAL_LISTENER_QUEUES.has('async')).toBe(true); - expect(GLOBAL_LISTENER_QUEUES.has('my-custom-queue')).toBe(true); - - expect(GLOBAL_LISTENER_QUEUES.get('sync') instanceof SyncListenerQueue).toBe(true); - expect(GLOBAL_LISTENER_QUEUES.get('async') instanceof AsyncListenerQueue).toBe(true); - expect(GLOBAL_LISTENER_QUEUES.get('my-custom-queue') instanceof SyncListenerQueue).toBe(true); - - // Verify states reference the correct queues - expect(defaultState._queue).toBe(GLOBAL_LISTENER_QUEUES.get('sync')); - expect(syncState._queue).toBe(GLOBAL_LISTENER_QUEUES.get('sync')); - expect(asyncState._queue).toBe(GLOBAL_LISTENER_QUEUES.get('async')); - expect(customState._queue).toBe(GLOBAL_LISTENER_QUEUES.get('my-custom-queue')); - }); - - it('should handle shared queues and isolation correctly', () => { - createListenerQueue('shared', { async: false }); - - const state1 = createState(0, { queue: { key: 'shared' } }); - const state2 = createState(0, { queue: { key: 'shared' } }); - const isolatedState = createState(0, { queue: { key: 'sync' } }); - - // Verify queue sharing - expect(state1._queue).toBe(state2._queue); - expect(state1._queue).not.toBe(isolatedState._queue); - - // Test isolation - const listener1 = vi.fn(); - const listener2 = vi.fn(); - const isolatedListener = vi.fn(); - - state1.listen(listener1); - state2.listen(listener2); - isolatedState.listen(isolatedListener); - - state1.set(1); - expect(listener1).toHaveBeenCalled(); - expect(listener2).not.toHaveBeenCalled(); - expect(isolatedListener).not.toHaveBeenCalled(); - }); - - it('should process sync immediately and async in next tick', async () => { - const syncState = createState(0, { queue: { key: 'sync', async: false } }); - const asyncState = createState(0, { queue: { key: 'async', async: true } }); - const syncListener = vi.fn(); - const asyncListener = vi.fn(); - - syncState.listen( - async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - }, - { priority: EStateListenerQueuePriority.EARLY } - ); - syncState.listen( - (context) => { - syncListener(context); - }, - { priority: EStateListenerQueuePriority.DEFAULT } - ); - asyncState.listen( - async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - }, - { priority: EStateListenerQueuePriority.EARLY } - ); - asyncState.listen( - (context) => { - asyncListener(context); - }, - { priority: EStateListenerQueuePriority.LATE } - ); - - // Act - syncState.set(1); - asyncState.set(2); - - // Sync should be immediate and not await promise in earlier listeners - expect(syncListener).toHaveBeenCalledWith({ - source: 'state_set', - value: 1, - prevValue: 0 - }); - - // Async should be deferred and await promise in earlier listeners - expect(asyncListener).not.toHaveBeenCalled(); - - // Wait for async processing to complete - await new Promise((resolve) => setTimeout(resolve, 15)); - expect(asyncListener).toHaveBeenCalledWith({ - source: 'state_set', - value: 2, - prevValue: 0 - }); - }); - - it('should handle manual processing and queue inspection', () => { - const state = createState(0, { queue: { key: 'sync' } }); - const listener = vi.fn(); - state.listen(listener); - - // Disable auto-processing - state.set(1, { processListenerQueue: false }); - - // Queue should have items - expect(GLOBAL_LISTENER_QUEUES.get('sync')?.length).toBe(1); - expect(listener).not.toHaveBeenCalled(); - - // Manual processing - processListenerQueue('sync'); - expect(GLOBAL_LISTENER_QUEUES.get('sync')?.length).toBe(0); - expect(listener).toHaveBeenCalled(); - - // Test processAllQueues - createListenerQueue('test-queue', { async: false }); - const testState = createState(0, { queue: { key: 'test-queue' } }); - const testListener = vi.fn(); - testState.listen(testListener); - - state.set(2, { processListenerQueue: false }); - testState.set(3, { processListenerQueue: false }); - - processAllListenerQueues(); - - expect(listener).toHaveBeenCalledTimes(2); - expect(testListener).toHaveBeenCalled(); - - // Test non-existent queue handling - expect(() => processListenerQueue('non-existent')).not.toThrow(); - }); -}); diff --git a/packages/feature-state/src/listener-queue.ts b/packages/feature-state/src/listener-queue.ts deleted file mode 100644 index 55a97557..00000000 --- a/packages/feature-state/src/listener-queue.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { FlatQueue } from './FlatQueue'; -import { TListenerQueueItem } from './types'; - -export const GLOBAL_LISTENER_QUEUES = new Map(); - -export function createListenerQueue( - key: string, - options: TCreateListenerQueueOptions -): ListenerQueue { - const { async = false } = options; - const queue = async ? new AsyncListenerQueue() : new SyncListenerQueue(); - GLOBAL_LISTENER_QUEUES.set(key, queue); - return queue; -} - -export interface TCreateListenerQueueOptions { - async?: boolean; -} - -export function getListenerQueue(key: string): ListenerQueue | undefined { - return GLOBAL_LISTENER_QUEUES.get(key); -} - -export function processListenerQueue(key: string): void { - const queue = GLOBAL_LISTENER_QUEUES.get(key); - if (queue != null) { - void queue.process(); - } -} - -export function processAllListenerQueues(): void { - for (const [_, queue] of GLOBAL_LISTENER_QUEUES) { - void queue.process(); - } -} - -export class SyncListenerQueue extends FlatQueue implements ListenerQueue { - public process(): void { - let item: TListenerQueueItem | null; - while ((item = this.pop()) != null) { - void item.callback(item.context); - } - } -} - -export class AsyncListenerQueue extends FlatQueue implements ListenerQueue { - public async process(): Promise { - let item: TListenerQueueItem | null; - while ((item = this.pop()) != null) { - await item.callback(item.context); - } - } -} - -export interface ListenerQueue extends FlatQueue { - process(): void | Promise; -} diff --git a/packages/feature-state/src/types.ts b/packages/feature-state/src/types.ts new file mode 100644 index 00000000..6edad106 --- /dev/null +++ b/packages/feature-state/src/types.ts @@ -0,0 +1,72 @@ +import { type TAnyFeature, type TFeatureHost } from 'feature-core'; + +/** State object returned by `createState()`. */ +export type TState = TFeatureHost< + TStateBase, + GFeatures +>; + +/** + * Core state API used by feature installers. + * Use this type when a feature only needs the base methods, + * regardless of which other features are already installed on the host. + */ +export interface TStateBase { + /** @internal */ + _listeners: TListener[]; + /** Raw backing value. Mutate it directly only when you will call `notify()` yourself. */ + _v: GValue; + /** Current state value. Assigning a new value is equivalent to calling `set()`. */ + value: GValue; + /** + * Notifies all listeners. Bypasses the equality check, so it fires even if the value + * has not changed. Useful after mutating `_v` or `value` in place. + */ + notify(options?: TStateNotifyOptions): void; + /** Returns the current state value. */ + get(): GValue; + /** Sets the value and notifies listeners if it changed by reference. */ + set( + newValueOrUpdater: GValue | ((value: GValue) => GValue), + options?: TStateSetOptions + ): void; + /** Registers a callback for future changes. Returns an unsubscribe function. */ + listen(callback: TListenerCallback): () => void; + /** Like `listen`, but also calls the callback immediately with the current value. */ + subscribe(callback: TListenerCallback): () => void; +} + +export interface TStateNotifyOptions { + /** Extra fields merged into each listener's context for this notification. */ + listenerContext?: TAdditionalListenerContext; + /** Previous value passed to listeners. Not inferred automatically; pass it when listeners need a snapshot. */ + prevValue?: GValue; +} + +export type TStateSetOptions = Omit, 'prevValue'>; + +export interface TListener { + [key: string]: unknown; + callback: TListenerCallback; +} + +export type TListenerCallback = (context: TListenerContext) => Promise | void; + +export interface TListenerContext extends TAdditionalListenerContext { + /** The new state value. */ + value: GValue; + /** Previous value snapshot. Equals `value` on the initial call from `subscribe()`. */ + prevValue?: GValue; +} + +export interface TAdditionalListenerContext { + [key: string]: unknown; + /** Identifies what triggered the change, e.g. `'stateSet'` for `set()`. */ + source?: string; + /** When true, signals that the change is a background sync. Consumers can use this to suppress UI updates. */ + background?: boolean; +} + +/** Extracts the value type from a state. */ +export type TStateValue = + GState extends TState ? GValue : never; diff --git a/packages/feature-state/src/types/features.ts b/packages/feature-state/src/types/features.ts deleted file mode 100644 index 392a038b..00000000 --- a/packages/feature-state/src/types/features.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { type TNestedPath } from '@blgc/utils'; -import { type TListenerCallback, type TListenerOptions, type TStateSetOptions } from './state'; - -export interface TUndoFeature { - key: 'undo'; - api: { - undo: (options?: TStateSetOptions) => void; - _history: GValue[]; - }; -} - -export interface TMultiUndoFeature { - key: 'multiundo'; - api: { - multiUndo: (count: number) => void; - }; -} - -export interface TPersistFeature { - key: 'persist'; - api: { - persist: () => Promise; - loadFormStorage: () => Promise; - deleteFormStorage: () => Promise; - }; -} - -export interface TSelectorFeature { - key: 'selector'; - api: { - listenToSelected: ( - queueIf: TNestedPath[] | ((value: GValue) => unknown), - callback: TListenerCallback, - options?: Omit, 'queueIf'> - ) => () => void; - }; -} diff --git a/packages/feature-state/src/types/index.ts b/packages/feature-state/src/types/index.ts deleted file mode 100644 index c2f42aa5..00000000 --- a/packages/feature-state/src/types/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './features'; -export * from './state'; diff --git a/packages/feature-state/src/types/state.ts b/packages/feature-state/src/types/state.ts deleted file mode 100644 index ad670f8d..00000000 --- a/packages/feature-state/src/types/state.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { TWithFeatures, type TFeatureDefinition } from '@blgc/types/features'; -import { type TNestedPath } from '@blgc/utils'; -import type { ListenerQueue } from '../listener-queue'; - -export type TState = TWithFeatures< - { - _v: GValue; - _listeners: TListener[]; - _queue: ListenerQueue; - /** - * Triggers all registered listeners to run with the current state value. - */ - _notify: (options?: TStateNotifyOptions) => void; - /** - * Retrieves the current state value. - * - * @example - * ```js - * const currentState = $state.get(); - * ``` - * - * @returns The current state value of type `GValue`. - */ - get: () => GValue; - /** - * Updates the state value. - * - * @example - * ```js - * $state.set("Hello World"); - * ``` - * - * @param newValueOrUpdater - The new value to set for the state, of type `GValue`. - */ - set: ( - newValueOrUpdater: GValue | ((value: GValue) => GValue), - options?: TStateSetOptions - ) => void; - /** - * Subscribes to state changes without immediately invoking the callback. - * Use this to listen for changes that occur after the subscription. - * - * @param callback - The callback function to execute when the state changes. - * @param level - Optional parameter to specify the listener's priority level. - * @returns A function that, when called, will unsubscribe the listener. - */ - listen: (callback: TListenerCallback, options?: TListenerOptions) => () => void; - /** - * Subscribes to state changes and invokes the callback immediately with the current state value. - * - * @example - * ```js - * import { $state } from '../store'; - * - * const unsubscribe = $state.subscribe(value => { - * console.log(value); - * }); - * ``` - * - * @param callback - The callback function to execute when the state changes. - * @param level - Optional parameter to specify the listener's priority level. - * @returns A function that, when called, will unsubscribe the listener. - */ - subscribe: ( - callback: TListenerCallback, - options?: Partial, 'callback'>> - ) => () => void; - }, - GFeatures ->; - -export type TListenerCallback = (context: TListenerContext) => Promise | void; - -export interface TListenerContext extends TAdditionalListenerContext { - value: GValue; - prevValue?: GValue; -} - -export interface TAdditionalListenerContext { - [key: string]: unknown; - source?: string; - background?: boolean; - changedProperties?: TNestedPath[]; -} - -export interface TListener { - key?: string; - priority: number; - callback: TListenerCallback; - queueIf?: (context: TListenerContext) => boolean; -} - -export type TListenerOptions = Partial, 'callback'>>; - -export interface TListenerQueueItem { - callback: TListener['callback']; - context: TListenerContext; -} - -export interface TStateNotifyOptions { - processListenerQueue?: boolean; - listenerContext?: TAdditionalListenerContext; - prevValue?: GValue; -} - -export type TStateSetOptions = Omit, 'prevValue'>; - -export type TNullableStateValue = - NonNullable extends TState - ? GValue | (Extract extends never ? never : null) - : never; - -export type TStateValue = S extends TState ? V : never; -export type TStateFeatures = S extends TState ? F : never; diff --git a/packages/head-metadata/package.json b/packages/head-metadata/package.json index f71e6811..162010c1 100644 --- a/packages/head-metadata/package.json +++ b/packages/head-metadata/package.json @@ -38,7 +38,7 @@ "xml-tokenizer": "workspace:*" }, "devDependencies": { - "@types/node": "^25.6.0", + "@types/node": "^25.9.1", "rollup-presets": "workspace:*" }, "size-limit": [ diff --git a/packages/head-metadata/src/__tests__/resources/e2e/bento-com.html b/packages/head-metadata/src/__tests__/resources/e2e/bento-com.html deleted file mode 100644 index ea4238fc..00000000 --- a/packages/head-metadata/src/__tests__/resources/e2e/bento-com.html +++ /dev/null @@ -1,1038 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - -Bento.com Japanese cuisine and restaurant guide - - - - - - - - - - - - - - -
- -
- - - - - - - - - - -
- - - - - -
-
-
-
-
-
-Bento.com -
-
-"Always fresh, never fishy" -
- - -
- -
-
-
-
- - -
- -
-
- - -
- -
-
Share:
- -
- - -
-
Follow:
- -
- -
- -
-

Latest reviews

-
- - - - - -
- -
- - -
-
Ageha isn't super-fancy, but they turn out reliable, good-quality kushiage skewers at reasonable prices. There are four prix-fixe menus at both lunch and dinner, with somewhat more premium ingredients as the price level rises. Most full meals comprise eight or ten skewers of vegetables, seafood and meats, plus soup, rice, pickles and dessert. - Ingredients are lightly coated before.... - [Continue reading] -
-
-
- - - -
- -
- - -
-
We'd love this place even if it weren't for the gorgeous bayside view. They serve great food and inspired original cocktails, and they run their own craft brewery, coffee roastery and gin distillery on premises. When the weather is nice you can enjoy the fresh ocean breezes out on their waterfront terrace. - The food menu focuses on casual American classics, among them some of the.... - [Continue reading] -
-
-
- - -
- -
- - -
-
Fish is known for their three-curry combo plates, which come with your choice of a main curry (pork, chicken, extra-spicy chicken, and various weekly specials) paired with a mixed-bean curry and a keema. The recommended pork curry is particularly fiery, with a good mix of spices, while the three-bean curry is mild and gently spiced and the keema is rich and meaty. - Curry combos come.... - [Continue reading] -
-
-
- - -
- -
- - -
-
Tomato ramen is the unusual specialty here, and a bowl of it incorporates juice and pulp from five and a half tomatoes as well as pork, onions, greens and other ingredients. Tomako's soup is richer and more meaty than other tomato-based ramen bowls we've had, and the thin, firm noodles do a good job soaking up the flavors. - Tomato ramen topped with oven-grilled cheese is the most.... - [Continue reading] -
-
-
- - - -
- -
- - -
-
This standing bar inside a regional antenna shop showcases sake from Toyama, Ishikawa and Fukui Prefectures - collectively known as the Hokuriku region. Between the selection behind the bar and the self-service sake dispensers, they boast the largest collection of Hokuriku sake in Kansai. - When you order at the bar, sake comes in 60ml servings, or three-part tasting flights of 45ml each. .... - [Continue reading] -
-
-
- - -
- -
- - -
-
This lively dining bar specializes in smoked foods and smoky cocktails, and stocks a good selection of whiskies. Homemade smoked items like bacon, cheese, chicken wings and quail eggs are skillfully prepared in the bar's custom smoker, while zucchini fritters, fries and similar dishes are served with the bar's own smoked mayonnaise and smoked ketchup. - One standout, though it's not.... - [Continue reading] -
-
-
- - - - - -
- -
- - -
-

City guides

-
-
-
-
-
-
- Tokyo -
-
-
- -
-
-
-
- Fukuoka -
-
-
- -
-
-
-
- Nagoya -
-
-
- -
-
-
-
- Yokohama -
-
-
- -
-
-
- -
-
- -
-
-
- -
-
- - -
-

Travel tools

-
- - - -
-
-
- - - -
- -
-
- - -
-
-
- - - -
- -
-
- -
-
-
- - - -
- -
-
- -
-
-
- - - -
-
- Search tips -
-
- - -
-
-
-
-
- - -
-
- - -
-

Exploring Japanese cuisine

-
- - -
-
- - -
- All about ramen, tonkatsu, tempura and grilled chicken on sticks, with menu-reading guides -
-
-
-
- -
-
- -
- Recipes -
-
- Making Japanese dishes at home -
-
-
-
- -
-
- - -
- Holiday meals, kitchen tools, sake snacks -
-
-
-
- - - -
- -
-
- - -
- Food-related travels around Japan -
-
-
-
- -
-
- - -
- Kitchen tools, sake snacks, holiday meals -
-
-
-
- - -
-
- - - -
-

Articles and special features

-
- - -
-
- -
- -
- An introduction to different types of sake, plus a glossary of sake terms -
-
-
-
-
- - -
-
- - -
- Menu-reading help for izakaya and sushi shops -
-
-
-
- - - - - -
- - -
-
- - -
- A combination museum, restaurant complex and mini-theme park, with eight ramen shops to try -
-
-
-
- - -
-
- - -
- Sample regional delicacies and local sake without leaving the capital -
-
-
-
- - - - -
-
-
- - -
-
- -
- -
- Learn the secrets of Osaka's favorite octopus snack -
-
-
-
-
- -
-
- -
- -
- What are style-savvy brewers wearing at festivals and tastings around the country? -
-
-
-
-
- - - -
- -
-
- -
- -
- A bustling warren of tiny shops catering to both restaurants and consumers -
-
-
-
-
- -
-
- -
- -
- Tour of one of Kyoto's luxurious department-store food halls -
-
-
-
-
- - - -
-
-
- - - -
-
- - -
- Explore Kobe's biggest sake museum, built in an old brewery building -
-
-
-
- -
-
- - -
- Different styles of ramen explained, with a noodle-term glossary -
-
-
-
- - - -
- - -
-
- - -
- Find the best craft-beer bars in Osaka, Kobe and Kyoto -
-
-
-
- - -
-
- - -
- Finding great pork is easy, but where do you go for great turnips? Here are some suggestions. -
-
-
-
- - - - -
-

Travel tools

-
- -
-
-
- - - -
- -
-
- - -
-
-
- - - -
- -
-
- -
-
-
- - - -
- -
-
- - -
-
-
- - - -
-
- Search tips -
-
- - -
-
-
-
-
- - - -
-
- - - - -
- - - - -
- -


- - - -
- -
Share:
- -
- - -
- -
Follow:
- -
- - -
-
Sister sites:
-
-
-
-
-
Craft Beer Bars Japan
-
-
-
Bars, retailers and festivals
-
-
-
-
-
-
-
Animal Cafes
-
-
-
Cat, rabbit and bird cafe guide
-
-
-
-
-
-
-
Where in Tokyo
-
-
-
Fun things to do in the big city
-
-
-
-
-
-
-
tokyopicks.com
-
-
-
Neighborhood guides and top-five lists from Tokyo experts
-
-
-
-
-
-
-
Barking Inu
-
-
-
Sushi dictionary and Japan Android apps
-
-
-
-
- -
-
- - -
- -
- - - - - - -
 
-
-
-
- -
-
- -
- - -
- -
- - -
-
- - - - - - - - - - diff --git a/packages/head-metadata/src/__tests__/resources/e2e/bento-com.json b/packages/head-metadata/src/__tests__/resources/e2e/bento-com.json deleted file mode 100644 index ba5d5bf3..00000000 --- a/packages/head-metadata/src/__tests__/resources/e2e/bento-com.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "link": { - "alternate": "http://bento.com/bentonews.xml", - "apple-touch-icon-precomposed": "/apple-touch-icon-120x120.png", - "icon": "/favicon-16x16.png", - "search": "bentotokyosearch.xml", - "stylesheet": "css/bento-front-grid.css" - }, - "meta": { - "bitly-verification": "0bcee6f100be", - "charset": "sjis", - "description": "The Tokyo Food Page is a complete guide to Japanese food and restaurants in Tokyo, featuring recipes, articles on Japanese cooking, restaurant listings, culinary travel tips and more.", - "google-site-verification": "eOJ2O8Jr70PfyLxj7o-KkvXxl8oJKTB-YIFYyOPcgMQ", - "og:image": "http://bento.com/pix/icon-1000-400-tokyo2.jpg", - "og:site_name": "Bento.com", - "og:title": "Bento.com Japanese cuisine and restaurant guide", - "og:url": "http://bento.com/tokyofood.html", - "theme-color": "#000000", - "viewport": "width=device-width, initial-scale=1.0" - }, - "title": "Bento.com Japanese cuisine and restaurant guide" -} diff --git a/packages/head-metadata/src/__tests__/resources/e2e/bento-com_partial.html b/packages/head-metadata/src/__tests__/resources/e2e/bento-com_partial.html deleted file mode 100644 index 926b725c..00000000 --- a/packages/head-metadata/src/__tests__/resources/e2e/bento-com_partial.html +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - -Bento.com Japanese cuisine and restaurant guide - - - - - - - - - - - - - diff --git a/packages/head-metadata/src/__tests__/resources/e2e/bento-com_partial.json b/packages/head-metadata/src/__tests__/resources/e2e/bento-com_partial.json deleted file mode 100644 index ba5d5bf3..00000000 --- a/packages/head-metadata/src/__tests__/resources/e2e/bento-com_partial.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "link": { - "alternate": "http://bento.com/bentonews.xml", - "apple-touch-icon-precomposed": "/apple-touch-icon-120x120.png", - "icon": "/favicon-16x16.png", - "search": "bentotokyosearch.xml", - "stylesheet": "css/bento-front-grid.css" - }, - "meta": { - "bitly-verification": "0bcee6f100be", - "charset": "sjis", - "description": "The Tokyo Food Page is a complete guide to Japanese food and restaurants in Tokyo, featuring recipes, articles on Japanese cooking, restaurant listings, culinary travel tips and more.", - "google-site-verification": "eOJ2O8Jr70PfyLxj7o-KkvXxl8oJKTB-YIFYyOPcgMQ", - "og:image": "http://bento.com/pix/icon-1000-400-tokyo2.jpg", - "og:site_name": "Bento.com", - "og:title": "Bento.com Japanese cuisine and restaurant guide", - "og:url": "http://bento.com/tokyofood.html", - "theme-color": "#000000", - "viewport": "width=device-width, initial-scale=1.0" - }, - "title": "Bento.com Japanese cuisine and restaurant guide" -} diff --git a/packages/head-metadata/src/__tests__/resources/e2e/google-unformatted.html b/packages/head-metadata/src/__tests__/resources/e2e/google-unformatted.html deleted file mode 100644 index a07f6846..00000000 --- a/packages/head-metadata/src/__tests__/resources/e2e/google-unformatted.html +++ /dev/null @@ -1,14 +0,0 @@ - Google



 

Erweiterte Suche

© 2025 - Datenschutzerkl�rung - Nutzungsbedingungen

\ No newline at end of file diff --git a/packages/head-metadata/src/__tests__/resources/e2e/google-unformatted.json b/packages/head-metadata/src/__tests__/resources/e2e/google-unformatted.json deleted file mode 100644 index ee386090..00000000 --- a/packages/head-metadata/src/__tests__/resources/e2e/google-unformatted.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "link": {}, - "meta": { - "og:description": "Bundestagswahl 2025! #GoogleDoodle", - "og:image": "https://www.google.com/logos/doodles/2025/german-federal-election-2025-6753651837110659-2x.png", - "og:image:height": "460", - "og:image:width": "1150", - "twitter:card": "summary_large_image", - "twitter:description": "Bundestagswahl 2025! #GoogleDoodle", - "twitter:image": "https://www.google.com/logos/doodles/2025/german-federal-election-2025-6753651837110659-2x.png", - "twitter:site": "@GoogleDoodles", - "twitter:title": "Bundestagswahl 2025" - }, - "title": "Google" -} diff --git a/packages/head-metadata/src/__tests__/resources/e2e/google.html b/packages/head-metadata/src/__tests__/resources/e2e/google.html deleted file mode 100644 index 7638a236..00000000 --- a/packages/head-metadata/src/__tests__/resources/e2e/google.html +++ /dev/null @@ -1,920 +0,0 @@ - - - - - - - - - - - - - - - Google - - - - - - - -
-
- Suche - Bilder - Maps - Play - YouTube - News - Gmail - Drive - Mehr » -
- -
-
-
-
-
-
-

-
-
- - - - - - -
  - -
- -
-
- - -
- Erweiterte Suche -
- - -
-

- -

- © 2025 - Datenschutzerkl�rung - - Nutzungsbedingungen -

-
- - - - - diff --git a/packages/head-metadata/src/__tests__/resources/e2e/google.json b/packages/head-metadata/src/__tests__/resources/e2e/google.json deleted file mode 100644 index ee386090..00000000 --- a/packages/head-metadata/src/__tests__/resources/e2e/google.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "link": {}, - "meta": { - "og:description": "Bundestagswahl 2025! #GoogleDoodle", - "og:image": "https://www.google.com/logos/doodles/2025/german-federal-election-2025-6753651837110659-2x.png", - "og:image:height": "460", - "og:image:width": "1150", - "twitter:card": "summary_large_image", - "twitter:description": "Bundestagswahl 2025! #GoogleDoodle", - "twitter:image": "https://www.google.com/logos/doodles/2025/german-federal-election-2025-6753651837110659-2x.png", - "twitter:site": "@GoogleDoodles", - "twitter:title": "Bundestagswahl 2025" - }, - "title": "Google" -} diff --git a/packages/head-metadata/src/__tests__/resources/e2e/paddle.html b/packages/head-metadata/src/__tests__/resources/e2e/paddle.html deleted file mode 100644 index ab84c14b..00000000 --- a/packages/head-metadata/src/__tests__/resources/e2e/paddle.html +++ /dev/null @@ -1,1229 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Paddle - Payments, tax and subscription management for SaaS and digital products - - - - - - - - - - - - - -
-

Affected by Digital River winding down?

- We’ve got you covered in 48 hours -
- - - - -
-
- -
-
-

Put your billing operations on autopilot

-
-

As a merchant of record, we manage your payments, tax and compliance needs, so you can focus on growth.

-
- HubX Logo - MacPaw Logo - Runna Logo - Geoguessr Logo -
- -
-
-
- -
-
-
- -
- Platform Collage -
-
-
-
-
- - -
-
-
- Products -

Your all-in-one payments -infrastructure

-
-
-
-
- - -
-
-
-
-
-

Billing

-

The only complete billing solution for digital products. Payments, tax, subscription management and more, all handled for you.

- Discover Billing -
- SaaS billing dashboard -
-
-
-

ProfitWell Metrics

-

Keep a finger on the pulse of your business with accurate, accessible revenue reporting for subscription and SaaS companies - completely free.

- Discover ProfitWell Metrics -
- Metrics SaaS analytics dashboard -
-
-
-

Retain

-

A smarter way to recover failed payments, Retain automatically recovers failed card payments and increases customer retention. Set it up once and we’ll do the rest.

- Discover Retain -
- Retain automatic customer retention software -
-
-
-
- - -
-
-
- - -
-
-
- Results -

Over 5,000+ software businesses use Paddle to scale their commercial operations

-
- -
-
- - - - - -
-
-
-
-

- 122 million - transactions processed -

-
-
-

- $89 million - in sales taxes remitted last year -

-
-
-

- 5,000+ - customers using Paddle -

-
-
-
-
- - -
-
-
- Fortinet - MacPaw - Laravel - Adaptavist - GeoGuessr - n8n.io - tailwind labs - removebg - BeyondCode - Daylite - GETBLOCK -
-
- Fortinet - MacPaw - Laravel - Adaptavist - GeoGuessr - n8n.io - tailwind labs - removebg - BeyondCode - Daylite - GETBLOCK -
-
-
- - -
-
-
- - -
-
-
- Our model -

How is Paddle different?

-

Paddle provides more than just the plumbing for your revenue. As a merchant of record, we do it for you.

- - What is a merchant of record? - -
-
-
- -

Build and maintain relationships with payment providers

-
-
- -

Take on liability for charging and remitting sales taxes, globally

-
-
- -

Take on liability for all fraud that takes place on our platform

-
-
- -

Reconcile your revenue data across billing and payment methods

-
-
- -

Handle all billing-related support queries for you

-
-
- -

Reduce churn by recovering failed payments

-
-
-
-
- - -
-
-
- -
-

Join 5,000+ businesses already growing with Paddle

-

We built the complete payment stack, so you don‘t have to

- -
- -
-
-
- - - - -
- - - - - - - - - - - - - - - - - - - - - - diff --git a/packages/head-metadata/src/__tests__/resources/e2e/paddle.json b/packages/head-metadata/src/__tests__/resources/e2e/paddle.json deleted file mode 100644 index 889e0d52..00000000 --- a/packages/head-metadata/src/__tests__/resources/e2e/paddle.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "link": { - "canonical": "https://www.paddle.com", - "preload": "https://js.hsforms.net/forms/v2.js", - "stylesheet": "/_next/static/css/9200dd3d7924dc8e.css" - }, - "meta": { - "description": "We are the merchant of record for B2B & B2C software businesses. Helping increase global conversions, reducing churn, staying compliant, and scaling up fast.", - "og:description": "We are the merchant of record for B2B & B2C software businesses. Helping increase global conversions, reducing churn, staying compliant, and scaling up fast.", - "og:image": "https://images.prismic.io/paddle/a01787c4-75fa-408c-8b63-16a85b255826_paddle-share-image.png?auto=compress,format&rect=0,19,1200,591&w=1280&h=630", - "og:title": "Paddle - Payments, tax and subscription management for SaaS and digital products", - "robots": "index", - "twitter:card": "summary_large_image", - "twitter:description": "We are the merchant of record for B2B & B2C software businesses. Helping increase global conversions, reducing churn, staying compliant, and scaling up fast.", - "twitter:image": "https://images.prismic.io/paddle/a01787c4-75fa-408c-8b63-16a85b255826_paddle-share-image.png?auto=compress,format&rect=0,14,1200,600&w=1024&h=512", - "twitter:title": "Paddle - Payments, tax and subscription management for SaaS and digital products", - "viewport": "width=device-width, initial-scale=1, maximum-scale=5" - }, - "title": "Paddle - Payments, tax and subscription management for SaaS and digital products" -} diff --git a/packages/head-metadata/src/__tests__/resources/e2e/starterstory.html b/packages/head-metadata/src/__tests__/resources/e2e/starterstory.html deleted file mode 100644 index a91973f4..00000000 --- a/packages/head-metadata/src/__tests__/resources/e2e/starterstory.html +++ /dev/null @@ -1,2998 +0,0 @@ - - - - Starter Story: Learn How People Are Starting Successful Businesses - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - -
-
-
- -
- - - - -
-
- - - - - -
-
-
-
-
- -
Starter Story
-
-
- Unlock the secrets to 7-figure online businesses -
-
- Dive into our database of 4,413 case studies & join our community of thousands of - successful founders. -
- -
-
-
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
- - Join thousands of founders -
-
- -
-
- - - - - -
- - - - - - - - - - - - - - - - -
- - - - diff --git a/packages/head-metadata/src/__tests__/resources/e2e/starterstory.json b/packages/head-metadata/src/__tests__/resources/e2e/starterstory.json deleted file mode 100644 index ade8b483..00000000 --- a/packages/head-metadata/src/__tests__/resources/e2e/starterstory.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "title": "Starter Story: Learn How People Are Starting Successful Businesses", - "meta": { - "description": "Starter Story interviews successful entrepreneurs and shares the stories behind their businesses. In each interview, we ask how they got started, how they grew, and how they run their business today.", - "og:title": "Starter Story: Learn How People Are Starting Successful Businesses", - "og:description": "Starter Story interviews successful entrepreneurs and shares the stories behind their businesses. In each interview, we ask how they got started, how they grew, and how they run their business today.", - "og:url": "https://www.starterstory.com", - "og:image": "https://d1coqmn8qm80r4.cloudfront.net/production/images/6fd0cbcde3a17eb5", - "og:image:width": "1024", - "og:image:height": "512", - "twitter:card": "summary_large_image", - "twitter:site": "@starter_story", - "twitter:title": "Starter Story: Learn How People Are Starting Successful Businesses", - "twitter:description": "Starter Story interviews successful entrepreneurs and shares the stories behind their businesses. In each interview, we ask how they got started, how they grew, and how they run their business today.", - "twitter:creator": "@thepatwalls", - "twitter:image": "https://d1coqmn8qm80r4.cloudfront.net/production/images/6fd0cbcde3a17eb5", - "viewport": "width=device-width, initial-scale=1.0", - "csrf-param": "authenticity_token", - "csrf-token": "Y/r2blv1BSrDAQ+KLrVUMUqVgc5W0UQXdS5chzqCA3BGvpgvK4LKV6rZteu/Ef4ApzMHW5k04EXQvR4wmg4EQg==" - }, - "link": { - "stylesheet": "https://d1kpq1xlswihti.cloudfront.net/assets/non_essential-5983ca74615a995e158d7aefdbad7fb8f78ee5eb023bc4852a51b13135b36d7b.css", - "icon": "https://d1kpq1xlswihti.cloudfront.net/assets/starterstory_favicon-1d56fe8e0cb50101dd68673cc80986ee5e7b409621e7da39a5154ac25d36bfb2.ico", - "alternate": "https://www.starterstory.com/feed?format=rss" - } -} diff --git a/packages/head-metadata/src/__tests__/resources/e2e/youtube.html b/packages/head-metadata/src/__tests__/resources/e2e/youtube.html deleted file mode 100644 index dc47c913..00000000 --- a/packages/head-metadata/src/__tests__/resources/e2e/youtube.html +++ /dev/null @@ -1,15804 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Jai Howitt - YouTube - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- -
- - - - - - -
-
-
-
-
-
- About - Press - Copyright - Contact us - Creators - Advertise - Developers - Impressum - Cancel Memberships - Terms - Privacy - Policy &Safety - How YouTube works - Test new features - -
- - - - - - - - - - - - - - - - - - diff --git a/packages/head-metadata/src/__tests__/resources/e2e/youtube.json b/packages/head-metadata/src/__tests__/resources/e2e/youtube.json deleted file mode 100644 index ac96a766..00000000 --- a/packages/head-metadata/src/__tests__/resources/e2e/youtube.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "meta": { - "theme-color": "rgba(255, 255, 255, 0.98)", - "description": "Building in public. Weekly raw episodes on Mondays & some other stuff sprinkled in.Much love,Jaihttps://www.instagram.com/jai.journeys", - "keywords": "Just Jai howitt artofmondays art of mondays", - "og:title": "Jai Howitt", - "og:site_name": "YouTube", - "og:url": "https://www.youtube.com/channel/UCgAZ8TVwkH_gUH_rVbrunaA", - "og:image": "https://yt3.googleusercontent.com/ytc/AIdro_n4YnbRzb7802She3I2Tq7lPWbXsDhHItRK8SX6ImO3wtg=s900-c-k-c0x00ffffff-no-rj", - "og:image:width": "900", - "og:image:height": "900", - "og:description": "Building in public. Weekly raw episodes on Mondays & some other stuff sprinkled in.\n\nMuch love,\nJai\n\nhttps://www.instagram.com/jai.journeys\n", - "al:ios:app_store_id": "544007664", - "al:ios:app_name": "YouTube", - "al:ios:url": "vnd.youtube://www.youtube.com/channel/UCgAZ8TVwkH_gUH_rVbrunaA", - "al:android:url": "https://www.youtube.com/channel/UCgAZ8TVwkH_gUH_rVbrunaA?feature=applinks", - "al:android:app_name": "YouTube", - "al:android:package": "com.google.android.youtube", - "al:web:url": "https://www.youtube.com/channel/UCgAZ8TVwkH_gUH_rVbrunaA?feature=applinks", - "og:type": "profile", - "og:video:tag": "mondays", - "fb:app_id": "87741124305", - "twitter:card": "summary", - "twitter:site": "@youtube", - "twitter:url": "https://www.youtube.com/channel/UCgAZ8TVwkH_gUH_rVbrunaA", - "twitter:title": "Jai Howitt", - "twitter:description": "Building in public. Weekly raw episodes on Mondays & some other stuff sprinkled in.\n\nMuch love,\nJai\n\nhttps://www.instagram.com/jai.journeys\n", - "twitter:image": "https://yt3.googleusercontent.com/ytc/AIdro_n4YnbRzb7802She3I2Tq7lPWbXsDhHItRK8SX6ImO3wtg=s900-c-k-c0x00ffffff-no-rj", - "twitter:app:name:iphone": "YouTube", - "twitter:app:id:iphone": "544007664", - "twitter:app:name:ipad": "YouTube", - "twitter:app:id:ipad": "544007664", - "twitter:app:url:iphone": "vnd.youtube://www.youtube.com/channel/UCgAZ8TVwkH_gUH_rVbrunaA", - "twitter:app:url:ipad": "vnd.youtube://www.youtube.com/channel/UCgAZ8TVwkH_gUH_rVbrunaA", - "twitter:app:name:googleplay": "YouTube", - "twitter:app:id:googleplay": "com.google.android.youtube", - "twitter:app:url:googleplay": "https://www.youtube.com/channel/UCgAZ8TVwkH_gUH_rVbrunaA" - }, - "title": "Jai Howitt - YouTube", - "link": { - "shortcut icon": "https://www.youtube.com/s/desktop/8df21d66/img/logos/favicon.ico", - "icon": "https://www.youtube.com/s/desktop/8df21d66/img/logos/favicon_144x144.png", - "preload": "https://www.youtube.com/s/_/ytmainappweb/_/js/k=ytmainappweb.kevlar_base.en_US.QVf4ASTs3oE.es5.O/d=0/br=1/rs=AGKMywFzBElaMTZqv0bMqJHrSdSpi4kx7A", - "stylesheet": "https://www.youtube.com/s/_/ytmainappweb/_/ss/k=ytmainappweb.kevlar_base.HrTonLT-ODE.L.B1.O/am=AAAECQ/d=0/br=1/rs=AGKMywEP101ZRKVcNYsT1M6YX5N_tcp9IA", - "search": "https://www.youtube.com/opensearch?locale=en_US", - "manifest": "/manifest.webmanifest", - "canonical": "https://www.youtube.com/channel/UCgAZ8TVwkH_gUH_rVbrunaA", - "alternate": "ios-app://544007664/vnd.youtube/www.youtube.com/channel/UCgAZ8TVwkH_gUH_rVbrunaA", - "image_src": "https://yt3.googleusercontent.com/ytc/AIdro_n4YnbRzb7802She3I2Tq7lPWbXsDhHItRK8SX6ImO3wtg=s900-c-k-c0x00ffffff-no-rj" - } -} diff --git a/packages/openapi-ts-router/package.json b/packages/openapi-ts-router/package.json index 696e7692..e2b74116 100644 --- a/packages/openapi-ts-router/package.json +++ b/packages/openapi-ts-router/package.json @@ -42,11 +42,11 @@ "@blgc/config": "workspace:*", "@types/express": "^5.0.6", "@types/express-serve-static-core": "^5.1.1", - "@types/node": "^25.6.0", + "@types/node": "^25.9.1", "express": "^5.2.1", - "hono": "^4.12.16", + "hono": "^4.12.21", "rollup-presets": "workspace:*", - "valibot": "1.3.1", + "valibot": "1.4.0", "validation-adapters": "workspace:*" }, "size-limit": [ diff --git a/packages/rollup-presets/package.json b/packages/rollup-presets/package.json index 1117ab11..9a4f80fe 100644 --- a/packages/rollup-presets/package.json +++ b/packages/rollup-presets/package.json @@ -41,8 +41,8 @@ }, "devDependencies": { "@blgc/config": "workspace:*", - "@types/node": "^25.6.0", - "rollup": "^4.60.2", + "@types/node": "^25.9.1", + "rollup": "^4.60.4", "type-fest": "^5.6.0" } } diff --git a/packages/tuple-result/README.md b/packages/tuple-result/README.md index 64ddc0c1..5af22e95 100644 --- a/packages/tuple-result/README.md +++ b/packages/tuple-result/README.md @@ -17,338 +17,217 @@

-`tuple-result` is a minimal, functional, and tree-shakable Result library for TypeScript that prioritizes simplicity and serialization. +`tuple-result` is a TypeScript Result that stays plain JavaScript. Success and failure use `[isOk, error, value]`, so normal destructuring, `if/else`, and JSON serialization work without adapters or a chained API. -- **🔮 Simple, declarative API**: Intuitive array destructuring with full type safety -- **🍃 Lightweight & Tree Shakable**: Function-based design with ~150B core -- **⚡ High Performance**: Minimal overhead with just a 3-element array -- **🔍 Easy Serialization**: Simple array format perfect for wire transmission -- **📦 Zero Dependencies**: Standalone library ensuring ease of use in various environments -- **🔧 Functional Helpers**: Powerful map, unwrap, and utility functions -- **🧵 Type Safe**: Full TypeScript support with literal types and type guards - -### 📚 Examples - -- [React Router v7](https://github.com/builder-group/community/tree/develop/examples/tuple-result/react-router/basic) ([CodeSandbox](https://codesandbox.io/p/devbox/zkdsr2)) - -### 🌟 Motivation - -Build a minimal, functional Result library that prioritizes simplicity and serialization. While libraries like [ts-results](https://github.com/vultix/ts-results) and [neverthrow](https://github.com/supermacro/neverthrow) offer robust features, their class-based implementations can create challenges with serialization and bundle size. `tuple-result` provides a functional alternative using simple arrays - combining minimal overhead (~150B core), easy serialization for APIs and frameworks like React Router, and helper functions while adhering to the KISS principle. - -### ⚖️ Alternatives - -- [ts-results](https://github.com/vultix/ts-results) -- [neverthrow](https://github.com/supermacro/neverthrow) - -## 📖 Usage - -`tuple-result` provides a simple approach to error handling. Here's how to use it: - -### 1. Creating Results +- Return typed errors as values for expected failure paths +- Let TypeScript narrow `error` and `value` after the `isOk` check +- Use helpers like `mapOk`, `mapErr`, and `match` only when they reduce noise +- Send results through loaders, workers, APIs, or storage as plain arrays ```ts -import { Err, Ok } from 'tuple-result'; - -const success = Ok(42); -const failure = Err('Something went wrong'); -``` - -### 2. Working with Results +import { Err, fromArray, Ok, tAsync } from 'tuple-result'; -```ts -// Method-based approach -if (success.isOk()) { - console.log(success.value); // 42 -} - -// Array destructuring approach -const [ok, error, value] = success; -if (ok) { - console.log(value); // 42 +async function loadUser(id: string) { + const [isFetchOk, fetchErr, response] = await tAsync(fetch(`/api/users/${id}`)); + if (!isFetchOk) return Err(fetchErr); + if (!response.ok) return Err(new Error(`HTTP ${response.status}`)); + return tAsync(response.json() as Promise<{ name: string }>); } -// Direct unwrapping (throws on error) -const value = success.unwrap(); // 42 -``` - -### 3. Wrapping Functions - -```ts -import { t, tAsync } from 'tuple-result'; - -// Wrap synchronous functions -const result = t(() => JSON.parse('invalid')); // Err(SyntaxError) - -// Wrap promises -const asyncResult = await tAsync(fetch('/api/data')); // Ok(Response) or Err(Error) -``` - -### 4. Safe Value Extraction - -```ts -import { unwrapErr, unwrapOr } from 'tuple-result'; +const [isOk, userErr, user] = await loadUser('42'); -// Provide defaults -const value = unwrapOr(failure, 0); // 0 -``` - -### 5. Transforming Results - -```ts -import { mapErr, mapOk } from 'tuple-result'; - -// Transform success values -const doubled = mapOk(success, (x) => x * 2); // Ok(84) - -// Transform errors -const wrapped = mapErr(failure, (e) => `Error: ${e}`); // Err('Error: Something went wrong') -``` - -### 6. Pattern Matching - -```ts -import { match } from 'tuple-result'; - -// Clean conditional logic -const message = match(result, { - ok: (value) => `Success: ${value}`, - err: (error) => `Error: ${error}` -}); - -// Complex transformations -const processed = match(result, { - ok: (user) => ({ ...user, displayName: user.name.toUpperCase() }), - err: (error) => ({ id: 0, name: 'Unknown', error: error.message }) -}); -``` - -### 7. Serialization - -```ts -// Convert to serializable format -const serialized = success.toArray(); // [true, undefined, 42] +if (isOk) { + console.log(user.name); // TypeScript knows user is defined here +} else { + console.error(userErr); // TypeScript knows userErr is defined here +} -// Reconstruct from serialized format -const reconstructed = fromArray(serialized); // Back to TResult with methods +// Results are arrays, so JSON round-trips without a custom serializer +const serialized = JSON.stringify(Ok(42)); // '[true,null,42]' +const result = fromArray(JSON.parse(serialized)); +result.unwrap(); // 42 ``` -## 📚 API Reference - -### Core Functions - -#### `Ok(value: T): OkResult` - -Creates a successful result containing the given value. +## Install -```ts -const result = Ok(42); -console.log(result.unwrap()); // 42 -console.log(result.isOk()); // true +```bash +npm install tuple-result ``` -#### `Err(error: E): ErrResult` +## Usage -Creates an error result containing the given error. +Create a result with `Ok` or `Err`, then read it with array destructuring. TypeScript narrows the type automatically after the `isOk` check: ```ts -const result = Err('Something went wrong'); -console.log(result.isErr()); // true -console.log(result.error); // 'Something went wrong' -``` - -### Type Guards - -#### `isOk(result: TResult): result is OkResult` +import { Err, Ok } from 'tuple-result'; -Type guard to check if a result is successful. +const countResult = Ok(42); +const configResult = Err('Missing config'); -```ts -if (isOk(result)) { - // TypeScript knows result is OkResult here - console.log(result.unwrap()); +// Array destructuring: readable without knowing the library +const [isCountOk, countErr, count] = countResult; +if (isCountOk) { + console.log(count); // 42 +} else { + console.error(countErr); } -``` -#### `isErr(result: TResult): result is ErrResult` - -Type guard to check if a result is an error. - -```ts -if (isErr(result)) { - // TypeScript knows result is ErrResult here - console.log(result.error); +// Method-based access is also available +if (countResult.isOk()) { + console.log(countResult.value); // 42 } ``` -### Unwrapping Functions - -#### `unwrap(result: TResult): T` - -Extracts the value from a result, throwing if it's an error. +Wrap a call that may throw or a promise that may reject with `t` or `tAsync`: ```ts -try { - const value = unwrap(success); // 42 -} catch (error) { - // Handle error -} -``` - -#### `unwrapOk(result: TResult): T` +import { t, tAsync } from 'tuple-result'; -Extracts the value from an Ok result, throwing if it's an error. +// Wrap a synchronous call that may throw +const result = t(() => JSON.parse('invalid')); // Err(SyntaxError) -```ts -const value = unwrapOk(success); // 42 +// Wrap a promise that may reject +const userResult = await tAsync( + fetch('/api/user').then(async (response) => { + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return (await response.json()) as { name: string }; + }) +); ``` -#### `unwrapErr(result: TResult): E` - -Extracts the error from an Err result, throwing if it's successful. +Transform the success or error value without unpacking the result first: ```ts -const error = unwrapErr(failure); // 'Something went wrong' -``` +import { Err, mapErr, mapOk, match, Ok, unwrapOr } from 'tuple-result'; -#### `unwrapOr(result: TResult, defaultValue: T): T` +const doubled = mapOk(Ok(21), (x) => x * 2); // Ok(42) +const wrapped = mapErr(Err(404), (c) => `HTTP ${c}`); // Err('HTTP 404') -Extracts the value from a result, returning a default if it's an error. +const message = match(Err('Missing config'), { + ok: (config) => `Config: ${config}`, + err: (configErr) => `Error: ${configErr}` +}); -```ts -const value = unwrapOr(failure, 0); // 0 +const count = unwrapOr(Err('Missing count'), 0); // 0 ``` -#### `unwrapOrNull(result: TResult): T | null` - -Extracts the value from a result, returning null if it's an error. +Results serialize to plain arrays and reconstruct from them without a custom serializer: ```ts -const value = unwrapOrNull(failure); // null -``` +import { fromArray, isOk, Ok, type TResultArray } from 'tuple-result'; -### Transformation Functions +// Serialize to a plain array +const countArray = Ok(42).toArray(); // [true, undefined, 42] -#### `mapOk(result: TResult, mapFn: (value: T) => U): TResult` +// Reconstruct from a plain array +const countResult = fromArray(countArray); -Maps the value inside an Ok result using the provided function. +// Standalone helpers work on plain arrays directly, no conversion needed +isOk([true, undefined, 42] as const); // true -```ts -const doubled = mapOk(Ok(21), (x) => x * 2); // Ok(42) +// JSON turns inactive undefined slots into null; tuple-result accepts both +const jsonCountArray = JSON.parse(JSON.stringify(countArray)) as TResultArray; +fromArray(jsonCountArray).unwrap(); // 42 ``` -#### `mapErr(result: TResult, mapFn: (error: E) => F): TResult` +## API -Maps the error inside an Err result using the provided function. +### Core -```ts -const wrapped = mapErr(Err(404), (code) => `HTTP ${code}`); // Err('HTTP 404') -``` +The constructors and types that form every result value. -### Pattern Matching Functions +| Export | Description | +| --------------------------- | ------------------------------------------------------------------------------ | +| `Ok(value)` / `ok(value)` | Creates a successful result. | +| `Err(error)` / `err(error)` | Creates an error result. | +| `TResult` | Union of `OkResult` and `ErrResult`. Supports array destructuring. | +| `TOkResultArray` | Plain tuple form for successful results: `[true, undefined \| null, T]`. | +| `TErrResultArray` | Plain tuple form for error results: `[false, E, undefined \| null]`. | +| `TResultArray` | Plain tuple form for either branch. Accepts JSON roundtrip arrays. | +| `TResultLike` | Any accepted result shape: `TResult` or `TResultArray`. | -#### `match(result: TResult | TResultArray, handlers: { ok: (value: T) => R; err: (error: E) => R }): R` +### Type guards -Pattern matches on a result, calling the appropriate handler. Similar to Rust's `match!` macro. +Narrow a result to its `Ok` or `Err` branch. Both functions accept `TResult` and `TResultArray` so no conversion is needed before calling them. -```ts -const message = match(result, { - ok: (value) => `Success: ${value}`, - err: (error) => `Error: ${error}` -}); -``` +| Export | Description | +| --------------- | ---------------------------------------------------------- | +| `isOk(result)` | Returns `true` and narrows to the successful result shape. | +| `isErr(result)` | Returns `true` and narrows to the error result shape. | -### Function Wrappers +### Unwrapping -#### `t(fn: (...args: Args) => T, ...args: Args): TResult` +Extract the inner value when you are confident about the branch, or provide a fallback for the error case. -Wraps a synchronous function call in a Result. +| Export | Description | +| --------------------------- | ----------------------------------------------------------- | +| `unwrap(result)` | Returns the value or throws the stored error value exactly. | +| `unwrapOk(result)` | Returns the value or throws. | +| `unwrapErr(result)` | Returns the error or throws. | +| `unwrapOr(result, default)` | Returns the value or `default` on error. | +| `unwrapOrNull(result)` | Returns the value or `null` on error. | +| `unwrapOrUndefined(result)` | Returns the value or `undefined` on error. | -```ts -const result = t(() => JSON.parse('invalid')); // Err(SyntaxError) -const safeDivide = (a: number, b: number) => t(() => a / b, a, b); -``` +### Transformation -#### `tAsync(promise: Promise): Promise>` +Transform the value inside a result without unwrapping it. Each helper returns a new result, leaving the original unchanged. -Wraps a Promise in a Result. +| Export | Description | +| ------------------------- | ------------------------------------------------------------------------- | +| `mapOk(result, fn)` | Transforms the success value with `fn`. Passes errors through unchanged. | +| `mapErr(result, fn)` | Transforms the error value with `fn`. Passes successes through unchanged. | +| `match(result, handlers)` | Calls `handlers.ok` or `handlers.err` and returns the result. | -```ts -const result = await tAsync(fetch('/api/data')); // Ok(Response) or Err(Error) -``` +### Wrappers -### Serialization Functions +Convert throwing functions and rejecting promises into results. -#### `toArray()` (Instance Method) +| Export | Description | +| ----------------- | -------------------------------------------------------- | +| `t(fn, ...args)` | Wraps a synchronous call. Returns `Err` if it throws. | +| `tAsync(promise)` | Wraps a promise-like value. Returns `Err` if it rejects. | -Converts a result to a plain array for serialization. +### Serialization -```ts -const result = Ok(42); -const serialized = result.toArray(); // [true, undefined, 42] -``` - -#### `fromArray(array: TResultArray): TResult` - -Creates a result instance from a plain array. - -```ts -const result = fromArray([true, undefined, 42]); // Ok(42) with methods -``` +Convert between result instances and plain arrays for storage, JSON, or cross-boundary transport. -## ❓ FAQ +| Export | Description | +| ------------------ | ---------------------------------------------------------------------- | +| `toArray(result)` | Converts to a plain tuple with `undefined` in the inactive slot. | +| `fromArray(array)` | Reconstructs an `OkResult` or `ErrResult` instance from a plain tuple. | -### Why both `TResult` and `TResultArray`? +## FAQ -**`TResultArray` is a subset of `TResult`** - same array structure, but `TResult` adds convenience methods. +### How does it compare to neverthrow, ts-results, and Effect? -- **`TResult`**: Full-featured classes with `.isOk()`, `.unwrap()`, `.value` methods -- **`TResultArray`**: Plain arrays perfect for serialization (React Router, APIs, JSON) +`tuple-result` optimizes for plain JavaScript control flow and serialization. Use it when you want typed error values without committing the whole code path to chained Result methods or a larger effect system. -**Key benefit:** All helper functions work with both types seamlessly. +- [neverthrow](https://github.com/supermacro/neverthrow): Result type with a class-based API and rich transformation methods +- [ts-results](https://github.com/vultix/ts-results): Result and Option types with a class-based API +- [Effect](https://github.com/Effect-TS/effect): full FP ecosystem with typed errors, concurrency, and dependency injection -```typescript -const classResult = Ok('hello'); -const arrayResult = [true, undefined, 'hello'] as const; +### What is the difference between `TResult` and `TResultArray`? -isOk(classResult); // ✅ works -isOk(arrayResult); // ✅ also works -``` - -### When do I use each? - -**Use `TResult` by default.** You get `TResultArray` from: +`TResult` is an `OkResult` or `ErrResult` instance with convenience methods (`.isOk()`, `.unwrap()`, `.value`). `TResultArray` is the plain tuple shape with the same `[isOk, error, value]` structure but no methods. It is useful for serialization, JSON, and frameworks like React Router. -- React Router loaders: `useLoaderData()` -- JSON parsing: `JSON.parse(response)` -- API responses +Most standalone helpers accept both types, so no conversion is needed in normal control flow. Use `fromArray()` to add methods back after deserializing. -**For serialization:** `result.toArray()` → send over network → use helpers directly on received arrays or deserialize using `fromArray(result)`. +### Why do the plain tuple types allow `null`? -No conversion needed - helpers work with both! +In memory, `Ok(value).toArray()` returns `[true, undefined, value]` and `Err(error).toArray()` returns `[false, error, undefined]`. -### Why do helper functions have overloads? +JSON cannot preserve `undefined`, so `JSON.stringify()` turns the inactive slot into `null`. `TResultArray` accepts both `undefined` and `null` in inactive slots so roundtrips keep working without an extra conversion step. -**TypeScript compatibility.** Since `TResult` (classes) and `TResultArray` (plain arrays) have the same structure but different types, overloads ensure all helper functions work seamlessly with both: +### Why array destructuring instead of chaining or `.match()`? -```typescript -// These all work the same way -unwrapOr(Ok(42), 0); // ✅ TResult -unwrapOr([true, undefined, 42], 0); // ✅ TResultArray -unwrapOr(someResult, 0); // ✅ Either type -``` - -Without overloads, complex types can cause TypeScript errors: +Array destructuring is native JavaScript. It requires no library knowledge and maps naturally to `if/else` control flow. Name the tuple from the domain value: `const [isUserOk, userErr, user] = result`. The pattern is inspired by the [try operator proposal](https://github.com/arthurfiorette/proposal-try-operator). -```typescript -// ❌ Sometimes fails with complex types -const result: TResultArray = [false, new Error('Not found'), undefined]; -unwrapOr(result, defaultUser); // Type 'TResultArray' is not assignable to parameter type 'TResult' -``` +### Why do `unwrap` and `unwrapOk` both exist? -Overloads ensure compatibility in all scenarios. +Use `unwrap` when you want an Err result to throw its stored error value. Use `unwrapOk` when you want a branch assertion that throws `Expected an Ok result` if the result is Err. -## 💡 Resources / References +### Does `unwrap` always throw an `Error` instance? -- [try operator proposal](https://github.com/arthurfiorette/proposal-try-operator) - ECMAScript proposal that inspired our array destructuring -- [ts-results](https://github.com/vultix/ts-results) -- [neverthrow](https://github.com/supermacro/neverthrow) +No. JavaScript can throw any value, and `unwrap` preserves the Err payload instead of converting it. `unwrap(Err('Missing config'))` throws the string `'Missing config'`. Use `Err(new Error('Missing config'))` when you want conventional exception behavior with a stack trace. Prefer normal tuple destructuring when you want typed domain errors. diff --git a/packages/tuple-result/package.json b/packages/tuple-result/package.json index e6756f49..6528b18f 100644 --- a/packages/tuple-result/package.json +++ b/packages/tuple-result/package.json @@ -1,9 +1,23 @@ { "name": "tuple-result", - "version": "0.0.12", + "version": "0.1.0-beta.1", "private": false, - "description": "A minimal, functional, and tree-shakable Result library for TypeScript that prioritizes simplicity and serialization", - "keywords": [], + "description": "TypeScript Result as [isOk, error, value] tuples with typed errors and JSON serialization.", + "keywords": [ + "result", + "result-type", + "error-handling", + "typed-errors", + "error-as-value", + "typescript", + "tuple", + "json", + "serializable", + "destructuring", + "either", + "functional", + "fp" + ], "homepage": "https://builder.group/?utm_source=package-json", "bugs": { "url": "https://github.com/builder-group/community/issues" diff --git a/packages/tuple-result/src/index.test.ts b/packages/tuple-result/src/index.test.ts index a8d2b554..e84f15a6 100644 --- a/packages/tuple-result/src/index.test.ts +++ b/packages/tuple-result/src/index.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, expectTypeOf, it } from 'vitest'; import { Err, + err, fromArray, isErr, isOk, @@ -8,288 +9,282 @@ import { mapOk, match, Ok, + ok, t, tAsync, toArray, + unwrap, unwrapErr, unwrapOk, unwrapOr, unwrapOrNull, unwrapOrUndefined, - type TResult + type TResult, + type TResultArray } from './index'; -describe('tuple-result', () => { - describe('OkResult class', () => { - it('should create Ok results with correct behavior', () => { - const result = Ok(42); - expect(result.value).toBe(42); - expect(result.unwrap()).toBe(42); - expect(result.isOk()).toBe(true); - expect(result.isErr()).toBe(false); +describe('tuple-result package', () => { + describe('constructors', () => { + it('should create destructurable Ok results', () => { + // Prepare + const userResult = Ok({ name: 'Ada' }); + + // Act + const [isUserOk, userErr, user] = userResult; + + // Assert + expect(isUserOk).toBe(true); + expect(userErr).toBeUndefined(); + expect(user).toEqual({ name: 'Ada' }); + expect(userResult.value).toEqual({ name: 'Ada' }); + expect(userResult.unwrap()).toEqual({ name: 'Ada' }); + expect(userResult.isOk()).toBe(true); + expect(userResult.isErr()).toBe(false); }); - it('should support array destructuring', () => { - const result = Ok('hello'); - const [ok, error, value] = result; - expect(ok).toBe(true); - expect(error).toBe(undefined); - expect(value).toBe('hello'); - }); + it('should create destructurable Err results', () => { + // Prepare + const configResult = Err<{ url: string }, string>('Missing config'); - it('should handle edge cases', () => { - const result = Ok(null); - expect(result.value).toBe(null); - expect(result.unwrap()).toBe(null); - }); - }); + // Act + const [isConfigOk, configErr, config] = configResult; - describe('ErrResult class', () => { - it('should create Err results with correct behavior', () => { - const result = Err('Some error'); - expect(result.error).toBe('Some error'); - expect(() => result.unwrap()).toThrowError(); - expect(result.isOk()).toBe(false); - expect(result.isErr()).toBe(true); + // Assert + expect(isConfigOk).toBe(false); + expect(configErr).toBe('Missing config'); + expect(config).toBeUndefined(); + expect(configResult.error).toBe('Missing config'); + expect(configResult.isOk()).toBe(false); + expect(configResult.isErr()).toBe(true); }); - it('should support array destructuring', () => { - const result = Err('oops'); - const [ok, error, value] = result; - expect(ok).toBe(false); - expect(error).toBe('oops'); - expect(value).toBe(undefined); + it('should expose lowercase constructor aliases', () => { + // Assert + expect(ok('value').value).toBe('value'); + expect(err('error').error).toBe('error'); }); }); - describe('isOk function', () => { - it('should narrow types correctly', () => { - const okResult: TResult = Ok(99); - if (isOk(okResult)) { - expect(okResult.value).toBe(99); - } - }); - }); + describe('type guards', () => { + it('should narrow method-based result instances', () => { + // Prepare + const userResult: TResult<{ name: string }, Error> = Ok({ name: 'Ada' }); - describe('isErr function', () => { - it('should narrow types correctly', () => { - const errResult: TResult = Err('Failure'); - if (isErr(errResult)) { - expect(errResult.error).toBe('Failure'); + // Assert + if (isOk(userResult)) { + expectTypeOf(userResult.value).toEqualTypeOf<{ name: string }>(); + expect(userResult.value.name).toBe('Ada'); } }); - }); - describe('unwrapOk function', () => { - it('should extract value from Ok result', () => { - const result = Ok('Success'); - expect(unwrapOk(result)).toBe('Success'); - }); + it('should narrow plain result arrays', () => { + // Prepare + const configResult = createConfigArrayResult(); - it('should throw error for Err result', () => { - const result = Err('Error occurred'); - expect(() => unwrapOk(result)).toThrow(); + // Assert + if (isErr(configResult)) { + expectTypeOf(configResult[1]).toEqualTypeOf(); + expect(configResult[1]).toBe('Missing config'); + } }); }); - describe('unwrapErr function', () => { - it('should extract error from Err result', () => { - const result = Err('Error occurred'); - expect(unwrapErr(result)).toBe('Error occurred'); - }); + describe('unwrapping', () => { + it('should unwrap success values', () => { + // Prepare + const userResult = Ok({ name: 'Ada' }); - it('should throw error for Ok result', () => { - const result = Ok('No error'); - expect(() => unwrapErr(result)).toThrow(); + // Assert + expect(unwrap(userResult)).toEqual({ name: 'Ada' }); + expect(unwrapOk(userResult)).toEqual({ name: 'Ada' }); }); - }); - describe('unwrapOr function', () => { - it('should return value for Ok result', () => { - const result = Ok(42); - expect(unwrapOr(result, 0)).toBe(42); - }); + it('should unwrap error values', () => { + // Prepare + const configResult = Err<{ url: string }, string>('Missing config'); - it('should return default for Err result', () => { - const result = Err('Error occurred'); - expect(unwrapOr(result, 0)).toBe(0); + // Assert + expect(unwrapErr(configResult)).toBe('Missing config'); }); - }); - describe('unwrapOrNull function', () => { - it('should return value for Ok result', () => { - const result = Ok(42); - expect(unwrapOrNull(result)).toBe(42); - }); - - it('should return null for Err result', () => { - const result = Err('Error occurred'); - expect(unwrapOrNull(result)).toBe(null); - }); - }); + it('should throw the stored error when unwrapping Err results', () => { + // Prepare + const configErr = { code: 'missing_config' }; + const configResult = Err(configErr); + let thrownByFunction: unknown; + let thrownByMethod: unknown; + + // Act + try { + unwrap(configResult); + } catch (error) { + thrownByFunction = error; + } + try { + configResult.unwrap(); + } catch (error) { + thrownByMethod = error; + } - describe('unwrapOrUndefined function', () => { - it('should return value for Ok result', () => { - const result = Ok(42); - expect(unwrapOrUndefined(result)).toBe(42); + // Assert + expect(thrownByFunction).toBe(configErr); + expect(thrownByMethod).toBe(configErr); }); - it('should return undefined for Err result', () => { - const result = Err('Error occurred'); - expect(unwrapOrUndefined(result)).toBe(undefined); + it('should throw branch assertion errors when unwrapping the wrong branch', () => { + // Assert + expect(() => unwrapOk(Err('Missing value'))).toThrow('Expected an Ok result'); + expect(() => unwrapErr(Ok('value'))).toThrow('Expected an Err result'); }); - }); - describe('mapOk function', () => { - it('should transform Ok values', () => { - const result = Ok(21); - const doubled = mapOk(result, (x: number) => x * 2); - expect(doubled.unwrap()).toBe(42); - }); + it('should use fallback values for Err results', () => { + // Prepare + const userResult = Err<{ name: string }, string>('Missing user'); - it('should leave Err results unchanged', () => { - const result = Err('Error occurred'); - const mapped = mapOk(result, (x: number) => x * 2); - expect(mapped.isErr()).toBe(true); - expect(mapped.error).toBe('Error occurred'); + // Assert + expect(unwrapOr(userResult, { name: 'Fallback' })).toEqual({ name: 'Fallback' }); + expect(unwrapOrNull(userResult)).toBeNull(); + expect(unwrapOrUndefined(userResult)).toBeUndefined(); }); }); - describe('mapErr function', () => { - it('should transform Err values', () => { - const result = Err('Error occurred'); - const wrapped = mapErr(result, (err) => `Wrapped: ${err}`); - expect(wrapped.error).toBe('Wrapped: Error occurred'); - }); + describe('transformation', () => { + it('should transform success values and pass errors through', () => { + // Prepare + const countResult = Ok(21); + const missingCountResult = Err('Missing count'); - it('should leave Ok results unchanged', () => { - const result = Ok(42); - const mapped = mapErr(result, (err) => `Wrapped: ${err}`); - expect(mapped.isOk()).toBe(true); - expect(mapped.unwrap()).toBe(42); - }); - }); + // Act + const doubledCountResult = mapOk(countResult, (count) => count * 2); + const unchangedMissingCountResult = mapOk(missingCountResult, (count) => count * 2); - describe('match function', () => { - it('should call ok handler for Ok results', () => { - const result = Ok(42); - const message = match(result, { - ok: (value) => `Success: ${value}`, - err: (error) => `Error: ${error}` - }); - expect(message).toBe('Success: 42'); + // Assert + expect(unwrap(doubledCountResult)).toBe(42); + expect(unwrapErr(unchangedMissingCountResult)).toBe('Missing count'); }); - it('should call err handler for Err results', () => { - const result = Err('Something went wrong'); - const message = match(result, { - ok: (value) => `Success: ${value}`, - err: (error) => `Error: ${error}` - }); - expect(message).toBe('Error: Something went wrong'); - }); + it('should transform error values and pass successes through', () => { + // Prepare + const countResult = Ok(42); + const missingCountResult = Err(404); - it('should work with complex transformations', () => { - const result = Ok({ name: 'John', age: 30 }); - const processed = match(result, { - ok: (user) => ({ ...user, displayName: user.name.toUpperCase() }), - err: (error) => ({ name: 'Unknown', age: 0, displayName: 'UNKNOWN' }) - }); - expect(processed).toEqual({ - name: 'John', - age: 30, - displayName: 'JOHN' - }); + // Act + const unchangedCountResult = mapErr(countResult, (code) => `HTTP ${code}`); + const wrappedMissingCountResult = mapErr(missingCountResult, (code) => `HTTP ${code}`); + + // Assert + expect(unwrap(unchangedCountResult)).toBe(42); + expect(unwrapErr(wrappedMissingCountResult)).toBe('HTTP 404'); }); - it('should work with plain arrays', () => { - const okArray: [true, undefined, string] = [true, undefined, 'hello']; - const errArray: [false, string, undefined] = [false, 'oops', undefined]; + it('should match on both result branches', () => { + // Prepare + const userResult = Ok<{ name: string }, string>({ name: 'Ada' }); + const missingUserResult = Err<{ name: string }, string>('Missing user'); - const okMessage = match(okArray, { - ok: (value) => `Got: ${value}`, - err: (error) => `Failed: ${error}` + // Act + const userLabel = match(userResult, { + ok: (user) => user.name, + err: (userErr) => userErr }); - expect(okMessage).toBe('Got: hello'); - - const errMessage = match(errArray, { - ok: (value) => `Got: ${value}`, - err: (error) => `Failed: ${error}` + const missingUserLabel = match(missingUserResult, { + ok: (user) => user.name, + err: (userErr) => userErr }); - expect(errMessage).toBe('Failed: oops'); - }); - }); - - describe('toArray function', () => { - it('should convert Ok and Err results to arrays', () => { - const okResult = Ok('success'); - const errResult = Err('error'); - expect(toArray(okResult)).toEqual([true, undefined, 'success']); - expect(toArray(errResult)).toEqual([false, 'error', undefined]); + // Assert + expect(userLabel).toBe('Ada'); + expect(missingUserLabel).toBe('Missing user'); }); }); - describe('fromArray function', () => { - it('should create results from arrays', () => { - const okArray: [true, undefined, string] = [true, undefined, 'success']; - const errArray: [false, string, undefined] = [false, 'error', undefined]; - - const okResult = fromArray(okArray); - const errResult = fromArray(errArray); + describe('wrappers', () => { + it('should wrap synchronous functions', () => { + // Prepare + const parseConfig = (input: string) => JSON.parse(input) as { port: number }; + const parseErr = new SyntaxError('Invalid JSON'); - expect(okResult.unwrap()).toBe('success'); - expect(() => errResult.unwrap()).toThrow(); - }); - }); + // Act + const configResult = t(parseConfig, '{"port":3000}'); + const invalidConfigResult = t(() => { + throw parseErr; + }); - describe('t function', () => { - it('should wrap successful and throwing functions', () => { - const successFn = (x: number) => x * 2; - const result = t(successFn, 21); - expect(result.unwrap()).toBe(42); - - const throwingFn = () => { - throw new Error('oops'); - }; - const errorResult = t(throwingFn); - expect(errorResult.isErr()).toBe(true); + // Assert + expect(unwrap(configResult)).toEqual({ port: 3000 }); + expect(unwrapErr(invalidConfigResult)).toBe(parseErr); }); - it('should work with URL constructor when wrapped in function', () => { - const createUrl = (url: string) => new URL(url); + it('should wrap promises', async () => { + // Prepare + const requestErr = new Error('Request failed'); - // Valid URL - const validResult = t(createUrl, 'https://example.com'); - expect(validResult.isOk()).toBe(true); - expect(validResult.unwrap().href).toBe('https://example.com/'); + // Act + const userResult = await tAsync(Promise.resolve({ name: 'Ada' })); + const missingUserResult = await tAsync(Promise.reject(requestErr)); - // Invalid URL - const invalidResult = t(createUrl, 'not-a-valid-url'); - expect(invalidResult.isErr()).toBe(true); - expect(invalidResult.error).toBeInstanceOf(TypeError); + // Assert + expect(unwrap(userResult)).toEqual({ name: 'Ada' }); + expect(unwrapErr(missingUserResult)).toBe(requestErr); }); }); - describe('tAsync function', () => { - it('should wrap resolved and rejected promises', async () => { - const promise = Promise.resolve(42); - const result = await tAsync(promise); - expect(result.unwrap()).toBe(42); + describe('serialization', () => { + it('should convert method-based results to plain arrays', () => { + // Prepare + const userResult = Ok<{ name: string }, string>({ name: 'Ada' }); + const missingUserResult = Err<{ name: string }, string>('Missing user'); + + // Act + const userArray = toArray(userResult); + const missingUserArray = toArray(missingUserResult); - const rejectingPromise = Promise.reject('oops'); - const errorResult = await tAsync(rejectingPromise); - expect(errorResult.isErr()).toBe(true); + // Assert + expect(userArray).toEqual([true, undefined, { name: 'Ada' }]); + expect(missingUserArray).toEqual([false, 'Missing user', undefined]); }); - }); - describe('JSON serialization', () => { - it('should be directly stringifiable', () => { - const okResult = Ok(42); - const errResult = Err('error'); + it('should reconstruct method-based results from plain arrays', () => { + // Prepare + const userArray = [true, undefined, { name: 'Ada' }] as const satisfies TResultArray< + { name: string }, + string + >; + const missingUserArray = [false, 'Missing user', undefined] as const satisfies TResultArray< + { name: string }, + string + >; + + // Act + const userResult = fromArray(userArray); + const missingUserResult = fromArray(missingUserArray); + + // Assert + expect(unwrap(userResult)).toEqual({ name: 'Ada' }); + expect(unwrapErr(missingUserResult)).toBe('Missing user'); + }); - expect(JSON.stringify(okResult)).toBe('[true,null,42]'); - expect(JSON.stringify(errResult)).toBe('[false,"error",null]'); + it('should support JSON roundtrips', () => { + // Prepare + const countResult = Ok(42); + const missingCountResult = Err('Missing count'); + + // Act + const countArray = JSON.parse(JSON.stringify(countResult)) as TResultArray; + const missingCountArray = JSON.parse(JSON.stringify(missingCountResult)) as TResultArray< + number, + string + >; + + // Assert + expect(JSON.stringify(Ok(42))).toBe('[true,null,42]'); + expect(JSON.stringify(Err('error'))).toBe('[false,"error",null]'); + expect(unwrap(countArray)).toBe(42); + expect(unwrapErr(missingCountArray)).toBe('Missing count'); }); }); }); + +function createConfigArrayResult(): TResultArray<{ url: string }, string> { + return [false, 'Missing config', undefined]; +} diff --git a/packages/tuple-result/src/index.ts b/packages/tuple-result/src/index.ts index fafd3100..3e78db44 100644 --- a/packages/tuple-result/src/index.ts +++ b/packages/tuple-result/src/index.ts @@ -1,417 +1,263 @@ /** - * Represents a successful result as an array with literal types. - * Structure: [true, undefined, T] where T is the success value. - * Extends Array to provide both array destructuring and common Result methods. + * A successful result. Structure: `[true, undefined, value]`. + * Supports array destructuring as `[isOk, error, value]` and method-based access. */ -export class OkResult extends Array { +export class OkResult extends Array { declare 0: true; declare 1: undefined; - declare 2: T; + declare 2: GValue; declare length: 3; - constructor(value: T) { + constructor(value: GValue) { super(3); this[0] = true; this[1] = undefined; this[2] = value; } - /** - * Gets the success value from this result. - * @returns The success value of type T - */ - public get value(): T { + /** The success value. */ + public get value(): GValue { return this[2]; } - /** - * Gets the error value (always undefined for Ok results). - * @returns Always undefined for Ok results - */ - public get error(): E | undefined { + /** Always `undefined` for Ok results. */ + public get error(): GError | undefined { return this[1]; } - /** - * Type guard to check if this result is successful. - * @returns Always true for OkResult instances - */ - public isOk(): this is OkResult { + public isOk(): this is OkResult { return true; } - /** - * Type guard to check if this result is an error. - * @returns Always false for OkResult instances - */ - public isErr(): this is ErrResult { + public isErr(): this is ErrResult { return false; } - /** - * Extracts the success value from this result. - * @returns The success value of type T - */ - public unwrap(): T { + /** Returns the success value. */ + public unwrap(): GValue { return this[2]; } - /** - * Converts the result to a plain array for serialization. - * @returns A plain array [true, undefined, T] - */ - public toArray(): [true, undefined, T] { + /** Converts to a plain serializable array `[true, undefined, value]`. */ + public toArray(): TOkResultArray { return [true, undefined, this[2]]; } } /** - * Represents an error result as an array with literal types. - * Structure: [false, E, undefined] where E is the error value. - * Extends Array to provide both array destructuring and common Result methods. + * An error result. Structure: `[false, error, undefined]`. + * Supports array destructuring as `[isOk, error, value]` and method-based access. */ -export class ErrResult extends Array { +export class ErrResult extends Array { declare 0: false; - declare 1: E; + declare 1: GError; declare 2: undefined; declare length: 3; - constructor(error: E) { + constructor(error: GError) { super(3); this[0] = false; this[1] = error; this[2] = undefined; } - /** - * Gets the success value (always undefined for Err results). - * @returns Always undefined for Err results - */ - public get value(): T | undefined { + /** Always `undefined` for Err results. */ + public get value(): GValue | undefined { return this[2]; } - /** - * Gets the error value from this result. - * @returns The error value of type E - */ - public get error(): E { + /** The error value. */ + public get error(): GError { return this[1]; } - /** - * Type guard to check if this result is successful. - * @returns Always false for ErrResult instances - */ - public isOk(): this is OkResult { + public isOk(): this is OkResult { return false; } - /** - * Type guard to check if this result is an error. - * @returns Always true for ErrResult instances - */ - public isErr(): this is ErrResult { + public isErr(): this is ErrResult { return true; } - /** - * Attempts to extract the success value, but always throws since this is an error result. - * @throws The error contained in this result - */ + /** Throws the stored error value exactly. */ public unwrap(): never { - const error = this[1]; - if (error instanceof Error) { - throw error; - } else if (typeof error === 'string') { - throw new Error(error); - } else { - throw new Error('Unknown error'); - } + // Note: Preserve the typed Err payload instead of coercing it into an Error + throw this[1]; } - /** - * Converts the result to a plain array for serialization. - * @returns A plain array [false, E, undefined] - */ - public toArray(): [false, E, undefined] { + /** Converts to a plain serializable array `[false, error, undefined]`. */ + public toArray(): TErrResultArray { return [false, this[1], undefined]; } } -/** - * Union type representing either a successful or error result. - * Can be destructured as [boolean, E | undefined, T | undefined]. - */ -export type TResult = OkResult | ErrResult; +/** A method-based result instance. Supports array destructuring as `[isOk, error, value]`. */ +export type TResult = OkResult | ErrResult; -/** - * Represents a result as a plain array. - * Can be destructured as [boolean, E | undefined, T | undefined]. - */ -export type TResultArray = [true, undefined, T] | [false, E, undefined]; +/** Plain array form for successful results. The inactive error slot may be `null` after JSON parsing. */ +export type TOkResultArray = readonly [true, undefined | null, GValue]; -/** - * Creates a successful result containing the given value. - * @param value - The success value to wrap - * @returns An OkResult instance - */ -export function Ok(value: T): OkResult { +/** Plain array form for error results. The inactive value slot may be `null` after JSON parsing. */ +export type TErrResultArray = readonly [false, GError, undefined | null]; + +/** Plain result array: `[true, undefined | null, value]` or `[false, error, undefined | null]`. */ +export type TResultArray = TOkResultArray | TErrResultArray; + +/** Any result shape accepted by helpers: method-based result instance or plain result array. */ +export type TResultLike = TResult | TResultArray; + +/** Creates a successful result. */ +export function Ok(value: GValue): OkResult { return new OkResult(value); } export const ok = Ok; -/** - * Creates an error result containing the given error. - * @param error - The error value to wrap - * @returns An ErrResult instance - */ -export function Err(error: E): ErrResult { +/** Creates an error result. */ +export function Err(error: GError): ErrResult { return new ErrResult(error); } export const err = Err; -/** - * Type guard to check if a result is successful. - * @param result - The result to check - * @returns True if the result is Ok, false otherwise - */ -export function isOk(result: TResult): result is OkResult; -export function isOk(result: TResultArray): result is [true, undefined, T]; -export function isOk( - result: TResult | TResultArray -): result is OkResult | [true, undefined, T] { +/** Returns `true` and narrows to the successful result shape. */ +export function isOk( + result: TResultLike +): result is OkResult | TOkResultArray { return result[0]; } -/** - * Type guard to check if a result is an error. - * @param result - The result to check - * @returns True if the result is Err, false otherwise - */ -export function isErr(result: TResult): result is ErrResult; -export function isErr(result: TResultArray): result is [false, E, undefined]; -export function isErr( - result: TResult | TResultArray -): result is ErrResult | [false, E, undefined] { +/** Returns `true` and narrows to the error result shape. */ +export function isErr( + result: TResultLike +): result is ErrResult | TErrResultArray { return !result[0]; } -/** - * Extracts the value from a result, throwing if it's an error. - * @param result - The result to unwrap - * @returns The success value - * @throws The error if the result is Err - */ -export function unwrap(result: TResult): T; -export function unwrap(result: TResultArray): T; -export function unwrap(result: TResult | TResultArray): T { +/** Extracts the success value. Throws the stored error value exactly if the result is Err. */ +export function unwrap(result: TResultLike): GValue { if (result[0]) { return result[2]; } - const error = result[1]; - if (error instanceof Error) { - throw error; - } else if (typeof error === 'string') { - throw new Error(error); - } else { - throw new Error('Unknown error'); - } + // Note: Preserve the typed Err payload instead of coercing it into an Error + throw result[1]; } -/** - * Extracts the value from an Ok result, throwing if it's an error. - * @param result - The result to unwrap - * @returns The success value - * @throws Error if the result is not Ok - */ -export function unwrapOk(result: TResult): T; -export function unwrapOk(result: TResultArray): T; -export function unwrapOk(result: TResult | TResultArray): T { +/** Extracts the success value. Throws a generic `Error` if the result is not Ok. */ +export function unwrapOk(result: TResultLike): GValue { if (result[0]) { return result[2]; } + throw new Error('Expected an Ok result'); } -/** - * Extracts the error from an Err result, throwing if it's successful. - * @param result - The result to unwrap - * @returns The error value - * @throws Error if the result is not Err - */ -export function unwrapErr(result: TResult): E; -export function unwrapErr(result: TResultArray): E; -export function unwrapErr(result: TResult | TResultArray): E { +/** Extracts the error value. Throws a generic `Error` if the result is not Err. */ +export function unwrapErr(result: TResultLike): GError { if (!result[0]) { return result[1]; } + throw new Error('Expected an Err result'); } -/** - * Extracts the value from a result, returning a default if it's an error. - * @param result - The result to unwrap - * @param defaultValue - The value to return if the result is Err - * @returns The success value or the default value - */ -export function unwrapOr(result: TResult, defaultValue: T): T; -export function unwrapOr(result: TResultArray, defaultValue: T): T; -export function unwrapOr(result: TResult | TResultArray, defaultValue: T): T { +/** Extracts the success value, returning `defaultValue` if the result is an error. */ +export function unwrapOr( + result: TResultLike, + defaultValue: GValue +): GValue { return result[0] ? result[2] : defaultValue; } -/** - * Extracts the value from a result, returning null if it's an error. - * @param result - The result to unwrap - * @returns The success value or null - */ -export function unwrapOrNull(result: TResultArray): T | null; -export function unwrapOrNull(result: TResult): T | null; -export function unwrapOrNull(result: TResult | TResultArray): T | null { +/** Extracts the success value, returning `null` if the result is an error. */ +export function unwrapOrNull(result: TResultLike): GValue | null { return result[0] ? result[2] : null; } -/** - * Extracts the value from a result, returning undefined if it's an error. - * @param result - The result to unwrap - * @returns The success value or undefined - */ -export function unwrapOrUndefined(result: TResultArray): T | undefined; -export function unwrapOrUndefined(result: TResult): T | undefined; -export function unwrapOrUndefined(result: TResult | TResultArray): T | undefined { +/** Extracts the success value, returning `undefined` if the result is an error. */ +export function unwrapOrUndefined( + result: TResultLike +): GValue | undefined { return result[0] ? result[2] : undefined; } -/** - * Maps the value inside an Ok result using the provided function. - * Returns a new result with the mapped value or the original error. - * @param result - The result to map - * @param mapFn - Function to transform the success value - * @returns A new result with the mapped value or the original error - */ -export function mapOk(result: TResult, mapFn: (value: T) => U): TResult; -export function mapOk(result: TResultArray, mapFn: (value: T) => U): TResult; -export function mapOk( - result: TResult | TResultArray, - mapFn: (value: T) => U -): TResult { +/** Transforms the success value with `mapFn`. Passes errors through unchanged. */ +export function mapOk( + result: TResultLike, + mapFn: (value: GValue) => GNextValue +): TResult { if (result[0]) { - return Ok(mapFn(result[2])); + return Ok(mapFn(result[2])); } - return Err(result[1]); + + return Err(result[1]); } -/** - * Maps the error inside an Err result using the provided function. - * Returns a new result with the mapped error or the original success value. - * @param result - The result to map - * @param mapFn - Function to transform the error value - * @returns A new result with the mapped error or the original success value - */ -export function mapErr(result: TResult, mapFn: (error: E) => F): TResult; -export function mapErr(result: TResultArray, mapFn: (error: E) => F): TResult; -export function mapErr( - result: TResult | TResultArray, - mapFn: (error: E) => F -): TResult { +/** Transforms the error value with `mapFn`. Passes successes through unchanged. */ +export function mapErr( + result: TResultLike, + mapFn: (error: GError) => GNextError +): TResult { if (!result[0]) { - return Err(mapFn(result[1])); + return Err(mapFn(result[1])); } - return Ok(result[2]); + + return Ok(result[2]); } -/** - * Converts a result to a plain array for serialization. - * @param result - The result to convert - * @returns A plain array representation - */ -export function toArray(result: TResult): TResultArray; -export function toArray(result: TResultArray): TResultArray; -export function toArray(result: TResult | TResultArray): TResultArray { +/** Converts a result to a plain array with `undefined` in the inactive slot. */ +export function toArray( + result: TResultLike +): TResultArray { return result[0] ? [true, undefined, result[2]] : [false, result[1], undefined]; } -/** - * Creates a result from a plain array. - * @param array - The array to convert - * @returns A result instance with methods - */ -export function fromArray(array: TResultArray): TResult; -export function fromArray(array: TResult): TResult; -export function fromArray(array: TResult | TResultArray): TResult { - if (array[0]) { - return new OkResult(array[2] as T); - } else { - return new ErrResult(array[1] as E); +/** Reconstructs an `OkResult` or `ErrResult` instance from a result array. */ +export function fromArray( + result: TResultArray +): TResult { + if (result[0]) { + return Ok(result[2]); } + + return Err(result[1]); } -/** - * Wraps a synchronous function call in a result. - * @param fn - The function to wrap - * @param args - Arguments to pass to the function - * @returns A result containing the function's return value or error - */ -export function t( - fn: (...args: Args) => T, - ...args: Args -): TResult { +/** Wraps a synchronous function call in a result. Returns `Err` if the function throws. */ +export function t( + fn: (...args: GArgs) => GValue, + ...args: GArgs +): TResult { try { - const result = fn(...args); - return Ok(result as T); + return Ok(fn(...args)); } catch (error) { - return Err(error); + return Err(error); } } -/** - * Wraps a Promise in a Result. - * @param promise - The promise to wrap - * @returns A Promise that resolves to a result - */ -export async function tAsync(promise: Promise): Promise> { +/** Wraps a promise in a result. Returns `Err` if the promise rejects. */ +export async function tAsync( + promise: PromiseLike +): Promise> { try { - const result = await promise; - return Ok(result); + return Ok(await promise); } catch (error) { - return Err(error); + return Err(error); } } -/** - * Pattern matches on a result, calling the appropriate handler. - * Similar to Rust's match! macro but following KISS principles. - * @param result - The result to match on - * @param handlers - Object with ok and err handlers - * @returns The result of calling the appropriate handler - */ -export function match( - result: TResult, - handlers: { - ok: (value: T) => R; - err: (error: E) => R; - } -): R; -export function match( - result: TResultArray, +/** Calls the matching handler and returns its return value. */ +export function match( + result: TResultLike, handlers: { - ok: (value: T) => R; - err: (error: E) => R; + ok: (value: GValue) => GReturn; + err: (error: GError) => GReturn; } -): R; -export function match( - result: TResult | TResultArray, - handlers: { - ok: (value: T) => R; - err: (error: E) => R; - } -): R { +): GReturn { if (result[0]) { return handlers.ok(result[2]); } + return handlers.err(result[1]); } diff --git a/packages/validatenv/README.md b/packages/validatenv/README.md index 1d5d7874..7fe003c0 100644 --- a/packages/validatenv/README.md +++ b/packages/validatenv/README.md @@ -17,67 +17,415 @@

-> Status: Experimental +`validatenv` turns environment variables into typed config before your app starts. It validates with Standard Schema validators, reports every invalid variable in one error, and works with any env-like object. -`validatenv` is a typesafe library for validating environment variables. +- Use Zod, Valibot, ArkType, or built-in validators without adapter packages +- Infer transformed output types: `z.string().transform(Number)` returns `number` +- Cover common env shapes: boolean, number, port, URL, host, email, JSON +- Clean raw env values with `preprocess` when a value needs small input cleanup +- Share one spec style across Node.js, Bun, Deno, and Vite defines -### 🌟 Motivation +```ts +import { booleanValidator, portValidator, validateEnv } from 'validatenv'; +import * as z from 'zod'; + +const env = validateEnv(process.env, { + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + DATABASE_URL: z.string().url(), + PORT: portValidator, // number, validated 1-65535, no Zod required + DEBUG: booleanValidator // parses "true", "yes", "1", "on" and their opposites +}); + +// env.NODE_ENV is 'development' | 'production' | 'test' +// env.PORT is number, inferred from the validator +// env.DEBUG is boolean, inferred from the validator +``` + +## Install + +```bash +npm install validatenv +``` + +## Usage + +`validateEnv` reads a spec object where each key maps to a validator. The object key is the environment variable name by default, and validation throws synchronously if any variable is missing or invalid: + +```ts +import { booleanValidator, portValidator, validateEnv } from 'validatenv'; +import * as z from 'zod'; + +const env = validateEnv(process.env, { + PORT: portValidator, + DEBUG: booleanValidator, + API_URL: z.string().url() +}); +``` + +Use an object spec when the env key differs from the output key, or when you need a default, raw cleanup, a description, or an example shown in error messages: + +```ts +const env = validateEnv(process.env, { + dbUrl: { + envKey: 'DATABASE_URL', + validator: z.string().url(), + description: 'Postgres connection URL', + example: 'postgres://user:password@localhost:5432/app' + } +}); +``` + +Pass any [Standard Schema](https://github.com/standard-schema/standard-schema) compatible validator directly: no adapter needed. + +```ts +import * as v from 'valibot'; + +const env = validateEnv(process.env, { + APP_NAME: v.string() +}); +``` + +## API + +### `validateEnv(env, specs)` + +Validates all variables in `specs` against `env` and returns a typed object. Throws synchronously if any variable fails, listing every failed variable and its issue in a single error message. + +```ts +const env = validateEnv(process.env, { + PORT: portValidator, + DATABASE_URL: z.string().url() +}); +``` + +Each spec entry can be a validator directly, an object spec, or a static config value copied into the output without validation. Object specs support these fields: + +| Field | Required | Description | +| -------------- | -------- | ----------------------------------------------------------------- | +| `validator` | yes | Any Standard Schema compatible validator or built-in validator | +| `envKey` | no | Override the environment variable name (defaults to the spec key) | +| `preprocess` | no | Clean the raw value before defaults and validation run | +| `defaultValue` | no | Static value or default helper function applied before validation | +| `description` | no | Human-readable description shown in error messages | +| `example` | no | Example value shown in error messages | -Create a type-safe, straightforward, and lightweight library for validating environment variables using existing validation libraries like [Valibot](https://valibot.dev/) or [Zod](https://zod.dev/). -Additionally, I didn't trust existing libraries, as reading environment variables is a particularly vulnerable task. +### `validateEnvVar(source, key, validatorOrSpec)` -### ⚖️ Alternatives +Validates a single environment variable. Use this when you need to validate one value outside of a full spec: -- [envalid](https://github.com/af/envalid) +```ts +import { portValidator, validateEnvVar } from 'validatenv'; + +const port = validateEnvVar(process.env, 'PORT', portValidator); +``` + +Pass the source explicitly so the same helper works in Node.js, Bun, Deno, Vite, and tests. + +### `createEnv(options)` + +Validates grouped server, client, and shared specs. On the client, server specs are not validated, but reading a server-only key throws: + +```ts +import { createEnv, urlValidator } from 'validatenv'; + +const env = createEnv({ + env: process.env, + isServer: typeof window === 'undefined', + server: { + DATABASE_URL: urlValidator + }, + client: { + NEXT_PUBLIC_API_URL: urlValidator + }, + shared: { + APP_VERSION: '1.2.3' + } +}); +``` + +This helper is useful when you want one typed env object with a runtime access guard. It does not stop bundlers from including imported schema code or variable names in client bundles. Use separate server and client modules when those names are sensitive. + +Access is guarded by the returned output key. For example, `{ dbUrl: { envKey: 'DATABASE_URL', validator } }` blocks `env.dbUrl` in client mode. + +## Validators + +Built-in validators implement the Standard Schema interface and require no schema library: + +| Validator | Output | Description | +| ------------------ | --------- | ------------------------------------------------ | +| `stringValidator` | `string` | Requires a string value | +| `booleanValidator` | `boolean` | Parses `true`, `false`, `yes`, `no`, `1`, `0` | +| `numberValidator` | `number` | Parses finite numbers | +| `portValidator` | `number` | Parses integer ports from `1` to `65535` | +| `emailValidator` | `string` | Checks a practical email shape | +| `hostValidator` | `string` | Accepts fully qualified domains or IP addresses | +| `urlValidator` | `string` | Requires a valid URL | +| `jsonValidator` | `unknown` | Parses JSON strings and returns the parsed value | + +Schema transforms are supported. The inferred type follows the output of the transform: + +```ts +const env = validateEnv(process.env, { + PORT: z.string().transform(Number) +}); + +env.PORT; // number +``` -## 📖 Usage +When a schema transforms its input, the default should match the schema input type, not the output type: + +```ts +const env = validateEnv(process.env, { + PORT: { + validator: z.string().transform(Number), + defaultValue: '3000' // string input, number output + } +}); + +env.PORT; // number +``` + +## Preprocess + +Use `preprocess` for small raw-value cleanup before defaults and validation run. Keep parsing and transforms in the validator whenever possible: + +```ts +import { + emptyStringAsUndefined, + pipePreprocess, + stringValidator, + stripTrailingSlash, + urlValidator, + validateEnv +} from 'validatenv'; + +const env = validateEnv(process.env, { + API_KEY: { + validator: stringValidator, + preprocess: emptyStringAsUndefined, + defaultValue: 'local-api-key' + }, + API_URL: { + validator: urlValidator, + preprocess: pipePreprocess(emptyStringAsUndefined, stripTrailingSlash), + defaultValue: 'http://localhost:3000' + } +}); +``` + +Included helpers: + +| Helper | Description | +| ------------------------ | ----------------------------------------------------------- | +| `emptyStringAsUndefined` | Treats blank strings and nullish values like missing values | +| `stripTrailingSlash` | Removes one trailing slash from string values | +| `pipePreprocess` | Combines preprocess helpers into one `preprocess` function | + +The included preprocess helpers expect string env values. They return `undefined` for nullish values and throw for other non-string values. + +## Defaults + +Default helpers supply a fallback value after preprocessing and before validation. Pass a static value or one of the built-in helpers as `defaultValue`: ```ts import { devDefault, - numberMiddleware, + localDefault, + pipeDefaults, portValidator, - validateEnv, - validateEnvVar + testDefault, + urlValidator, + validateEnv } from 'validatenv'; -import { zValidator } from 'validation-adapters/zod'; -import * as z from 'zod'; -// Load environment variables -import 'dotenv/config'; -// Validate multiple environment variables const env = validateEnv(process.env, { - // Built-in validator - port: { - envKey: 'SERVER_PORT', // Read from SERVER_PORT instead of port + PORT: { validator: portValidator, - defaultValue: devDefault(3000) // Uses default only in development environment + defaultValue: devDefault(3000) // only active when NODE_ENV=development }, + API_URL: { + validator: urlValidator, + defaultValue: pipeDefaults( + localDefault('http://localhost:3000'), // NODE_ENV=local or development + testDefault('http://localhost:3000') // NODE_ENV=test + ) + } +}); +``` - // Zod validator with middleware - MAX_CONNECTIONS: { - validator: zValidator(z.number().min(1).max(100)), - middlewares: [numberMiddleware], // Converts string input to number - defaultValue: 10 - }, +Available helpers: + +| Helper | Active when | +| -------------- | -------------------------------------------------------------- | +| `devDefault` | `NODE_ENV` is `development` | +| `localDefault` | `NODE_ENV` is `local` or `development` | +| `testDefault` | `NODE_ENV` is `test` | +| `ciDefault` | `CI` is set | +| `envDefault` | `NODE_ENV` matches values you provide | +| `pipeDefaults` | tries each helper in order, returns first non-undefined result | + +Returning `undefined` from a default function means the value stays undefined. Required variables still fail unless another default supplies a value. + +## Vite + +### `createViteEnvDefine(env, specs)` + +Validates the spec against `env` and returns a Vite-compatible `define` object. Use this in `vite.config.ts` to validate and inject values into the client bundle: + +```ts +import { createViteEnvDefine, stringValidator, urlValidator } from 'validatenv'; +import { defineConfig, loadEnv } from 'vite'; + +export default defineConfig(({ mode }) => { + const env = { + ...process.env, + ...loadEnv(mode, process.cwd(), '') + }; + + return { + define: createViteEnvDefine(env, { + PACKAGE_VERSION: { + envKey: 'npm_package_version', + validator: stringValidator, + defaultValue: '0.0.0' + }, + VITE_API_URL: urlValidator + }) + }; +}); +``` + +The helper returns Vite-compatible replacement expressions: - // Static value - NODE_ENV: 'development' +```ts +{ + 'import.meta.env.PACKAGE_VERSION': '"0.0.0"', + 'import.meta.env.VITE_API_URL': '"https://api.example.com"' +} +``` + +Only keys listed in the spec are injected. Keep server-only values out of this spec. + +## FAQ + +### How does it compare to envalid, t3-env, and dotenv-safe? + +`validatenv` focuses on Standard Schema compatibility and runtime portability. Use it when you want to validate any env-like object with Zod, Valibot, ArkType, or built-in validators without tying the API to a specific framework. + +- [envalid](https://github.com/af/envalid): env validation with built-in validators and a custom validator API +- [t3-env](https://github.com/t3-oss/t3-env): type-safe env validation with Zod, designed around Next.js and tRPC +- [dotenv-safe](https://github.com/rolodato/dotenv-safe): checks that required env keys are present + +### When should I use `validateEnv` or `createEnv`? + +Use `validateEnv` when you want the smallest primitive: pass one env-like source and one spec, then get validated typed config back. It is the best default for scripts, CLIs, server-only apps, Vite config, and separate server/client modules. + +Use `createEnv` when you want one app-level env object split into `server`, `client`, and `shared` specs. It validates only client-safe specs in client mode and throws if server-only output keys are read there. This is a DX guard, not a bundler security boundary. + +### When should I use `preprocess` instead of a schema transform? + +Use `preprocess` for raw env cleanup that should happen before defaults, such as treating empty strings like missing values: + +```ts +const env = validateEnv(process.env, { + API_URL: { + validator: urlValidator, + preprocess: emptyStringAsUndefined, + defaultValue: 'http://localhost:3000' + } }); +``` -// Validate single environment variable -const apiKey = validateEnvVar( - 'API_KEY', - { - validator: zValidator(z.string().min(10)), - description: 'API authentication key', // Shown in validation error messages for better debugging - example: 'abc123xyz789' // Provides usage example in error messages +Use schema transforms for parsing and semantic normalization, such as string-to-number, string-to-boolean, JSON parsing, or app-specific output shapes: + +```ts +const env = validateEnv(process.env, { + PORT: z.string().transform(Number) +}); +``` + +In short: `preprocess` cleans raw input so defaults and validators receive the right value; the validator decides the final typed output. + +### Can I put static values in the spec? + +Yes. Top-level raw values are copied into the validated output as static config values. Use this for constants that are already known in code: + +```ts +const env = validateEnv(process.env, { + DATABASE_URL: z.string().url(), + PACKAGE_VERSION: '1.2.3' +}); +``` + +Static entries are not validated. If a value should still pass through a validator, put it into the source object or use `defaultValue` with an object spec. + +### Does it read `.env` files? + +No. Load env files with `dotenv` or your runtime's built-in mechanism before calling `validateEnv`. + +### How do I make a variable optional? + +Use `defaultValue` to supply a fallback when the variable is absent. If no fallback makes sense, use a validator that accepts `undefined`: + +```ts +const env = validateEnv(process.env, { + // Falls back to 3000 when PORT is not set + PORT: { + validator: portValidator, + defaultValue: 3000 }, - process.env -); + // Absent value passes through as undefined + SENTRY_DSN: z.string().url().optional() +}); -// Type-safe access -console.log(env.port); // number -console.log(env.MAX_CONNECTIONS); // number -console.log(apiKey); // string +// env.PORT is number +// env.SENTRY_DSN is string | undefined ``` + +### Can I use it without Zod or Valibot? + +Yes. The built-in validators cover the most common patterns: string, boolean, number, port, URL, email, host, and JSON. No schema library is required. + +### What happens when validation fails? + +`validateEnv` throws synchronously with a single error message that lists every failed variable and its issue. The process does not continue. + +``` +Environment validation failed: + +Invalid value for DATABASE_URL +Description: Postgres connection URL +Example: postgres://user:password@localhost:5432/app +Error: Must be a valid URL + +Invalid value for PORT +Error: Must be a valid port number (1-65535) +``` + +`description` and `example` fields on the spec object are included in the error when set. + +### Does it support async validators? + +No. Env validation is synchronous by design so startup failure is immediate and deterministic. + +### Can I use it in Bun or Deno? + +Yes. `validateEnv` accepts any `Record` as the env source. Pass `process.env` in Node.js and Bun, or `Deno.env.toObject()` in Deno. + +### How do I separate server and client env? + +Validate each source in its own module and import only what each side needs: + +```ts +// server/env.ts +export const env = validateEnv(process.env, { + DATABASE_URL: z.string().url() +}); + +// client/env.ts +export const publicEnv = validateEnv(import.meta.env, { + VITE_API_URL: z.string().url() +}); +``` + +`validatenv` does not enforce this boundary automatically. If server variable names are sensitive, keep server and client schemas in separate files so the client bundle never imports the server schema. diff --git a/packages/validatenv/package.json b/packages/validatenv/package.json index 6ce27da1..0ebd0ae3 100644 --- a/packages/validatenv/package.json +++ b/packages/validatenv/package.json @@ -1,9 +1,26 @@ { "name": "validatenv", - "version": "0.0.44", + "version": "0.1.0-beta.1", "private": false, - "description": "Validate environment variables", - "keywords": [], + "description": "Validate env variables into typed config with Standard Schema validators and fail-fast errors.", + "keywords": [ + "env", + "environment-variables", + "env-validation", + "validation", + "typescript", + "standard-schema", + "zod", + "valibot", + "arktype", + "dotenv", + "config", + "vite", + "startup-validation", + "fail-fast", + "type-safe", + "process-env" + ], "homepage": "https://builder.group/?utm_source=package-json", "bugs": { "url": "https://github.com/builder-group/community/issues" @@ -35,8 +52,7 @@ "update:latest": "pnpm update --latest" }, "dependencies": { - "@blgc/utils": "workspace:*", - "validation-adapter": "workspace:*" + "@standard-schema/spec": "^1.1.0" }, "devDependencies": { "@blgc/config": "workspace:*", diff --git a/packages/validatenv/src/create-env.test.ts b/packages/validatenv/src/create-env.test.ts new file mode 100644 index 00000000..9882d4bc --- /dev/null +++ b/packages/validatenv/src/create-env.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, expectTypeOf, it } from 'vitest'; +import { createEnv } from './create-env'; +import { stringValidator, urlValidator } from './validators'; + +describe('createEnv function', () => { + describe('types', () => { + it('should infer the full env object from server, client, and shared specs', () => { + const env = createEnv({ + env: { + DATABASE_URL: 'https://db.example.com', + VITE_API_URL: 'https://api.example.com', + NODE_ENV: 'test' + }, + isServer: true, + server: { + DATABASE_URL: urlValidator + }, + client: { + VITE_API_URL: urlValidator + }, + shared: { + NODE_ENV: stringValidator, + APP_VERSION: '1.2.3' + } + }); + + expectTypeOf(env).toEqualTypeOf< + Readonly<{ + DATABASE_URL: string; + VITE_API_URL: string; + NODE_ENV: string; + APP_VERSION: string; + }> + >(); + }); + + it('should infer the env object when optional spec groups are omitted', () => { + const env = createEnv({ + env: { + VITE_API_URL: 'https://api.example.com' + }, + client: { + VITE_API_URL: urlValidator + } + }); + + expectTypeOf(env).toEqualTypeOf< + Readonly<{ + VITE_API_URL: string; + }> + >(); + }); + }); + + describe('validation', () => { + it('should validate server, client, and shared specs in server mode', () => { + const env = createEnv({ + env: { + DATABASE_URL: 'https://db.example.com', + VITE_API_URL: 'https://api.example.com', + NODE_ENV: 'test' + }, + isServer: true, + server: { + DATABASE_URL: urlValidator + }, + client: { + VITE_API_URL: urlValidator + }, + shared: { + NODE_ENV: stringValidator + } + }); + + expect(env).toEqual({ + DATABASE_URL: 'https://db.example.com', + VITE_API_URL: 'https://api.example.com', + NODE_ENV: 'test' + }); + }); + + it('should skip server spec validation in client mode', () => { + const env = createEnv({ + env: { + DATABASE_URL: 'not-a-url', + VITE_API_URL: 'https://api.example.com', + NODE_ENV: 'test' + }, + isServer: false, + server: { + DATABASE_URL: urlValidator + }, + client: { + VITE_API_URL: urlValidator + }, + shared: { + NODE_ENV: stringValidator + } + }); + + expect(env).toEqual({ + VITE_API_URL: 'https://api.example.com', + NODE_ENV: 'test' + }); + }); + }); + + describe('client access guard', () => { + it('should block server keys in client mode', () => { + const env = createEnv({ + env: { + VITE_API_URL: 'https://api.example.com' + }, + isServer: false, + server: { + dbUrl: { + envKey: 'DATABASE_URL', + validator: urlValidator + } + }, + client: { + VITE_API_URL: urlValidator + } + }); + + expect(() => env.dbUrl).toThrow( + 'Attempted to access server-only env key dbUrl on the client.' + ); + }); + + it('should pass the blocked key to a custom invalid access handler', () => { + const env = createEnv({ + env: {}, + isServer: false, + server: { + DATABASE_URL: urlValidator + }, + onInvalidAccess(key) { + throw new Error(`Blocked ${key}`); + } + }); + + expect(() => env.DATABASE_URL).toThrow('Blocked DATABASE_URL'); + }); + + it('should ignore module interop probe keys in client mode', () => { + const env = createEnv({ + env: {}, + isServer: false, + server: { + DATABASE_URL: urlValidator + } + }); + + expect(Reflect.get(env, '__esModule')).toBeUndefined(); + expect(Reflect.get(env, '$$typeof')).toBeUndefined(); + }); + }); + + describe('spec groups', () => { + it('should reject duplicate output keys across spec groups', () => { + expect(() => + createEnv({ + env: {}, + isServer: true, + server: { + API_URL: urlValidator + }, + client: { + API_URL: urlValidator + } + }) + ).toThrow('Env spec key API_URL is declared in both server and client specs.'); + }); + }); +}); diff --git a/packages/validatenv/src/create-env.ts b/packages/validatenv/src/create-env.ts new file mode 100644 index 00000000..f8ef92f6 --- /dev/null +++ b/packages/validatenv/src/create-env.ts @@ -0,0 +1,123 @@ +import type { TEnv, TEnvData, TEnvSpecs } from './types'; +import { validateEnv } from './validate-env'; + +/** Validates grouped server, client, and shared env specs with a client-side server access guard. */ +export function createEnv< + GServerSpecs extends Record = TEmptyEnvSpecs, + GClientSpecs extends Record = TEmptyEnvSpecs, + GSharedSpecs extends Record = TEmptyEnvSpecs +>( + options: TCreateEnvOptions +): TCreateEnvData { + const { + env, + server: serverSpecs = {}, + client: clientSpecs = {}, + shared: sharedSpecs = {}, + isServer = isServerRuntime(), + onInvalidAccess = throwInvalidServerAccess + } = options; + + assertUniqueSpecKeys([ + { name: 'server', specs: serverSpecs }, + { name: 'client', specs: clientSpecs }, + { name: 'shared', specs: sharedSpecs } + ]); + + const activeSpecs = isServer + ? { + ...sharedSpecs, + ...serverSpecs, + ...clientSpecs + } + : { + ...sharedSpecs, + ...clientSpecs + }; + const envData = validateEnv(env, activeSpecs); + + if (isServer) { + return envData as TCreateEnvData; + } + + const serverSpecKeys = new Set(Object.keys(serverSpecs)); + return new Proxy(envData, { + get(target, propertyKey, receiver) { + if (typeof propertyKey !== 'string') { + return Reflect.get(target, propertyKey, receiver); + } + + const shouldIgnoreProxyKey = propertyKey === '__esModule' || propertyKey === '$$typeof'; + if (shouldIgnoreProxyKey) { + return undefined; + } + + if (serverSpecKeys.has(propertyKey)) { + return onInvalidAccess(propertyKey); + } + + return Reflect.get(target, propertyKey, receiver); + } + }) as TCreateEnvData; +} + +export interface TCreateEnvOptions< + GServerSpecs extends Record = TEmptyEnvSpecs, + GClientSpecs extends Record = TEmptyEnvSpecs, + GSharedSpecs extends Record = TEmptyEnvSpecs +> { + /** Environment source, such as `process.env`, `import.meta.env`, or a test object. */ + env: TEnv; + /** Server-only specs. Client access throws unless `onInvalidAccess` overrides it. */ + server?: TEnvSpecs; + /** Client-exposed specs validated on both server and client. */ + client?: TEnvSpecs; + /** Runtime-neutral specs shared by server and client code. */ + shared?: TEnvSpecs; + /** Runtime side. Defaults to a global runtime check. */ + isServer?: boolean; + /** Handles client-side reads of server-only keys. Must not return. */ + onInvalidAccess?: (key: string) => never; +} + +export type TCreateEnvData< + GServerSpecs extends Record = TEmptyEnvSpecs, + GClientSpecs extends Record = TEmptyEnvSpecs, + GSharedSpecs extends Record = TEmptyEnvSpecs +> = Readonly & TEnvData & TEnvData>>; + +type TEmptyEnvSpecs = Record; + +type TSimplify = { + [Key in keyof GValue]: GValue[Key]; +}; + +function isServerRuntime(): boolean { + return !('window' in globalThis) || 'Deno' in globalThis; +} + +function throwInvalidServerAccess(key: string): never { + throw new Error(`Attempted to access server-only env key ${key} on the client.`); +} + +function assertUniqueSpecKeys(groups: TEnvSpecGroup[]): void { + const seenKeys = new Map(); + + for (const group of groups) { + for (const key of Object.keys(group.specs)) { + const existingGroupName = seenKeys.get(key); + if (existingGroupName != null) { + throw new Error( + `Env spec key ${key} is declared in both ${existingGroupName} and ${group.name} specs.` + ); + } + + seenKeys.set(key, group.name); + } + } +} + +interface TEnvSpecGroup { + name: string; + specs: Record; +} diff --git a/packages/validatenv/src/defaults.test.ts b/packages/validatenv/src/defaults.test.ts index d2684c05..986f5a86 100644 --- a/packages/validatenv/src/defaults.test.ts +++ b/packages/validatenv/src/defaults.test.ts @@ -1,119 +1,54 @@ import { describe, expect, it } from 'vitest'; import { ciDefault, - combineDefaults, devDefault, envDefault, localDefault, + pipeDefaults, testDefault } from './defaults'; -describe('defaults', () => { - describe('envDefault', () => { - it('should return value when NODE_ENV matches any allowed env', () => { - const defaultValue = 'test-value'; - const fn = envDefault(defaultValue, ['staging', 'development']); +describe('defaults module', () => { + describe('envDefault function', () => { + it('should return value when NODE_ENV is allowed', () => { + const getDefault = envDefault('staging-value', ['staging', 'development']); - expect(fn({ NODE_ENV: 'staging' })).toBe(defaultValue); - expect(fn({ NODE_ENV: 'development' })).toBe(defaultValue); + expect(getDefault({ NODE_ENV: 'staging' })).toBe('staging-value'); }); - it('should return undefined when NODE_ENV does not match allowed envs', () => { - const defaultValue = 'test-value'; - const fn = envDefault(defaultValue, ['staging', 'development']); + it('should return undefined when NODE_ENV is not allowed', () => { + const getDefault = envDefault('staging-value', ['staging', 'development']); - expect(fn({ NODE_ENV: 'production' })).toBeUndefined(); - expect(fn({ NODE_ENV: 'test' })).toBeUndefined(); + expect(getDefault({ NODE_ENV: 'production' })).toBeUndefined(); }); }); - describe('combineDefaults', () => { - const defaultValue = 'test-value'; - const ciValue = 'ci-value'; + describe('pipeDefaults function', () => { + it('should return the first matching default value', () => { + const getDefault = pipeDefaults(ciDefault('ci-value'), devDefault('dev-value')); - it('should try defaults in order and return first matching value', () => { - const combined = combineDefaults(ciDefault(ciValue), devDefault(defaultValue)); - - // When CI is set, should return CI value regardless of NODE_ENV - expect(combined({ CI: 'true', NODE_ENV: 'development' })).toBe(ciValue); - - // When CI is not set but NODE_ENV is development, return dev value - expect(combined({ NODE_ENV: 'development' })).toBe(defaultValue); - - // When neither condition is met, return undefined - expect(combined({ NODE_ENV: 'production' })).toBeUndefined(); + expect(getDefault({ CI: 'true', NODE_ENV: 'development' })).toBe('ci-value'); }); - it('should work with multiple defaults in priority order', () => { - const combined = combineDefaults( - ciDefault('ci-value'), - testDefault('test-value'), - devDefault('dev-value') - ); + it('should return undefined when no defaults match', () => { + const getDefault = pipeDefaults(testDefault('test-value'), devDefault('dev-value')); - expect(combined({ CI: 'true' })).toBe('ci-value'); - expect(combined({ NODE_ENV: 'test' })).toBe('test-value'); - expect(combined({ NODE_ENV: 'development' })).toBe('dev-value'); - expect(combined({ NODE_ENV: 'production' })).toBeUndefined(); + expect(getDefault({ NODE_ENV: 'production' })).toBeUndefined(); }); }); - describe('convenience functions', () => { - const defaultValue = 'test-value'; - - describe('devDefault', () => { - const fn = devDefault(defaultValue); - - it('should return value when NODE_ENV is development', () => { - expect(fn({ NODE_ENV: 'development' })).toBe(defaultValue); - }); - - it('should return undefined when NODE_ENV is not development', () => { - expect(fn({ NODE_ENV: 'production' })).toBeUndefined(); - expect(fn({ NODE_ENV: 'test' })).toBeUndefined(); - }); + describe('convenience defaults', () => { + it('should resolve NODE_ENV defaults for their intended environments', () => { + expect(devDefault('dev-value')({ NODE_ENV: 'development' })).toBe('dev-value'); + expect(localDefault('local-value')({ NODE_ENV: 'local' })).toBe('local-value'); + expect(testDefault('test-value')({ NODE_ENV: 'test' })).toBe('test-value'); }); - describe('localDefault', () => { - const fn = localDefault(defaultValue); - - it('should return value when NODE_ENV is local or development', () => { - expect(fn({ NODE_ENV: 'local' })).toBe(defaultValue); - expect(fn({ NODE_ENV: 'development' })).toBe(defaultValue); - }); - - it('should return undefined when NODE_ENV is not local or development', () => { - expect(fn({ NODE_ENV: 'production' })).toBeUndefined(); - expect(fn({ NODE_ENV: 'test' })).toBeUndefined(); - }); - }); - - describe('testDefault', () => { - const fn = testDefault(defaultValue); - - it('should return value when NODE_ENV is test', () => { - expect(fn({ NODE_ENV: 'test' })).toBe(defaultValue); - }); - - it('should return undefined when NODE_ENV is not test', () => { - expect(fn({ NODE_ENV: 'production' })).toBeUndefined(); - expect(fn({ NODE_ENV: 'development' })).toBeUndefined(); - }); - }); - - describe('ciDefault', () => { - const fn = ciDefault(defaultValue); - - it('should return value when CI env is set', () => { - expect(fn({ CI: 'true' })).toBe(defaultValue); - expect(fn({ CI: '1' })).toBe(defaultValue); - }); + it('should resolve ciDefault from a truthy CI env value', () => { + const getDefault = ciDefault('ci-value'); - it('should return undefined when CI env is not set', () => { - expect(fn({})).toBeUndefined(); - expect(fn({ CI: '' })).toBeUndefined(); - expect(fn({ CI: undefined })).toBeUndefined(); - }); + expect(getDefault({ CI: 'true' })).toBe('ci-value'); + expect(getDefault({ CI: '' })).toBeUndefined(); }); }); }); diff --git a/packages/validatenv/src/defaults.ts b/packages/validatenv/src/defaults.ts index e3cdb20b..3a64cac5 100644 --- a/packages/validatenv/src/defaults.ts +++ b/packages/validatenv/src/defaults.ts @@ -1,19 +1,14 @@ -import { TDefaultValueFn } from './types'; +import type { TEnvDefaultFn } from './types'; -export function envDefault(value: GValue, allowedEnvs: string[]): TDefaultValueFn { - return (env) => { - if (!allowedEnvs.includes(env['NODE_ENV'] as string)) { - return undefined; - } - return value; - }; -} +/** Tries each default function in order and returns the first non-undefined result. */ +export function pipeDefaults( + firstDefault: TEnvDefaultFn, + ...defaults: TEnvDefaultFn[] +): TEnvDefaultFn { + const allDefaults = [firstDefault, ...defaults]; -export function combineDefaults( - ...defaults: TDefaultValueFn[] -): TDefaultValueFn { return (env) => { - for (const defaultFn of defaults) { + for (const defaultFn of allDefaults) { const result = defaultFn(env); if (result !== undefined) { return result; @@ -23,19 +18,37 @@ export function combineDefaults( }; } -export function devDefault(value: GValue): TDefaultValueFn { +/** Returns value when NODE_ENV in the env source matches one of the allowed values. */ +export function envDefault( + value: GValue, + allowedEnvs: readonly string[] +): TEnvDefaultFn { + return (env) => { + const nodeEnv = env['NODE_ENV']; + if (typeof nodeEnv !== 'string' || !allowedEnvs.includes(nodeEnv)) { + return undefined; + } + return value; + }; +} + +/** Active when NODE_ENV is "development". */ +export function devDefault(value: GValue): TEnvDefaultFn { return envDefault(value, ['development']); } -export function localDefault(value: GValue): TDefaultValueFn { +/** Active when NODE_ENV is "local" or "development". */ +export function localDefault(value: GValue): TEnvDefaultFn { return envDefault(value, ['local', 'development']); } -export function testDefault(value: GValue): TDefaultValueFn { +/** Active when NODE_ENV is "test". */ +export function testDefault(value: GValue): TEnvDefaultFn { return envDefault(value, ['test']); } -export function ciDefault(value: GValue): TDefaultValueFn { +/** Active when the CI environment variable is set to a truthy value. */ +export function ciDefault(value: GValue): TEnvDefaultFn { return (env) => { if (!env['CI']) { return undefined; diff --git a/packages/validatenv/src/index.ts b/packages/validatenv/src/index.ts index 13f58893..3f9e4973 100644 --- a/packages/validatenv/src/index.ts +++ b/packages/validatenv/src/index.ts @@ -1,5 +1,7 @@ +export * from './create-env'; export * from './defaults'; -export * from './middlewares'; +export * from './preprocess'; export * from './types'; export * from './validate-env'; export * from './validators'; +export * from './vite-env-define'; diff --git a/packages/validatenv/src/lib/format-thrown-error.ts b/packages/validatenv/src/lib/format-thrown-error.ts new file mode 100644 index 00000000..c8f9fb93 --- /dev/null +++ b/packages/validatenv/src/lib/format-thrown-error.ts @@ -0,0 +1,7 @@ +export function formatThrownError(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + return String(error); +} diff --git a/packages/validatenv/src/lib/index.ts b/packages/validatenv/src/lib/index.ts new file mode 100644 index 00000000..f89c6801 --- /dev/null +++ b/packages/validatenv/src/lib/index.ts @@ -0,0 +1,2 @@ +export * from './format-thrown-error'; +export * from './standard-schema'; diff --git a/packages/validatenv/src/lib/standard-schema.test.ts b/packages/validatenv/src/lib/standard-schema.test.ts new file mode 100644 index 00000000..148b7d0b --- /dev/null +++ b/packages/validatenv/src/lib/standard-schema.test.ts @@ -0,0 +1,69 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import { describe, expect, it } from 'vitest'; +import { validateStandardSchema } from './standard-schema'; + +describe('validateStandardSchema function', () => { + it('should return the Standard Schema output value', () => { + // Prepare + const schema = createStandardSchema(() => ({ value: 3000 })); + + // Act + const result = validateStandardSchema(schema, '3000', { envKey: 'PORT' }); + + // Assert + expect(result).toEqual({ + success: true, + value: 3000 + }); + }); + + it('should format Standard Schema issues with env context', () => { + // Prepare + const schema = createStandardSchema(() => ({ + issues: [{ message: 'Must be a number', path: [{ key: 'server' }, 'port'] }] + })); + + // Act + const result = validateStandardSchema(schema, 'invalid', { + envKey: 'PORT', + description: 'Server port', + example: '3000' + }); + + // Assert + expect(result).toEqual({ + success: false, + error: + 'Invalid value for PORT\nDescription: Server port\nExample: 3000\nError: server.port: Must be a number' + }); + }); + + it('should reject async validators because validateEnv is synchronous', () => { + // Prepare + const schema = createStandardSchema(async () => ({ value: 'secret' })); + + // Act + const result = validateStandardSchema(schema, 'secret', { envKey: 'API_KEY' }); + + // Assert + expect(result).toEqual({ + success: false, + error: + 'Validator for API_KEY returned a Promise. validateEnv only supports synchronous Standard Schema validators.' + }); + }); +}); + +function createStandardSchema( + validate: ( + value: unknown + ) => StandardSchemaV1.Result | Promise> +): StandardSchemaV1 { + return { + '~standard': { + version: 1, + vendor: 'test', + validate + } + }; +} diff --git a/packages/validatenv/src/lib/standard-schema.ts b/packages/validatenv/src/lib/standard-schema.ts new file mode 100644 index 00000000..004853a2 --- /dev/null +++ b/packages/validatenv/src/lib/standard-schema.ts @@ -0,0 +1,118 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import { formatThrownError } from './format-thrown-error'; + +/** + * Runs a Standard Schema validator synchronously and returns a success or failure result. + * Async validators are rejected immediately with an error. + */ +export function validateStandardSchema( + validator: StandardSchemaV1, + value: unknown, + options: TValidateStandardSchemaOptions +): TStandardSchemaValidationResult { + const { envKey, description, example } = options; + + let result: StandardSchemaV1.Result | Promise>; + try { + result = validator['~standard'].validate(value); + } catch (error) { + return { + success: false, + error: `Error validating ${envKey}: ${formatThrownError(error)}` + }; + } + + if (isPromiseLike(result)) { + // Note: Avoid unhandled rejections after reporting that async validators are unsupported + void Promise.resolve(result).catch(() => undefined); + return { + success: false, + error: `Validator for ${envKey} returned a Promise. validateEnv only supports synchronous Standard Schema validators.` + }; + } + + if (result.issues != null) { + return { + success: false, + error: formatInvalidEnvError(envKey, result.issues, description, example) + }; + } + + return { + success: true, + value: result.value + }; +} + +interface TValidateStandardSchemaOptions { + envKey: string; + description?: string; + example?: string; +} + +type TStandardSchemaValidationResult = + | { + success: true; + value: GValue; + } + | { + success: false; + error: string; + }; + +function formatInvalidEnvError( + envKey: string, + issues: readonly StandardSchemaV1.Issue[], + description?: string, + example?: string +): string { + const finalDescription = description != null ? `\nDescription: ${description}` : ''; + const finalExample = example != null ? `\nExample: ${example}` : ''; + const finalIssues = + issues.length > 0 + ? issues.map(formatStandardSchemaIssue).join(', ') + : 'Unknown validation error'; + + return `Invalid value for ${envKey}${finalDescription}${finalExample}\nError: ${finalIssues}`; +} + +function formatStandardSchemaIssue(issue: StandardSchemaV1.Issue): string { + const path = formatStandardSchemaPath(issue.path); + if (path == null) { + return issue.message; + } + + return `${path}: ${issue.message}`; +} + +function formatStandardSchemaPath(path: StandardSchemaV1.Issue['path']): string | null { + if (!path?.length) { + return null; + } + + return path + .map((segment) => (typeof segment === 'object' && 'key' in segment ? segment.key : segment)) + .join('.'); +} + +function isPromiseLike(value: unknown): value is Promise { + if (typeof value !== 'object' || value == null || !('then' in value)) { + return false; + } + + return typeof value.then === 'function'; +} + +export function isStandardSchemaValidator( + value: unknown +): value is StandardSchemaV1 { + return ( + typeof value === 'object' && + value != null && + '~standard' in value && + typeof value['~standard'] === 'object' && + value['~standard'] != null && + 'validate' in value['~standard'] && + typeof value['~standard'].validate === 'function' + ); +} diff --git a/packages/validatenv/src/middlewares.test.ts b/packages/validatenv/src/middlewares.test.ts deleted file mode 100644 index c04c5b6c..00000000 --- a/packages/validatenv/src/middlewares.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { booleanMiddleware, nonEmptyStringMiddleware, numberMiddleware } from './middlewares'; - -describe('middleware functions', () => { - describe('booleanMiddleware', () => { - it('should return undefined for undefined input', () => { - expect(booleanMiddleware(undefined)).toBeUndefined(); - }); - - it('should correctly parse true values', () => { - const trueValues = ['true', 't', 'yes', 'on', '1']; - trueValues.forEach((value) => { - expect(booleanMiddleware(value)).toBe(true); - }); - }); - - it('should correctly parse false values', () => { - const falseValues = ['false', 'f', 'no', 'off', '0']; - falseValues.forEach((value) => { - expect(booleanMiddleware(value)).toBe(false); - }); - }); - - it('should return undefined for invalid values', () => { - expect(booleanMiddleware('invalid')).toBeUndefined(); - }); - - it('should be case insensitive', () => { - expect(booleanMiddleware('TRUE')).toBe(true); - expect(booleanMiddleware('False')).toBe(false); - }); - }); - - describe('numberMiddleware', () => { - it('should return undefined for undefined input', () => { - expect(numberMiddleware(undefined)).toBeUndefined(); - }); - - it('should correctly parse valid numbers', () => { - expect(numberMiddleware('123')).toBe(123); - expect(numberMiddleware('-123')).toBe(-123); - expect(numberMiddleware('123.456')).toBe(123.456); - }); - - it('should return undefined for invalid numbers', () => { - expect(numberMiddleware('not-a-number')).toBeUndefined(); - expect(numberMiddleware('')).toBeUndefined(); - }); - }); - - describe('nonEmptyStringMiddleware', () => { - it('should return undefined for undefined input', () => { - expect(nonEmptyStringMiddleware(undefined)).toBe(undefined); - }); - - it('should return undefined for empty string', () => { - expect(nonEmptyStringMiddleware('')).toBe(undefined); - }); - - it('should return undefined for whitespace-only string', () => { - expect(nonEmptyStringMiddleware(' ')).toBe(undefined); - expect(nonEmptyStringMiddleware('\t')).toBe(undefined); - expect(nonEmptyStringMiddleware('\n')).toBe(undefined); - }); - - it('should return the original string for non-empty input', () => { - expect(nonEmptyStringMiddleware('hello')).toBe('hello'); - expect(nonEmptyStringMiddleware(' hello ')).toBe(' hello '); - expect(nonEmptyStringMiddleware('123')).toBe('123'); - }); - }); -}); diff --git a/packages/validatenv/src/middlewares.ts b/packages/validatenv/src/middlewares.ts deleted file mode 100644 index 457eb441..00000000 --- a/packages/validatenv/src/middlewares.ts +++ /dev/null @@ -1,36 +0,0 @@ -export function booleanMiddleware(input: string | undefined): boolean | undefined { - if (input === undefined) { - return undefined; - } - switch (input.toLowerCase()) { - case 'true': - case 't': - case 'yes': - case 'on': - case '1': - return true; - case 'false': - case 'f': - case 'no': - case 'off': - case '0': - return false; - default: - return undefined; - } -} - -export function numberMiddleware(input: string | undefined): number | undefined { - if (input === undefined) { - return undefined; - } - const num = parseFloat(input); - return Number.isNaN(num) ? undefined : num; -} - -export function nonEmptyStringMiddleware(input: string | undefined): string | undefined { - if (input === undefined) { - return undefined; - } - return input.trim() === '' ? undefined : input; -} diff --git a/packages/validatenv/src/preprocess.test.ts b/packages/validatenv/src/preprocess.test.ts new file mode 100644 index 00000000..9e57bf73 --- /dev/null +++ b/packages/validatenv/src/preprocess.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, expectTypeOf, it } from 'vitest'; +import { emptyStringAsUndefined, pipePreprocess, stripTrailingSlash } from './preprocess'; +import type { TEnvPreprocess } from './types'; + +describe('preprocess module', () => { + describe('emptyStringAsUndefined function', () => { + it('should return undefined for blank strings', () => { + expect(emptyStringAsUndefined('')).toBeUndefined(); + expect(emptyStringAsUndefined(' ')).toBeUndefined(); + }); + + it('should return undefined for nullish values', () => { + expect(emptyStringAsUndefined(undefined)).toBeUndefined(); + expect(emptyStringAsUndefined(null)).toBeUndefined(); + }); + + it('should reject non-string values', () => { + expect(() => emptyStringAsUndefined(123)).toThrow('Expected a string value.'); + }); + + it('should preserve non-empty strings', () => { + expect(emptyStringAsUndefined('hello')).toBe('hello'); + expect(emptyStringAsUndefined(' hello ')).toBe(' hello '); + }); + }); + + describe('stripTrailingSlash function', () => { + it('should remove one trailing slash', () => { + expect(stripTrailingSlash('https://api.example.com/')).toBe('https://api.example.com'); + }); + + it('should preserve strings without a trailing slash', () => { + expect(stripTrailingSlash('https://api.example.com')).toBe('https://api.example.com'); + }); + + it('should return undefined for nullish values', () => { + expect(stripTrailingSlash(undefined)).toBeUndefined(); + expect(stripTrailingSlash(null)).toBeUndefined(); + }); + + it('should reject non-string values', () => { + expect(() => stripTrailingSlash(123)).toThrow('Expected a string value.'); + }); + }); + + describe('pipePreprocess function', () => { + it('should infer the shared preprocess output type', () => { + const preprocess = pipePreprocess(emptyStringAsUndefined, stripTrailingSlash); + + expectTypeOf(preprocess).toEqualTypeOf>(); + }); + + it('should run preprocess functions in order', () => { + const preprocess = pipePreprocess(emptyStringAsUndefined, stripTrailingSlash); + + expect(preprocess('https://api.example.com/')).toBe('https://api.example.com'); + }); + + it('should stop when a preprocess returns undefined', () => { + const preprocess = pipePreprocess(emptyStringAsUndefined, () => 'should-not-run'); + + expect(preprocess('')).toBeUndefined(); + }); + }); +}); diff --git a/packages/validatenv/src/preprocess.ts b/packages/validatenv/src/preprocess.ts new file mode 100644 index 00000000..ab213b0d --- /dev/null +++ b/packages/validatenv/src/preprocess.ts @@ -0,0 +1,48 @@ +import type { TEnvPreprocess } from './types'; + +/** Chains preprocess functions in order. Returns undefined early if any step returns undefined. */ +export function pipePreprocess( + firstPreprocess: TEnvPreprocess, + ...preprocesses: TEnvPreprocess[] +): TEnvPreprocess { + const allPreprocesses = [firstPreprocess, ...preprocesses]; + + return (value) => { + let nextValue: unknown = value; + + for (const preprocess of allPreprocesses) { + nextValue = preprocess(nextValue); + if (nextValue === undefined) { + return undefined; + } + } + + return nextValue as GValue; + }; +} + +/** Treats blank strings and nullish values as undefined. Throws for non-string, non-nullish values. */ +export function emptyStringAsUndefined(value: unknown): string | undefined { + if (value == null) { + return undefined; + } + + if (typeof value !== 'string') { + throw new Error('Expected a string value.'); + } + + return value.trim() === '' ? undefined : value; +} + +/** Removes one trailing slash from string values. Throws for non-string, non-nullish values. */ +export function stripTrailingSlash(value: unknown): string | undefined { + if (value == null) { + return undefined; + } + + if (typeof value !== 'string') { + throw new Error('Expected a string value.'); + } + + return value.endsWith('/') ? value.slice(0, -1) : value; +} diff --git a/packages/validatenv/src/types.ts b/packages/validatenv/src/types.ts index d7bd936c..7e97f2ad 100644 --- a/packages/validatenv/src/types.ts +++ b/packages/validatenv/src/types.ts @@ -1,86 +1,64 @@ -import type { TBaseValidationContext, TValidator } from 'validation-adapter'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; -export type TEnvData = Record; +/** Environment variable source read by `validateEnv()` and `validateEnvVar()`. */ +export type TEnv = Record; -export type TEnv = NodeJS.ProcessEnv | Record; +/** Configures how a single environment value is resolved and validated. */ +export interface TEnvSpec { + /** Environment key to read. Defaults to the output object key. */ + envKey?: string; -export type TEnvMiddleware = (value: string | undefined) => GValue | undefined; + /** Standard Schema-compatible validator for the final value. */ + validator: TEnvValidator; -export type TDefaultValueFn = (env: TEnv) => GValue | undefined; + /** Default used when the resolved value is `undefined`. */ + defaultValue?: GInput | TEnvDefaultFn; -export type TEnvSpec = { - /** - * Optional custom environment variable key to look up in process.env. - * If not provided, the object property name will be used. - * @example - * ```ts - * // Will look for process.env.DATABASE_URL instead of process.env.dbUrl - * dbUrl: { - * envKey: 'DATABASE_URL', - * } - * ``` - */ - envKey?: string; + /** Cleans the raw value before defaults and validation run. */ + preprocess?: TEnvPreprocess; + + /** Extra context added to validation errors. */ + description?: string; - /** - * Optional value to use instead of looking up in process.env. - * @example - * ```ts - * value: 'https://example.com' - * ``` - */ - value?: string; + /** Valid value example added to validation errors. */ + example?: string; +} - /** - * The validator function to validate the environment variable - */ - validator: TValidator>; +/** Standard Schema-compatible validator used by an env spec. */ +export type TEnvValidator = StandardSchemaV1; - /** - * Optional default value or function to generate default value. - * @example - * ```ts - * // Static default value - * defaultValue: 3000 - * - * // Function that returns default based on other env vars - * defaultValue: (env) => env.NODE_ENV === 'development' ? 3000 : 8080 - * ``` - */ - defaultValue?: GValue | TDefaultValueFn; +/** Cleans a raw env value before defaults and validation run. */ +export type TEnvPreprocess = (value: unknown) => GValue | undefined; - /** - * Optional array of middleware functions to transform the value. - * @example - * ```ts - * middlewares: [ - * (value) => value?.toLowerCase(), - * (value) => value === 'true' ? true : false - * ] - * ``` - */ - middlewares?: TEnvMiddleware[]; +/** Computes a default value from the full env source. */ +export type TEnvDefaultFn = (env: TEnv) => GValue | undefined; - /** - * Optional description of the environment variable. - * Used in error messages to provide more context. - * @example "The port number for the server to listen on" - */ - description?: string; +/** Env spec object accepted by `validateEnv()`, `createEnv()`, and `createViteEnvDefine()`. */ +export type TEnvSpecs> = GSpecs & + TEnvSpecEntriesConstraint; - /** - * Optional example of valid values. - * Used in error messages to help users fix invalid values. - * @example "3000, 8080, etc." - */ - example?: string; +// Note: Keep defaultValue and preprocess tied to the validator input type +// without widening validateEnv inference +type TEnvSpecEntriesConstraint> = { + [Key in keyof GSpecs]: TEnvSpecEntryConstraint; }; -export type TEnvSpecValue = - | TEnvSpec - // | TValidator> - | GValue; +type TEnvSpecEntryConstraint = GSpecEntry extends { validator: infer GValidator } + ? GValidator extends TEnvValidator + ? TEnvSpec + : never + : GSpecEntry extends TEnvValidator + ? TEnvValidator + : GSpecEntry; -export type TEnvSpecs = { - [Key in keyof GEnvData]: TEnvSpecValue; +/** Validated env object returned from a spec object. */ +export type TEnvData> = { + [Key in keyof GSpecs]: TEnvSpecEntryOutput; }; + +type TEnvSpecEntryOutput = + GSpecEntry extends TEnvSpec + ? GOutput + : GSpecEntry extends TEnvValidator + ? GOutput + : GSpecEntry; diff --git a/packages/validatenv/src/validate-env.test.ts b/packages/validatenv/src/validate-env.test.ts index 773898e0..0a07afb6 100644 --- a/packages/validatenv/src/validate-env.test.ts +++ b/packages/validatenv/src/validate-env.test.ts @@ -1,181 +1,381 @@ -import { createValidator } from 'validation-adapter'; -import { describe, expect, it } from 'vitest'; -import { booleanMiddleware, numberMiddleware } from './middlewares'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import { describe, expect, expectTypeOf, it } from 'vitest'; +import { emptyStringAsUndefined, pipePreprocess, stripTrailingSlash } from './preprocess'; +import type { TEnv, TEnvSpec } from './types'; import { validateEnv, validateEnvVar } from './validate-env'; -import { stringValidator } from './validators'; - -describe('validateEnv function', () => { - it('should validate and transform environment variables', () => { - const env = { - PORT: '3000', - DEBUG: 'true', - API_URL: 'http://api.example.com' - }; - - const result = validateEnv(env, { - PORT: { - validator: createValidator([]), - middlewares: [numberMiddleware] - }, - DEBUG: { - validator: createValidator([]), - middlewares: [booleanMiddleware] - }, - API_URL: { - validator: createValidator([]) - }, - plainValue: 'static-value' - }); +import { booleanValidator, numberValidator, stringValidator } from './validators'; - expect(result).toEqual({ - PORT: 3000, - DEBUG: true, - API_URL: 'http://api.example.com', - plainValue: 'static-value' - }); - }); +describe('validate-env module', () => { + describe('validateEnv function', () => { + describe('types', () => { + it('should infer output values from schemas, specs, and static entries', () => { + const transformSchema = createStringToNumberSchema(); + const env = validateEnv( + { + PORT: '3000', + DEBUG: 'true', + API_URL: 'http://api.example.com', + TRANSFORMED_PORT: '4000' + }, + { + PORT: numberValidator, + DEBUG: booleanValidator, + API_URL: { validator: stringValidator }, + TRANSFORMED_PORT: { + validator: transformSchema, + defaultValue: '4000' + }, + PACKAGE_VERSION: '1.2.3', + BUILD_META: { + commitSha: 'abc123' + } + } + ); - it('should use default values when variables are undefined', () => { - const env = {}; - - const result = validateEnv(env, { - PORT: { - validator: createValidator([]), - defaultValue: 3000, - middlewares: [numberMiddleware] - }, - DEBUG: { - validator: createValidator([]), - defaultValue: false - } - }); + expectTypeOf(env).toEqualTypeOf<{ + PORT: number; + DEBUG: boolean; + API_URL: string; + TRANSFORMED_PORT: number; + PACKAGE_VERSION: string; + BUILD_META: { + commitSha: string; + }; + }>(); + }); - expect(result).toEqual({ - PORT: 3000, - DEBUG: false - }); - }); + it('should require defaults and preprocess output to match validator input', () => { + const transformSchema = createStringToNumberSchema(); - it('should support default value function', () => { - const env = { NODE_ENV: 'development' }; + expectTypeOf<{ + validator: typeof transformSchema; + defaultValue: '3000'; + }>().toExtend>(); + expectTypeOf<{ + validator: typeof transformSchema; + defaultValue: 3000; + }>().not.toExtend>(); + expectTypeOf<{ + validator: typeof transformSchema; + preprocess: () => '3000'; + }>().toExtend>(); + expectTypeOf<{ + validator: typeof transformSchema; + preprocess: () => 3000; + }>().not.toExtend>(); - const result = validateEnv(env, { - PORT: { - validator: createValidator([]), - defaultValue: (env) => (env['NODE_ENV'] === 'development' ? 3000 : 8080), - middlewares: [numberMiddleware] - } - }); + const invalidDefault = () => + // @ts-expect-error defaultValue must match the validator input type. + validateEnv({}, { PORT: { validator: transformSchema, defaultValue: 3000 } }); + const invalidPreprocess = () => + // @ts-expect-error preprocess must return the validator input type. + validateEnv({}, { PORT: { validator: transformSchema, preprocess: () => 3000 } }); - expect(result).toEqual({ - PORT: 3000 + void invalidDefault; + void invalidPreprocess; + }); }); - }); - it('should support value override', () => { - const env = { - PORT: '3000' - }; - - const result = validateEnv(env, { - PORT: { - value: '8080', - validator: createValidator([]), - middlewares: [numberMiddleware] - } + describe('validation', () => { + it('should validate direct schema specs', () => { + const result = validateEnv( + { + PORT: '3000', + DEBUG: 'true' + }, + { + PORT: createStringToNumberSchema(), + DEBUG: booleanValidator + } + ); + + expect(result).toEqual({ + PORT: 3000, + DEBUG: true + }); + }); + + it('should validate object specs with custom env keys', () => { + const result = validateEnv( + { + SERVICE_URL: 'https://api.example.com/' + }, + { + API_URL: { + envKey: 'SERVICE_URL', + validator: stringValidator, + preprocess: stripTrailingSlash + } + } + ); + + expect(result).toEqual({ + API_URL: 'https://api.example.com' + }); + }); + + it('should throw all validation errors together', () => { + expect(() => + validateEnv( + { + PORT: 'not-a-number', + API_KEY: '' + }, + { + PORT: { + validator: numberValidator, + description: 'The port number for the server to listen on', + example: '3000, 8080' + }, + API_KEY: { + validator: createStandardSchema(() => ({ + issues: [{ message: 'Must not be empty' }] + })) + } + } + ) + ).toThrow( + 'Environment validation failed:\n\nInvalid value for PORT\nDescription: The port number for the server to listen on\nExample: 3000, 8080\nError: Must be a valid number\n\nInvalid value for API_KEY\nError: Must not be empty' + ); + }); + + it('should pass through static spec entries', () => { + const result = validateEnv( + {}, + { + PACKAGE_VERSION: '1.2.3', + BUILD_META: { + commitSha: 'abc123' + } + } + ); + + expect(result).toEqual({ + PACKAGE_VERSION: '1.2.3', + BUILD_META: { + commitSha: 'abc123' + } + }); + }); + + it('should report object specs with invalid validators', () => { + const invalidSpecs = { + API_KEY: { + validator: { validate: () => ({ value: 'secret' }) } + } + } as unknown as Record; + + expect(() => validateEnv({}, invalidSpecs)).toThrow( + 'Environment validation failed:\n\nValidator for API_KEY must implement the Standard Schema interface.' + ); + }); + + it('should report preprocess errors', () => { + expect(() => + validateEnv( + { + API_KEY: 'secret' + }, + { + API_KEY: { + validator: stringValidator, + preprocess: () => { + throw new Error('Failed to clean value'); + } + } + } + ) + ).toThrow( + 'Environment validation failed:\n\nError preprocessing API_KEY: Failed to clean value' + ); + }); }); - expect(result).toEqual({ PORT: 8080 }); - }); - it('should throw error when validation fails', () => { - const env = { - PORT: 'not-a-number' - }; + describe('defaults', () => { + it('should use default values when variables are undefined', () => { + const result = validateEnv( + {}, + { + PORT: { + validator: numberValidator, + defaultValue: 3000 + } + } + ); + + expect(result).toEqual({ + PORT: 3000 + }); + }); + + it('should support default value functions', () => { + const result = validateEnv<{ PORT: TEnvSpec }>( + { NODE_ENV: 'development' }, + { + PORT: { + validator: numberValidator, + defaultValue: (env: TEnv) => (env['NODE_ENV'] === 'development' ? 3000 : 8080) + } + } + ); + + expect(result).toEqual({ + PORT: 3000 + }); + }); - expect(() => - validateEnv(env, { - PORT: { - validator: createValidator([ + it('should validate default values through the schema', () => { + const result = validateEnv( + {}, + { + PORT: { + validator: createStringToNumberSchema(), + defaultValue: '3000' + } + } + ); + + expect(result).toEqual({ + PORT: 3000 + }); + }); + + it('should apply defaults after preprocessing', () => { + const result = validateEnv<{ API_KEY: TEnvSpec }>( + { + API_KEY: '' + }, + { + API_KEY: { + validator: stringValidator, + preprocess: pipePreprocess(emptyStringAsUndefined, stripTrailingSlash), + defaultValue: 'fallback' + } + } + ); + + expect(result).toEqual({ + API_KEY: 'fallback' + }); + }); + + it('should report default value function errors', () => { + expect(() => + validateEnv( + {}, { - key: 'number', - validate: (cx) => { - if (typeof cx.value !== 'number') { - cx.registerError({ - code: 'invalid_type', - message: 'Must be a number' - }); + PORT: { + validator: numberValidator, + defaultValue: () => { + throw new Error('Missing fallback'); } } } - ]), - middlewares: [numberMiddleware], - description: 'The port number for the server to listen on', - example: '3000, 8080' - } - }) - ).toThrow( - 'Environment validation failed:\n\nInvalid value for PORT\nDescription: The port number for the server to listen on\nExample: 3000, 8080\nError: Must be a number' - ); + ) + ).toThrow( + 'Environment validation failed:\n\nError evaluating default value for PORT: Missing fallback' + ); + }); + }); }); - it('should support custom environment variable keys', () => { - const env = { - DATABASE_URL: 'postgresql://localhost:5432/mydb', - REDIS_CONNECTION: 'redis://localhost:6379' - }; - - const result = validateEnv(env, { - dbUrl: { - envKey: 'DATABASE_URL', - validator: createValidator([]) - }, - redisUrl: { - envKey: 'REDIS_CONNECTION', - validator: createValidator([]) - } + describe('validateEnvVar function', () => { + describe('types', () => { + it('should infer values from supported overloads', () => { + const apiKey = validateEnvVar({ API_KEY: 'secret' }, 'API_KEY', stringValidator); + const port = validateEnvVar( + { + PORT: '3000' + }, + { + envKey: 'PORT', + validator: numberValidator + } + ); + + expectTypeOf(apiKey).toEqualTypeOf(); + expectTypeOf(port).toEqualTypeOf(); + }); }); - expect(result).toEqual({ - dbUrl: 'postgresql://localhost:5432/mydb', - redisUrl: 'redis://localhost:6379' + describe('validation', () => { + it('should validate a single environment variable from a key and schema', () => { + const value = validateEnvVar({ API_KEY: 'secret-123' }, 'API_KEY', stringValidator); + + expect(value).toBe('secret-123'); + }); + + it('should validate a single environment variable from a key and spec', () => { + const value = validateEnvVar( + { + PORT: '3000' + }, + 'PORT', + { + validator: numberValidator + } + ); + + expect(value).toBe(3000); + }); + + it('should validate a single environment variable from an object spec', () => { + const value = validateEnvVar( + { + API_KEY: 'secret-123' + }, + { + envKey: 'API_KEY', + validator: stringValidator, + description: 'API key for authentication', + example: 'secret-123' + } + ); + + expect(value).toBe('secret-123'); + }); + + it('should throw validation errors for invalid values', () => { + expect(() => + validateEnvVar( + { + API_KEY: 0 + }, + { + envKey: 'API_KEY', + validator: stringValidator, + description: 'API key for authentication', + example: 'secret-123' + } + ) + ).toThrow('Environment validation failed: Invalid value for API_KEY'); + }); }); }); }); -describe('validateEnvVar function', () => { - it('should validate a single env value', () => { - const env = { - API_KEY: 'secret-123' - }; - - const value = validateEnvVar( - { - envKey: 'API_KEY', - validator: stringValidator, - description: 'API key for authentication', - example: 'secret-123' - }, - env - ); - - expect(value).toBe('secret-123'); - }); +function createStringToNumberSchema(): StandardSchemaV1 { + return createStandardSchema((value) => { + if (typeof value !== 'string') { + return { + issues: [{ message: 'Must be a string' }] + }; + } - it('should throw error for invalid value', () => { - const env = { - API_KEY: 0 as any - }; - - expect(() => - validateEnvVar( - { - envKey: 'API_KEY', - validator: stringValidator, - description: 'API key for authentication', - example: 'secret-123' - }, - env - ) - ).toThrow('Environment validation failed: Invalid value for API_KEY'); + return { value: Number(value) }; }); -}); +} + +function createStandardSchema( + validate: ( + value: unknown + ) => StandardSchemaV1.Result | Promise> +): StandardSchemaV1 { + return { + '~standard': { + version: 1, + vendor: 'test', + validate + } + }; +} diff --git a/packages/validatenv/src/validate-env.ts b/packages/validatenv/src/validate-env.ts index ccd240a5..916f3491 100644 --- a/packages/validatenv/src/validate-env.ts +++ b/packages/validatenv/src/validate-env.ts @@ -1,106 +1,192 @@ -import { createValidationContext, TValidationError } from 'validation-adapter'; -import { TDefaultValueFn, TEnv, TEnvData, TEnvSpec, TEnvSpecs, TEnvSpecValue } from './types'; +import { formatThrownError, isStandardSchemaValidator, validateStandardSchema } from './lib'; +import { + type TEnv, + type TEnvData, + type TEnvDefaultFn, + type TEnvSpec, + type TEnvSpecs, + type TEnvValidator +} from './types'; -export function validateEnv( +/** Validates environment variables and throws one error containing every failed variable. */ +export function validateEnv>( env: TEnv, - specs: TEnvSpecs -): GEnvData { - const result: Partial = {}; + specs: TEnvSpecs +): TEnvData { + const envData: Partial> = {}; const errors: string[] = []; - for (const [recordKey, spec] of Object.entries(specs) as [ - keyof GEnvData, - TEnvSpecValue - ][]) { - if (!isEnvSpec(spec)) { - result[recordKey] = spec as GEnvData[keyof GEnvData]; + for (const outputKey of Object.keys(specs) as Array) { + const specValue = specs[outputKey]; + if (!isStandardSchemaValidator(specValue) && !isEnvSpecLike(specValue)) { + envData[outputKey] = specValue as TEnvData[typeof outputKey]; continue; } - const processResult = processEnvVar({ ...spec, envKey: spec.envKey ?? String(recordKey) }, env); - if (!processResult.success) { - errors.push(processResult.error); + const resolvedSpec = resolveEnvSpec(outputKey, specValue); + if (!resolvedSpec.success) { + errors.push(resolvedSpec.error); continue; } - result[recordKey] = processResult.value; + + const validationResult = validateEnvSpec(resolvedSpec.spec, env); + if (!validationResult.success) { + errors.push(validationResult.error); + continue; + } + + envData[outputKey] = validationResult.value as TEnvData[typeof outputKey]; } if (errors.length > 0) { throw new Error(`Environment validation failed:\n\n${errors.join('\n\n')}`); } - return result as GEnvData; + return envData as TEnvData; } -export function validateEnvVar(spec: TEnvSpec, env: TEnv = process.env): GValue { - const result = processEnvVar(spec, env); +/** Validates one environment variable and throws when validation fails. */ +export function validateEnvVar( + env: TEnv, + envKey: string, + spec: TEnvValidator | Omit, 'envKey'> +): GOutput; +export function validateEnvVar( + env: TEnv, + spec: TEnvSpecWithEnvKey +): GOutput; +export function validateEnvVar( + env: TEnv, + specOrEnvKey: string | TEnvSpecWithEnvKey, + spec?: TEnvValidator | Omit, 'envKey'> +): GOutput { + const isKeySpec = typeof specOrEnvKey === 'string'; + const resolvedSpec = isKeySpec + ? resolveEnvSpec(specOrEnvKey, spec) + : resolveEnvSpec(specOrEnvKey.envKey, specOrEnvKey); + if (!resolvedSpec.success) { + throw new Error(`Environment validation failed: ${resolvedSpec.error}`); + } + + const result = validateEnvSpec(resolvedSpec.spec, env); if (!result.success) { throw new Error(`Environment validation failed: ${result.error}`); } + return result.value; } -function processEnvVar( - spec: TEnvSpec, - env: TEnv -): { success: true; value: GValue } | { success: false; error: string } { - const { validator, defaultValue, middlewares = [], description, example, envKey } = spec; - - let value: unknown; - if (spec.value != null) { - value = spec.value; - } else if (envKey != null) { - value = env[envKey]; - } else { - value = undefined; - } - - // Apply middlewares if any - if (middlewares.length > 0) { - for (const middleware of middlewares) { - value = middleware(value as string); - } - } +type TEnvSpecWithEnvKey = TEnvSpec & { + envKey: string; +}; - // Handle undefined values with defaultValue - if (value === undefined) { - if (typeof defaultValue === 'function') { - try { - value = (defaultValue as TDefaultValueFn)(env); - } catch (error) { - return { - success: false, - error: `Error evaluating default value for ${String(envKey)}: ${error}` - }; +function resolveEnvSpec( + outputKey: string, + specValue: unknown +): TResolveEnvSpecResult { + if (isStandardSchemaValidator(specValue)) { + return { + success: true, + spec: { + envKey: outputKey, + validator: specValue } - } else if (defaultValue !== undefined) { - value = defaultValue; - } + }; } - const validationContext = createValidationContext(value as GValue); - // TODO: Support async validators? - void validator.validate(validationContext); - - if (validationContext.hasError()) { - const finalDescription = description != null ? `\nDescription: ${description}` : ''; - const finalExample = example != null ? `\nExample: ${example}` : ''; - const finalErrors = `\nError: ${validationContext.errors - .map((e: TValidationError) => e.message) - .join(', ')}`; + if (!isEnvSpecLike(specValue)) { + return { + success: false, + error: `Spec for ${outputKey} must be a Standard Schema validator or env spec.` + }; + } + const { validator, envKey = outputKey } = specValue; + if (!isStandardSchemaValidator(validator)) { return { success: false, - error: `Invalid value for ${String(envKey)}${finalDescription}${finalExample}${finalErrors}` + error: `Validator for ${outputKey} must implement the Standard Schema interface.` }; } return { success: true, - value: validationContext.value as GValue + spec: { + ...specValue, + envKey, + validator + } }; } -function isEnvSpec(value: TEnvSpecValue): value is TEnvSpec { +type TResolveEnvSpecResult = + | { + success: true; + spec: TEnvSpecWithEnvKey; + } + | { + success: false; + error: string; + }; + +function isEnvSpecLike( + value: unknown +): value is TEnvSpecCandidate { return typeof value === 'object' && value != null && 'validator' in value; } + +type TEnvSpecCandidate = Omit, 'validator'> & { + validator: unknown; +}; + +function validateEnvSpec( + spec: TEnvSpecWithEnvKey, + env: TEnv +): TEnvSpecValidationResult { + const { validator, defaultValue, preprocess, description, example, envKey } = spec; + let value: unknown = env[envKey]; + + // Preprocess + if (preprocess != null) { + try { + value = preprocess(value); + } catch (error) { + return { + success: false, + error: `Error preprocessing ${envKey}: ${formatThrownError(error)}` + }; + } + } + + // Apply default + if (value === undefined) { + try { + value = + typeof defaultValue === 'function' + ? (defaultValue as TEnvDefaultFn)(env) + : defaultValue; + } catch (error) { + return { + success: false, + error: `Error evaluating default value for ${envKey}: ${formatThrownError(error)}` + }; + } + } + + // Validate + return validateStandardSchema(validator, value, { + envKey, + description, + example + }); +} + +type TEnvSpecValidationResult = + | { + success: true; + value: GOutput; + } + | { + success: false; + error: string; + }; diff --git a/packages/validatenv/src/validators.test.ts b/packages/validatenv/src/validators.test.ts index ebd57e22..bf40fa40 100644 --- a/packages/validatenv/src/validators.test.ts +++ b/packages/validatenv/src/validators.test.ts @@ -1,5 +1,6 @@ -import { createValidationContext } from 'validation-adapter'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; import { describe, expect, it } from 'vitest'; +import type { TEnvValidator } from './types'; import { booleanValidator, emailValidator, @@ -11,211 +12,121 @@ import { urlValidator } from './validators'; -describe('validators', () => { - describe('stringValidator', () => { - it('should validate valid strings', async () => { - const context = createValidationContext('test'); - await stringValidator.validate(context); - expect(context.hasError()).toBe(false); - expect(context.value).toBe('test'); +describe('validators module', () => { + describe('stringValidator schema', () => { + it('should accept strings', () => { + expectValidatorValue(stringValidator, 'test', 'test'); }); - it('should reject non-string values', async () => { - const context = createValidationContext(123 as any); - await stringValidator.validate(context); - expect(context.hasError()).toBe(true); + it('should reject non-string values', () => { + expectValidatorIssue(stringValidator, 123); }); }); - describe('booleanValidator', () => { - it('should validate true values', async () => { - const trueValues = ['true', 't', 'yes', 'on', '1']; - for (const value of trueValues) { - const context = createValidationContext(value as any); - await booleanValidator.validate(context); - expect(context.hasError()).toBe(false); - expect(context.value).toBe(true); - } + describe('booleanValidator schema', () => { + it('should parse boolean aliases', () => { + expectValidatorValue(booleanValidator, 'yes', true); + expectValidatorValue(booleanValidator, 'False', false); + expectValidatorValue(booleanValidator, true, true); }); - it('should validate false values', async () => { - const falseValues = ['false', 'f', 'no', 'off', '0']; - for (const value of falseValues) { - const context = createValidationContext(value as any); - await booleanValidator.validate(context); - expect(context.hasError()).toBe(false); - expect(context.value).toBe(false); - } + it('should reject unsupported values', () => { + expectValidatorIssue(booleanValidator, 'invalid'); }); + }); - it('should reject invalid boolean values', async () => { - const context = createValidationContext('invalid' as any); - await booleanValidator.validate(context); - expect(context.hasError()).toBe(true); + describe('numberValidator schema', () => { + it('should parse finite numbers from strings and numbers', () => { + expectValidatorValue(numberValidator, '123', 123); + expectValidatorValue(numberValidator, -123.45, -123.45); }); - it('should be case insensitive', async () => { - const upperContext = createValidationContext('TRUE' as any); - await booleanValidator.validate(upperContext); - expect(upperContext.hasError()).toBe(false); - expect(upperContext.value).toBe(true); - - const lowerContext = createValidationContext('false' as any); - await booleanValidator.validate(lowerContext); - expect(lowerContext.hasError()).toBe(false); - expect(lowerContext.value).toBe(false); + it('should reject empty and non-numeric values', () => { + expectValidatorIssue(numberValidator, ''); + expectValidatorIssue(numberValidator, 'not-a-number'); }); }); - describe('numberValidator', () => { - it('should validate valid numbers', async () => { - const context = createValidationContext('123' as any); - await numberValidator.validate(context); - expect(context.hasError()).toBe(false); - expect(context.value).toBe(123); + describe('emailValidator schema', () => { + it('should accept email addresses', () => { + expectValidatorValue(emailValidator, 'user+label@example.com', 'user+label@example.com'); }); - it('should validate negative numbers', async () => { - const context = createValidationContext('-123.45' as any); - await numberValidator.validate(context); - expect(context.hasError()).toBe(false); - expect(context.value).toBe(-123.45); + it('should reject malformed email addresses', () => { + expectValidatorIssue(emailValidator, 'test@domain'); }); + }); - it('should reject invalid numbers', async () => { - const context = createValidationContext('not-a-number' as any); - await numberValidator.validate(context); - expect(context.hasError()).toBe(true); + describe('hostValidator schema', () => { + it('should accept domain names and IP addresses', () => { + expectValidatorValue(hostValidator, 'example.com', 'example.com'); + expectValidatorValue(hostValidator, '192.168.1.1', '192.168.1.1'); }); - }); - describe('emailValidator', () => { - it('should validate valid email addresses', async () => { - const validEmails = ['test@example.com', 'user.name@domain.co.uk', 'user+label@domain.com']; - for (const email of validEmails) { - const context = createValidationContext(email); - await emailValidator.validate(context); - expect(context.hasError()).toBe(false); - expect(context.value).toBe(email); - } - }); - - it('should reject invalid email addresses', async () => { - const invalidEmails = ['test@', '@domain.com', 'test@domain', 'test.com']; - for (const email of invalidEmails) { - const context = createValidationContext(email); - await emailValidator.validate(context); - expect(context.hasError()).toBe(true); - } + it('should reject invalid hosts', () => { + expectValidatorIssue(hostValidator, 'domain'); + expectValidatorIssue(hostValidator, '256.256.256.256'); }); }); - describe('hostValidator', () => { - it('should validate valid domain names', async () => { - const validDomains = ['example.com', 'sub.domain.co.uk', 'domain.io']; - for (const domain of validDomains) { - const context = createValidationContext(domain); - await hostValidator.validate(context); - expect(context.hasError()).toBe(false); - expect(context.value).toBe(domain); - } - }); - - it('should validate valid IPv4 addresses', async () => { - const validIPs = ['192.168.1.1', '10.0.0.0', '172.16.254.1']; - for (const ip of validIPs) { - const context = createValidationContext(ip); - await hostValidator.validate(context); - expect(context.hasError()).toBe(false); - expect(context.value).toBe(ip); - } - }); - - it('should validate valid IPv6 addresses', async () => { - const validIPs = ['2001:0db8:85a3:0000:0000:8a2e:0370:7334']; - for (const ip of validIPs) { - const context = createValidationContext(ip); - await hostValidator.validate(context); - expect(context.hasError()).toBe(false); - expect(context.value).toBe(ip); - } - }); - - it('should reject invalid hosts', async () => { - const invalidHosts = ['invalid', 'domain', '256.256.256.256', 'domain.-com']; - for (const host of invalidHosts) { - const context = createValidationContext(host); - await hostValidator.validate(context); - expect(context.hasError()).toBe(true); - } + describe('portValidator schema', () => { + it('should parse port numbers inside the valid range', () => { + expectValidatorValue(portValidator, '1', 1); + expectValidatorValue(portValidator, '65535', 65535); }); - }); - describe('portValidator', () => { - it('should validate valid port numbers', async () => { - const validPorts = ['80', '443', '8080', '1', '65535']; - for (const port of validPorts) { - const context = createValidationContext(port as any); - await portValidator.validate(context); - expect(context.hasError()).toBe(false); - expect(context.value).toBe(parseInt(port, 10)); - } - }); - - it('should reject invalid port numbers', async () => { - const invalidPorts = ['0', '65536', '-1', 'abc', '3.14']; - for (const port of invalidPorts) { - const context = createValidationContext(port as any); - await portValidator.validate(context); - expect(context.hasError()).toBe(true); - } + it('should reject invalid port numbers', () => { + expectValidatorIssue(portValidator, '0'); + expectValidatorIssue(portValidator, '65536'); + expectValidatorIssue(portValidator, '3.14'); }); }); - describe('urlValidator', () => { - it('should validate valid URLs', async () => { - const validURLs = [ - 'https://example.com', - 'http://localhost:3000', - 'https://sub.domain.co.uk/path?query=1', - 'http://127.0.0.1/api' - ]; - for (const url of validURLs) { - const context = createValidationContext(url); - await urlValidator.validate(context); - expect(context.hasError()).toBe(false); - expect(context.value).toBe(url); - } - }); - - it('should reject invalid URLs', async () => { - const invalidURLs = ['not-a-url', 'http://', 'example.com']; - for (const url of invalidURLs) { - const context = createValidationContext(url); - await urlValidator.validate(context); - expect(context.hasError()).toBe(true); - } + describe('urlValidator schema', () => { + it('should accept URLs', () => { + expectValidatorValue(urlValidator, 'https://example.com/path', 'https://example.com/path'); + }); + + it('should reject invalid URLs', () => { + expectValidatorIssue(urlValidator, 'example.com'); }); }); - describe('jsonValidator', () => { - it('should validate valid JSON', async () => { - const validJSON = ['{"key": "value"}', '[1, 2, 3]', '"string"', '123', 'true', 'null']; - for (const json of validJSON) { - const context = createValidationContext(json); - await jsonValidator.validate(context); - expect(context.hasError()).toBe(false); - expect(context.value).toEqual(JSON.parse(json)); - } - }); - - it('should reject invalid JSON', async () => { - const invalidJSON = ['{invalid}', '[1, 2,]', '{"key": value}']; - for (const json of invalidJSON) { - const context = createValidationContext(json); - await jsonValidator.validate(context); - expect(context.hasError()).toBe(true); - } + describe('jsonValidator schema', () => { + it('should parse JSON values', () => { + expectValidatorValue(jsonValidator, '{"key":"value"}', { key: 'value' }); + }); + + it('should reject malformed JSON values', () => { + expectValidatorIssue(jsonValidator, '{invalid}'); }); }); }); + +function expectValidatorValue( + schema: TEnvValidator, + value: unknown, + expectedValue: GValue +): void { + const result = validateSync(schema, value); + + expect(result).toEqual({ value: expectedValue }); +} + +function expectValidatorIssue(schema: TEnvValidator, value: unknown): void { + const result = validateSync(schema, value); + + expect(result).toHaveProperty('issues'); +} + +function validateSync( + schema: TEnvValidator, + value: unknown +): StandardSchemaV1.Result { + const result = schema['~standard'].validate(value); + if (result instanceof Promise) { + throw new Error('Test schema unexpectedly returned a Promise.'); + } + + return result; +} diff --git a/packages/validatenv/src/validators.ts b/packages/validatenv/src/validators.ts index 8e0023e9..34751201 100644 --- a/packages/validatenv/src/validators.ts +++ b/packages/validatenv/src/validators.ts @@ -1,164 +1,188 @@ -import { isFQDN, isIP } from '@blgc/utils'; -import { createValidator } from 'validation-adapter'; - -export const stringValidator = createValidator([ - { - key: 'string', - validate: (ctx) => { - if (typeof ctx.value !== 'string') { - ctx.registerError({ - code: 'invalid_type', - message: 'Must be a string' - }); - } - } +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import type { TEnvValidator } from './types'; + +/** Requires a string value. */ +export const stringValidator = defineEnvValidator((value) => { + if (typeof value !== 'string') { + return invalid('Must be a string'); } -]); - -const VALID_TRUE_VALUES = ['true', 't', 'yes', 'on', '1']; -const VALID_FALSE_VALUES = ['false', 'f', 'no', 'off', '0']; -export const booleanValidator = createValidator([ - { - key: 'boolean', - validate: (ctx) => { - if (typeof ctx.value !== 'string') { - ctx.registerError({ - code: 'invalid_boolean', - message: 'Must be a valid boolean value' - }); - return; - } - - const value = ctx.value.toLowerCase(); - if (!VALID_TRUE_VALUES.includes(value) && !VALID_FALSE_VALUES.includes(value)) { - ctx.registerError({ - code: 'invalid_boolean', - message: 'Must be a valid boolean value' - }); - return; - } - - ctx.value = VALID_TRUE_VALUES.includes(value); - } + + return valid(value); +}); + +/** Accepts "true", "t", "yes", "on", "1" and their false counterparts. Returns a boolean. */ +export const booleanValidator = defineEnvValidator((value) => { + if (typeof value === 'boolean') { + return valid(value); } -]); - -export const numberValidator = createValidator([ - { - key: 'number', - validate: (ctx) => { - const num = Number(ctx.value); - if (Number.isNaN(num)) { - ctx.registerError({ - code: 'invalid_number', - message: 'Must be a valid number' - }); - return; - } - ctx.value = num; - } + + if (typeof value !== 'string') { + return invalid('Must be a valid boolean value'); } -]); - -// Intentionally non-exhaustive email validation -export const emailValidator = createValidator([ - { - key: 'email', - validate: (ctx) => { - if (typeof ctx.value !== 'string') { - ctx.registerError({ - code: 'invalid_email', - message: 'Must be a string' - }); - return; - } - - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(ctx.value)) { - ctx.registerError({ - code: 'invalid_email', - message: 'Must be a valid email address' - }); - } - } + + const normalizedValue = value.toLowerCase(); + if (validTrueValues.includes(normalizedValue)) { + return valid(true); } -]); - -export const hostValidator = createValidator([ - { - key: 'host', - validate: (ctx) => { - if (!isFQDN(ctx.value) && !isIP(ctx.value)) { - ctx.registerError({ - code: 'invalid_host', - message: 'Must be a valid domain name or IP address' - }); - } - } + + if (validFalseValues.includes(normalizedValue)) { + return valid(false); } -]); - -export const portValidator = createValidator([ - { - key: 'port', - validate: (ctx) => { - const portNum = Number(ctx.value); - if (Number.isNaN(portNum) || !Number.isInteger(portNum) || portNum < 1 || portNum > 65535) { - ctx.registerError({ - code: 'invalid_port', - message: 'Must be a valid port number (1-65535)' - }); - return; - } - ctx.value = portNum; - } + + return invalid('Must be a valid boolean value'); +}); + +const validTrueValues = ['true', 't', 'yes', 'on', '1']; +const validFalseValues = ['false', 'f', 'no', 'off', '0']; + +/** Parses finite numbers from strings or number values. */ +export const numberValidator = defineEnvValidator((value) => { + const numberValue = parseNumberValue(value); + if (numberValue == null) { + return invalid('Must be a valid number'); + } + + return valid(numberValue); +}); + +/** Checks a practical email shape: local part, at sign, domain with at least one dot. */ +export const emailValidator = defineEnvValidator((value) => { + if (typeof value !== 'string') { + return invalid('Must be a string'); + } + + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { + return invalid('Must be a valid email address'); + } + + return valid(value); +}); + +/** Accepts fully qualified domain names and IPv4 or IPv6 addresses. */ +export const hostValidator = defineEnvValidator((value) => { + if (typeof value !== 'string' || (!isFqdn(value) && !isIpAddress(value))) { + return invalid('Must be a valid domain name or IP address'); } -]); - -export const urlValidator = createValidator([ - { - key: 'url', - validate: (ctx) => { - if (typeof ctx.value !== 'string') { - ctx.registerError({ - code: 'invalid_url', - message: 'Must be a string' - }); - return; - } - - try { - new URL(ctx.value); - } catch { - ctx.registerError({ - code: 'invalid_url', - message: 'Must be a valid URL' - }); - } + + return valid(value); +}); + +/** Validates integer port numbers in the range 1 to 65535. */ +export const portValidator = defineEnvValidator((value) => { + const portValue = parseNumberValue(value); + if (portValue == null || !Number.isInteger(portValue) || portValue < 1 || portValue > 65535) { + return invalid('Must be a valid port number (1-65535)'); + } + + return valid(portValue); +}); + +/** Requires a valid URL parseable by the WHATWG URL standard. */ +export const urlValidator = defineEnvValidator((value) => { + if (typeof value !== 'string') { + return invalid('Must be a string'); + } + + try { + new URL(value); + } catch { + return invalid('Must be a valid URL'); + } + + return valid(value); +}); + +/** Parses a JSON string and returns the parsed value. Output type is unknown. */ +export const jsonValidator = defineEnvValidator((value) => { + if (typeof value !== 'string') { + return invalid('Must be a string'); + } + + try { + return valid(JSON.parse(value)); + } catch { + return invalid('Must be valid JSON'); + } +}); + +function defineEnvValidator( + validate: (value: unknown) => StandardSchemaV1.Result +): TEnvValidator { + return { + '~standard': { + version: 1, + vendor: 'validatenv', + validate } + }; +} + +function valid(value: GValue): StandardSchemaV1.SuccessResult { + return { value }; +} + +function invalid(message: string): StandardSchemaV1.FailureResult { + return { + issues: [{ message }] + }; +} + +function parseNumberValue(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value !== 'string' || value.trim() === '') { + return null; } -]); - -export const jsonValidator = createValidator([ - { - key: 'json', - validate: (ctx) => { - if (typeof ctx.value !== 'string') { - ctx.registerError({ - code: 'invalid_json', - message: 'Must be a string' - }); - return; - } - - try { - ctx.value = JSON.parse(ctx.value); - } catch { - ctx.registerError({ - code: 'invalid_json', - message: 'Must be valid JSON' - }); - } + + const numberValue = Number(value); + if (!Number.isFinite(numberValue)) { + return null; + } + + return numberValue; +} + +function isFqdn(value: string): boolean { + const normalizedValue = value.endsWith('.') ? value.slice(0, -1) : value; + const labels = normalizedValue.split('.'); + if (normalizedValue.length > 253 || labels.length < 2) { + return false; + } + + return labels.every(isFqdnLabel) && /[a-z]/i.test(labels[labels.length - 1] ?? ''); +} + +function isFqdnLabel(label: string): boolean { + return /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(label); +} + +function isIpAddress(value: string): boolean { + return isIpv4Address(value) || isIpv6Address(value); +} + +function isIpv4Address(value: string): boolean { + const segments = value.split('.'); + if (segments.length !== 4) { + return false; + } + + return segments.every((segment) => { + if (!/^\d+$/.test(segment)) { + return false; } + + const numberValue = Number(segment); + return numberValue >= 0 && numberValue <= 255; + }); +} + +function isIpv6Address(value: string): boolean { + try { + new URL(`http://[${value}]/`); + return true; + } catch { + return false; } -]); +} diff --git a/packages/validatenv/src/vite-env-define.test.ts b/packages/validatenv/src/vite-env-define.test.ts new file mode 100644 index 00000000..8c1e6eea --- /dev/null +++ b/packages/validatenv/src/vite-env-define.test.ts @@ -0,0 +1,108 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import { describe, expect, expectTypeOf, it } from 'vitest'; +import { booleanValidator, numberValidator, urlValidator } from './validators'; +import { createViteEnvDefine } from './vite-env-define'; + +describe('createViteEnvDefine function', () => { + describe('types', () => { + it('should infer Vite define keys from the returned env keys', () => { + const define = createViteEnvDefine( + { + PORT: '3000', + API_CORE_URL: 'https://api.example.com' + }, + { + PORT: numberValidator, + VITE_API_CORE_URL: { + envKey: 'API_CORE_URL', + validator: urlValidator + } + } + ); + + expectTypeOf(define).toEqualTypeOf<{ + 'import.meta.env.PORT': string; + 'import.meta.env.VITE_API_CORE_URL': string; + }>(); + }); + }); + + describe('validation', () => { + it('should validate and stringify values for Vite define', () => { + const define = createViteEnvDefine( + { + PORT: '3000', + DEBUG: 'true', + API_CORE_URL: 'https://api.example.com' + }, + { + PORT: numberValidator, + DEBUG: booleanValidator, + VITE_API_CORE_URL: { + envKey: 'API_CORE_URL', + validator: urlValidator + } + } + ); + + expect(define).toEqual({ + 'import.meta.env.PORT': '3000', + 'import.meta.env.DEBUG': 'true', + 'import.meta.env.VITE_API_CORE_URL': '"https://api.example.com"' + }); + }); + + it('should stringify undefined values as JavaScript undefined', () => { + const define = createViteEnvDefine( + {}, + { + OPTIONAL_VALUE: createStandardSchema(() => ({ + value: undefined + })) + } + ); + + expect(define).toEqual({ + 'import.meta.env.OPTIONAL_VALUE': 'undefined' + }); + }); + + it('should throw validation errors from the env spec', () => { + expect(() => + createViteEnvDefine( + { + PORT: 'invalid' + }, + { + PORT: numberValidator + } + ) + ).toThrow('Environment validation failed'); + }); + + it('should reject values that cannot be serialized for Vite define', () => { + expect(() => + createViteEnvDefine( + {}, + { + VALUE: createStandardSchema(() => ({ + value: Symbol('test') + })) + } + ) + ).toThrow('Cannot serialize VALUE for Vite define.'); + }); + }); +}); + +function createStandardSchema( + validate: (value: unknown) => StandardSchemaV1.Result +): StandardSchemaV1 { + return { + '~standard': { + version: 1, + vendor: 'test', + validate + } + }; +} diff --git a/packages/validatenv/src/vite-env-define.ts b/packages/validatenv/src/vite-env-define.ts new file mode 100644 index 00000000..6128bab4 --- /dev/null +++ b/packages/validatenv/src/vite-env-define.ts @@ -0,0 +1,39 @@ +import type { TEnv, TEnvData, TEnvSpecs } from './types'; +import { validateEnv } from './validate-env'; + +/** Validates env values and returns a Vite `define` object for `import.meta.env.*`. */ +export function createViteEnvDefine>( + env: TEnv, + specs: TEnvSpecs +): TViteEnvDefine> { + const values = validateEnv(env, specs); + const define: Record = {}; + + for (const [key, value] of Object.entries(values)) { + define[`import.meta.env.${key}`] = stringifyViteDefineValue(key, value); + } + + return define as TViteEnvDefine>; +} + +/** Vite define object keyed as `import.meta.env.KEY`, with JSON-serialized values. */ +export type TViteEnvDefine = Record> = { + [Key in keyof GEnvData & string as `import.meta.env.${Key}`]: string; +}; + +function stringifyViteDefineValue(key: string, value: unknown): string { + if (value === undefined) { + return 'undefined'; + } + + try { + const stringifiedValue = JSON.stringify(value); + if (stringifiedValue != null) { + return stringifiedValue; + } + } catch { + // Fall through to the shared error below + } + + throw new Error(`Cannot serialize ${key} for Vite define.`); +} diff --git a/packages/validation-adapters/package.json b/packages/validation-adapters/package.json index 23502b49..03f89493 100644 --- a/packages/validation-adapters/package.json +++ b/packages/validation-adapters/package.json @@ -104,9 +104,9 @@ "@blgc/config": "workspace:*", "@standard-schema/spec": "^1.1.0", "rollup-presets": "workspace:*", - "valibot": "1.3.1", + "valibot": "1.4.0", "yup": "^1.7.1", - "zod": "^4.4.2" + "zod": "^4.4.3" }, "size-limit": [ { diff --git a/packages/xml-tokenizer/package.json b/packages/xml-tokenizer/package.json index 52eaaaf0..35f97f99 100644 --- a/packages/xml-tokenizer/package.json +++ b/packages/xml-tokenizer/package.json @@ -39,12 +39,12 @@ "@blgc/config": "workspace:*", "@types/sax": "^1.2.7", "@types/xml2js": "^0.4.14", - "camaro": "^6.2.3", - "fast-xml-parser": "^5.7.2", + "camaro": "^6.3.2", + "fast-xml-parser": "^5.8.0", "rollup-presets": "workspace:*", "sax": "^1.6.0", "saxen": "^11.0.2", - "txml": "^5.2.1", + "txml": "^6.0.0", "xml2js": "^0.6.2" }, "size-limit": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5c1500c..843f3abc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,11 +12,11 @@ importers: specifier: workspace:* version: link:packages/config '@changesets/changelog-github': - specifier: ^0.6.0 - version: 0.6.0 + specifier: ^0.7.0 + version: 0.7.0 '@changesets/cli': specifier: ^2.31.0 - version: 2.31.0(@types/node@25.6.0) + version: 2.31.0(@types/node@25.9.1) '@eslint/js': specifier: ^9.39.2 version: 9.39.4 @@ -25,55 +25,55 @@ importers: version: 4.7.1(prettier@3.8.3) '@size-limit/esbuild': specifier: ^12.1.0 - version: 12.1.0(size-limit@12.1.0(jiti@2.6.1)) + version: 12.1.0(size-limit@12.1.0(jiti@2.7.0)) '@size-limit/esbuild-why': specifier: ^12.1.0 - version: 12.1.0(size-limit@12.1.0(jiti@2.6.1)) + version: 12.1.0(size-limit@12.1.0(jiti@2.7.0)) '@size-limit/preset-small-lib': specifier: ^12.1.0 - version: 12.1.0(size-limit@12.1.0(jiti@2.6.1)) + version: 12.1.0(size-limit@12.1.0(jiti@2.7.0)) eslint: specifier: ^9.39.2 - version: 9.39.4(jiti@2.6.1) + version: 9.39.4(jiti@2.7.0) prettier: specifier: ^3.8.3 version: 3.8.3 rollup: - specifier: ^4.60.2 - version: 4.60.2 + specifier: ^4.60.4 + version: 4.60.4 shx: specifier: ^0.4.0 version: 0.4.0 size-limit: specifier: ^12.1.0 - version: 12.1.0(jiti@2.6.1) + version: 12.1.0(jiti@2.7.0) turbo: - specifier: ^2.9.8 - version: 2.9.8 + specifier: ^2.9.14 + version: 2.9.14 typescript: specifier: ^6.0.3 version: 6.0.3 vite: - specifier: ^8.0.10 - version: 8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0) + specifier: ^8.0.14 + version: 8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0) vitest: - specifier: ^4.1.5 - version: 4.1.5(@types/node@25.6.0)(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)) + specifier: ^4.1.7 + version: 4.1.7(@types/node@25.9.1)(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)) apps/web: dependencies: '@tanstack/react-router': - specifier: ^1.169.1 - version: 1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + specifier: ^1.170.7 + version: 1.170.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@tanstack/react-router-devtools': - specifier: ^1.166.13 - version: 1.166.13(@tanstack/react-router@1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.169.1)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + specifier: ^1.167.0 + version: 1.167.0(@tanstack/react-router@1.170.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@tanstack/router-core@1.171.5)(csstype@3.2.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@tanstack/react-start': - specifier: ^1.167.62 - version: 1.167.62(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)) + specifier: ^1.168.10 + version: 1.168.10(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)) '@vercel/analytics': specifier: ^2.0.1 - version: 2.0.1(react@19.2.5) + version: 2.0.1(react@19.2.6) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -84,48 +84,48 @@ importers: specifier: workspace:* version: link:../../packages/feature-fetch react: - specifier: ^19.2.5 - version: 19.2.5 + specifier: ^19.2.6 + version: 19.2.6 react-dom: - specifier: ^19.2.5 - version: 19.2.5(react@19.2.5) + specifier: ^19.2.6 + version: 19.2.6(react@19.2.6) tailwind-merge: - specifier: ^3.5.0 - version: 3.5.0 + specifier: ^3.6.0 + version: 3.6.0 zod: - specifier: ^4.4.2 - version: 4.4.2 + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@mdx-js/rollup': specifier: ^3.1.1 - version: 3.1.1(rollup@4.60.2) + version: 3.1.1(rollup@4.60.4) '@tailwindcss/typography': specifier: ^0.5.19 - version: 0.5.19(tailwindcss@4.2.4) + version: 0.5.19(tailwindcss@4.3.0) '@tailwindcss/vite': - specifier: ^4.2.4 - version: 4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)) + specifier: ^4.3.0 + version: 4.3.0(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)) '@types/mdx': specifier: ^2.0.13 version: 2.0.13 '@types/node': - specifier: ^25.6.0 - version: 25.6.0 + specifier: ^25.9.1 + version: 25.9.1 '@types/react': - specifier: ^19.2.14 - version: 19.2.14 + specifier: ^19.2.15 + version: 19.2.15 '@types/react-dom': specifier: ^19.2.3 - version: 19.2.3(@types/react@19.2.14) + version: 19.2.3(@types/react@19.2.15) '@vitejs/plugin-react': - specifier: ^6.0.1 - version: 6.0.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)) + specifier: ^6.0.2 + version: 6.0.2(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)) nitro: specifier: 3.0.260429-beta - version: 3.0.260429-beta(dotenv@17.4.2)(jiti@2.6.1)(rollup@4.60.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0))(xml2js@0.6.2) + version: 3.0.260429-beta(chokidar@5.0.0)(dotenv@17.4.2)(jiti@2.7.0)(rollup@4.60.4)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0))(xml2js@0.6.2) tailwindcss: - specifier: ^4.2.4 - version: 4.2.4 + specifier: ^4.3.0 + version: 4.3.0 examples/ecsify/vanilla/basic: dependencies: @@ -138,29 +138,29 @@ importers: version: 5.9.3 vite: specifier: ^7.3.1 - version: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) + version: 7.3.2(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0) - examples/feature-fetch/vanilla/open-meteo: + examples/feature-fetch/vanilla/basic: dependencies: feature-fetch: specifier: workspace:* version: link:../../../../packages/feature-fetch + gql.tada: + specifier: ^1.9.2 + version: 1.9.2(graphql@16.14.0)(typescript@6.0.3) devDependencies: openapi-typescript: specifier: ^7.10.1 - version: 7.13.0(typescript@5.9.3) + version: 7.13.0(typescript@6.0.3) typescript: - specifier: ^5.9.3 - version: 5.9.3 + specifier: ~6.0.2 + version: 6.0.3 vite: - specifier: ^7.3.1 - version: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) + specifier: ^8.0.12 + version: 8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0) examples/feature-form/react/basic: dependencies: - '@blgc/utils': - specifier: workspace:* - version: link:../../../../packages/utils feature-form: specifier: workspace:* version: link:../../../../packages/feature-form @@ -168,57 +168,54 @@ importers: specifier: workspace:* version: link:../../../../packages/feature-react react: - specifier: ^19.2.3 - version: 19.2.4 + specifier: ^19.2.6 + version: 19.2.6 react-dom: - specifier: ^19.2.3 - version: 19.2.4(react@19.2.4) - valibot: - specifier: 1.2.0 - version: 1.2.0(typescript@5.9.3) - validation-adapters: - specifier: workspace:* - version: link:../../../../packages/validation-adapters + specifier: ^19.2.6 + version: 19.2.6(react@19.2.6) zod: - specifier: ^4.3.5 - version: 4.3.6 + specifier: ^4.4.3 + version: 4.4.3 devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.4.0(jiti@2.7.0)) + '@types/node': + specifier: ^24.12.3 + version: 24.12.4 '@types/react': - specifier: ^19.2.8 - version: 19.2.14 + specifier: ^19.2.14 + version: 19.2.15 '@types/react-dom': specifier: ^19.2.3 - version: 19.2.3(@types/react@19.2.14) - '@typescript-eslint/eslint-plugin': - specifier: ^8.53.0 - version: 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': - specifier: ^8.53.0 - version: 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + version: 19.2.3(@types/react@19.2.15) '@vitejs/plugin-react': - specifier: ^5.1.2 - version: 5.2.0(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) + specifier: ^6.0.1 + version: 6.0.2(vite@8.0.14(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)) eslint: - specifier: ^9.39.2 - version: 9.39.4(jiti@2.6.1) + specifier: ^10.3.0 + version: 10.4.0(jiti@2.7.0) eslint-plugin-react-hooks: - specifier: ^7.0.1 - version: 7.0.1(eslint@9.39.4(jiti@2.6.1)) + specifier: ^7.1.1 + version: 7.1.1(eslint@10.4.0(jiti@2.7.0)) eslint-plugin-react-refresh: - specifier: ^0.4.26 - version: 0.4.26(eslint@9.39.4(jiti@2.6.1)) + specifier: ^0.5.2 + version: 0.5.2(eslint@10.4.0(jiti@2.7.0)) + globals: + specifier: ^17.6.0 + version: 17.6.0 typescript: - specifier: ^5.9.3 - version: 5.9.3 + specifier: ~6.0.2 + version: 6.0.3 + typescript-eslint: + specifier: ^8.59.2 + version: 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3) vite: - specifier: ^7.3.1 - version: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) + specifier: ^8.0.12 + version: 8.0.14(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0) - examples/feature-state/react/counter: + examples/feature-state/react/basic: dependencies: - '@blgc/utils': - specifier: workspace:* - version: link:../../../../packages/utils feature-react: specifier: workspace:* version: link:../../../../packages/feature-react @@ -226,42 +223,48 @@ importers: specifier: workspace:* version: link:../../../../packages/feature-state react: - specifier: ^19.2.3 - version: 19.2.4 + specifier: ^19.2.6 + version: 19.2.6 react-dom: - specifier: ^19.2.3 - version: 19.2.4(react@19.2.4) + specifier: ^19.2.6 + version: 19.2.6(react@19.2.6) devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.4.0(jiti@2.7.0)) + '@types/node': + specifier: ^24.12.3 + version: 24.12.4 '@types/react': - specifier: ^19.2.8 - version: 19.2.14 + specifier: ^19.2.14 + version: 19.2.15 '@types/react-dom': specifier: ^19.2.3 - version: 19.2.3(@types/react@19.2.14) - '@typescript-eslint/eslint-plugin': - specifier: ^8.53.0 - version: 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': - specifier: ^8.53.0 - version: 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + version: 19.2.3(@types/react@19.2.15) '@vitejs/plugin-react': - specifier: ^5.1.2 - version: 5.2.0(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) + specifier: ^6.0.1 + version: 6.0.2(vite@8.0.14(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)) eslint: - specifier: ^9.39.2 - version: 9.39.4(jiti@2.6.1) + specifier: ^10.3.0 + version: 10.4.0(jiti@2.7.0) eslint-plugin-react-hooks: - specifier: ^7.0.1 - version: 7.0.1(eslint@9.39.4(jiti@2.6.1)) + specifier: ^7.1.1 + version: 7.1.1(eslint@10.4.0(jiti@2.7.0)) eslint-plugin-react-refresh: - specifier: ^0.4.26 - version: 0.4.26(eslint@9.39.4(jiti@2.6.1)) + specifier: ^0.5.2 + version: 0.5.2(eslint@10.4.0(jiti@2.7.0)) + globals: + specifier: ^17.6.0 + version: 17.6.0 typescript: - specifier: ^5.9.3 - version: 5.9.3 + specifier: ~6.0.2 + version: 6.0.3 + typescript-eslint: + specifier: ^8.59.2 + version: 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3) vite: - specifier: ^7.3.1 - version: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) + specifier: ^8.0.12 + version: 8.0.14(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0) examples/openapi-ts-router/express/petstore: dependencies: @@ -334,19 +337,6 @@ importers: specifier: ^4.21.0 version: 4.21.0 - examples/tuple-result/vanilla/basic: - dependencies: - tuple-result: - specifier: workspace:* - version: link:../../../../packages/tuple-result - devDependencies: - typescript: - specifier: ~5.9.3 - version: 5.9.3 - vite: - specifier: ^7.3.1 - version: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) - examples/xml-tokenizer/vanilla/playground: dependencies: camaro: @@ -379,7 +369,7 @@ importers: version: 5.9.3 vite: specifier: ^7.3.1 - version: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) + version: 7.3.2(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0) packages/config: dependencies: @@ -390,51 +380,51 @@ importers: specifier: ^4.7.1 version: 4.7.1(prettier@3.8.3) '@next/eslint-plugin-next': - specifier: ^16.2.4 - version: 16.2.4 + specifier: ^16.2.6 + version: 16.2.6 '@typescript-eslint/eslint-plugin': - specifier: ^8.59.1 - version: 8.59.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + specifier: ^8.59.4 + version: 8.59.4(@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) '@typescript-eslint/parser': - specifier: ^8.59.1 - version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + specifier: ^8.59.4 + version: 8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.4(jiti@2.6.1)) + version: 10.1.8(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-only-warn: specifier: ^1.2.1 version: 1.2.1 eslint-plugin-react: specifier: ^7.37.5 - version: 7.37.5(eslint@9.39.4(jiti@2.6.1)) + version: 7.37.5(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-react-hooks: specifier: ^7.1.1 - version: 7.1.1(eslint@9.39.4(jiti@2.6.1)) + version: 7.1.1(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-turbo: - specifier: ^2.9.8 - version: 2.9.8(eslint@9.39.4(jiti@2.6.1))(turbo@2.9.8) + specifier: ^2.9.14 + version: 2.9.14(eslint@9.39.4(jiti@2.7.0))(turbo@2.9.14) globals: specifier: ^17.6.0 version: 17.6.0 prettier-plugin-css-order: specifier: ^2.2.0 - version: 2.2.0(postcss@8.5.13)(prettier@3.8.3) + version: 2.2.0(postcss@8.5.15)(prettier@3.8.3) prettier-plugin-packagejson: specifier: ^3.0.2 version: 3.0.2(prettier@3.8.3) prettier-plugin-tailwindcss: specifier: ^0.8.0 - version: 0.8.0(@ianvs/prettier-plugin-sort-imports@4.7.1(prettier@3.8.3))(prettier-plugin-css-order@2.2.0(postcss@8.5.13)(prettier@3.8.3))(prettier@3.8.3) + version: 0.8.0(@ianvs/prettier-plugin-sort-imports@4.7.1(prettier@3.8.3))(prettier-plugin-css-order@2.2.0(postcss@8.5.15)(prettier@3.8.3))(prettier@3.8.3) typescript-eslint: - specifier: ^8.59.1 - version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + specifier: ^8.59.4 + version: 8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) vitest: - specifier: ^4.1.5 - version: 4.1.5(@types/node@25.6.0)(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)) + specifier: ^4.1.7 + version: 4.1.7(@types/node@25.9.1)(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)) devDependencies: eslint: specifier: ^9.39.2 - version: 9.39.4(jiti@2.6.1) + version: 9.39.4(jiti@2.7.0) prettier: specifier: ^3.8.3 version: 3.8.3 @@ -464,24 +454,11 @@ importers: specifier: workspace:* version: link:../rollup-presets - packages/eprel-client: - dependencies: - '@blgc/types': - specifier: workspace:* - version: link:../types - feature-fetch: - specifier: workspace:* - version: link:../feature-fetch + packages/feature-core: devDependencies: '@blgc/config': specifier: workspace:* version: link:../config - dotenv: - specifier: ^17.4.2 - version: 17.4.2 - openapi-typescript: - specifier: ^7.13.0 - version: 7.13.0(typescript@6.0.3) rollup-presets: specifier: workspace:* version: link:../rollup-presets @@ -490,13 +467,13 @@ importers: dependencies: '@0no-co/graphql.web': specifier: ^1.2.0 - version: 1.2.0(graphql@16.13.2) - '@blgc/types': - specifier: workspace:* - version: link:../types - '@blgc/utils': + version: 1.2.0(graphql@16.14.0) + feature-core: specifier: workspace:* - version: link:../utils + version: link:../feature-core + openapi-typescript-helpers: + specifier: ^0.1.0 + version: 0.1.0 tuple-result: specifier: workspace:* version: link:../tuple-result @@ -504,30 +481,24 @@ importers: '@blgc/config': specifier: workspace:* version: link:../config - '@types/url-parse': - specifier: ^1.4.11 - version: 1.4.11 msw: - specifier: ^2.14.2 - version: 2.14.2(@types/node@25.6.0)(typescript@6.0.3) + specifier: ^2.14.6 + version: 2.14.6(@types/node@25.9.1)(typescript@6.0.3) rollup-presets: specifier: workspace:* version: link:../rollup-presets packages/feature-form: dependencies: - '@blgc/types': - specifier: workspace:* - version: link:../types - '@blgc/utils': + '@standard-schema/spec': + specifier: ^1.1.0 + version: 1.1.0 + feature-core: specifier: workspace:* - version: link:../utils + version: link:../feature-core feature-state: specifier: workspace:* version: link:../feature-state - validation-adapter: - specifier: workspace:* - version: link:../validation-adapter devDependencies: '@blgc/config': specifier: workspace:* @@ -538,12 +509,9 @@ importers: packages/feature-logger: dependencies: - '@blgc/types': - specifier: workspace:* - version: link:../types - '@blgc/utils': + feature-core: specifier: workspace:* - version: link:../utils + version: link:../feature-core devDependencies: '@blgc/config': specifier: workspace:* @@ -554,19 +522,16 @@ importers: packages/feature-react: dependencies: - '@blgc/types': - specifier: workspace:* - version: link:../types - '@blgc/utils': + feature-core: specifier: workspace:* - version: link:../utils + version: link:../feature-core devDependencies: '@blgc/config': specifier: workspace:* version: link:../config '@types/react': - specifier: ^19.2.14 - version: 19.2.14 + specifier: ^19.2.15 + version: 19.2.15 feature-form: specifier: workspace:* version: link:../feature-form @@ -574,20 +539,17 @@ importers: specifier: workspace:* version: link:../feature-state react: - specifier: ^19.2.5 - version: 19.2.5 + specifier: ^19.2.6 + version: 19.2.6 rollup-presets: specifier: workspace:* version: link:../rollup-presets packages/feature-state: dependencies: - '@blgc/types': - specifier: workspace:* - version: link:../types - '@blgc/utils': + feature-core: specifier: workspace:* - version: link:../utils + version: link:../feature-core devDependencies: '@blgc/config': specifier: workspace:* @@ -603,8 +565,8 @@ importers: version: link:../xml-tokenizer devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.6.0 + specifier: ^25.9.1 + version: 25.9.1 rollup-presets: specifier: workspace:* version: link:../rollup-presets @@ -628,20 +590,20 @@ importers: specifier: ^5.1.1 version: 5.1.1 '@types/node': - specifier: ^25.6.0 - version: 25.6.0 + specifier: ^25.9.1 + version: 25.9.1 express: specifier: ^5.2.1 version: 5.2.1 hono: - specifier: ^4.12.16 - version: 4.12.16 + specifier: ^4.12.21 + version: 4.12.21 rollup-presets: specifier: workspace:* version: link:../rollup-presets valibot: - specifier: 1.3.1 - version: 1.3.1(typescript@6.0.3) + specifier: 1.4.0 + version: 1.4.0(typescript@6.0.3) validation-adapters: specifier: workspace:* version: link:../validation-adapters @@ -650,7 +612,7 @@ importers: dependencies: '@rollup/plugin-commonjs': specifier: ^29.0.2 - version: 29.0.2(rollup@4.60.2) + version: 29.0.2(rollup@4.60.4) execa: specifier: 9.6.1 version: 9.6.1 @@ -659,40 +621,27 @@ importers: version: 1.1.1 rollup-plugin-dts: specifier: ^6.4.1 - version: 6.4.1(rollup@4.60.2)(typescript@6.0.3) + version: 6.4.1(rollup@4.60.4)(typescript@6.0.3) rollup-plugin-esbuild: specifier: ^6.2.1 - version: 6.2.1(esbuild@0.28.0)(rollup@4.60.2) + version: 6.2.1(esbuild@0.28.0)(rollup@4.60.4) rollup-plugin-node-externals: specifier: 9.0.1 - version: 9.0.1(rollup@4.60.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)) + version: 9.0.1(rollup@4.60.4)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)) devDependencies: '@blgc/config': specifier: workspace:* version: link:../config '@types/node': - specifier: ^25.6.0 - version: 25.6.0 + specifier: ^25.9.1 + version: 25.9.1 rollup: - specifier: ^4.60.2 - version: 4.60.2 + specifier: ^4.60.4 + version: 4.60.4 type-fest: specifier: ^5.6.0 version: 5.6.0 - packages/split-flap-board: - dependencies: - lit: - specifier: ^3.3.2 - version: 3.3.2 - devDependencies: - '@blgc/config': - specifier: workspace:* - version: link:../config - rollup-presets: - specifier: workspace:* - version: link:../rollup-presets - packages/tuple-result: devDependencies: '@blgc/config': @@ -722,19 +671,16 @@ importers: packages/validatenv: dependencies: - '@blgc/utils': - specifier: workspace:* - version: link:../utils - validation-adapter: - specifier: workspace:* - version: link:../validation-adapter + '@standard-schema/spec': + specifier: ^1.1.0 + version: 1.1.0 devDependencies: '@blgc/config': specifier: workspace:* version: link:../config '@types/node': specifier: ^25.6.0 - version: 25.6.0 + version: 25.9.1 rollup-presets: specifier: workspace:* version: link:../rollup-presets @@ -768,27 +714,14 @@ importers: specifier: workspace:* version: link:../rollup-presets valibot: - specifier: 1.3.1 - version: 1.3.1(typescript@6.0.3) + specifier: 1.4.0 + version: 1.4.0(typescript@6.0.3) yup: specifier: ^1.7.1 version: 1.7.1 zod: - specifier: ^4.4.2 - version: 4.4.2 - - packages/webito: - dependencies: - ecsify: - specifier: workspace:* - version: link:../ecsify - devDependencies: - '@blgc/config': - specifier: workspace:* - version: link:../config - rollup-presets: - specifier: workspace:* - version: link:../rollup-presets + specifier: ^4.4.3 + version: 4.4.3 packages/xml-tokenizer: devDependencies: @@ -802,11 +735,11 @@ importers: specifier: ^0.4.14 version: 0.4.14 camaro: - specifier: ^6.2.3 - version: 6.2.3 + specifier: ^6.3.2 + version: 6.3.2 fast-xml-parser: - specifier: ^5.7.2 - version: 5.7.2 + specifier: ^5.8.0 + version: 5.8.0 rollup-presets: specifier: workspace:* version: link:../rollup-presets @@ -817,8 +750,8 @@ importers: specifier: ^11.0.2 version: 11.0.2 txml: - specifier: ^5.2.1 - version: 5.2.1 + specifier: ^6.0.0 + version: 6.0.0 xml2js: specifier: ^0.6.2 version: 0.6.2 @@ -826,11 +759,11 @@ importers: templates/desktop-tauri: dependencies: '@tanstack/react-router': - specifier: ^1.169.1 - version: 1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + specifier: ^1.170.7 + version: 1.170.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@tanstack/react-router-devtools': - specifier: ^1.166.13 - version: 1.166.13(@tanstack/react-router@1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.169.1)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + specifier: ^1.167.0 + version: 1.167.0(@tanstack/react-router@1.170.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@tanstack/router-core@1.171.5)(csstype@3.2.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@tauri-apps/api': specifier: ^2.11.0 version: 2.11.0 @@ -844,91 +777,91 @@ importers: specifier: ^2.1.1 version: 2.1.1 react: - specifier: ^19.2.5 - version: 19.2.5 + specifier: ^19.2.6 + version: 19.2.6 react-dom: - specifier: ^19.2.5 - version: 19.2.5(react@19.2.5) + specifier: ^19.2.6 + version: 19.2.6(react@19.2.6) tailwind-merge: - specifier: ^3.5.0 - version: 3.5.0 + specifier: ^3.6.0 + version: 3.6.0 devDependencies: '@blgc/config': specifier: workspace:* version: link:../../packages/config '@tailwindcss/vite': - specifier: ^4.2.4 - version: 4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)) + specifier: ^4.3.0 + version: 4.3.0(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)) '@tanstack/router-plugin': - specifier: ^1.167.32 - version: 1.167.32(@tanstack/react-router@1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)) + specifier: ^1.168.10 + version: 1.168.10(@tanstack/react-router@1.170.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)) '@tauri-apps/cli': - specifier: ^2.11.0 - version: 2.11.0 + specifier: ^2.11.2 + version: 2.11.2 '@types/node': - specifier: ^25.6.0 - version: 25.6.0 + specifier: ^25.9.1 + version: 25.9.1 '@types/react': - specifier: ^19.2.14 - version: 19.2.14 + specifier: ^19.2.15 + version: 19.2.15 '@types/react-dom': specifier: ^19.2.3 - version: 19.2.3(@types/react@19.2.14) + version: 19.2.3(@types/react@19.2.15) '@vitejs/plugin-react': - specifier: ^6.0.1 - version: 6.0.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)) + specifier: ^6.0.2 + version: 6.0.2(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)) tailwindcss: - specifier: ^4.2.4 - version: 4.2.4 + specifier: ^4.3.0 + version: 4.3.0 templates/web-tanstack: dependencies: '@tanstack/react-router': - specifier: ^1.169.1 - version: 1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + specifier: ^1.170.7 + version: 1.170.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@tanstack/react-router-devtools': - specifier: ^1.166.13 - version: 1.166.13(@tanstack/react-router@1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.169.1)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + specifier: ^1.167.0 + version: 1.167.0(@tanstack/react-router@1.170.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@tanstack/router-core@1.171.5)(csstype@3.2.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@tanstack/react-start': - specifier: ^1.167.62 - version: 1.167.62(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)) + specifier: ^1.168.10 + version: 1.168.10(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)) clsx: specifier: ^2.1.1 version: 2.1.1 react: - specifier: ^19.2.5 - version: 19.2.5 + specifier: ^19.2.6 + version: 19.2.6 react-dom: - specifier: ^19.2.5 - version: 19.2.5(react@19.2.5) + specifier: ^19.2.6 + version: 19.2.6(react@19.2.6) tailwind-merge: - specifier: ^3.5.0 - version: 3.5.0 + specifier: ^3.6.0 + version: 3.6.0 devDependencies: '@blgc/config': specifier: workspace:* version: link:../../packages/config '@tailwindcss/vite': - specifier: ^4.2.4 - version: 4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)) + specifier: ^4.3.0 + version: 4.3.0(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)) '@types/node': - specifier: ^25.6.0 - version: 25.6.0 + specifier: ^25.9.1 + version: 25.9.1 '@types/react': - specifier: ^19.2.14 - version: 19.2.14 + specifier: ^19.2.15 + version: 19.2.15 '@types/react-dom': specifier: ^19.2.3 - version: 19.2.3(@types/react@19.2.14) + version: 19.2.3(@types/react@19.2.15) '@vitejs/plugin-react': - specifier: ^6.0.1 - version: 6.0.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)) + specifier: ^6.0.2 + version: 6.0.2(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)) nitro: specifier: 3.0.260429-beta - version: 3.0.260429-beta(dotenv@17.4.2)(jiti@2.6.1)(rollup@4.60.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0))(xml2js@0.6.2) + version: 3.0.260429-beta(chokidar@5.0.0)(dotenv@17.4.2)(jiti@2.7.0)(rollup@4.60.4)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0))(xml2js@0.6.2) tailwindcss: - specifier: ^4.2.4 - version: 4.2.4 + specifier: ^4.3.0 + version: 4.3.0 packages: @@ -940,6 +873,12 @@ packages: graphql: optional: true + '@0no-co/graphqlsp@1.15.4': + resolution: {integrity: sha512-Nt1DVHcZ08lKRKwhiU0amXH77fSdrO6DzyjLE0DkCxfbM/N1SAs32d76y1xtCzM5H9eT0iDS7SdksgRXWJu05g==} + peerDependencies: + graphql: ^15.5.0 || ^16.0.0 || ^17.0.0 + typescript: ^5.0.0 || ^6.0.0 + '@assemblyscript/loader@0.10.1': resolution: {integrity: sha512-H71nDOOL8Y7kWRLqf6Sums+01Q5msqBW2KhDUTemh1tvY04eSkSXrK0uj/4mmY0Xr16/3zyZmsrxN7CKuRbNRg==} @@ -1023,18 +962,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-react-jsx-self@7.27.1': - resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx-source@7.27.1': - resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} @@ -1060,8 +987,8 @@ packages: '@changesets/changelog-git@0.2.1': resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==} - '@changesets/changelog-github@0.6.0': - resolution: {integrity: sha512-wA2/y4hR/A1K411cCT75rz0d46Iezxp1WYRFoFJDIUpkQ6oDBAIUiU7BZkDCmYgz0NBl94X1lgcZO+mHoiHnFg==} + '@changesets/changelog-github@0.7.0': + resolution: {integrity: sha512-rBsbRvc4TVn+FvFnOVM3LxlFJfTXXCp8gfVJ+0BubxWNSVnLuAzowi5j+IEraLLP52w8AAs9QfKbPS3MMiXQJA==} '@changesets/cli@2.31.0': resolution: {integrity: sha512-AhI4enNTgHu2IZr6K4WZyf0EPch4XVMn1yOMFmCD9gsfBGqMYaHXls5HyDv6/CL5axVQABz68eG30eCtbr2wFg==} @@ -1451,18 +1378,39 @@ packages: resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@eslint/config-helpers@0.4.2': resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@eslint/core@0.17.0': resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@eslint/eslintrc@3.3.5': resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + '@eslint/js@9.39.4': resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1471,10 +1419,38 @@ packages: resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@eslint/plugin-kit@0.4.1': resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/plugin-kit@0.7.1': + resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@gql.tada/cli-utils@1.7.3': + resolution: {integrity: sha512-3iQY5E/jvv3Lnh6D1Mh7zr+Bb9C/TGk1DHkm+lbIjQBnZAu2m+BcTcr1e3spUt6Aa6HG/xAN2XxpbWw9oZALEg==} + peerDependencies: + '@0no-co/graphqlsp': ^1.12.13 + '@gql.tada/svelte-support': 1.0.2 + '@gql.tada/vue-support': 1.0.2 + graphql: ^15.5.0 || ^16.0.0 || ^17.0.0 + typescript: ^5.0.0 || ^6.0.0 + peerDependenciesMeta: + '@gql.tada/svelte-support': + optional: true + '@gql.tada/vue-support': + optional: true + + '@gql.tada/internal@1.0.9': + resolution: {integrity: sha512-Bp8yi+kLrzIJ3l5Dfxhz48H4OCH2LCX+pShaPcJgh+oiBt6clrjUKDYNDD3Z78aDQ3+Tyrxe4dd0MfLgpSLPPg==} + peerDependencies: + graphql: ^15.5.0 || ^16.0.0 || ^17.0.0 + typescript: ^5.0.0 || ^6.0.0 + '@hono/node-server@1.19.13': resolution: {integrity: sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==} engines: {node: '>=18.14.1'} @@ -1525,8 +1501,8 @@ packages: resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - '@inquirer/confirm@6.0.12': - resolution: {integrity: sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og==} + '@inquirer/confirm@6.0.13': + resolution: {integrity: sha512-wkGPC7yJ5WJk1DJ5SX7fzk+gfj4BM8cf5dDDi71B/551xHrdsZVRJOC0WyikXd0pEsb/9cLniuE4atbsMqmFkw==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' @@ -1534,8 +1510,8 @@ packages: '@types/node': optional: true - '@inquirer/core@11.1.9': - resolution: {integrity: sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg==} + '@inquirer/core@11.1.10': + resolution: {integrity: sha512-a4Q5BXHQAHa9eO202sTaFCHFYVB3x5fauDuThEAdZ9gfn76pSxiKU7wWcEH0N1O0XmQvNfQNU6QXpiRxmYQx+A==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' @@ -1588,12 +1564,6 @@ packages: resolution: {integrity: sha512-RZEL52er3eH+zpczz2xPyVIxVId+tx0pfTRxYjvE4Vi14plgHzqX7q+4TTJvJDmaxR9pdVjGwFnrY0Oe23fN7Q==} engines: {node: '>=12'} - '@lit-labs/ssr-dom-shim@1.5.1': - resolution: {integrity: sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==} - - '@lit/reactive-element@2.1.2': - resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==} - '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} @@ -1608,8 +1578,8 @@ packages: peerDependencies: rollup: '>=2' - '@mswjs/interceptors@0.41.8': - resolution: {integrity: sha512-pRLMNKTSGRoLq+KnEB/7OY5vijw1XmcheAAOiv6pj7W1FG32kAGqj1C/RK/cqxRGr1Fh+zBi8sDur8kj3EQv6A==} + '@mswjs/interceptors@0.41.9': + resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} engines: {node: '>=18'} '@napi-rs/wasm-runtime@1.1.4': @@ -1618,8 +1588,8 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@next/eslint-plugin-next@16.2.4': - resolution: {integrity: sha512-tOX826JJ96gYK/go18sPUgMq9FK1tqxBFfUCEufJb5XIkWFFmpgU7mahJANKGkHs7F41ir3tReJ3Lv5La0RvhA==} + '@next/eslint-plugin-next@16.2.6': + resolution: {integrity: sha512-Z8l6o4JWKUl755x4R+wogD86KPeU+Ckw4K+SYG4kHeOJtRenDeK+OSbGcqZpDtbwn9DsJVdir2UxmwXuinUbUw==} '@nodable/entities@2.1.0': resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} @@ -1664,12 +1634,12 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - '@oxc-project/types@0.127.0': - resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} - '@oxc-project/types@0.128.0': resolution: {integrity: sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==} + '@oxc-project/types@0.132.0': + resolution: {integrity: sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==} + '@preact/signals-core@1.14.1': resolution: {integrity: sha512-vxPpfXqrwUe9lpjqfYNjAF/0RF/eFGeLgdJzdmIIZjpOnTmGmAB4BjWone562mJGMRP4frU6iZ6ei3PDsu52Ng==} @@ -1683,23 +1653,17 @@ packages: resolution: {integrity: sha512-V09ayfnb5GyysmvARbt+voFZAjGcf7hSYxOYxSkCc4fbH/DTfq5YWoec8cflvmHHqyIFbqvmGKmYFzqhr9zxDg==} engines: {node: '>=18.17.0', npm: '>=9.5.0'} - '@rolldown/binding-android-arm64@1.0.0-rc.17': - resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - '@rolldown/binding-android-arm64@1.0.0-rc.18': resolution: {integrity: sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.0.0-rc.17': - resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==} + '@rolldown/binding-android-arm64@1.0.2': + resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] - os: [darwin] + os: [android] '@rolldown/binding-darwin-arm64@1.0.0-rc.18': resolution: {integrity: sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==} @@ -1707,10 +1671,10 @@ packages: cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-rc.17': - resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==} + '@rolldown/binding-darwin-arm64@1.0.2': + resolution: {integrity: sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==} engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] + cpu: [arm64] os: [darwin] '@rolldown/binding-darwin-x64@1.0.0-rc.18': @@ -1719,11 +1683,11 @@ packages: cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.0.0-rc.17': - resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==} + '@rolldown/binding-darwin-x64@1.0.2': + resolution: {integrity: sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] - os: [freebsd] + os: [darwin] '@rolldown/binding-freebsd-x64@1.0.0-rc.18': resolution: {integrity: sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==} @@ -1731,11 +1695,11 @@ packages: cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': - resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==} + '@rolldown/binding-freebsd-x64@1.0.2': + resolution: {integrity: sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==} engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] + cpu: [x64] + os: [freebsd] '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18': resolution: {integrity: sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==} @@ -1743,12 +1707,11 @@ packages: cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': - resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.2': + resolution: {integrity: sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==} engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] + cpu: [arm] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18': resolution: {integrity: sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==} @@ -1757,12 +1720,12 @@ packages: os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': - resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} + '@rolldown/binding-linux-arm64-gnu@1.0.2': + resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.18': resolution: {integrity: sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==} @@ -1771,12 +1734,12 @@ packages: os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': - resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} + '@rolldown/binding-linux-arm64-musl@1.0.2': + resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==} engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] + cpu: [arm64] os: [linux] - libc: [glibc] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18': resolution: {integrity: sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==} @@ -1785,10 +1748,10 @@ packages: os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': - resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} + '@rolldown/binding-linux-ppc64-gnu@1.0.2': + resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==} engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] + cpu: [ppc64] os: [linux] libc: [glibc] @@ -1799,10 +1762,10 @@ packages: os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': - resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} + '@rolldown/binding-linux-s390x-gnu@1.0.2': + resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==} engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] + cpu: [s390x] os: [linux] libc: [glibc] @@ -1813,12 +1776,12 @@ packages: os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': - resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} + '@rolldown/binding-linux-x64-gnu@1.0.2': + resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.18': resolution: {integrity: sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==} @@ -1827,11 +1790,12 @@ packages: os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': - resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} + '@rolldown/binding-linux-x64-musl@1.0.2': + resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==} engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] + cpu: [x64] + os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.18': resolution: {integrity: sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==} @@ -1839,21 +1803,21 @@ packages: cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': - resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==} + '@rolldown/binding-openharmony-arm64@1.0.2': + resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==} engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] + cpu: [arm64] + os: [openharmony] '@rolldown/binding-wasm32-wasi@1.0.0-rc.18': resolution: {integrity: sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': - resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==} + '@rolldown/binding-wasm32-wasi@1.0.2': + resolution: {integrity: sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==} engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] + cpu: [wasm32] '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18': resolution: {integrity: sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==} @@ -1861,10 +1825,10 @@ packages: cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': - resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==} + '@rolldown/binding-win32-arm64-msvc@1.0.2': + resolution: {integrity: sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==} engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] + cpu: [arm64] os: [win32] '@rolldown/binding-win32-x64-msvc@1.0.0-rc.18': @@ -1873,20 +1837,17 @@ packages: cpu: [x64] os: [win32] - '@rolldown/pluginutils@1.0.0-beta.40': - resolution: {integrity: sha512-s3GeJKSQOwBlzdUrj4ISjJj5SfSh+aqn0wjOar4Bx95iV1ETI7F6S/5hLcfAxZ9kXDcyrAkxPlqmd1ZITttf+w==} - - '@rolldown/pluginutils@1.0.0-rc.17': - resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} + '@rolldown/binding-win32-x64-msvc@1.0.2': + resolution: {integrity: sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] '@rolldown/pluginutils@1.0.0-rc.18': resolution: {integrity: sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==} - '@rolldown/pluginutils@1.0.0-rc.3': - resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} - - '@rolldown/pluginutils@1.0.0-rc.7': - resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} '@rollup/plugin-commonjs@29.0.2': resolution: {integrity: sha512-S/ggWH1LU7jTyi9DxZOKyxpVd4hF/OZ0JrEbeLjXk/DFXwRny0tjD2c992zOUYQobLrVkRVMDdmHP16HKP7GRg==} @@ -1906,141 +1867,141 @@ packages: rollup: optional: true - '@rollup/rollup-android-arm-eabi@4.60.2': - resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==} + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.60.2': - resolution: {integrity: sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==} + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.60.2': - resolution: {integrity: sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==} + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.60.2': - resolution: {integrity: sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==} + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.60.2': - resolution: {integrity: sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==} + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.60.2': - resolution: {integrity: sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==} + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.60.2': - resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} cpu: [arm] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.60.2': - resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} cpu: [arm] os: [linux] libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.60.2': - resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.60.2': - resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.60.2': - resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} cpu: [loong64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.60.2': - resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} cpu: [loong64] os: [linux] libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.60.2': - resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} cpu: [ppc64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.60.2': - resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} cpu: [ppc64] os: [linux] libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.60.2': - resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} cpu: [riscv64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.60.2': - resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} cpu: [riscv64] os: [linux] libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.60.2': - resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} cpu: [s390x] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.60.2': - resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} cpu: [x64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.60.2': - resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openbsd-x64@4.60.2': - resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.60.2': - resolution: {integrity: sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==} + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.60.2': - resolution: {integrity: sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==} + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.60.2': - resolution: {integrity: sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==} + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.60.2': - resolution: {integrity: sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==} + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.60.2': - resolution: {integrity: sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==} + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} cpu: [x64] os: [win32] @@ -2077,69 +2038,69 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@tailwindcss/node@4.2.4': - resolution: {integrity: sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==} + '@tailwindcss/node@4.3.0': + resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} - '@tailwindcss/oxide-android-arm64@4.2.4': - resolution: {integrity: sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==} + '@tailwindcss/oxide-android-arm64@4.3.0': + resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==} engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.2.4': - resolution: {integrity: sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==} + '@tailwindcss/oxide-darwin-arm64@4.3.0': + resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==} engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.2.4': - resolution: {integrity: sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==} + '@tailwindcss/oxide-darwin-x64@4.3.0': + resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==} engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.2.4': - resolution: {integrity: sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==} + '@tailwindcss/oxide-freebsd-x64@4.3.0': + resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==} engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4': - resolution: {integrity: sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==} engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.2.4': - resolution: {integrity: sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==} + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-arm64-musl@4.2.4': - resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==} + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [musl] - '@tailwindcss/oxide-linux-x64-gnu@4.2.4': - resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==} + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-x64-musl@4.2.4': - resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==} + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [musl] - '@tailwindcss/oxide-wasm32-wasi@4.2.4': - resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==} + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -2150,20 +2111,20 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.2.4': - resolution: {integrity: sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==} + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==} engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.2.4': - resolution: {integrity: sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==} + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==} engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.2.4': - resolution: {integrity: sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==} + '@tailwindcss/oxide@4.3.0': + resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==} engines: {node: '>= 20'} '@tailwindcss/typography@0.5.19': @@ -2171,43 +2132,43 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' - '@tailwindcss/vite@4.2.4': - resolution: {integrity: sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==} + '@tailwindcss/vite@4.3.0': + resolution: {integrity: sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==} peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 - '@tanstack/history@1.161.6': - resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==} + '@tanstack/history@1.162.0': + resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==} engines: {node: '>=20.19'} - '@tanstack/react-router-devtools@1.166.13': - resolution: {integrity: sha512-6yKRFFJrEEOiGp5RAAuGCYsl81M4XAhJmLcu9PKj+HZle4A3dsP60lwHoqQYWHMK9nKKFkdXR+D8qxzxqtQbEA==} + '@tanstack/react-router-devtools@1.167.0': + resolution: {integrity: sha512-nGw095EG7IHx0h5NtlEmzf6vcCTaFNPWdTSuDKazajhN0ct/v/TkekJ9J6KYUCeV1a8/2ZmToc58M+0rrOyn7w==} engines: {node: '>=20.19'} peerDependencies: - '@tanstack/react-router': ^1.168.15 - '@tanstack/router-core': ^1.168.11 + '@tanstack/react-router': ^1.170.0 + '@tanstack/router-core': ^1.170.0 react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' peerDependenciesMeta: '@tanstack/router-core': optional: true - '@tanstack/react-router@1.169.1': - resolution: {integrity: sha512-MBtQKSvac3OCcsSa6oBpDrrN90IV47I6Gtv05NxhbFVh+gVjtqvs6HSU4XM9+y5sHZPgS+35eArflX4vM8GEnQ==} + '@tanstack/react-router@1.170.7': + resolution: {integrity: sha512-4Q8M8Q9sGobhyt72yW+vxzUOSPfrCAHwjPOCb7+ocReQnFQ37t9Qh4RNG4+7zsFQ4tW9C8LpllRe49aK2RYy2Q==} engines: {node: '>=20.19'} peerDependencies: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-start-client@1.166.47': - resolution: {integrity: sha512-9g2BRXcB8ODsytbJpxxJfk73hAPrHAlb3UHSeelDnufh9c147hgjdhQj8gG9/KwXXLCS6AwAZMEbB/Mwn62qWA==} + '@tanstack/react-start-client@1.168.2': + resolution: {integrity: sha512-1vnTk3qTh6pJjFaYQvBpTJsmGOb6vE3qY/747uU3ys6kS3NSElmc5YLHk4hlk3S52EVQq/ntuZuJtkS96rQULA==} engines: {node: '>=22.12.0'} peerDependencies: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-start-rsc@0.0.41': - resolution: {integrity: sha512-0l1bZZfWhdT0lT6cWD6jtLs0A3u+9hSzOjIVanm7dj+XHfkqbV8m9i4Bni+3JsIObICmhed9A6wqkERWe4kgAw==} + '@tanstack/react-start-rsc@0.1.10': + resolution: {integrity: sha512-NU4J+8Sm1i5N9skk1X4nTG4sTelCjyc+ny5n56KtTLnPkEm3chvYqjT4CxcoPoHYzE2vE0oAA28YahSQwUkMbQ==} engines: {node: '>=22.12.0'} peerDependencies: '@rspack/core': '>=2.0.0-0' @@ -2223,15 +2184,15 @@ packages: react-server-dom-rspack: optional: true - '@tanstack/react-start-server@1.166.51': - resolution: {integrity: sha512-5pUZCuNyqE06MbWE27+AoNFvcEQb6XbtXKm+yt1Dvd0/bijkN0LVBtV0sX+qqx+lMUtr5HOOCbRMAvDGYYgSyw==} + '@tanstack/react-start-server@1.167.7': + resolution: {integrity: sha512-/Tq3tH7XiCT9K5cYD753/Le6IRjRNHjbN/Jk/Wkpa4pJ3exta5QM7ccCRwVWEd9oIzUvrIfYKWs2JiK7K/R13g==} engines: {node: '>=22.12.0'} peerDependencies: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-start@1.167.62': - resolution: {integrity: sha512-ZBLl/WLxE0l4X7hncqwQaw69le+Ct8NZPKuyRgi9sY1pR1fK+QFYkb396w3+LNIdgGe/6p+tqjvqoUAa8+G/Bw==} + '@tanstack/react-start@1.168.10': + resolution: {integrity: sha512-wISys9u9HDAA7pi298SITc+DzERreR8vmSNGkfxlK0L3n1RlalCdfTKO67JoAdqVNz9FSP9PktgciMhfuHdvaQ==} engines: {node: '>=22.12.0'} peerDependencies: '@rsbuild/core': ^2.0.0 @@ -2253,32 +2214,30 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/router-core@1.169.1': - resolution: {integrity: sha512-x+2gIGKTTE1qAn7tLieGfrB5ciOviDmmi2ox9fAWUubRV+yTU5ruGFXocoCIWF+lB+SOtnHjo2E9BLSWyYoEmA==} + '@tanstack/router-core@1.171.5': + resolution: {integrity: sha512-BfilbQqqWiQwJn68cD8wmk1ajEWIO3IlEA1zVuWslWbiVc23CDn+6ACO5tfPAcc96ED37hxela5ij3VBvAtusw==} engines: {node: '>=20.19'} - hasBin: true - '@tanstack/router-devtools-core@1.167.3': - resolution: {integrity: sha512-fJ1VMhyQgnoashTrP763c2HRc9kofgF61L7Jb3F6eTHAmCKtGVx8BRtiFt37sr3U0P0jmaaiiSPGP6nT5JtVNg==} + '@tanstack/router-devtools-core@1.168.0': + resolution: {integrity: sha512-wQoQhlBK7nlZgqzaqdYXKWNTpdHdsaREdaPhFZVH0/Ador+F+eM3/NF2i3f2LPeS0GgKraZUQXe1Q/1+KHyEYg==} engines: {node: '>=20.19'} peerDependencies: - '@tanstack/router-core': ^1.168.11 + '@tanstack/router-core': ^1.170.0 csstype: ^3.0.10 peerDependenciesMeta: csstype: optional: true - '@tanstack/router-generator@1.166.39': - resolution: {integrity: sha512-j2OW/UvpjM/DT9tHVmuhWW1k6UOezTRrPqBPZFFmIth0fY7iTPqK+Erqpo8r5yGTRGCbMvOS4sL3H2MldnIZew==} + '@tanstack/router-generator@1.167.9': + resolution: {integrity: sha512-B2MiJVYyI/C+5O7Hzu9owZPHuXCxKKFweUdMLToAZLg2H7iYoXYzgqAFISUBBlL8jKWugEVwNMUE3ajBGZep4w==} engines: {node: '>=20.19'} - '@tanstack/router-plugin@1.167.32': - resolution: {integrity: sha512-i9BA6GzUCoM20UYZ77orXzHwD5zM0OQTtLuPNbqTTSG38CvR6viRFP/d+QFo2aRNyCvun8PR7zSa49bslSggEQ==} + '@tanstack/router-plugin@1.168.10': + resolution: {integrity: sha512-s3bWi8pT+p8D70aev6CBwCQp/2SlkO16wxXDtK6a9JAQYK7ARemp3qdzX5EDnVmB9Mc6ex5f9eyrN6eL/V+tLg==} engines: {node: '>=20.19'} - hasBin: true peerDependencies: '@rsbuild/core': '>=1.0.2 || ^2.0.0' - '@tanstack/react-router': ^1.169.1 + '@tanstack/react-router': ^1.170.7 vite: '>=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0' vite-plugin-solid: ^2.11.10 || ^3.0.0-0 webpack: '>=5.92.0' @@ -2294,21 +2253,20 @@ packages: webpack: optional: true - '@tanstack/router-utils@1.161.7': - resolution: {integrity: sha512-VkY0u7ax/GD0qU6ZLLnfPC+UMxVzxRbvZp4yV4iUSXjgJZ/siAT5/QlLm9FEDJ9QDoC0VD9W7f00tKKreUI7Ng==} + '@tanstack/router-utils@1.162.1': + resolution: {integrity: sha512-62layyTGmclHDQS/eidwKRfN1hhCKwViG7iEBcVmL0MXgcAB3OOucWCEcDDGd9Cu11H6b4QQ5oOo47MWIqwz0A==} engines: {node: '>=20.19'} - '@tanstack/start-client-core@1.168.1': - resolution: {integrity: sha512-P0gtOPMzHONjDP0fLL7NWJ25MWBrwxh45tMObgzKH7ziHXciB1s3eiUUjNWISr/vcPXVptppgaBVJ8IGZpR1fQ==} + '@tanstack/start-client-core@1.170.2': + resolution: {integrity: sha512-ZYYwAvdhPHSxjuA5ERP0YTHLomOo3XD3eL/PqlzL3qQhuxE4l3xjKzeBOWsYBo86cejmTsXmfoCHBgqVodPr4w==} engines: {node: '>=22.12.0'} - hasBin: true - '@tanstack/start-fn-stubs@1.161.6': - resolution: {integrity: sha512-Y6QSlGiLga8cHfvxGGaonXIlt2bIUTVdH6AMjmpMp7+ANNCp+N96GQbjjhLye3JkaxDfP68x5iZA8NK4imgRig==} + '@tanstack/start-fn-stubs@1.162.0': + resolution: {integrity: sha512-QWfUZ3Yo923tdQn38LyKMU8rcTw69zc+T4dAvgTWV4O56SqFRsGfS0lSWIMhJRwXIx/bvdi7nTUBDdZtTHtpTQ==} engines: {node: '>=22.12.0'} - '@tanstack/start-plugin-core@1.169.17': - resolution: {integrity: sha512-9VIDnVAu3h/JYqYBbrNBgDpg37uWLbOM2tZgMoLIuW/oXbyv70Iy68NthNqgISnGWrrPyuRs3wcGwYdaPrUw4A==} + '@tanstack/start-plugin-core@1.171.3': + resolution: {integrity: sha512-7RE1CByxF6fB6S4WwPK1FE/7T+hhZhqOWc83coQwuPCu1SlW2n+skZNWxJ85KVJgMoDX1G4vW3fvY3GRST4T3w==} engines: {node: '>=22.12.0'} peerDependencies: '@rsbuild/core': ^2.0.0 @@ -2319,98 +2277,97 @@ packages: vite: optional: true - '@tanstack/start-server-core@1.167.29': - resolution: {integrity: sha512-ZuTrFOIbmNh9wL3W6hOfHmcvcJaxoLOGw4rMRY4J9D0Ue756+l9ub6hjqKVRcNxB43gc9ewE0mQiE8ofANjo1A==} + '@tanstack/start-server-core@1.169.2': + resolution: {integrity: sha512-MAAONdJfamDNFETs1E1ocNU73qVkcLBIeZHNnraZmVSYFxCXJ2eXkUZxCcc99dbHEnKZaxKMk3t2NXpiZ23lqg==} engines: {node: '>=22.12.0'} - '@tanstack/start-storage-context@1.166.34': - resolution: {integrity: sha512-mIre+HDvahOnUmP3vQx+x4kvUzam/uVYpCphudR/Czzi0Crfm0JyyLMNv7hHxkfqMg9aTrxYtDTZHR3isrUKhg==} + '@tanstack/start-storage-context@1.167.7': + resolution: {integrity: sha512-jmTe7mvU4by/nA1IAciJ6iqCh2s0rOIj7vNZ7db48aG9T99GiOkMmkmEquYzxecav/YMBifx6N0vmdNAkcKolg==} engines: {node: '>=22.12.0'} '@tanstack/store@0.9.3': resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} - '@tanstack/virtual-file-routes@1.161.7': - resolution: {integrity: sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ==} + '@tanstack/virtual-file-routes@1.162.0': + resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==} engines: {node: '>=20.19'} - hasBin: true '@tauri-apps/api@2.11.0': resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==} - '@tauri-apps/cli-darwin-arm64@2.11.0': - resolution: {integrity: sha512-UfMeDNlgIP252rm/KSTuu8yHatPua5TjtUEUf+jyIzVwBNcIl7Ywkdpfj+e5jVVg3EfCTp+4gwuL1dNpgF8clg==} + '@tauri-apps/cli-darwin-arm64@2.11.2': + resolution: {integrity: sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@tauri-apps/cli-darwin-x64@2.11.0': - resolution: {integrity: sha512-lY1+aPlgyMN7vgjtCdQ3+WODfZkebAcxnrCrO0HjqDpKSXieDkrJbimqeaoM4RwhTSrCLRHfVYiYrfE5E131tg==} + '@tauri-apps/cli-darwin-x64@2.11.2': + resolution: {integrity: sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@tauri-apps/cli-linux-arm-gnueabihf@2.11.0': - resolution: {integrity: sha512-5uCP0AusgN3NrKC8EpkuJwjek1k8pEffBdugJSpXPey/QGbPEb8vZ542n/giJ2mZPjMSllDkdhG2QIDpBY4PpQ==} + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.2': + resolution: {integrity: sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA==} engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@tauri-apps/cli-linux-arm64-gnu@2.11.0': - resolution: {integrity: sha512-loDPqtRHMSbIcrH2VBd4GgHoQlF7jJnrZj7MxA2lj1cixS/jEgMAPFqj83U6Wvjete4HfYplbE/gCpSFifA9jw==} + '@tauri-apps/cli-linux-arm64-gnu@2.11.2': + resolution: {integrity: sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@tauri-apps/cli-linux-arm64-musl@2.11.0': - resolution: {integrity: sha512-DtSE8ZBlB9H+L+eHkfZ3myt00EVEyAB3e41juEHoE2qT88fgVlJvyrwa9SZYc/xTwCS9TnmK+R84tpg+ZsAg7Q==} + '@tauri-apps/cli-linux-arm64-musl@2.11.2': + resolution: {integrity: sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@tauri-apps/cli-linux-riscv64-gnu@2.11.0': - resolution: {integrity: sha512-5QdgS4LD+kntClI1aj2JmwjW38LosNXxwCe8viIHEwqYIWuMPdNEIau6/cLogI38Yzx9DnfCPRfEWLyI+5li8Q==} + '@tauri-apps/cli-linux-riscv64-gnu@2.11.2': + resolution: {integrity: sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] libc: [glibc] - '@tauri-apps/cli-linux-x64-gnu@2.11.0': - resolution: {integrity: sha512-5UynPXo3Zq9khjVdAbD+YogeLltdVUeOah2ioSIM3tu6H7wY9vMy6rgGJhv9r5R8ZXmk9GttMippdqYJWrnLnA==} + '@tauri-apps/cli-linux-x64-gnu@2.11.2': + resolution: {integrity: sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@tauri-apps/cli-linux-x64-musl@2.11.0': - resolution: {integrity: sha512-CNz7fHbApz1Zyhhq73jtGn9JqgNEV/lIWnTnUo6h6ujw+mHsTmkLszvJSM8W6JBaDjNpTTFr/RSNoVL5FMwcTg==} + '@tauri-apps/cli-linux-x64-musl@2.11.2': + resolution: {integrity: sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@tauri-apps/cli-win32-arm64-msvc@2.11.0': - resolution: {integrity: sha512-K+br+VXZ+Xx0n/9FdWohpW5Ugq+2FQUpJScqcPl1hTxXfh3fgjYgt4qA2NgrjlJo+zZPNrmUMl+NLvm0ufEqBQ==} + '@tauri-apps/cli-win32-arm64-msvc@2.11.2': + resolution: {integrity: sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@tauri-apps/cli-win32-ia32-msvc@2.11.0': - resolution: {integrity: sha512-OFV+s3MLZnd75zl0ZAFU5riMpGK4waUEA8ZDuijDsnkU0btz/gHhqh5jVlOn8thyvgdtT3Xyoxqo099MMifH3g==} + '@tauri-apps/cli-win32-ia32-msvc@2.11.2': + resolution: {integrity: sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] - '@tauri-apps/cli-win32-x64-msvc@2.11.0': - resolution: {integrity: sha512-AeDTWBd2cOZ6TX133BWsoo+LutG9o0JRcgjMsIfLE13ZugpgCMv/2dJbUiBGeRvbPOGin5A3aYmsArPVV6ZSHQ==} + '@tauri-apps/cli-win32-x64-msvc@2.11.2': + resolution: {integrity: sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@tauri-apps/cli@2.11.0': - resolution: {integrity: sha512-W5Wbuqsb2pHFPTj4TaRNKTj5rwXhDShPiLSY9T18y4ouSR/NNCptAEFxFsBtyNRgL6Vs1a/q9LzfqqYzEwC+Jw==} + '@tauri-apps/cli@2.11.2': + resolution: {integrity: sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw==} engines: {node: '>= 10'} hasBin: true @@ -2432,51 +2389,39 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} - '@turbo/darwin-64@2.9.8': - resolution: {integrity: sha512-zU1P95ygDpsQ+2QHh7CVTqvYwi9UBlhKWzoIyUnP3vUoge7H9SQEzrd8dj+XcTrslAp9Db3vIBcXtMVoTEYDnA==} + '@turbo/darwin-64@2.9.14': + resolution: {integrity: sha512-t7QiPflaEyBE4oayeZtSmu4mEfjgIrcNlNNl1z1dmIVPqEdtA7+CfTf8d7KXsOGPh6aNgWjKxyvQg9uGfDQF+A==} cpu: [x64] os: [darwin] - '@turbo/darwin-arm64@2.9.8': - resolution: {integrity: sha512-nKRFI5ZhCGUi4eXNlrojzWcT/CehMj0raot1WE4lw5qf66ZxZHbRbBqcwNEy+ZLY7RkJJRY+TaU89fuj3BcgGg==} + '@turbo/darwin-arm64@2.9.14': + resolution: {integrity: sha512-d23147mC9BsCPA9mJ0h/ubcpbRgcJBXbcG3+Vq7YLhjz3IXuvQsJ1UXH8f4MD76ZjJ4m/E4aRdJV+MW88CDfbw==} cpu: [arm64] os: [darwin] - '@turbo/linux-64@2.9.8': - resolution: {integrity: sha512-Wf/kQpVDCaWM3P5d6lKvJnqjYn/ofUBGbT4h4vRFrdC4N6B/nsun03S2kQNJJMXpXg39woeS4CI367RMU3/OAg==} + '@turbo/linux-64@2.9.14': + resolution: {integrity: sha512-P3ZKB5tuUDdDQWuAsACGUR1qv9W7BNWxdxqVJ0kZNuNNPRaVYTPPikLcp79+GiEcW3npsR+KyP38lnQiBc5aSA==} cpu: [x64] os: [linux] - '@turbo/linux-arm64@2.9.8': - resolution: {integrity: sha512-v6S3HuKVoa9CEx16IxKj1i/+crxXx22A9O80zW1350zyUlcX0T/zLOxVf1k+ruK/7ssXnDJVg8uSYOxlYRedlA==} + '@turbo/linux-arm64@2.9.14': + resolution: {integrity: sha512-ZRTlzcUMrrPv9ZuDzRF9n60Ym13bKeG9jDB8WjxyLhWNzV+AJQN+zdpIk3NJYf2zQsGUm1mNar2P0elRzLw25g==} cpu: [arm64] os: [linux] - '@turbo/windows-64@2.9.8': - resolution: {integrity: sha512-JaefWOJNBazDylAn3f+lLB34XMNu8nEBbgPRP/Ewysg81cBubGfcyyyzpQOGVuMwfaqdNAE/kitG7w3AbJn9/g==} + '@turbo/windows-64@2.9.14': + resolution: {integrity: sha512-exanwN6sIduZwykYeiTQj8kCmOhazP5WOz3bvXMcYtjhL6Z3iRWLewKrXCBq0bqwSP3iBMb/AerRCnHI4lx46A==} cpu: [x64] os: [win32] - '@turbo/windows-arm64@2.9.8': - resolution: {integrity: sha512-Or6ljjB4TiiwCdVKDYWew0SokQ9kep5zruL8P3nbum9WdkH5XA41rQID4Ulc215Z+R3DrB+qXSHPsJjU3/n2ng==} + '@turbo/windows-arm64@2.9.14': + resolution: {integrity: sha512-fVdCsnmYoKICsycbWuuGp6Jvi51/3G/UluFWuAUCvR8PIW5IJkAk5BM9UF8PSm0Q2IphWHFZjYEgjHsh3B9y/g==} cpu: [arm64] os: [win32] '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} - - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - - '@types/babel__traverse@7.28.0': - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -2492,12 +2437,18 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/express-serve-static-core@5.1.1': resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} @@ -2525,11 +2476,14 @@ packages: '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + '@types/node@24.12.4': + resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} + '@types/node@25.5.2': resolution: {integrity: sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==} - '@types/node@25.6.0': - resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + '@types/node@25.9.1': + resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} '@types/qs@6.15.0': resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==} @@ -2542,8 +2496,8 @@ packages: peerDependencies: '@types/react': ^19.2.0 - '@types/react@19.2.14': - resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/react@19.2.15': + resolution: {integrity: sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==} '@types/sax@1.2.7': resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} @@ -2560,141 +2514,76 @@ packages: '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} - '@types/trusted-types@2.0.7': - resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} - '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - '@types/url-parse@1.4.11': - resolution: {integrity: sha512-FKvKIqRaykZtd4n47LbK/W/5fhQQ1X7cxxzG9A48h0BGN+S04NH7ervcCjM8tyR0lyGru83FAHSmw2ObgKoESg==} - '@types/xml2js@0.4.14': resolution: {integrity: sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==} - '@typescript-eslint/eslint-plugin@8.58.0': - resolution: {integrity: sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@typescript-eslint/parser': ^8.58.0 - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/eslint-plugin@8.59.1': - resolution: {integrity: sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@typescript-eslint/parser': ^8.59.1 - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/parser@8.58.0': - resolution: {integrity: sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==} + '@typescript-eslint/eslint-plugin@8.59.4': + resolution: {integrity: sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: + '@typescript-eslint/parser': ^8.59.4 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.59.1': - resolution: {integrity: sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==} + '@typescript-eslint/parser@8.59.4': + resolution: {integrity: sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.58.0': - resolution: {integrity: sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/project-service@8.59.1': - resolution: {integrity: sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/scope-manager@8.58.0': - resolution: {integrity: sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/scope-manager@8.59.1': - resolution: {integrity: sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/tsconfig-utils@8.58.0': - resolution: {integrity: sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==} + '@typescript-eslint/project-service@8.59.4': + resolution: {integrity: sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/tsconfig-utils@8.59.1': - resolution: {integrity: sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==} + '@typescript-eslint/scope-manager@8.59.4': + resolution: {integrity: sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.58.0': - resolution: {integrity: sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==} + '@typescript-eslint/tsconfig-utils@8.59.4': + resolution: {integrity: sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.59.1': - resolution: {integrity: sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==} + '@typescript-eslint/type-utils@8.59.4': + resolution: {integrity: sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.58.0': - resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/types@8.59.1': - resolution: {integrity: sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==} + '@typescript-eslint/types@8.59.4': + resolution: {integrity: sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.58.0': - resolution: {integrity: sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==} + '@typescript-eslint/typescript-estree@8.59.4': + resolution: {integrity: sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/typescript-estree@8.59.1': - resolution: {integrity: sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/utils@8.58.0': - resolution: {integrity: sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/utils@8.59.1': - resolution: {integrity: sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==} + '@typescript-eslint/utils@8.59.4': + resolution: {integrity: sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.58.0': - resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/visitor-keys@8.59.1': - resolution: {integrity: sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==} + '@typescript-eslint/visitor-keys@8.59.4': + resolution: {integrity: sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@ungap/structured-clone@1.3.0': - resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@ungap/structured-clone@1.3.1': + resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} '@vercel/analytics@2.0.1': resolution: {integrity: sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==} @@ -2725,14 +2614,8 @@ packages: vue-router: optional: true - '@vitejs/plugin-react@5.2.0': - resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} - engines: {node: ^20.19.0 || >=22.12.0} - peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - - '@vitejs/plugin-react@6.0.1': - resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==} + '@vitejs/plugin-react@6.0.2': + resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 @@ -2744,11 +2627,11 @@ packages: babel-plugin-react-compiler: optional: true - '@vitest/expect@4.1.5': - resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} + '@vitest/expect@4.1.7': + resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==} - '@vitest/mocker@4.1.5': - resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==} + '@vitest/mocker@4.1.7': + resolution: {integrity: sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -2758,20 +2641,20 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.5': - resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} + '@vitest/pretty-format@4.1.7': + resolution: {integrity: sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==} - '@vitest/runner@4.1.5': - resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==} + '@vitest/runner@4.1.7': + resolution: {integrity: sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==} - '@vitest/snapshot@4.1.5': - resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==} + '@vitest/snapshot@4.1.7': + resolution: {integrity: sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==} - '@vitest/spy@4.1.5': - resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} + '@vitest/spy@4.1.7': + resolution: {integrity: sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==} - '@vitest/utils@4.1.5': - resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} + '@vitest/utils@4.1.7': + resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==} accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} @@ -2810,8 +2693,8 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} - ansis@4.2.0: - resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + ansis@4.3.0: + resolution: {integrity: sha512-44mvgtPvohuU/70DdY5Oz2AIrLJ9k6/5x4KmoSvPwO+5Moijo0+N9D0fKbbYZQWP1hNm5CpOf+E01jhxG/r8xg==} engines: {node: '>=14'} anymatch@3.1.3: @@ -2966,6 +2849,10 @@ packages: resolution: {integrity: sha512-HtgOb4cXhr++JQX98bv1UwL/eHJklrtalcD9v+TW3Voc5bsT8ph4vJBS/kcZRjdpqT3fyywRKjasNcWo4vOqIQ==} engines: {node: '>= 12.0.0'} + camaro@6.3.2: + resolution: {integrity: sha512-3tCg252POSUvE4DMgAzU3iQaDaxqUaZAnp0ExZnH2YWG+1vnAov0UMfG6VLPHwaXQiFivbvL8LIzMrhnPl690A==} + engines: {node: '>= 16.0.0'} + caniuse-lite@1.0.30001786: resolution: {integrity: sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA==} @@ -3009,6 +2896,10 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -3288,8 +3179,8 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - enhanced-resolve@5.21.0: - resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} + enhanced-resolve@5.21.6: + resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} engines: {node: '>=10.13.0'} enquirer@2.4.1: @@ -3399,22 +3290,16 @@ packages: eslint-plugin-only-warn@1.2.1: resolution: {integrity: sha512-j37hwfaQDEOfkZ1Dpvu/HnWLavlzQxQxfbrU/9Jb4R9qzrE1eTYuRJyrxq7LzLRI8miG5FOV6veoUVhx7AI84w==} - eslint-plugin-react-hooks@7.0.1: - resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} - engines: {node: '>=18'} - peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 - eslint-plugin-react-hooks@7.1.1: resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} engines: {node: '>=18'} peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 - eslint-plugin-react-refresh@0.4.26: - resolution: {integrity: sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==} + eslint-plugin-react-refresh@0.5.2: + resolution: {integrity: sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==} peerDependencies: - eslint: '>=8.40' + eslint: ^9 || ^10 eslint-plugin-react@7.37.5: resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} @@ -3422,8 +3307,8 @@ packages: peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 - eslint-plugin-turbo@2.9.8: - resolution: {integrity: sha512-czP2GjPzR6G+BpVEKXNa7Vs45/Zz0QjUEyB+SSLNpVJTqBe331AlE9IxkC2Eh00H6a1hf/Q6BVwf+1WJupyVYA==} + eslint-plugin-turbo@2.9.14: + resolution: {integrity: sha512-ROTlsO1JBJLATxtDNd7t22vviSb0hD8fKnjOO0WRgtxJW3VBRNO3BLAC129GPZSvEvtMH/f71Y2TzrqGPjLpEw==} peerDependencies: eslint: '>6.6.0' turbo: '>2.0.0' @@ -3432,6 +3317,10 @@ packages: resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + eslint-visitor-keys@3.4.3: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -3444,6 +3333,16 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + eslint@10.4.0: + resolution: {integrity: sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + eslint@9.39.4: resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3458,6 +3357,10 @@ packages: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} @@ -3558,21 +3461,21 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-wrap-ansi@0.2.0: - resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} fast-xml-builder@1.1.4: resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==} - fast-xml-builder@1.1.5: - resolution: {integrity: sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==} + fast-xml-builder@1.2.0: + resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} fast-xml-parser@5.5.10: resolution: {integrity: sha512-go2J2xODMc32hT+4Xr/bBGXMaIoiCwrwp2mMtAvKyvEFW6S/v5Gn2pBmE4nvbwNjGhpcAiOwEv7R6/GZ6XRa9w==} hasBin: true - fast-xml-parser@5.7.2: - resolution: {integrity: sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==} + fast-xml-parser@5.8.0: + resolution: {integrity: sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==} hasBin: true fastq@1.20.1: @@ -3721,8 +3624,8 @@ packages: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} - goober@2.1.18: - resolution: {integrity: sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==} + goober@2.1.19: + resolution: {integrity: sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg==} peerDependencies: csstype: ^3.0.10 @@ -3730,11 +3633,17 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + gql.tada@1.9.2: + resolution: {integrity: sha512-QxRHVpxtrOVdYXz6oavq0lBM+Zdp0swapLGJcD4SLpXDcsD337BHDFrzqqjfkbepv0sSAiO0LGabu1kI5D5Gyg==} + hasBin: true + peerDependencies: + typescript: ^5.0.0 || ^6.0.0 + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - graphql@16.13.2: - resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==} + graphql@16.14.0: + resolution: {integrity: sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} h3@2.0.1-rc.20: @@ -3820,8 +3729,8 @@ packages: resolution: {integrity: sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==} engines: {node: '>=16.9.0'} - hono@4.12.16: - resolution: {integrity: sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==} + hono@4.12.21: + resolution: {integrity: sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==} engines: {node: '>=16.9.0'} hookable@6.1.1: @@ -3928,8 +3837,8 @@ packages: resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} engines: {node: '>= 0.4'} - is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} engines: {node: '>= 0.4'} is-data-view@1.0.2: @@ -4081,8 +3990,8 @@ packages: isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} - isbot@5.1.39: - resolution: {integrity: sha512-obH0yYahGXdzNxo+djmHhBYThUKDkz565cxkIlt2L9hXfv1NlaLKoDBHo6KxXsYrIXx2RK3x5vY36CfZcobxEw==} + isbot@5.1.40: + resolution: {integrity: sha512-yNeeynhhtIVRBk12tBV4eHNxwB42HzR4Q3Ea7vCOiJhImGaAIdIMrbJtacQlBizGLjUPw+akkFI5Dn9T70XoVQ==} engines: {node: '>=18'} isexe@2.0.0: @@ -4092,8 +4001,8 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} - jiti@2.6.1: - resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true js-levenshtein@1.1.6: @@ -4222,17 +4131,8 @@ packages: engines: {node: '>= 12.0.0'} lilconfig@3.1.3: - resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} - engines: {node: '>=14'} - - lit-element@4.2.2: - resolution: {integrity: sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==} - - lit-html@3.3.2: - resolution: {integrity: sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw==} - - lit@3.3.2: - resolution: {integrity: sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==} + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} @@ -4428,8 +4328,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - msw@2.14.2: - resolution: {integrity: sha512-D2bTe0tpuf9nw4DA39wFaqUD/hRPKj0DKpo2lAqu+A47Ifg4+h0hbfn6QxVOsiUY2uhgEN6TTpGSHDsc+ysYNg==} + msw@2.14.6: + resolution: {integrity: sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -4601,6 +4501,9 @@ packages: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} + openapi-typescript-helpers@0.1.0: + resolution: {integrity: sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==} + openapi-typescript@7.13.0: resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==} hasBin: true @@ -4769,8 +4672,8 @@ packages: resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} engines: {node: '>=4'} - postcss@8.5.13: - resolution: {integrity: sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==} + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} postcss@8.5.8: @@ -4909,29 +4812,16 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} - react-dom@19.2.4: - resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} - peerDependencies: - react: ^19.2.4 - - react-dom@19.2.5: - resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} + react-dom@19.2.6: + resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} peerDependencies: - react: ^19.2.5 + react: ^19.2.6 react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - react-refresh@0.18.0: - resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} - engines: {node: '>=0.10.0'} - - react@19.2.4: - resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} - engines: {node: '>=0.10.0'} - - react@19.2.5: - resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} + react@19.2.6: + resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} engines: {node: '>=0.10.0'} read-yaml-file@1.1.0: @@ -4946,6 +4836,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + rechoir@0.6.2: resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==} engines: {node: '>= 0.10'} @@ -5008,8 +4902,8 @@ packages: engines: {node: '>= 0.4'} hasBin: true - resolve@2.0.0-next.6: - resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} + resolve@2.0.0-next.7: + resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} engines: {node: '>= 0.4'} hasBin: true @@ -5020,13 +4914,13 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rolldown@1.0.0-rc.17: - resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} + rolldown@1.0.0-rc.18: + resolution: {integrity: sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - rolldown@1.0.0-rc.18: - resolution: {integrity: sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==} + rolldown@1.0.2: + resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -5056,8 +4950,8 @@ packages: vite: optional: true - rollup@4.60.2: - resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==} + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -5117,18 +5011,23 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.1: + resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} + engines: {node: '>=10'} + hasBin: true + send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} - seroval-plugins@1.5.2: - resolution: {integrity: sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg==} + seroval-plugins@1.5.4: + resolution: {integrity: sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==} engines: {node: '>=10'} peerDependencies: seroval: ^1.0 - seroval@1.5.2: - resolution: {integrity: sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==} + seroval@1.5.4: + resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==} engines: {node: '>=10'} serve-static@2.2.1: @@ -5325,6 +5224,9 @@ packages: strnum@2.2.3: resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==} + strnum@2.3.0: + resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -5351,11 +5253,11 @@ packages: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} - tailwind-merge@3.5.0: - resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} - tailwindcss@4.2.4: - resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==} + tailwindcss@4.3.0: + resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} @@ -5461,13 +5363,17 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - turbo@2.9.8: - resolution: {integrity: sha512-REEB2rVTVDTf4hav1gJ5dIsGylWZrNonvjXFtk1dCi8gND3PhZtnYkyry1bra/Fo+iP6ctTEZbg6vWfdfHq/1A==} + turbo@2.9.14: + resolution: {integrity: sha512-BQqXRr4UoWI3UPFrtznCLykYHxwxWh53iCB57x092jPMjIlW1wnm3N895g5irpiXmnxUhREBB0n6+y8BHhs4nw==} hasBin: true txml@5.2.1: resolution: {integrity: sha512-MGKvU6UjehglVhfQAtW/sxeqAky64fJGHAkaxXROh13quYOgKE71C1VPKhKGR8M+AhMWEdz/aaPL/rxpJ9fmKw==} + txml@6.0.0: + resolution: {integrity: sha512-SJ1tLEiSsraRDvSxCHhwhvS5e3YALvRWxNDKR2vCX3gQG4MVepj6dWEclnbnMEOUZ2s690lWC4pgpRjKj6lhkw==} + engines: {node: '>=18.0.0'} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -5504,8 +5410,8 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} - typescript-eslint@8.59.1: - resolution: {integrity: sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==} + typescript-eslint@8.59.4: + resolution: {integrity: sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -5531,11 +5437,14 @@ packages: undefsafe@2.0.5: resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - undici-types@7.19.2: - resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} undici@7.25.0: resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} @@ -5693,8 +5602,8 @@ packages: typescript: optional: true - valibot@1.3.1: - resolution: {integrity: sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==} + valibot@1.4.0: + resolution: {integrity: sha512-iC/x7fVcSyOwlm/VSt7RlHnzNGLGvR9GnxdifUeWoCJo0q4ZZvrVkIHC6faTlkxG47I2Y4UrFquPuVHCrOnrLg==} peerDependencies: typescript: '>=5' peerDependenciesMeta: @@ -5751,13 +5660,13 @@ packages: yaml: optional: true - vite@8.0.10: - resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==} + vite@8.0.14: + resolution: {integrity: sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.1.0 + '@vitejs/devtools': ^0.1.18 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -5802,20 +5711,20 @@ packages: vite: optional: true - vitest@4.1.5: - resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} + vitest@4.1.7: + resolution: {integrity: sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.5 - '@vitest/browser-preview': 4.1.5 - '@vitest/browser-webdriverio': 4.1.5 - '@vitest/coverage-istanbul': 4.1.5 - '@vitest/coverage-v8': 4.1.5 - '@vitest/ui': 4.1.5 + '@vitest/browser-playwright': 4.1.7 + '@vitest/browser-preview': 4.1.7 + '@vitest/browser-webdriverio': 4.1.7 + '@vitest/coverage-istanbul': 4.1.7 + '@vitest/coverage-v8': 4.1.7 + '@vitest/ui': 4.1.7 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -5906,6 +5815,10 @@ packages: resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} + xml-naming@0.1.0: + resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} + engines: {node: '>=16.0.0'} + xml2js@0.6.2: resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} engines: {node: '>=4.0.0'} @@ -5957,23 +5870,26 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} - zod@4.4.2: - resolution: {integrity: sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} snapshots: - '@0no-co/graphql.web@1.2.0(graphql@16.13.2)': + '@0no-co/graphql.web@1.2.0(graphql@16.14.0)': optionalDependencies: - graphql: 16.13.2 + graphql: 16.14.0 + + '@0no-co/graphqlsp@1.15.4(graphql@16.14.0)(typescript@6.0.3)': + dependencies: + '@gql.tada/internal': 1.0.9(graphql@16.14.0)(typescript@6.0.3) + graphql: 16.14.0 + typescript: 6.0.3 '@assemblyscript/loader@0.10.1': {} @@ -6076,16 +5992,6 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/runtime@7.29.2': {} '@babel/template@7.28.6': @@ -6125,7 +6031,7 @@ snapshots: outdent: 0.5.0 prettier: 2.8.8 resolve-from: 5.0.0 - semver: 7.7.4 + semver: 7.8.1 '@changesets/assemble-release-plan@6.0.10': dependencies: @@ -6134,13 +6040,13 @@ snapshots: '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 - semver: 7.7.4 + semver: 7.8.1 '@changesets/changelog-git@0.2.1': dependencies: '@changesets/types': 6.1.0 - '@changesets/changelog-github@0.6.0': + '@changesets/changelog-github@0.7.0': dependencies: '@changesets/get-github-info': 0.8.0 '@changesets/types': 6.1.0 @@ -6148,7 +6054,7 @@ snapshots: transitivePeerDependencies: - encoding - '@changesets/cli@2.31.0(@types/node@25.6.0)': + '@changesets/cli@2.31.0(@types/node@25.9.1)': dependencies: '@changesets/apply-release-plan': 7.1.1 '@changesets/assemble-release-plan': 6.0.10 @@ -6164,7 +6070,7 @@ snapshots: '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@changesets/write': 0.4.0 - '@inquirer/external-editor': 1.0.3(@types/node@25.6.0) + '@inquirer/external-editor': 1.0.3(@types/node@25.9.1) '@manypkg/get-packages': 1.1.3 ansi-colors: 4.1.3 enquirer: 2.4.1 @@ -6173,7 +6079,7 @@ snapshots: package-manager-detector: 0.2.11 picocolors: 1.1.1 resolve-from: 5.0.0 - semver: 7.7.4 + semver: 7.8.1 spawndamnit: 3.0.1 term-size: 2.2.1 transitivePeerDependencies: @@ -6199,7 +6105,7 @@ snapshots: '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 picocolors: 1.1.1 - semver: 7.7.4 + semver: 7.8.1 '@changesets/get-github-info@0.8.0': dependencies: @@ -6445,9 +6351,14 @@ snapshots: '@esbuild/win32-x64@0.28.0': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@10.4.0(jiti@2.7.0))': + dependencies: + eslint: 10.4.0(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.7.0))': dependencies: - eslint: 9.39.4(jiti@2.6.1) + eslint: 9.39.4(jiti@2.7.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -6460,14 +6371,30 @@ snapshots: transitivePeerDependencies: - supports-color + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + '@eslint/config-helpers@0.4.2': dependencies: '@eslint/core': 0.17.0 + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 + '@eslint/core@0.17.0': dependencies: '@types/json-schema': 7.0.15 + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + '@eslint/eslintrc@3.3.5': dependencies: ajv: 6.14.0 @@ -6482,15 +6409,39 @@ snapshots: transitivePeerDependencies: - supports-color + '@eslint/js@10.0.1(eslint@10.4.0(jiti@2.7.0))': + optionalDependencies: + eslint: 10.4.0(jiti@2.7.0) + '@eslint/js@9.39.4': {} '@eslint/object-schema@2.1.7': {} + '@eslint/object-schema@3.0.5': {} + '@eslint/plugin-kit@0.4.1': dependencies: '@eslint/core': 0.17.0 levn: 0.4.1 + '@eslint/plugin-kit@0.7.1': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@gql.tada/cli-utils@1.7.3(@0no-co/graphqlsp@1.15.4(graphql@16.14.0)(typescript@6.0.3))(graphql@16.14.0)(typescript@6.0.3)': + dependencies: + '@0no-co/graphqlsp': 1.15.4(graphql@16.14.0)(typescript@6.0.3) + '@gql.tada/internal': 1.0.9(graphql@16.14.0)(typescript@6.0.3) + graphql: 16.14.0 + typescript: 6.0.3 + + '@gql.tada/internal@1.0.9(graphql@16.14.0)(typescript@6.0.3)': + dependencies: + '@0no-co/graphql.web': 1.2.0(graphql@16.14.0) + graphql: 16.14.0 + typescript: 6.0.3 + '@hono/node-server@1.19.13(hono@4.12.12)': dependencies: hono: 4.12.12 @@ -6524,37 +6475,37 @@ snapshots: '@inquirer/ansi@2.0.5': {} - '@inquirer/confirm@6.0.12(@types/node@25.6.0)': + '@inquirer/confirm@6.0.13(@types/node@25.9.1)': dependencies: - '@inquirer/core': 11.1.9(@types/node@25.6.0) - '@inquirer/type': 4.0.5(@types/node@25.6.0) + '@inquirer/core': 11.1.10(@types/node@25.9.1) + '@inquirer/type': 4.0.5(@types/node@25.9.1) optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.1 - '@inquirer/core@11.1.9(@types/node@25.6.0)': + '@inquirer/core@11.1.10(@types/node@25.9.1)': dependencies: '@inquirer/ansi': 2.0.5 '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@25.6.0) + '@inquirer/type': 4.0.5(@types/node@25.9.1) cli-width: 4.1.0 - fast-wrap-ansi: 0.2.0 + fast-wrap-ansi: 0.2.2 mute-stream: 3.0.0 signal-exit: 4.1.0 optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.1 - '@inquirer/external-editor@1.0.3(@types/node@25.6.0)': + '@inquirer/external-editor@1.0.3(@types/node@25.9.1)': dependencies: chardet: 2.1.1 iconv-lite: 0.7.2 optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.1 '@inquirer/figures@2.0.5': {} - '@inquirer/type@4.0.5(@types/node@25.6.0)': + '@inquirer/type@4.0.5(@types/node@25.9.1)': optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.1 '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -6582,12 +6533,6 @@ snapshots: '@lastolivegames/becsy@0.15.5': {} - '@lit-labs/ssr-dom-shim@1.5.1': {} - - '@lit/reactive-element@2.1.2': - dependencies: - '@lit-labs/ssr-dom-shim': 1.5.1 - '@manypkg/find-root@1.1.0': dependencies: '@babel/runtime': 7.29.2 @@ -6606,7 +6551,7 @@ snapshots: '@mdx-js/mdx@3.1.1': dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 '@types/mdx': 2.0.13 @@ -6634,17 +6579,17 @@ snapshots: transitivePeerDependencies: - supports-color - '@mdx-js/rollup@3.1.1(rollup@4.60.2)': + '@mdx-js/rollup@3.1.1(rollup@4.60.4)': dependencies: '@mdx-js/mdx': 3.1.1 - '@rollup/pluginutils': 5.3.0(rollup@4.60.2) - rollup: 4.60.2 + '@rollup/pluginutils': 5.3.0(rollup@4.60.4) + rollup: 4.60.4 source-map: 0.7.6 vfile: 6.0.3 transitivePeerDependencies: - supports-color - '@mswjs/interceptors@0.41.8': + '@mswjs/interceptors@0.41.9': dependencies: '@open-draft/deferred-promise': 2.2.0 '@open-draft/logger': 0.3.0 @@ -6660,7 +6605,7 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true - '@next/eslint-plugin-next@16.2.4': + '@next/eslint-plugin-next@16.2.6': dependencies: fast-glob: 3.3.1 @@ -6706,10 +6651,10 @@ snapshots: '@open-draft/until@2.1.0': {} - '@oxc-project/types@0.127.0': {} - '@oxc-project/types@0.128.0': {} + '@oxc-project/types@0.132.0': {} + '@preact/signals-core@1.14.1': {} '@redocly/ajv@8.11.2': @@ -6735,117 +6680,111 @@ snapshots: transitivePeerDependencies: - supports-color - '@rolldown/binding-android-arm64@1.0.0-rc.17': - optional: true - '@rolldown/binding-android-arm64@1.0.0-rc.18': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + '@rolldown/binding-android-arm64@1.0.2': optional: true '@rolldown/binding-darwin-arm64@1.0.0-rc.18': optional: true - '@rolldown/binding-darwin-x64@1.0.0-rc.17': + '@rolldown/binding-darwin-arm64@1.0.2': optional: true '@rolldown/binding-darwin-x64@1.0.0-rc.18': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + '@rolldown/binding-darwin-x64@1.0.2': optional: true '@rolldown/binding-freebsd-x64@1.0.0-rc.18': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + '@rolldown/binding-freebsd-x64@1.0.2': optional: true '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + '@rolldown/binding-linux-arm-gnueabihf@1.0.2': optional: true '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + '@rolldown/binding-linux-arm64-gnu@1.0.2': optional: true '@rolldown/binding-linux-arm64-musl@1.0.0-rc.18': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + '@rolldown/binding-linux-arm64-musl@1.0.2': optional: true '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18': optional: true - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + '@rolldown/binding-linux-ppc64-gnu@1.0.2': optional: true '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + '@rolldown/binding-linux-s390x-gnu@1.0.2': optional: true '@rolldown/binding-linux-x64-gnu@1.0.0-rc.18': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + '@rolldown/binding-linux-x64-gnu@1.0.2': optional: true '@rolldown/binding-linux-x64-musl@1.0.0-rc.18': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + '@rolldown/binding-linux-x64-musl@1.0.2': optional: true '@rolldown/binding-openharmony-arm64@1.0.0-rc.18': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + '@rolldown/binding-openharmony-arm64@1.0.2': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.18': dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.18': + '@rolldown/binding-wasm32-wasi@1.0.2': dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': - optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + '@rolldown/binding-win32-arm64-msvc@1.0.2': optional: true '@rolldown/binding-win32-x64-msvc@1.0.0-rc.18': optional: true - '@rolldown/pluginutils@1.0.0-beta.40': {} - - '@rolldown/pluginutils@1.0.0-rc.17': {} + '@rolldown/binding-win32-x64-msvc@1.0.2': + optional: true '@rolldown/pluginutils@1.0.0-rc.18': {} - '@rolldown/pluginutils@1.0.0-rc.3': {} - - '@rolldown/pluginutils@1.0.0-rc.7': {} + '@rolldown/pluginutils@1.0.1': {} - '@rollup/plugin-commonjs@29.0.2(rollup@4.60.2)': + '@rollup/plugin-commonjs@29.0.2(rollup@4.60.4)': dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.60.2) + '@rollup/pluginutils': 5.3.0(rollup@4.60.4) commondir: 1.0.1 estree-walker: 2.0.2 fdir: 6.5.0(picomatch@4.0.4) @@ -6853,236 +6792,236 @@ snapshots: magic-string: 0.30.21 picomatch: 4.0.4 optionalDependencies: - rollup: 4.60.2 + rollup: 4.60.4 - '@rollup/pluginutils@5.3.0(rollup@4.60.2)': + '@rollup/pluginutils@5.3.0(rollup@4.60.4)': dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 estree-walker: 2.0.2 picomatch: 4.0.4 optionalDependencies: - rollup: 4.60.2 + rollup: 4.60.4 - '@rollup/rollup-android-arm-eabi@4.60.2': + '@rollup/rollup-android-arm-eabi@4.60.4': optional: true - '@rollup/rollup-android-arm64@4.60.2': + '@rollup/rollup-android-arm64@4.60.4': optional: true - '@rollup/rollup-darwin-arm64@4.60.2': + '@rollup/rollup-darwin-arm64@4.60.4': optional: true - '@rollup/rollup-darwin-x64@4.60.2': + '@rollup/rollup-darwin-x64@4.60.4': optional: true - '@rollup/rollup-freebsd-arm64@4.60.2': + '@rollup/rollup-freebsd-arm64@4.60.4': optional: true - '@rollup/rollup-freebsd-x64@4.60.2': + '@rollup/rollup-freebsd-x64@4.60.4': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.60.2': + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.60.2': + '@rollup/rollup-linux-arm-musleabihf@4.60.4': optional: true - '@rollup/rollup-linux-arm64-gnu@4.60.2': + '@rollup/rollup-linux-arm64-gnu@4.60.4': optional: true - '@rollup/rollup-linux-arm64-musl@4.60.2': + '@rollup/rollup-linux-arm64-musl@4.60.4': optional: true - '@rollup/rollup-linux-loong64-gnu@4.60.2': + '@rollup/rollup-linux-loong64-gnu@4.60.4': optional: true - '@rollup/rollup-linux-loong64-musl@4.60.2': + '@rollup/rollup-linux-loong64-musl@4.60.4': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.60.2': + '@rollup/rollup-linux-ppc64-gnu@4.60.4': optional: true - '@rollup/rollup-linux-ppc64-musl@4.60.2': + '@rollup/rollup-linux-ppc64-musl@4.60.4': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.60.2': + '@rollup/rollup-linux-riscv64-gnu@4.60.4': optional: true - '@rollup/rollup-linux-riscv64-musl@4.60.2': + '@rollup/rollup-linux-riscv64-musl@4.60.4': optional: true - '@rollup/rollup-linux-s390x-gnu@4.60.2': + '@rollup/rollup-linux-s390x-gnu@4.60.4': optional: true - '@rollup/rollup-linux-x64-gnu@4.60.2': + '@rollup/rollup-linux-x64-gnu@4.60.4': optional: true - '@rollup/rollup-linux-x64-musl@4.60.2': + '@rollup/rollup-linux-x64-musl@4.60.4': optional: true - '@rollup/rollup-openbsd-x64@4.60.2': + '@rollup/rollup-openbsd-x64@4.60.4': optional: true - '@rollup/rollup-openharmony-arm64@4.60.2': + '@rollup/rollup-openharmony-arm64@4.60.4': optional: true - '@rollup/rollup-win32-arm64-msvc@4.60.2': + '@rollup/rollup-win32-arm64-msvc@4.60.4': optional: true - '@rollup/rollup-win32-ia32-msvc@4.60.2': + '@rollup/rollup-win32-ia32-msvc@4.60.4': optional: true - '@rollup/rollup-win32-x64-gnu@4.60.2': + '@rollup/rollup-win32-x64-gnu@4.60.4': optional: true - '@rollup/rollup-win32-x64-msvc@4.60.2': + '@rollup/rollup-win32-x64-msvc@4.60.4': optional: true '@sec-ant/readable-stream@0.4.1': {} '@sindresorhus/merge-streams@4.0.0': {} - '@size-limit/esbuild-why@12.1.0(size-limit@12.1.0(jiti@2.6.1))': + '@size-limit/esbuild-why@12.1.0(size-limit@12.1.0(jiti@2.7.0))': dependencies: esbuild-visualizer: 0.7.0 open: 11.0.0 - size-limit: 12.1.0(jiti@2.6.1) + size-limit: 12.1.0(jiti@2.7.0) - '@size-limit/esbuild@12.1.0(size-limit@12.1.0(jiti@2.6.1))': + '@size-limit/esbuild@12.1.0(size-limit@12.1.0(jiti@2.7.0))': dependencies: esbuild: 0.28.0 nanoid: 5.1.11 - size-limit: 12.1.0(jiti@2.6.1) + size-limit: 12.1.0(jiti@2.7.0) - '@size-limit/file@12.1.0(size-limit@12.1.0(jiti@2.6.1))': + '@size-limit/file@12.1.0(size-limit@12.1.0(jiti@2.7.0))': dependencies: - size-limit: 12.1.0(jiti@2.6.1) + size-limit: 12.1.0(jiti@2.7.0) - '@size-limit/preset-small-lib@12.1.0(size-limit@12.1.0(jiti@2.6.1))': + '@size-limit/preset-small-lib@12.1.0(size-limit@12.1.0(jiti@2.7.0))': dependencies: - '@size-limit/esbuild': 12.1.0(size-limit@12.1.0(jiti@2.6.1)) - '@size-limit/file': 12.1.0(size-limit@12.1.0(jiti@2.6.1)) - size-limit: 12.1.0(jiti@2.6.1) + '@size-limit/esbuild': 12.1.0(size-limit@12.1.0(jiti@2.7.0)) + '@size-limit/file': 12.1.0(size-limit@12.1.0(jiti@2.7.0)) + size-limit: 12.1.0(jiti@2.7.0) '@standard-schema/spec@1.1.0': {} - '@tailwindcss/node@4.2.4': + '@tailwindcss/node@4.3.0': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.21.0 - jiti: 2.6.1 + enhanced-resolve: 5.21.6 + jiti: 2.7.0 lightningcss: 1.32.0 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.2.4 + tailwindcss: 4.3.0 - '@tailwindcss/oxide-android-arm64@4.2.4': + '@tailwindcss/oxide-android-arm64@4.3.0': optional: true - '@tailwindcss/oxide-darwin-arm64@4.2.4': + '@tailwindcss/oxide-darwin-arm64@4.3.0': optional: true - '@tailwindcss/oxide-darwin-x64@4.2.4': + '@tailwindcss/oxide-darwin-x64@4.3.0': optional: true - '@tailwindcss/oxide-freebsd-x64@4.2.4': + '@tailwindcss/oxide-freebsd-x64@4.3.0': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.2.4': + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.2.4': + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.2.4': + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.2.4': + '@tailwindcss/oxide-linux-x64-musl@4.3.0': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.2.4': + '@tailwindcss/oxide-wasm32-wasi@4.3.0': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.2.4': + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.2.4': + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': optional: true - '@tailwindcss/oxide@4.2.4': + '@tailwindcss/oxide@4.3.0': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.2.4 - '@tailwindcss/oxide-darwin-arm64': 4.2.4 - '@tailwindcss/oxide-darwin-x64': 4.2.4 - '@tailwindcss/oxide-freebsd-x64': 4.2.4 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.4 - '@tailwindcss/oxide-linux-arm64-gnu': 4.2.4 - '@tailwindcss/oxide-linux-arm64-musl': 4.2.4 - '@tailwindcss/oxide-linux-x64-gnu': 4.2.4 - '@tailwindcss/oxide-linux-x64-musl': 4.2.4 - '@tailwindcss/oxide-wasm32-wasi': 4.2.4 - '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4 - '@tailwindcss/oxide-win32-x64-msvc': 4.2.4 - - '@tailwindcss/typography@0.5.19(tailwindcss@4.2.4)': + '@tailwindcss/oxide-android-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-x64': 4.3.0 + '@tailwindcss/oxide-freebsd-x64': 4.3.0 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.0 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.0 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-x64-musl': 4.3.0 + '@tailwindcss/oxide-wasm32-wasi': 4.3.0 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 + + '@tailwindcss/typography@0.5.19(tailwindcss@4.3.0)': dependencies: postcss-selector-parser: 6.0.10 - tailwindcss: 4.2.4 + tailwindcss: 4.3.0 - '@tailwindcss/vite@4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0))': + '@tailwindcss/vite@4.3.0(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0))': dependencies: - '@tailwindcss/node': 4.2.4 - '@tailwindcss/oxide': 4.2.4 - tailwindcss: 4.2.4 - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0) + '@tailwindcss/node': 4.3.0 + '@tailwindcss/oxide': 4.3.0 + tailwindcss: 4.3.0 + vite: 8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0) - '@tanstack/history@1.161.6': {} + '@tanstack/history@1.162.0': {} - '@tanstack/react-router-devtools@1.166.13(@tanstack/react-router@1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.169.1)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@tanstack/react-router-devtools@1.167.0(@tanstack/react-router@1.170.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@tanstack/router-core@1.171.5)(csstype@3.2.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@tanstack/react-router': 1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@tanstack/router-devtools-core': 1.167.3(@tanstack/router-core@1.169.1)(csstype@3.2.3) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@tanstack/react-router': 1.170.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tanstack/router-devtools-core': 1.168.0(@tanstack/router-core@1.171.5)(csstype@3.2.3) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@tanstack/router-core': 1.169.1 + '@tanstack/router-core': 1.171.5 transitivePeerDependencies: - csstype - '@tanstack/react-router@1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@tanstack/history': 1.161.6 - '@tanstack/react-store': 0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@tanstack/router-core': 1.169.1 - isbot: 5.1.39 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - - '@tanstack/react-start-client@1.166.47(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@tanstack/react-router': 1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@tanstack/router-core': 1.169.1 - '@tanstack/start-client-core': 1.168.1 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - - '@tanstack/react-start-rsc@0.0.41(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0))': - dependencies: - '@tanstack/react-router': 1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@tanstack/react-start-server': 1.166.51(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@tanstack/router-core': 1.169.1 - '@tanstack/router-utils': 1.161.7 - '@tanstack/start-client-core': 1.168.1 - '@tanstack/start-fn-stubs': 1.161.6 - '@tanstack/start-plugin-core': 1.169.17(@tanstack/react-router@1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(crossws@0.4.5(srvx@0.11.15))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)) - '@tanstack/start-server-core': 1.167.29(crossws@0.4.5(srvx@0.11.15)) - '@tanstack/start-storage-context': 1.166.34 + '@tanstack/react-router@1.170.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@tanstack/history': 1.162.0 + '@tanstack/react-store': 0.9.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tanstack/router-core': 1.171.5 + isbot: 5.1.40 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + '@tanstack/react-start-client@1.168.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@tanstack/react-router': 1.170.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tanstack/router-core': 1.171.5 + '@tanstack/start-client-core': 1.170.2 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + '@tanstack/react-start-rsc@0.1.10(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0))': + dependencies: + '@tanstack/react-router': 1.170.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tanstack/react-start-server': 1.167.7(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tanstack/router-core': 1.171.5 + '@tanstack/router-utils': 1.162.1 + '@tanstack/start-client-core': 1.170.2 + '@tanstack/start-fn-stubs': 1.162.0 + '@tanstack/start-plugin-core': 1.171.3(@tanstack/react-router@1.170.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(crossws@0.4.5(srvx@0.11.15))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)) + '@tanstack/start-server-core': 1.169.2(crossws@0.4.5(srvx@0.11.15)) + '@tanstack/start-storage-context': 1.167.7 pathe: 2.0.3 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) transitivePeerDependencies: - '@rsbuild/core' - crossws @@ -7091,33 +7030,33 @@ snapshots: - vite-plugin-solid - webpack - '@tanstack/react-start-server@1.166.51(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@tanstack/react-start-server@1.167.7(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@tanstack/history': 1.161.6 - '@tanstack/react-router': 1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@tanstack/router-core': 1.169.1 - '@tanstack/start-client-core': 1.168.1 - '@tanstack/start-server-core': 1.167.29(crossws@0.4.5(srvx@0.11.15)) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@tanstack/history': 1.162.0 + '@tanstack/react-router': 1.170.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tanstack/router-core': 1.171.5 + '@tanstack/start-client-core': 1.170.2 + '@tanstack/start-server-core': 1.169.2(crossws@0.4.5(srvx@0.11.15)) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) transitivePeerDependencies: - crossws - '@tanstack/react-start@1.167.62(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0))': + '@tanstack/react-start@1.168.10(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0))': dependencies: - '@tanstack/react-router': 1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@tanstack/react-start-client': 1.166.47(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@tanstack/react-start-rsc': 0.0.41(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)) - '@tanstack/react-start-server': 1.166.51(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@tanstack/router-utils': 1.161.7 - '@tanstack/start-client-core': 1.168.1 - '@tanstack/start-plugin-core': 1.169.17(@tanstack/react-router@1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(crossws@0.4.5(srvx@0.11.15))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)) - '@tanstack/start-server-core': 1.167.29(crossws@0.4.5(srvx@0.11.15)) + '@tanstack/react-router': 1.170.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tanstack/react-start-client': 1.168.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tanstack/react-start-rsc': 0.1.10(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)) + '@tanstack/react-start-server': 1.167.7(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tanstack/router-utils': 1.162.1 + '@tanstack/start-client-core': 1.170.2 + '@tanstack/start-plugin-core': 1.171.3(@tanstack/react-router@1.170.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(crossws@0.4.5(srvx@0.11.15))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)) + '@tanstack/start-server-core': 1.169.2(crossws@0.4.5(srvx@0.11.15)) pathe: 2.0.3 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0) + vite: 8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0) transitivePeerDependencies: - '@rspack/core' - crossws @@ -7126,42 +7065,42 @@ snapshots: - vite-plugin-solid - webpack - '@tanstack/react-store@0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@tanstack/react-store@0.9.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@tanstack/store': 0.9.3 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - use-sync-external-store: 1.6.0(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + use-sync-external-store: 1.6.0(react@19.2.6) - '@tanstack/router-core@1.169.1': + '@tanstack/router-core@1.171.5': dependencies: - '@tanstack/history': 1.161.6 + '@tanstack/history': 1.162.0 cookie-es: 3.1.1 - seroval: 1.5.2 - seroval-plugins: 1.5.2(seroval@1.5.2) + seroval: 1.5.4 + seroval-plugins: 1.5.4(seroval@1.5.4) - '@tanstack/router-devtools-core@1.167.3(@tanstack/router-core@1.169.1)(csstype@3.2.3)': + '@tanstack/router-devtools-core@1.168.0(@tanstack/router-core@1.171.5)(csstype@3.2.3)': dependencies: - '@tanstack/router-core': 1.169.1 + '@tanstack/router-core': 1.171.5 clsx: 2.1.1 - goober: 2.1.18(csstype@3.2.3) + goober: 2.1.19(csstype@3.2.3) optionalDependencies: csstype: 3.2.3 - '@tanstack/router-generator@1.166.39': + '@tanstack/router-generator@1.167.9': dependencies: '@babel/types': 7.29.0 - '@tanstack/router-core': 1.169.1 - '@tanstack/router-utils': 1.161.7 - '@tanstack/virtual-file-routes': 1.161.7 - jiti: 2.6.1 + '@tanstack/router-core': 1.171.5 + '@tanstack/router-utils': 1.162.1 + '@tanstack/virtual-file-routes': 1.162.0 + jiti: 2.7.0 magic-string: 0.30.21 prettier: 3.8.3 - zod: 3.25.76 + zod: 4.4.3 transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.167.32(@tanstack/react-router@1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0))': + '@tanstack/router-plugin@1.168.10(@tanstack/react-router@1.170.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) @@ -7169,26 +7108,26 @@ snapshots: '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 - '@tanstack/router-core': 1.169.1 - '@tanstack/router-generator': 1.166.39 - '@tanstack/router-utils': 1.161.7 - '@tanstack/virtual-file-routes': 1.161.7 - chokidar: 3.6.0 + '@tanstack/router-core': 1.171.5 + '@tanstack/router-generator': 1.167.9 + '@tanstack/router-utils': 1.162.1 + '@tanstack/virtual-file-routes': 1.162.0 + chokidar: 5.0.0 unplugin: 3.0.0 - zod: 3.25.76 + zod: 4.4.3 optionalDependencies: - '@tanstack/react-router': 1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0) + '@tanstack/react-router': 1.170.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + vite: 8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0) transitivePeerDependencies: - supports-color - '@tanstack/router-utils@1.161.7': + '@tanstack/router-utils@1.162.1': dependencies: '@babel/core': 7.29.0 '@babel/generator': 7.29.1 '@babel/parser': 7.29.3 '@babel/types': 7.29.0 - ansis: 4.2.0 + ansis: 4.3.0 babel-dead-code-elimination: 1.0.12 diff: 8.0.4 pathe: 2.0.3 @@ -7196,42 +7135,42 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/start-client-core@1.168.1': + '@tanstack/start-client-core@1.170.2': dependencies: - '@tanstack/router-core': 1.169.1 - '@tanstack/start-fn-stubs': 1.161.6 - '@tanstack/start-storage-context': 1.166.34 - seroval: 1.5.2 + '@tanstack/router-core': 1.171.5 + '@tanstack/start-fn-stubs': 1.162.0 + '@tanstack/start-storage-context': 1.167.7 + seroval: 1.5.4 - '@tanstack/start-fn-stubs@1.161.6': {} + '@tanstack/start-fn-stubs@1.162.0': {} - '@tanstack/start-plugin-core@1.169.17(@tanstack/react-router@1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(crossws@0.4.5(srvx@0.11.15))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0))': + '@tanstack/start-plugin-core@1.171.3(@tanstack/react-router@1.170.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(crossws@0.4.5(srvx@0.11.15))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0))': dependencies: '@babel/code-frame': 7.27.1 '@babel/core': 7.29.0 '@babel/types': 7.29.0 - '@rolldown/pluginutils': 1.0.0-beta.40 - '@tanstack/router-core': 1.169.1 - '@tanstack/router-generator': 1.166.39 - '@tanstack/router-plugin': 1.167.32(@tanstack/react-router@1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)) - '@tanstack/router-utils': 1.161.7 - '@tanstack/start-client-core': 1.168.1 - '@tanstack/start-server-core': 1.167.29(crossws@0.4.5(srvx@0.11.15)) + '@rolldown/pluginutils': 1.0.1 + '@tanstack/router-core': 1.171.5 + '@tanstack/router-generator': 1.167.9 + '@tanstack/router-plugin': 1.168.10(@tanstack/react-router@1.170.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)) + '@tanstack/router-utils': 1.162.1 + '@tanstack/start-client-core': 1.170.2 + '@tanstack/start-server-core': 1.169.2(crossws@0.4.5(srvx@0.11.15)) cheerio: 1.2.0 exsolve: 1.0.8 lightningcss: 1.32.0 pathe: 2.0.3 picomatch: 4.0.4 - seroval: 1.5.2 + seroval: 1.5.4 source-map: 0.7.6 srvx: 0.11.15 tinyglobby: 0.2.16 ufo: 1.6.4 - vitefu: 1.1.3(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)) + vitefu: 1.1.3(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)) xmlbuilder2: 4.0.3 - zod: 3.25.76 + zod: 4.4.3 optionalDependencies: - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0) + vite: 8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0) transitivePeerDependencies: - '@tanstack/react-router' - crossws @@ -7239,74 +7178,74 @@ snapshots: - vite-plugin-solid - webpack - '@tanstack/start-server-core@1.167.29(crossws@0.4.5(srvx@0.11.15))': + '@tanstack/start-server-core@1.169.2(crossws@0.4.5(srvx@0.11.15))': dependencies: - '@tanstack/history': 1.161.6 - '@tanstack/router-core': 1.169.1 - '@tanstack/start-client-core': 1.168.1 - '@tanstack/start-storage-context': 1.166.34 + '@tanstack/history': 1.162.0 + '@tanstack/router-core': 1.171.5 + '@tanstack/start-client-core': 1.170.2 + '@tanstack/start-storage-context': 1.167.7 fetchdts: 0.1.7 h3-v2: h3@2.0.1-rc.20(crossws@0.4.5(srvx@0.11.15)) - seroval: 1.5.2 + seroval: 1.5.4 transitivePeerDependencies: - crossws - '@tanstack/start-storage-context@1.166.34': + '@tanstack/start-storage-context@1.167.7': dependencies: - '@tanstack/router-core': 1.169.1 + '@tanstack/router-core': 1.171.5 '@tanstack/store@0.9.3': {} - '@tanstack/virtual-file-routes@1.161.7': {} + '@tanstack/virtual-file-routes@1.162.0': {} '@tauri-apps/api@2.11.0': {} - '@tauri-apps/cli-darwin-arm64@2.11.0': + '@tauri-apps/cli-darwin-arm64@2.11.2': optional: true - '@tauri-apps/cli-darwin-x64@2.11.0': + '@tauri-apps/cli-darwin-x64@2.11.2': optional: true - '@tauri-apps/cli-linux-arm-gnueabihf@2.11.0': + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.2': optional: true - '@tauri-apps/cli-linux-arm64-gnu@2.11.0': + '@tauri-apps/cli-linux-arm64-gnu@2.11.2': optional: true - '@tauri-apps/cli-linux-arm64-musl@2.11.0': + '@tauri-apps/cli-linux-arm64-musl@2.11.2': optional: true - '@tauri-apps/cli-linux-riscv64-gnu@2.11.0': + '@tauri-apps/cli-linux-riscv64-gnu@2.11.2': optional: true - '@tauri-apps/cli-linux-x64-gnu@2.11.0': + '@tauri-apps/cli-linux-x64-gnu@2.11.2': optional: true - '@tauri-apps/cli-linux-x64-musl@2.11.0': + '@tauri-apps/cli-linux-x64-musl@2.11.2': optional: true - '@tauri-apps/cli-win32-arm64-msvc@2.11.0': + '@tauri-apps/cli-win32-arm64-msvc@2.11.2': optional: true - '@tauri-apps/cli-win32-ia32-msvc@2.11.0': + '@tauri-apps/cli-win32-ia32-msvc@2.11.2': optional: true - '@tauri-apps/cli-win32-x64-msvc@2.11.0': + '@tauri-apps/cli-win32-x64-msvc@2.11.2': optional: true - '@tauri-apps/cli@2.11.0': + '@tauri-apps/cli@2.11.2': optionalDependencies: - '@tauri-apps/cli-darwin-arm64': 2.11.0 - '@tauri-apps/cli-darwin-x64': 2.11.0 - '@tauri-apps/cli-linux-arm-gnueabihf': 2.11.0 - '@tauri-apps/cli-linux-arm64-gnu': 2.11.0 - '@tauri-apps/cli-linux-arm64-musl': 2.11.0 - '@tauri-apps/cli-linux-riscv64-gnu': 2.11.0 - '@tauri-apps/cli-linux-x64-gnu': 2.11.0 - '@tauri-apps/cli-linux-x64-musl': 2.11.0 - '@tauri-apps/cli-win32-arm64-msvc': 2.11.0 - '@tauri-apps/cli-win32-ia32-msvc': 2.11.0 - '@tauri-apps/cli-win32-x64-msvc': 2.11.0 + '@tauri-apps/cli-darwin-arm64': 2.11.2 + '@tauri-apps/cli-darwin-x64': 2.11.2 + '@tauri-apps/cli-linux-arm-gnueabihf': 2.11.2 + '@tauri-apps/cli-linux-arm64-gnu': 2.11.2 + '@tauri-apps/cli-linux-arm64-musl': 2.11.2 + '@tauri-apps/cli-linux-riscv64-gnu': 2.11.2 + '@tauri-apps/cli-linux-x64-gnu': 2.11.2 + '@tauri-apps/cli-linux-x64-musl': 2.11.2 + '@tauri-apps/cli-win32-arm64-msvc': 2.11.2 + '@tauri-apps/cli-win32-ia32-msvc': 2.11.2 + '@tauri-apps/cli-win32-x64-msvc': 2.11.2 '@tauri-apps/plugin-opener@2.5.4': dependencies: @@ -7324,22 +7263,22 @@ snapshots: '@tsconfig/node16@1.0.4': {} - '@turbo/darwin-64@2.9.8': + '@turbo/darwin-64@2.9.14': optional: true - '@turbo/darwin-arm64@2.9.8': + '@turbo/darwin-arm64@2.9.14': optional: true - '@turbo/linux-64@2.9.8': + '@turbo/linux-64@2.9.14': optional: true - '@turbo/linux-arm64@2.9.8': + '@turbo/linux-arm64@2.9.14': optional: true - '@turbo/windows-64@2.9.8': + '@turbo/windows-64@2.9.14': optional: true - '@turbo/windows-arm64@2.9.8': + '@turbo/windows-arm64@2.9.14': optional: true '@tybys/wasm-util@0.10.2': @@ -7347,31 +7286,10 @@ snapshots: tslib: 2.8.1 optional: true - '@types/babel__core@7.20.5': - dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 - - '@types/babel__generator@7.27.0': - dependencies: - '@babel/types': 7.29.0 - - '@types/babel__template@7.4.4': - dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - - '@types/babel__traverse@7.28.0': - dependencies: - '@babel/types': 7.29.0 - '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 25.6.0 + '@types/node': 25.9.1 '@types/chai@5.2.3': dependencies: @@ -7380,7 +7298,7 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.1 '@types/debug@4.1.13': dependencies: @@ -7388,15 +7306,19 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/esrecurse@4.3.1': {} + '@types/estree-jsx@1.0.5': dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} + '@types/express-serve-static-core@5.1.1': dependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.1 '@types/qs': 6.15.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -7425,82 +7347,82 @@ snapshots: '@types/node@12.20.55': {} + '@types/node@24.12.4': + dependencies: + undici-types: 7.16.0 + '@types/node@25.5.2': dependencies: undici-types: 7.18.2 - '@types/node@25.6.0': + '@types/node@25.9.1': dependencies: - undici-types: 7.19.2 + undici-types: 7.24.6 '@types/qs@6.15.0': {} '@types/range-parser@1.2.7': {} - '@types/react-dom@19.2.3(@types/react@19.2.14)': + '@types/react-dom@19.2.3(@types/react@19.2.15)': dependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@types/react@19.2.14': + '@types/react@19.2.15': dependencies: csstype: 3.2.3 '@types/sax@1.2.7': dependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.1 '@types/send@1.2.1': dependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.1 '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 25.6.0 + '@types/node': 25.9.1 '@types/set-cookie-parser@2.4.10': dependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.1 '@types/statuses@2.0.6': {} - '@types/trusted-types@2.0.7': {} - '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} - '@types/url-parse@1.4.11': {} - '@types/xml2js@0.4.14': dependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.1 - '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/type-utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.58.0 - eslint: 9.39.4(jiti@2.6.1) + '@typescript-eslint/parser': 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/type-utils': 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.59.4 + eslint: 10.4.0(jiti@2.7.0) ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.59.1 - '@typescript-eslint/type-utils': 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) - '@typescript-eslint/utils': 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.59.1 - eslint: 9.39.4(jiti@2.6.1) + '@typescript-eslint/parser': 8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/type-utils': 8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.59.4 + eslint: 9.39.4(jiti@2.7.0) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.3) @@ -7508,218 +7430,171 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.58.0 - debug: 4.4.3 - eslint: 9.39.4(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)': + '@typescript-eslint/parser@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@typescript-eslint/scope-manager': 8.59.1 - '@typescript-eslint/types': 8.59.1 - '@typescript-eslint/typescript-estree': 8.59.1(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.59.1 + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.59.4 debug: 4.4.3 - eslint: 9.39.4(jiti@2.6.1) + eslint: 10.4.0(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.58.0(typescript@5.9.3)': + '@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) - '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.59.4 debug: 4.4.3 - typescript: 5.9.3 + eslint: 9.39.4(jiti@2.7.0) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.59.1(typescript@6.0.3)': + '@typescript-eslint/project-service@8.59.4(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@6.0.3) - '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@6.0.3) + '@typescript-eslint/types': 8.59.4 debug: 4.4.3 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.58.0': - dependencies: - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/visitor-keys': 8.58.0 - - '@typescript-eslint/scope-manager@8.59.1': - dependencies: - '@typescript-eslint/types': 8.59.1 - '@typescript-eslint/visitor-keys': 8.59.1 - - '@typescript-eslint/tsconfig-utils@8.58.0(typescript@5.9.3)': + '@typescript-eslint/scope-manager@8.59.4': dependencies: - typescript: 5.9.3 + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/visitor-keys': 8.59.4 - '@typescript-eslint/tsconfig-utils@8.59.1(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.59.4(typescript@6.0.3)': dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3) debug: 4.4.3 - eslint: 9.39.4(jiti@2.6.1) - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 + eslint: 10.4.0(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.59.1 - '@typescript-eslint/typescript-estree': 8.59.1(typescript@6.0.3) - '@typescript-eslint/utils': 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) debug: 4.4.3 - eslint: 9.39.4(jiti@2.6.1) + eslint: 9.39.4(jiti@2.7.0) ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.58.0': {} - - '@typescript-eslint/types@8.59.1': {} - - '@typescript-eslint/typescript-estree@8.58.0(typescript@5.9.3)': - dependencies: - '@typescript-eslint/project-service': 8.58.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/visitor-keys': 8.58.0 - debug: 4.4.3 - minimatch: 10.2.5 - semver: 7.7.4 - tinyglobby: 0.2.15 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color + '@typescript-eslint/types@8.59.4': {} - '@typescript-eslint/typescript-estree@8.59.1(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.59.4(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.59.1(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@6.0.3) - '@typescript-eslint/types': 8.59.1 - '@typescript-eslint/visitor-keys': 8.59.1 + '@typescript-eslint/project-service': 8.59.4(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@6.0.3) + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/visitor-keys': 8.59.4 debug: 4.4.3 minimatch: 10.2.5 - semver: 7.7.4 + semver: 7.8.1 tinyglobby: 0.2.16 ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) - eslint: 9.39.4(jiti@2.6.1) - typescript: 5.9.3 + '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.0(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.3) + eslint: 10.4.0(jiti@2.7.0) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)': + '@typescript-eslint/utils@8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.59.1 - '@typescript-eslint/types': 8.59.1 - '@typescript-eslint/typescript-estree': 8.59.1(typescript@6.0.3) - eslint: 9.39.4(jiti@2.6.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.3) + eslint: 9.39.4(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.58.0': - dependencies: - '@typescript-eslint/types': 8.58.0 - eslint-visitor-keys: 5.0.1 - - '@typescript-eslint/visitor-keys@8.59.1': + '@typescript-eslint/visitor-keys@8.59.4': dependencies: - '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/types': 8.59.4 eslint-visitor-keys: 5.0.1 - '@ungap/structured-clone@1.3.0': {} + '@ungap/structured-clone@1.3.1': {} - '@vercel/analytics@2.0.1(react@19.2.5)': + '@vercel/analytics@2.0.1(react@19.2.6)': optionalDependencies: - react: 19.2.5 + react: 19.2.6 - '@vitejs/plugin-react@5.2.0(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))': + '@vitejs/plugin-react@6.0.2(vite@8.0.14(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0))': dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) - '@rolldown/pluginutils': 1.0.0-rc.3 - '@types/babel__core': 7.20.5 - react-refresh: 0.18.0 - vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) - transitivePeerDependencies: - - supports-color + '@rolldown/pluginutils': 1.0.1 + vite: 8.0.14(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0) - '@vitejs/plugin-react@6.0.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0))': + '@vitejs/plugin-react@6.0.2(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0))': dependencies: - '@rolldown/pluginutils': 1.0.0-rc.7 - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0) + '@rolldown/pluginutils': 1.0.1 + vite: 8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0) - '@vitest/expect@4.1.5': + '@vitest/expect@4.1.7': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.5 - '@vitest/utils': 4.1.5 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.5(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0))': + '@vitest/mocker@4.1.7(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0))': dependencies: - '@vitest/spy': 4.1.5 + '@vitest/spy': 4.1.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - msw: 2.14.2(@types/node@25.6.0)(typescript@6.0.3) - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0) + msw: 2.14.6(@types/node@25.9.1)(typescript@6.0.3) + vite: 8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0) - '@vitest/pretty-format@4.1.5': + '@vitest/pretty-format@4.1.7': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.1.5': + '@vitest/runner@4.1.7': dependencies: - '@vitest/utils': 4.1.5 + '@vitest/utils': 4.1.7 pathe: 2.0.3 - '@vitest/snapshot@4.1.5': + '@vitest/snapshot@4.1.7': dependencies: - '@vitest/pretty-format': 4.1.5 - '@vitest/utils': 4.1.5 + '@vitest/pretty-format': 4.1.7 + '@vitest/utils': 4.1.7 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.5': {} + '@vitest/spy@4.1.7': {} - '@vitest/utils@4.1.5': + '@vitest/utils@4.1.7': dependencies: - '@vitest/pretty-format': 4.1.5 + '@vitest/pretty-format': 4.1.7 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 @@ -7755,7 +7630,7 @@ snapshots: dependencies: color-convert: 2.0.1 - ansis@4.2.0: {} + ansis@4.3.0: {} anymatch@3.1.3: dependencies: @@ -7938,6 +7813,10 @@ snapshots: dependencies: piscina: 3.2.0 + camaro@6.3.2: + dependencies: + piscina: 3.2.0 + caniuse-lite@1.0.30001786: {} ccount@2.0.1: {} @@ -7996,6 +7875,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -8062,9 +7945,9 @@ snapshots: optionalDependencies: srvx: 0.11.15 - css-declaration-sorter@7.4.0(postcss@8.5.13): + css-declaration-sorter@7.4.0(postcss@8.5.15): dependencies: - postcss: 8.5.13 + postcss: 8.5.15 css-select@5.2.2: dependencies: @@ -8195,7 +8078,8 @@ snapshots: dotenv@16.0.3: {} - dotenv@17.4.2: {} + dotenv@17.4.2: + optional: true dotenv@8.6.0: {} @@ -8226,7 +8110,7 @@ snapshots: dependencies: once: 1.4.0 - enhanced-resolve@5.21.0: + enhanced-resolve@5.21.6: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 @@ -8438,39 +8322,39 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)): + eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.7.0)): dependencies: - eslint: 9.39.4(jiti@2.6.1) + eslint: 9.39.4(jiti@2.7.0) eslint-plugin-only-warn@1.2.1: {} - eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-react-hooks@7.1.1(eslint@10.4.0(jiti@2.7.0)): dependencies: '@babel/core': 7.29.0 - '@babel/parser': 7.29.2 - eslint: 9.39.4(jiti@2.6.1) + '@babel/parser': 7.29.3 + eslint: 10.4.0(jiti@2.7.0) hermes-parser: 0.25.1 - zod: 4.3.6 - zod-validation-error: 4.0.2(zod@4.3.6) + zod: 4.4.3 + zod-validation-error: 4.0.2(zod@4.4.3) transitivePeerDependencies: - supports-color - eslint-plugin-react-hooks@7.1.1(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-react-hooks@7.1.1(eslint@9.39.4(jiti@2.7.0)): dependencies: '@babel/core': 7.29.0 '@babel/parser': 7.29.3 - eslint: 9.39.4(jiti@2.6.1) + eslint: 9.39.4(jiti@2.7.0) hermes-parser: 0.25.1 - zod: 4.4.2 - zod-validation-error: 4.0.2(zod@4.4.2) + zod: 4.4.3 + zod-validation-error: 4.0.2(zod@4.4.3) transitivePeerDependencies: - supports-color - eslint-plugin-react-refresh@0.4.26(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-react-refresh@0.5.2(eslint@10.4.0(jiti@2.7.0)): dependencies: - eslint: 9.39.4(jiti@2.6.1) + eslint: 10.4.0(jiti@2.7.0) - eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@2.7.0)): dependencies: array-includes: 3.1.9 array.prototype.findlast: 1.2.5 @@ -8478,7 +8362,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.3.2 - eslint: 9.39.4(jiti@2.6.1) + eslint: 9.39.4(jiti@2.7.0) estraverse: 5.3.0 hasown: 2.0.3 jsx-ast-utils: 3.3.5 @@ -8487,31 +8371,75 @@ snapshots: object.fromentries: 2.0.8 object.values: 1.2.1 prop-types: 15.8.1 - resolve: 2.0.0-next.6 + resolve: 2.0.0-next.7 semver: 6.3.1 string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-turbo@2.9.8(eslint@9.39.4(jiti@2.6.1))(turbo@2.9.8): + eslint-plugin-turbo@2.9.14(eslint@9.39.4(jiti@2.7.0))(turbo@2.9.14): dependencies: dotenv: 16.0.3 - eslint: 9.39.4(jiti@2.6.1) - turbo: 2.9.8 + eslint: 9.39.4(jiti@2.7.0) + turbo: 2.9.14 eslint-scope@8.4.0: dependencies: esrecurse: 4.3.0 estraverse: 5.3.0 + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.8 + esrecurse: 4.3.0 + estraverse: 5.3.0 + eslint-visitor-keys@3.4.3: {} eslint-visitor-keys@4.2.1: {} eslint-visitor-keys@5.0.1: {} - eslint@9.39.4(jiti@2.6.1): + eslint@10.4.0(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.0(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + eslint@9.39.4(jiti@2.7.0): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 @@ -8546,7 +8474,7 @@ snapshots: natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: - jiti: 2.6.1 + jiti: 2.7.0 transitivePeerDependencies: - supports-color @@ -8556,6 +8484,12 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.16.0) eslint-visitor-keys: 4.2.1 + espree@11.2.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 5.0.1 + esprima@4.0.1: {} esquery@1.7.0: @@ -8570,7 +8504,7 @@ snapshots: estree-util-attach-comments@3.0.0: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 estree-util-build-jsx@3.0.1: dependencies: @@ -8583,7 +8517,7 @@ snapshots: estree-util-scope@1.0.0: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 devlop: 1.1.0 estree-util-to-js@2.0.0: @@ -8703,7 +8637,7 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 - fast-wrap-ansi@0.2.0: + fast-wrap-ansi@0.2.2: dependencies: fast-string-width: 3.0.2 @@ -8711,9 +8645,10 @@ snapshots: dependencies: path-expression-matcher: 1.4.0 - fast-xml-builder@1.1.5: + fast-xml-builder@1.2.0: dependencies: path-expression-matcher: 1.5.0 + xml-naming: 0.1.0 fast-xml-parser@5.5.10: dependencies: @@ -8721,12 +8656,13 @@ snapshots: path-expression-matcher: 1.4.0 strnum: 2.2.3 - fast-xml-parser@5.7.2: + fast-xml-parser@5.8.0: dependencies: '@nodable/entities': 2.1.0 - fast-xml-builder: 1.1.5 + fast-xml-builder: 1.2.0 path-expression-matcher: 1.5.0 - strnum: 2.2.3 + strnum: 2.3.0 + xml-naming: 0.1.0 fastq@1.20.1: dependencies: @@ -8889,15 +8825,27 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 - goober@2.1.18(csstype@3.2.3): + goober@2.1.19(csstype@3.2.3): dependencies: csstype: 3.2.3 gopd@1.2.0: {} + gql.tada@1.9.2(graphql@16.14.0)(typescript@6.0.3): + dependencies: + '@0no-co/graphql.web': 1.2.0(graphql@16.14.0) + '@0no-co/graphqlsp': 1.15.4(graphql@16.14.0)(typescript@6.0.3) + '@gql.tada/cli-utils': 1.7.3(@0no-co/graphqlsp@1.15.4(graphql@16.14.0)(typescript@6.0.3))(graphql@16.14.0)(typescript@6.0.3) + '@gql.tada/internal': 1.0.9(graphql@16.14.0)(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - '@gql.tada/svelte-support' + - '@gql.tada/vue-support' + - graphql + graceful-fs@4.2.11: {} - graphql@16.13.2: {} + graphql@16.14.0: {} h3@2.0.1-rc.20(crossws@0.4.5(srvx@0.11.15)): dependencies: @@ -8943,7 +8891,7 @@ snapshots: hast-util-to-estree@3.1.3: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 comma-separated-tokens: 2.0.3 @@ -8964,7 +8912,7 @@ snapshots: hast-util-to-jsx-runtime@2.3.6: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 '@types/hast': 3.0.4 '@types/unist': 3.0.3 comma-separated-tokens: 2.0.3 @@ -9007,7 +8955,7 @@ snapshots: hono@4.12.12: {} - hono@4.12.16: {} + hono@4.12.21: {} hookable@6.1.1: {} @@ -9112,9 +9060,9 @@ snapshots: is-callable@1.2.7: {} - is-core-module@2.16.1: + is-core-module@2.16.2: dependencies: - hasown: 2.0.2 + hasown: 2.0.3 is-data-view@1.0.2: dependencies: @@ -9243,7 +9191,7 @@ snapshots: isarray@2.0.5: {} - isbot@5.1.39: {} + isbot@5.1.40: {} isexe@2.0.0: {} @@ -9256,7 +9204,7 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 - jiti@2.6.1: {} + jiti@2.7.0: {} js-levenshtein@1.1.6: {} @@ -9354,22 +9302,6 @@ snapshots: lilconfig@3.1.3: {} - lit-element@4.2.2: - dependencies: - '@lit-labs/ssr-dom-shim': 1.5.1 - '@lit/reactive-element': 2.1.2 - lit-html: 3.3.2 - - lit-html@3.3.2: - dependencies: - '@types/trusted-types': 2.0.7 - - lit@3.3.2: - dependencies: - '@lit/reactive-element': 2.1.2 - lit-element: 4.2.2 - lit-html: 3.3.2 - locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -9477,7 +9409,7 @@ snapshots: dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 - '@ungap/structured-clone': 1.3.0 + '@ungap/structured-clone': 1.3.1 devlop: 1.1.0 micromark-util-sanitize-uri: 2.0.1 trim-lines: 3.0.1 @@ -9528,7 +9460,7 @@ snapshots: micromark-extension-mdx-expression@3.0.1: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 devlop: 1.1.0 micromark-factory-mdx-expression: 2.0.3 micromark-factory-space: 2.0.1 @@ -9539,7 +9471,7 @@ snapshots: micromark-extension-mdx-jsx@3.0.2: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 devlop: 1.1.0 estree-util-is-identifier-name: 3.0.0 micromark-factory-mdx-expression: 2.0.3 @@ -9556,7 +9488,7 @@ snapshots: micromark-extension-mdxjs-esm@3.0.0: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 micromark-util-character: 2.1.1 @@ -9592,7 +9524,7 @@ snapshots: micromark-factory-mdx-expression@2.0.3: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 devlop: 1.1.0 micromark-factory-space: 2.0.1 micromark-util-character: 2.1.1 @@ -9656,7 +9588,7 @@ snapshots: micromark-util-events-to-acorn@2.0.3: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 '@types/unist': 3.0.3 devlop: 1.1.0 estree-util-visit: 2.0.0 @@ -9742,14 +9674,14 @@ snapshots: ms@2.1.3: {} - msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3): + msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3): dependencies: - '@inquirer/confirm': 6.0.12(@types/node@25.6.0) - '@mswjs/interceptors': 0.41.8 + '@inquirer/confirm': 6.0.13(@types/node@25.9.1) + '@mswjs/interceptors': 0.41.9 '@open-draft/deferred-promise': 3.0.0 '@types/statuses': 2.0.6 cookie: 1.1.1 - graphql: 16.13.2 + graphql: 16.14.0 headers-polyfill: 5.0.1 is-node-process: 1.2.0 outvariant: 1.4.3 @@ -9791,7 +9723,7 @@ snapshots: nice-try@1.0.5: {} - nitro@3.0.260429-beta(dotenv@17.4.2)(jiti@2.6.1)(rollup@4.60.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0))(xml2js@0.6.2): + nitro@3.0.260429-beta(chokidar@5.0.0)(dotenv@17.4.2)(jiti@2.7.0)(rollup@4.60.4)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0))(xml2js@0.6.2): dependencies: consola: 3.4.2 crossws: 0.4.5(srvx@0.11.15) @@ -9806,12 +9738,12 @@ snapshots: rolldown: 1.0.0-rc.18 srvx: 0.11.15 unenv: 2.0.0-rc.24 - unstorage: 2.0.0-alpha.7(db0@0.3.4)(ofetch@2.0.0-alpha.3) + unstorage: 2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4)(ofetch@2.0.0-alpha.3) optionalDependencies: dotenv: 17.4.2 - jiti: 2.6.1 - rollup: 4.60.2 - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0) + jiti: 2.7.0 + rollup: 4.60.4 + vite: 8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0) xml2js: 0.6.2 transitivePeerDependencies: - '@azure/app-configuration' @@ -9960,15 +9892,7 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 - openapi-typescript@7.13.0(typescript@5.9.3): - dependencies: - '@redocly/openapi-core': 1.34.11(supports-color@10.2.2) - ansi-colors: 4.1.3 - change-case: 5.4.4 - parse-json: 8.3.0 - supports-color: 10.2.2 - typescript: 5.9.3 - yargs-parser: 21.1.1 + openapi-typescript-helpers@0.1.0: {} openapi-typescript@7.13.0(typescript@6.0.3): dependencies: @@ -10110,20 +10034,20 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-less@6.0.0(postcss@8.5.13): + postcss-less@6.0.0(postcss@8.5.15): dependencies: - postcss: 8.5.13 + postcss: 8.5.15 - postcss-scss@4.0.9(postcss@8.5.13): + postcss-scss@4.0.9(postcss@8.5.15): dependencies: - postcss: 8.5.13 + postcss: 8.5.15 postcss-selector-parser@6.0.10: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss@8.5.13: + postcss@8.5.15: dependencies: nanoid: 3.3.12 picocolors: 1.1.1 @@ -10139,11 +10063,11 @@ snapshots: prelude-ls@1.2.1: {} - prettier-plugin-css-order@2.2.0(postcss@8.5.13)(prettier@3.8.3): + prettier-plugin-css-order@2.2.0(postcss@8.5.15)(prettier@3.8.3): dependencies: - css-declaration-sorter: 7.4.0(postcss@8.5.13) - postcss-less: 6.0.0(postcss@8.5.13) - postcss-scss: 4.0.9(postcss@8.5.13) + css-declaration-sorter: 7.4.0(postcss@8.5.15) + postcss-less: 6.0.0(postcss@8.5.15) + postcss-scss: 4.0.9(postcss@8.5.15) prettier: 3.8.3 transitivePeerDependencies: - postcss @@ -10154,12 +10078,12 @@ snapshots: optionalDependencies: prettier: 3.8.3 - prettier-plugin-tailwindcss@0.8.0(@ianvs/prettier-plugin-sort-imports@4.7.1(prettier@3.8.3))(prettier-plugin-css-order@2.2.0(postcss@8.5.13)(prettier@3.8.3))(prettier@3.8.3): + prettier-plugin-tailwindcss@0.8.0(@ianvs/prettier-plugin-sort-imports@4.7.1(prettier@3.8.3))(prettier-plugin-css-order@2.2.0(postcss@8.5.15)(prettier@3.8.3))(prettier@3.8.3): dependencies: prettier: 3.8.3 optionalDependencies: '@ianvs/prettier-plugin-sort-imports': 4.7.1(prettier@3.8.3) - prettier-plugin-css-order: 2.2.0(postcss@8.5.13)(prettier@3.8.3) + prettier-plugin-css-order: 2.2.0(postcss@8.5.15)(prettier@3.8.3) prettier@2.8.8: {} @@ -10210,23 +10134,14 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 - react-dom@19.2.4(react@19.2.4): - dependencies: - react: 19.2.4 - scheduler: 0.27.0 - - react-dom@19.2.5(react@19.2.5): + react-dom@19.2.6(react@19.2.6): dependencies: - react: 19.2.5 + react: 19.2.6 scheduler: 0.27.0 react-is@16.13.1: {} - react-refresh@0.18.0: {} - - react@19.2.4: {} - - react@19.2.5: {} + react@19.2.6: {} read-yaml-file@1.1.0: dependencies: @@ -10245,13 +10160,15 @@ snapshots: dependencies: picomatch: 2.3.2 + readdirp@5.0.0: {} + rechoir@0.6.2: dependencies: resolve: 1.22.12 recma-build-jsx@1.0.0: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 estree-util-build-jsx: 3.0.1 vfile: 6.0.3 @@ -10266,14 +10183,14 @@ snapshots: recma-parse@1.0.0: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 esast-util-from-js: 2.0.1 unified: 11.0.5 vfile: 6.0.3 recma-stringify@1.0.0: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 estree-util-to-js: 2.0.0 unified: 11.0.5 vfile: 6.0.3 @@ -10300,7 +10217,7 @@ snapshots: rehype-recma@1.0.0: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 '@types/hast': 3.0.4 hast-util-to-estree: 3.1.3 transitivePeerDependencies: @@ -10343,14 +10260,14 @@ snapshots: resolve@1.22.12: dependencies: es-errors: 1.3.0 - is-core-module: 2.16.1 + is-core-module: 2.16.2 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - resolve@2.0.0-next.6: + resolve@2.0.0-next.7: dependencies: es-errors: 1.3.0 - is-core-module: 2.16.1 + is-core-module: 2.16.2 node-exports-info: 1.6.0 object-keys: 1.1.1 path-parse: 1.0.7 @@ -10360,27 +10277,6 @@ snapshots: reusify@1.1.0: {} - rolldown@1.0.0-rc.17: - dependencies: - '@oxc-project/types': 0.127.0 - '@rolldown/pluginutils': 1.0.0-rc.17 - optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.17 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.17 - '@rolldown/binding-darwin-x64': 1.0.0-rc.17 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.17 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17 - '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17 - '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.17 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.17 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.17 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 - rolldown@1.0.0-rc.18: dependencies: '@oxc-project/types': 0.128.0 @@ -10402,62 +10298,83 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.18 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.18 - rollup-plugin-dts@6.4.1(rollup@4.60.2)(typescript@6.0.3): + rolldown@1.0.2: + dependencies: + '@oxc-project/types': 0.132.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.2 + '@rolldown/binding-darwin-arm64': 1.0.2 + '@rolldown/binding-darwin-x64': 1.0.2 + '@rolldown/binding-freebsd-x64': 1.0.2 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.2 + '@rolldown/binding-linux-arm64-gnu': 1.0.2 + '@rolldown/binding-linux-arm64-musl': 1.0.2 + '@rolldown/binding-linux-ppc64-gnu': 1.0.2 + '@rolldown/binding-linux-s390x-gnu': 1.0.2 + '@rolldown/binding-linux-x64-gnu': 1.0.2 + '@rolldown/binding-linux-x64-musl': 1.0.2 + '@rolldown/binding-openharmony-arm64': 1.0.2 + '@rolldown/binding-wasm32-wasi': 1.0.2 + '@rolldown/binding-win32-arm64-msvc': 1.0.2 + '@rolldown/binding-win32-x64-msvc': 1.0.2 + + rollup-plugin-dts@6.4.1(rollup@4.60.4)(typescript@6.0.3): dependencies: '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.5 convert-source-map: 2.0.0 magic-string: 0.30.21 - rollup: 4.60.2 + rollup: 4.60.4 typescript: 6.0.3 optionalDependencies: '@babel/code-frame': 7.29.0 - rollup-plugin-esbuild@6.2.1(esbuild@0.28.0)(rollup@4.60.2): + rollup-plugin-esbuild@6.2.1(esbuild@0.28.0)(rollup@4.60.4): dependencies: debug: 4.4.3 es-module-lexer: 1.7.0 esbuild: 0.28.0 get-tsconfig: 4.14.0 - rollup: 4.60.2 + rollup: 4.60.4 unplugin-utils: 0.2.5 transitivePeerDependencies: - supports-color - rollup-plugin-node-externals@9.0.1(rollup@4.60.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)): + rollup-plugin-node-externals@9.0.1(rollup@4.60.4)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)): optionalDependencies: - rollup: 4.60.2 - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0) + rollup: 4.60.4 + vite: 8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0) - rollup@4.60.2: + rollup@4.60.4: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.60.2 - '@rollup/rollup-android-arm64': 4.60.2 - '@rollup/rollup-darwin-arm64': 4.60.2 - '@rollup/rollup-darwin-x64': 4.60.2 - '@rollup/rollup-freebsd-arm64': 4.60.2 - '@rollup/rollup-freebsd-x64': 4.60.2 - '@rollup/rollup-linux-arm-gnueabihf': 4.60.2 - '@rollup/rollup-linux-arm-musleabihf': 4.60.2 - '@rollup/rollup-linux-arm64-gnu': 4.60.2 - '@rollup/rollup-linux-arm64-musl': 4.60.2 - '@rollup/rollup-linux-loong64-gnu': 4.60.2 - '@rollup/rollup-linux-loong64-musl': 4.60.2 - '@rollup/rollup-linux-ppc64-gnu': 4.60.2 - '@rollup/rollup-linux-ppc64-musl': 4.60.2 - '@rollup/rollup-linux-riscv64-gnu': 4.60.2 - '@rollup/rollup-linux-riscv64-musl': 4.60.2 - '@rollup/rollup-linux-s390x-gnu': 4.60.2 - '@rollup/rollup-linux-x64-gnu': 4.60.2 - '@rollup/rollup-linux-x64-musl': 4.60.2 - '@rollup/rollup-openbsd-x64': 4.60.2 - '@rollup/rollup-openharmony-arm64': 4.60.2 - '@rollup/rollup-win32-arm64-msvc': 4.60.2 - '@rollup/rollup-win32-ia32-msvc': 4.60.2 - '@rollup/rollup-win32-x64-gnu': 4.60.2 - '@rollup/rollup-win32-x64-msvc': 4.60.2 + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 fsevents: 2.3.3 rou3@0.8.1: {} @@ -10513,6 +10430,8 @@ snapshots: semver@7.7.4: {} + semver@7.8.1: {} + send@1.2.1: dependencies: debug: 4.4.3 @@ -10529,11 +10448,11 @@ snapshots: transitivePeerDependencies: - supports-color - seroval-plugins@1.5.2(seroval@1.5.2): + seroval-plugins@1.5.4(seroval@1.5.4): dependencies: - seroval: 1.5.2 + seroval: 1.5.4 - seroval@1.5.2: {} + seroval@1.5.4: {} serve-static@2.2.1: dependencies: @@ -10632,7 +10551,7 @@ snapshots: dependencies: semver: 7.7.4 - size-limit@12.1.0(jiti@2.6.1): + size-limit@12.1.0(jiti@2.7.0): dependencies: bytes-iec: 3.1.1 lilconfig: 3.1.3 @@ -10640,7 +10559,7 @@ snapshots: picocolors: 1.1.1 tinyglobby: 0.2.16 optionalDependencies: - jiti: 2.6.1 + jiti: 2.7.0 slash@3.0.0: {} @@ -10652,7 +10571,7 @@ snapshots: detect-newline: 4.0.1 git-hooks-list: 4.2.1 is-plain-obj: 4.1.0 - semver: 7.7.4 + semver: 7.8.1 sort-object-keys: 2.1.0 tinyglobby: 0.2.16 @@ -10761,6 +10680,8 @@ snapshots: strnum@2.2.3: {} + strnum@2.3.0: {} + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -10783,9 +10704,9 @@ snapshots: tagged-tag@1.0.0: {} - tailwind-merge@3.5.0: {} + tailwind-merge@3.6.0: {} - tailwindcss@4.2.4: {} + tailwindcss@4.3.0: {} tapable@2.3.3: {} @@ -10842,10 +10763,6 @@ snapshots: trough@2.2.0: {} - ts-api-utils@2.5.0(typescript@5.9.3): - dependencies: - typescript: 5.9.3 - ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3 @@ -10884,19 +10801,21 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - turbo@2.9.8: + turbo@2.9.14: optionalDependencies: - '@turbo/darwin-64': 2.9.8 - '@turbo/darwin-arm64': 2.9.8 - '@turbo/linux-64': 2.9.8 - '@turbo/linux-arm64': 2.9.8 - '@turbo/windows-64': 2.9.8 - '@turbo/windows-arm64': 2.9.8 + '@turbo/darwin-64': 2.9.14 + '@turbo/darwin-arm64': 2.9.14 + '@turbo/linux-64': 2.9.14 + '@turbo/linux-arm64': 2.9.14 + '@turbo/windows-64': 2.9.14 + '@turbo/windows-arm64': 2.9.14 txml@5.2.1: dependencies: through2: 3.0.2 + txml@6.0.0: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -10948,13 +10867,24 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3): + typescript-eslint@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3) + eslint: 10.4.0(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + typescript-eslint@8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.59.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) - '@typescript-eslint/parser': 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.59.1(typescript@6.0.3) - '@typescript-eslint/utils': 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) - eslint: 9.39.4(jiti@2.6.1) + '@typescript-eslint/eslint-plugin': 8.59.4(@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + eslint: 9.39.4(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -10974,9 +10904,11 @@ snapshots: undefsafe@2.0.5: {} + undici-types@7.16.0: {} + undici-types@7.18.2: {} - undici-types@7.19.2: {} + undici-types@7.24.6: {} undici@7.25.0: {} @@ -11038,8 +10970,9 @@ snapshots: picomatch: 4.0.4 webpack-virtual-modules: 0.6.2 - unstorage@2.0.0-alpha.7(db0@0.3.4)(ofetch@2.0.0-alpha.3): + unstorage@2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4)(ofetch@2.0.0-alpha.3): optionalDependencies: + chokidar: 5.0.0 db0: 0.3.4 ofetch: 2.0.0-alpha.3 @@ -11057,23 +10990,19 @@ snapshots: dependencies: punycode: 2.3.1 - use-sync-external-store@1.6.0(react@19.2.5): + use-sync-external-store@1.6.0(react@19.2.6): dependencies: - react: 19.2.5 + react: 19.2.6 util-deprecate@1.0.2: {} v8-compile-cache-lib@3.0.1: {} - valibot@1.2.0(typescript@5.9.3): - optionalDependencies: - typescript: 5.9.3 - valibot@1.2.0(typescript@6.0.3): optionalDependencies: typescript: 6.0.3 - valibot@1.3.1(typescript@6.0.3): + valibot@1.4.0(typescript@6.0.3): optionalDependencies: typescript: 6.0.3 @@ -11089,48 +11018,62 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0): + vite@7.3.2(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 postcss: 8.5.8 - rollup: 4.60.2 + rollup: 4.60.4 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.1 fsevents: 2.3.3 - jiti: 2.6.1 + jiti: 2.7.0 + lightningcss: 1.32.0 + tsx: 4.21.0 + + vite@8.0.14(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0): + dependencies: lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.2 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 24.12.4 + esbuild: 0.28.0 + fsevents: 2.3.3 + jiti: 2.7.0 tsx: 4.21.0 - vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0): + vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.13 - rolldown: 1.0.0-rc.17 + postcss: 8.5.15 + rolldown: 1.0.2 tinyglobby: 0.2.16 optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.1 esbuild: 0.28.0 fsevents: 2.3.3 - jiti: 2.6.1 + jiti: 2.7.0 tsx: 4.21.0 - vitefu@1.1.3(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)): + vitefu@1.1.3(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)): optionalDependencies: - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0) + vite: 8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0) - vitest@4.1.5(@types/node@25.6.0)(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)): + vitest@4.1.7(@types/node@25.9.1)(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)): dependencies: - '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)) - '@vitest/pretty-format': 4.1.5 - '@vitest/runner': 4.1.5 - '@vitest/snapshot': 4.1.5 - '@vitest/spy': 4.1.5 - '@vitest/utils': 4.1.5 + '@vitest/expect': 4.1.7 + '@vitest/mocker': 4.1.7(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)) + '@vitest/pretty-format': 4.1.7 + '@vitest/runner': 4.1.7 + '@vitest/snapshot': 4.1.7 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 @@ -11142,10 +11085,10 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0) + vite: 8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.1 transitivePeerDependencies: - msw @@ -11233,6 +11176,8 @@ snapshots: is-wsl: 3.1.1 powershell-utils: 0.1.0 + xml-naming@0.1.0: {} + xml2js@0.6.2: dependencies: sax: 1.6.0 @@ -11278,18 +11223,12 @@ snapshots: toposort: 2.0.2 type-fest: 2.19.0 - zod-validation-error@4.0.2(zod@4.3.6): + zod-validation-error@4.0.2(zod@4.4.3): dependencies: - zod: 4.3.6 - - zod-validation-error@4.0.2(zod@4.4.2): - dependencies: - zod: 4.4.2 - - zod@3.25.76: {} + zod: 4.4.3 zod@4.3.6: {} - zod@4.4.2: {} + zod@4.4.3: {} zwitch@2.0.4: {} diff --git a/templates/desktop-tauri/package.json b/templates/desktop-tauri/package.json index 19787949..2a4b2b24 100644 --- a/templates/desktop-tauri/package.json +++ b/templates/desktop-tauri/package.json @@ -29,25 +29,25 @@ "update:latest": "pnpm update --latest" }, "dependencies": { - "@tanstack/react-router": "^1.169.1", - "@tanstack/react-router-devtools": "^1.166.13", + "@tanstack/react-router": "^1.170.7", + "@tanstack/react-router-devtools": "^1.167.0", "@tauri-apps/api": "^2.11.0", "@tauri-apps/plugin-opener": "^2.5.4", "@tauri-apps/plugin-os": "^2.3.2", "clsx": "^2.1.1", - "react": "^19.2.5", - "react-dom": "^19.2.5", - "tailwind-merge": "^3.5.0" + "react": "^19.2.6", + "react-dom": "^19.2.6", + "tailwind-merge": "^3.6.0" }, "devDependencies": { "@blgc/config": "workspace:*", - "@tailwindcss/vite": "^4.2.4", - "@tanstack/router-plugin": "^1.167.32", - "@tauri-apps/cli": "^2.11.0", - "@types/node": "^25.6.0", - "@types/react": "^19.2.14", + "@tailwindcss/vite": "^4.3.0", + "@tanstack/router-plugin": "^1.168.10", + "@tauri-apps/cli": "^2.11.2", + "@types/node": "^25.9.1", + "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", - "tailwindcss": "^4.2.4" + "@vitejs/plugin-react": "^6.0.2", + "tailwindcss": "^4.3.0" } } diff --git a/templates/web-tanstack/package.json b/templates/web-tanstack/package.json index 558c1283..afac16fd 100644 --- a/templates/web-tanstack/package.json +++ b/templates/web-tanstack/package.json @@ -26,22 +26,22 @@ "update:latest": "pnpm update --latest" }, "dependencies": { - "@tanstack/react-router": "^1.169.1", - "@tanstack/react-router-devtools": "^1.166.13", - "@tanstack/react-start": "^1.167.62", + "@tanstack/react-router": "^1.170.7", + "@tanstack/react-router-devtools": "^1.167.0", + "@tanstack/react-start": "^1.168.10", "clsx": "^2.1.1", - "react": "^19.2.5", - "react-dom": "^19.2.5", - "tailwind-merge": "^3.5.0" + "react": "^19.2.6", + "react-dom": "^19.2.6", + "tailwind-merge": "^3.6.0" }, "devDependencies": { "@blgc/config": "workspace:*", - "@tailwindcss/vite": "^4.2.4", - "@types/node": "^25.6.0", - "@types/react": "^19.2.14", + "@tailwindcss/vite": "^4.3.0", + "@types/node": "^25.9.1", + "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", + "@vitejs/plugin-react": "^6.0.2", "nitro": "3.0.260429-beta", - "tailwindcss": "^4.2.4" + "tailwindcss": "^4.3.0" } } diff --git a/templates/web-tanstack/src/routes/__root.tsx b/templates/web-tanstack/src/routes/__root.tsx index f576c142..8852de20 100644 --- a/templates/web-tanstack/src/routes/__root.tsx +++ b/templates/web-tanstack/src/routes/__root.tsx @@ -1,7 +1,7 @@ -import { appConfig } from '@/environment'; import { createRootRoute, HeadContent, Scripts } from '@tanstack/react-router'; import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'; import React from 'react'; +import { appConfig } from '@/environment'; import styles from '../styles.css?url'; export const Route = createRootRoute({ @@ -9,7 +9,7 @@ export const Route = createRootRoute({ meta: [ { charSet: 'utf-8' }, { name: 'viewport', content: 'width=device-width, initial-scale=1' }, - { title: appConfig.name }, + { title: appConfig.name } ], links: [ {