Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions documentation/docs/core/cache.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,8 @@ client.cache.invalidate(getNote.setParams({ noteId: 1 }).cacheKey);
client.cache.invalidate("CUSTOM_CACHE_KEY");
// OR via RegExp
client.cache.invalidate(new RegExp("CUSTOM_CACHE_KEY"));
// OR everything - when called without arguments
client.cache.invalidate();
```

---
Expand Down
24 changes: 24 additions & 0 deletions documentation/docs/core/client.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,30 @@ Available removal methods:

---

10. ### Changing the base URL

Use `setUrl` to point the client at a different server at runtime - for example when switching tenants, regions or
environments. Every request sent afterwards uses the new base URL.

```tsx
import { createClient } from "@hyper-fetch/core";

export const client = createClient({ url: "https://eu.example.com" });

// highlight-next-line
client.setUrl("https://us.example.com", { abort: true, invalidate: true });
```

Cache keys don't include the base URL, so data cached for the previous server stays in place by default. Pass the
options when the data of both servers should not mix:

| Option | Default | Description |
|--------|---------|-------------|
| `abort` | `false` | Abort all requests that are still in progress for the previous URL. |
| `invalidate` | `false` | Invalidate the whole cache (and the requests aborted by `abort`), so mounted hooks refetch from the new URL. |

---

6. ### Hydration

We can hydrate the client with the data from the server. This is a powerful feature that allows us to pass the data from
Expand Down
25 changes: 25 additions & 0 deletions documentation/docs/sockets/adapter.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ You can pass adapter-specific options via the `adapterOptions` constructor optio
| `heartbeatMessage` | `string` | — | The message payload sent for heartbeats. |
| `pingTimeout` | `number` | — | Timeout (ms) to wait for a ping response. |
| `pongTimeout` | `number` | — | Timeout (ms) to wait for a pong response. |
| `decode` | `(event: MessageEvent) => { topic, data } \| null` | JSON `{ topic, data }` | Turns an incoming frame into a listener event. Return `null` to ignore the frame. |
| `encode` | `({ topic, payload }) => string \| ArrayBufferLike \| Blob \| ArrayBufferView` | `JSON.stringify({ topic, data })` | Turns an emitted message (and the heartbeat) into the frame passed to `websocket.send`. |

```tsx
const socket = createSocketClient({
Expand All @@ -60,6 +62,28 @@ const socket = createSocketClient({
});
```

#### Custom message format

By default every frame is a JSON `{ topic, data }` object. When your server speaks a different format - for example
`{ type, payload }` - map it with the `decode` and `encode` options instead of writing a custom adapter. Frames decoded
to `null` (pings, unknown messages) are ignored, and errors thrown by either function are reported through `onError`.

```tsx
const socket = createSocketClient({
url: "wss://example.com/ws",
adapterOptions: {
// highlight-start
decode: (event) => {
const message = JSON.parse(event.data);
if (message.type === "pong") return null;
return { topic: message.type, data: message.payload };
},
encode: ({ topic, payload }) => JSON.stringify({ type: topic, payload }),
// highlight-end
},
});
```

#### Dynamic options

Every adapter option can be resolved right before a connection attempt with the `onConnect` interceptor on the socket.
Expand Down Expand Up @@ -115,6 +139,7 @@ const socket = createSocketClient({
|--------|------|---------|-------------|
| `autoConnect` | `boolean` | `true` | Automatically connect when the socket is created. |
| `eventSourceInit` | `EventSourceInit` | — | Options passed to the underlying `EventSource` constructor (e.g., `withCredentials`). |
| `decode` | `(event: MessageEvent) => { topic, data } \| null` | JSON `{ topic, data }` | Turns an incoming message into a listener event. Return `null` to ignore the message. |

```tsx
const socket = createSocketClient({
Expand Down
14 changes: 14 additions & 0 deletions documentation/docs/sockets/socket.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,20 @@ attempt, so you never have to keep the socket in sync with your application stat

---

### Changing the URL

`setUrl` replaces the URL used by the **next** connection attempt - just like `setQueryParams`, call `reconnect()` to
apply it to a live connection. Listeners stay registered, so they keep receiving events from the new server.

```tsx
export const socket = createSocketClient({ url: "wss://eu.example.com/ws" });

socket.setUrl("wss://us.example.com/ws");
socket.reconnect();
```

---

### Authentication

To exchange authenticated events, set up the `queryParams` parameter with authentication parameter or resolve the
Expand Down
16 changes: 16 additions & 0 deletions packages/core/__tests__/features/cache/cache.base.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,22 @@ describe("Cache [ Base ]", () => {
unmount();
});

it("should invalidate every element when called without arguments", async () => {
const trigger = vi.fn();
const unmount = cache.events.onInvalidate(trigger);

cache.set(request.setCacheKey("1"), { ...response, ...details });
cache.set(request.setCacheKey("2"), { ...response, ...details });
cache.invalidate();

expect(cache.get("1")?.staleTime).toBe(0);
expect(cache.get("2")?.staleTime).toBe(0);
expect(trigger).toHaveBeenCalledTimes(2);
expect(trigger).toHaveBeenCalledWith("1");
expect(trigger).toHaveBeenCalledWith("2");
unmount();
});

it("should not add to cache when useCache is set to false", async () => {
const trigger = vi.fn();
const unmount = cache.events.onDataByKey(request.cacheKey, trigger);
Expand Down
149 changes: 149 additions & 0 deletions packages/core/__tests__/features/client/client.set-url.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { createHttpMockingServer } from "@hyper-fetch/testing";
import type { ResponseSuccessType } from "adapter";
import { getErrorMessage } from "adapter";
import { Client } from "client";
import type { HttpAdapterType } from "http-adapter";
import { xhrExtra } from "http-adapter";
import type { ResponseDetailsType } from "managers";
import { http, HttpResponse } from "msw";

const { server, resetMocks, startServer, stopServer, mockRequest } = createHttpMockingServer();

describe("Client [ setUrl ]", () => {
const response: ResponseSuccessType<any, HttpAdapterType> = {
data: 123,
error: null,
status: 200,
success: true,
extra: xhrExtra,
requestTimestamp: Date.now(),
responseTimestamp: Date.now(),
};
const details: ResponseDetailsType = {
retries: 0,
requestTimestamp: Date.now(),
responseTimestamp: Date.now(),
addedTimestamp: Date.now(),
triggerTimestamp: Date.now(),
isCanceled: false,
isOffline: false,
willRetry: false,
};

let client = new Client({ url: "http://first.test" });

beforeAll(() => {
startServer();
});

beforeEach(() => {
client = new Client({ url: "http://first.test" });
resetMocks();
vi.resetAllMocks();
});

afterAll(() => {
stopServer();
});

it("should send next requests to the new url", async () => {
server.use(
http.get("http://first.test/users", () => HttpResponse.json({ host: "first" })),
http.get("http://second.test/users", () => HttpResponse.json({ host: "second" })),
);
const request = client.createRequest<{ response: { host: string } }>()({
endpoint: "/users",
});

const first = await request.send();
client.setUrl("http://second.test");
const second = await request.send();

expect(client.url).toBe("http://second.test");
expect(first.data).toStrictEqual({ host: "first" });
expect(second.data).toStrictEqual({ host: "second" });
});

it("should be chainable", () => {
expect(client.setUrl("http://second.test")).toBe(client);
});

it("should do nothing when the url did not change", () => {
const abortSpy = vi.spyOn(client.requestManager, "abortAll");
const invalidateSpy = vi.spyOn(client.cache, "invalidate");

client.setUrl("http://first.test", { abort: true, invalidate: true });

expect(client.url).toBe("http://first.test");
expect(abortSpy).not.toHaveBeenCalled();
expect(invalidateSpy).not.toHaveBeenCalled();
});

it("should not abort requests nor touch the cache by default", async () => {
const request = client.createRequest<{ response: { value: number } }>()({
endpoint: "/users",
});
const data = mockRequest(request, { data: { value: 1 }, delay: 30 });
client.cache.set(request, { ...response, ...details });
const staleTime = client.cache.get(request.cacheKey)?.staleTime;
const abortSpy = vi.spyOn(client.requestManager, "abortAll");

const pending = request.send();
client.setUrl("http://second.test");
const result = await pending;

expect(abortSpy).not.toHaveBeenCalled();
expect(result.data).toStrictEqual(data);
expect(client.cache.get(request.cacheKey)?.staleTime).toBe(staleTime);
});

it("should abort ongoing requests when abort is enabled", async () => {
const request = client.createRequest<{ response: { value: number } }>()({
endpoint: "/users",
});
mockRequest(request, { data: { value: 1 }, delay: 30 });

const pending = request.send();
client.setUrl("http://second.test", { abort: true });
const result = await pending;

expect(result.data).toBeNull();
expect(result.error).toStrictEqual(getErrorMessage("abort"));
expect(result.status).toBe(0);
});

it("should invalidate the whole cache when invalidate is enabled", () => {
const usersRequest = client.createRequest()({ endpoint: "/users" });
const postsRequest = client.createRequest()({ endpoint: "/posts" });
const trigger = vi.fn();
const unmount = client.cache.events.onInvalidate(trigger);

client.cache.set(usersRequest, { ...response, ...details });
client.cache.set(postsRequest, { ...response, ...details });
client.setUrl("http://second.test", { invalidate: true });
unmount();

expect(client.cache.get(usersRequest.cacheKey)?.staleTime).toBe(0);
expect(client.cache.get(postsRequest.cacheKey)?.staleTime).toBe(0);
expect(trigger).toHaveBeenCalledWith(usersRequest.cacheKey);
expect(trigger).toHaveBeenCalledWith(postsRequest.cacheKey);
});

it("should notify consumers of aborted requests when abort and invalidate are enabled", async () => {
const request = client.createRequest<{ response: { value: number } }>()({
endpoint: "/users",
});
mockRequest(request, { data: { value: 1 }, delay: 30 });
const trigger = vi.fn();
const unmount = client.cache.events.onInvalidateByKey(request.cacheKey, trigger);

const pending = request.send();
client.setUrl("http://second.test", { abort: true, invalidate: true });
await pending;
unmount();

// Nothing was cached yet - the aborted request is reached only by the explicit invalidation
expect(client.cache.get(request.cacheKey)).toBeUndefined();
expect(trigger).toHaveBeenCalledTimes(1);
});
});
7 changes: 4 additions & 3 deletions packages/core/src/cache/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,9 +195,10 @@ export class Cache<Adapter extends AdapterInstance> {
/**
* Invalidate cache by cacheKey or partial matching with RegExp
* It emits invalidation event for each matching cacheKey and sets staleTime to 0 to indicate out of time cache
* @param key - cacheKey or Request instance or RegExp for partial matching
* Called without arguments it invalidates every cached element.
* @param cacheKeys - cacheKey or Request instance or RegExp for partial matching (defaults to all elements)
*/
invalidate = (cacheKeys: string | RegExp | RequestInstance | (string | RegExp | RequestInstance)[]) => {
invalidate = (cacheKeys: string | RegExp | RequestInstance | (string | RegExp | RequestInstance)[] = /.*/) => {
this.logger.debug({ title: "Revalidating cache element", type: "system", extra: { cacheKeys } });

const onInvalidate = (key: string | RegExp | RequestInstance) => {
Expand Down Expand Up @@ -285,7 +286,7 @@ export class Cache<Adapter extends AdapterInstance> {
*/
getAllKeys = async () => {
const keys = await this.lazyStorage?.keys();
const asyncKeys = [...keys || []];
const asyncKeys = [...(keys || [])];
const syncKeys = [...this.storage.keys()];

return [...new Set([...asyncKeys, ...syncKeys])];
Expand Down
38 changes: 36 additions & 2 deletions packages/core/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export class Client<
GlobalErrorType extends ClientErrorType = Error,
Adapter extends AdapterInstance = HttpAdapterType,
> {
readonly url: string;
url: string;
readonly mode: ClientMode;
public debug: boolean;

Expand Down Expand Up @@ -103,6 +103,38 @@ export class Client<
this.submitDispatcher.initialize(this as unknown as ClientInstance<{ adapter: Adapter }>);
}

/**
* Set the base url used by the next requests - e.g. when switching tenants, regions or environments at runtime.
* Cache keys don't include the base url, so data cached for the previous url stays in place unless you pass
* `invalidate: true`. Pass `abort: true` to cancel the requests that are still in progress for the previous url.
* @param url new base url
* @param options.abort abort all ongoing requests
* @param options.invalidate invalidate the cache (and requests aborted by `abort`), so active consumers refetch
*/
setUrl = (url: string, options: { abort?: boolean; invalidate?: boolean } = {}): Client<GlobalErrorType, Adapter> => {
if (url === this.url) {
return this;
}

const { abort = false, invalidate = false } = options;
const runningRequests = this.fetchDispatcher.getAllRunningRequests().map(({ request }) => request);

this.url = url;

if (abort) {
this.requestManager.abortAll();
}
if (invalidate) {
this.cache.invalidate();
// Aborted requests never land in the cache, so the full invalidation above would not notify their consumers
if (abort && runningRequests.length > 0) {
this.cache.invalidate(runningRequests);
}
}

return this;
};

/**
* This method enables the logger usage and display the logs in console
*/
Expand Down Expand Up @@ -409,7 +441,9 @@ export class Client<
options?: Partial<HydrationOptions> | ((item: HydrateDataType) => Partial<HydrationOptions>),
) => {
hydrationData?.forEach((item) => {
if (!item) {return;}
if (!item) {
return;
}

const { cacheKey, scope, response, ...fallbackOptions } = item;
const defaults = {
Expand Down
Loading
Loading