· English · Korean
Fynk combines an HTTP client, normalized entity cache, and query/mutation hooks for React and Vue. It is designed for sharing REST entities across screens, optimistic updates, and explicit session lifecycles.
Release status: 3.0.0-rc.2 is published, as confirmed by the maintainer. This checkout includes unpublished changes for the stable release. Read the migration notes before upgrading.
- HTTP requests with params, abort/timeout, retries, Retry-After and request/response interceptors.
- Concurrent eligible GET deduplication and TTL caching with configurable LRU capacity.
- Normalized entities and relations, including cyclic relations and shared references.
- React/Vue query, mutation and infinite-query integration; normalized updates notify subscribed queries.
- Per-mutation optimistic transactions and caller-local cancellation of shared HTTP requests.
- Session-scoped persistence, terminal logout and versioned snapshots that preserve numeric/string IDs.
- Optional synchronous model validators and typed validation/transaction errors.
- ESM, CommonJS and format-specific TypeScript declarations; a basic EventSource bridge for explicit cache updates.
10 identical concurrent GETs → 1 server request. Warm cache → 0 additional server requests. Fynk integrates request sharing and caching to reduce network work for repeated data reads.
The following measures the local rc.2 checkout: Node 22.16.0, macOS/Intel i7-9750H, 20ms server delay, 48-byte JSON, 10 concurrent requests per batch, 5 warmups and 50 measured batches. Times are whole-batch median / p95 in milliseconds.
| Library | Native sharing: time | Server requests | Equivalent sharing: time | Warm cache: time |
|---|---|---|---|---|
| fetch | 23.952 / 26.026 | 10 | 21.102 / 21.936 | 0.021 / 0.024 |
| axios | 24.609 / 26.646 | 10 | 21.180 / 22.292 | 0.024 / 0.029 |
| ky | 24.150 / 25.712 | 10 | 21.369 / 23.804 | 0.020 / 0.023 |
| got | 25.386 / 27.852 | 10 | 21.376 / 22.274 | 2.633 / 3.141 |
| alova | 22.381 / 23.415 | 1 | 22.338 / 24.056 | 0.117 / 0.167 |
| fynk | 21.151 / 22.129 | 1 | 21.233 / 22.229 | 0.071 / 0.095 |
Native sharing uses a cold response cache, with built-in sharing enabled for Fynk/Alova. Equivalent sharing adds a common Promise-sharing wrapper to fetch/Axios/Ky/Got, giving every library 1 server request. Every warm row makes 0 additional requests; one priming request is excluded from timing and counts. fetch/Axios/Ky use a minimal Map cache; Got uses HTTP caching, so features and processing costs differ.
90% fewer server requests and response-body bytes than unshared calls was observed. Alova achieved the same reduction. Raw fetch was faster than raw Fynk (23.729ms / 24.359ms median), and minimal Map caches were faster than Fynk's cache. Fynk's strength is integrated sharing and caching that reduces repeated network work.
Full results and raw samples include raw HTTP, a second shuffled run and a zero-delay control. See methodology for reproduction commands. These results do not establish an all-workload speed, rendering, memory or bundle-size advantage.
1.0.0 → 3.0.0 migration (Korean) · Current candidate: 3.0.0-rc.3 (unpublished).
To install the already published candidate:
npm install fynk@3.0.0-rc.2To evaluate the unpublished checkout:
npm ci
npm run build
npm pack
# In your application, install the generated archive:
npm install /absolute/path/to/fynk-3.0.0-rc.3.tgzIn this checkout, React ^19.0.0 and Vue ^3.4.0 are optional peers. Core-only consumers need neither. Install React when using fynk/react, or Vue when using fynk/vue. Got/Ky are benchmark development dependencies and are not installed for consumers. These are unpublished changes after rc.2. See supported environments and installation checks.
Create the client once per account/session. The examples below share src/data.ts; replace the API URL and account scope with your application's values. For SSR, create clients per request/session instead of sharing a module singleton across users.
// src/data.ts
import { createClient, fetchAdapter } from 'fynk';
export type User = { id: number; name: string };
export const client = createClient({
adapter: fetchAdapter('https://api.example.com'),
scope: 'account-123',
cache: { maxEntries: 1000, cleanupIntervalMs: 30_000 },
});
export const UserModel = client.defineModel<User>({
key: 'users',
id: user => user.id,
validate: value => typeof value === 'object' && value !== null
&& 'id' in value && typeof value.id === 'number'
&& 'name' in value && typeof value.name === 'string'
&& value.name.length > 0,
});Validators receive normalized entities (relation fields contain IDs). Return exactly true to accept; false, exceptions and asynchronous results are rejected. Validators must be pure synchronous functions. They add validation/copying overhead; models without a validator retain the ordinary write path.
const [first, second] = await Promise.all([
client.get<User>('/users/1'),
client.get<User>('/users/1'),
]);
const fresh = await client.get<User>('/users/1', {
meta: { staleTime: 0 },
timeout: 5000,
retry: { attempts: 2, delay: 100 },
});The first pair shares eligible in-flight work, or reads an existing fresh response. client.get() alone does not normalize entities; use a hook with model or call client.normalize(UserModel, result).
Explicit retry/response policies (retry, validateStatus, throwHttpErrors, response hooks or active response interceptors) bypass HTTP deduplication and response caching in rc.2. Non-JSON inputs such as FormData/Blob also bypass automatic sharing unless an explicit, content-aware dedupeKey is supplied. Custom keys do not override response-policy isolation. Query-level caching is separate.
// src/UserProfile.tsx
import { useState } from 'react';
import { useMutation, useQuery } from 'fynk/react';
import { client, UserModel, type User } from './data';
export function UserProfile({ userId }: { userId: number }) {
const [saveError, setSaveError] = useState<string | null>(null);
const { data, pending, error } = useQuery<User>(client, {
key: ['users', userId],
request: () => client.get<User>(`/users/${userId}`),
model: UserModel,
});
const { mutate, pending: saving } = useMutation<{ name: string }, User>(client, {
request: body => client.patch<User>(`/users/${userId}`, { body }),
optimistic: (draft, body) => draft.patch(UserModel, userId, body),
onSuccess: (user, draft) => draft.upsert(UserModel, user),
});
async function updateName() {
setSaveError(null);
try { await mutate({ name: 'Ada' }); }
catch (error) { setSaveError(String(error)); }
}
if (pending) return <p>Loading…</p>;
if (error) return <p role="alert">{String(error)}</p>;
return <section>
<h2>{data?.name}</h2>
<button disabled={saving || !data} onClick={updateName}>Update name</button>
{saveError && <p role="alert">{saveError}</p>}
</section>;
}Vue returns refs and accepts refs/getters for query keys and enabled. Capture mutation IDs in variables, as below, so a prop change cannot retarget a pending update.
<!-- src/UserProfile.vue -->
<script setup lang="ts">
import { ref } from 'vue';
import { useMutation, useQuery } from 'fynk/vue';
import { client, UserModel, type User } from './data';
const props = defineProps<{ userId: number }>();
const saveError = ref<string | null>(null);
const { data, pending, error } = useQuery<User>(client, {
key: () => ['users', props.userId],
request: () => client.get<User>(`/users/${props.userId}`),
model: UserModel,
});
const { mutate, pending: saving } = useMutation<{ id: number; name: string }, User>(client, {
request: vars => client.patch<User>(`/users/${vars.id}`, { body: { name: vars.name } }),
optimistic: (draft, vars) => draft.patch(UserModel, vars.id, { name: vars.name }),
onSuccess: (user, draft) => draft.upsert(UserModel, user),
});
async function updateName() {
saveError.value = null;
try { await mutate({ id: props.userId, name: 'Ada' }); }
catch (error) { saveError.value = String(error); }
}
</script>
<template>
<p v-if="pending">Loading…</p>
<p v-else-if="error" role="alert">{{ String(error) }}</p>
<section v-else>
<h2>{{ data?.name }}</h2>
<button :disabled="saving || !data" @click="updateName">Update name</button>
<p v-if="saveError" role="alert">{{ saveError }}</p>
</section>
</template>Both examples PATCH the existing user and apply the server's returned entity. Normal failures roll back their transaction. A validator can reject a rollback that conflicts with other writes: handle MutationTransactionError and reconcile its open transaction explicitly. Displaying an error alone does not complete that recovery. See the validator and transaction contract.
| Setting | Default / behavior |
|---|---|
| HTTP/query freshness | 30 seconds unless overridden |
cache.maxEntries |
1000 completed HTTP/query responses, shared LRU capacity |
cache.cleanupIntervalMs |
30,000 ms; cleanup stops when empty or disposed |
maxEntries: 0 |
No completed response cache; in-flight deduplication remains |
cleanupIntervalMs: 0 |
No background cleanup; reads/insertion/inspection still clean expired entries |
| Normalized entities | No automatic capacity eviction |
The capacity limits entries, not bytes or active requests. Eviction can increase network calls. HTTP key tracking is released when no cached response or active work remains.
refetch/polling invalidate the target query and the client's tracked HTTP cache. Precise query-to-HTTP dependency tracking is not implemented.invalidate(['users', 1])invalidates matching query keys; it does not automatically refetch or invalidate their HTTP dependencies.clearCache()clears scheduler responses, not normalized entities. Uselogout()to end a session.- Entity changes update existing list members; inserting an entity does not infer list membership.
- Infinite-query refetch resets to the first page on success. React's
hasNextPageis a boolean; Vue's is a function. See the framework docs for the full contracts.
// Browser only; register UserModel before hydrating legacy data.
await client.hydrate(localStorage, 'my-app:cache:rc2');
await client.persist(localStorage, 'my-app:cache:rc2');
// Terminal: create a new client for the next login.
await client.logout(localStorage, 'my-app:cache:rc2');Version-1 snapshots preserve 1 and "1" as distinct IDs. Legacy snapshots require registered models to recover ID types. Hydration validates the full snapshot before replacing cache state and does not rewrite storage automatically; call persist after successful migration.
rc.1 cannot read the new snapshot format. Keep the old storage key/backup when testing rc.2 if rollback is required. Already-overwritten legacy ID collisions cannot be recovered. Validators must be registered before hydration to check incoming entity schemas. Resolved relation graphs may be cyclic: persist the cache instead of JSON-stringifying resolved objects.
Use a separate client for each account/session. scope namespaces persistence; changing authentication inside one existing client does not automatically isolate old data. Logout is terminal, and storage removal requires passing the storage/key. The next login needs a new client.
- Core API — request policies, interceptors, cache, validation and persistence
- React / Vue — query, mutation and infinite-query APIs
- Migration through rc.2 / Migration to 2.0
- Changelog
- rc.2 validation report (Korean) / Follow-up plan (Korean)
npm ci
npm run build
npm test
npm run test:package
npm --prefix examples/react-app ci
npm --prefix examples/react-app run build
npm --prefix examples/vue-app ci
npm --prefix examples/vue-app run buildThe rc.2 local validation run passed 152 tests on each of Node 22 and 24, example builds, package consumer/type checks and browser update/rollback smoke checks. These results do not establish a performance or full-browser compatibility guarantee. Remote CI must pass for the commit being released; an earlier successful run does not verify this checkout.
Run npm run bench for repeated benchmarks with measured server request/body-byte counts, median/p95 and raw samples. npm run bench:check checks benchmark types. Resource cleanup and memory trends are covered separately by the stability checks below.
Precise query dependency tracking, expanded SSE recovery, and complete cache metrics remain follow-up work. The basic SSE bridge does not provide application-level sequence/catch-up guarantees.
Post-rc.2 development passed 154 tests on each of Node 22 and 24, including terminal subscription cleanup regressions. npm run test:stability checks repeated request, cancellation, subscription and session cleanup. See the stability report (Korean) for results and measurement limits.
The rc.3 candidate passed 159 tests on each of Node 22 and 24. See the release flow report (Korean) for browser evidence and remaining release gates.
Issues and pull requests are welcome. See MIT.
