|
1 | 1 | --- |
2 | 2 | slug: sdk-configuration |
3 | | -title: "SDK Configuration: Method-Specific Defaults, Functions, and the Socket SDK" |
| 3 | +title: "SDK Configuration" |
4 | 4 | authors: [maciej] |
5 | 5 | tags: [SDK, Sockets, Configuration, TypeScript] |
6 | 6 | date: 2026-04-29 |
7 | 7 | --- |
8 | 8 |
|
9 | | -:::info Version |
10 | | -This tutorial was written for **Hyper Fetch 8.0**. |
11 | | -::: |
12 | | - |
13 | | -> Hyper Fetch SDK configuration gives you method-level precision, full access to every Request setter through |
14 | | -> function-based values, and a Socket SDK that brings the same developer experience to real-time communication. |
| 9 | +Your API schema generates a fully typed SDK, but the generated code only knows about endpoints and types. Real projects |
| 10 | +still need caching policies, retry rules, auth settings, and response mappers. `$configure` lets you inject all of that |
| 11 | +into the SDK in one place, so every request comes pre-configured the moment you access it. It also makes testing easier |
| 12 | +by letting you swap in mocks without tools like `msw` or `nock`. |
15 | 13 |
|
16 | 14 | {/* truncate */} |
17 | 15 |
|
18 | | -## Precise Configuration with Dot-Path Keys |
| 16 | +:::info Version This tutorial was written for **Hyper Fetch 8.0**. ::: |
| 17 | + |
| 18 | +## Dot-path keys |
19 | 19 |
|
20 | | -The `$configure` API accepts **dot-path keys** that mirror the SDK's property chain. Since the SDK uses |
21 | | -`sdk.users.$get` to access a request, you configure it with `"users.$get"`: |
| 20 | +The SDK uses property chains like `sdk.users.$get`. Configuration keys mirror that path: |
22 | 21 |
|
23 | 22 | ```typescript |
24 | 23 | const configured = sdk.$configure({ |
| 24 | + // Cache the user list for 30s |
25 | 25 | "users.$get": { cache: true, cacheTime: 30000 }, |
| 26 | + // Not idempotent, should not retry |
26 | 27 | "users.$post": { retry: 0 }, |
| 28 | + // Serve stale profile data while revalidating in background |
27 | 29 | "users.$userId.$get": { staleTime: 5000 }, |
28 | 30 | }); |
29 | 31 | ``` |
30 | 32 |
|
31 | | -`"users.$get"` only affects `sdk.users.$get` — not `sdk.users.$post`, not `sdk.users.$userId.$get`. Each key maps |
32 | | -to exactly one request. |
| 33 | +`"users.$get"` targets exactly `sdk.users.$get`. It does not affect `$post` or nested routes. |
33 | 34 |
|
34 | | -Endpoint-group keys like `"/users"` or `"/users/*"` are also supported for applying broad defaults across all methods |
35 | | -on an endpoint. |
| 35 | +To apply settings across an entire resource, use endpoint-group keys: |
| 36 | + |
| 37 | +```typescript |
| 38 | +const configured = sdk.$configure({ |
| 39 | + // All /users endpoints get caching |
| 40 | + "/users": { cache: true }, |
| 41 | + // Admin routes require auth, no retry on privileged actions |
| 42 | + "/admin/*": { auth: true, retry: 0 }, |
| 43 | +}); |
| 44 | +``` |
36 | 45 |
|
37 | 46 | --- |
38 | 47 |
|
39 | | -## Function-Based Values |
| 48 | +## Function values |
40 | 49 |
|
41 | | -Plain objects cover simple settings, but `$configure` also accepts **functions**. A function receives the Request |
42 | | -instance and returns a modified one, giving you access to the full API: |
| 50 | +Plain objects cover simple settings. When you need mappers or conditional logic, pass a function that receives the |
| 51 | +`Request` instance and returns a modified one: |
43 | 52 |
|
44 | 53 | ```typescript |
45 | 54 | const configured = sdk.$configure({ |
46 | | - // Plain object for simple settings |
| 55 | + // Base retry for all requests |
47 | 56 | "*": { retry: 3 }, |
48 | 57 |
|
49 | | - // Function for advanced configuration |
| 58 | + // highlight-start |
| 59 | + // Normalize the API's snake_case into camelCase for the frontend |
50 | 60 | "users.$get": (request) => |
51 | | - request |
52 | | - .setResponseMapper(userListMapper) |
53 | | - .setCache(true) |
54 | | - .setCacheTime(30000), |
55 | | - |
56 | | - // Mocking for tests |
57 | | - "auth.login.$post": (request) => |
58 | | - request.setMock(() => ({ |
59 | | - data: { token: "test-token" }, |
60 | | - error: null, |
61 | | - status: 200, |
62 | | - })), |
| 61 | + request.setResponseMapper(snakeToCamelMapper).setCache(true).setCacheTime(30000), |
| 62 | + // highlight-end |
| 63 | + |
| 64 | + // Attach the org header that the billing API requires |
| 65 | + "billing.invoices.$get": (request) => |
| 66 | + request.setHeaders({ "X-Org-Id": getCurrentOrgId() }), |
63 | 67 | }); |
64 | 68 | ``` |
65 | 69 |
|
66 | | -This is particularly powerful for testing. Instead of mocking at the network level, you can create a test SDK with all |
67 | | -endpoints mocked through `$configure`: |
| 70 | +Every `Request` setter is available: mappers, headers, auth, mocks, interceptors. |
| 71 | + |
| 72 | +### SDK-level mocks for testing |
| 73 | + |
| 74 | +Instead of maintaining a separate network mock layer, mock directly on the SDK: |
68 | 75 |
|
69 | 76 | ```typescript |
70 | 77 | export const testSdk = sdk.$configure({ |
71 | | - "users.$get": (request) => request.setMock(() => ({ data: mockUsers })), |
72 | | - "users.$userId.$get": (request) => request.setMock(() => ({ data: mockUser })), |
73 | | - "posts.$get": (request) => request.setMock(() => ({ data: [] })), |
| 78 | + "users.$get": (req) => req.setMock(() => ({ data: [{ id: 1, name: "Alice" }] })), |
| 79 | + "users.$userId.$get": (req) => req.setMock(() => ({ data: { id: 1, name: "Alice" } })), |
| 80 | + "billing.invoices.$get": (req) => req.setMock(() => ({ data: [] })), |
74 | 81 | }); |
75 | 82 | ``` |
76 | 83 |
|
| 84 | +Import `testSdk` instead of `sdk` in your test setup. No interceptors, no service workers, no polyfills. |
| 85 | + |
77 | 86 | --- |
78 | 87 |
|
79 | | -## 3-Level Application Order |
| 88 | +## Application order |
| 89 | + |
| 90 | +When multiple keys match a single request, they stack in a fixed order: |
80 | 91 |
|
81 | | -Configurations stack deterministically. When multiple keys match, they apply in this order: |
| 92 | +```mermaid |
| 93 | + timeline |
| 94 | + title Configuration layers |
| 95 | + 1. Global : "*" : Applied first, base defaults |
| 96 | + 2. Endpoint groups : "/users", "/admin/*" : Domain-level policies |
| 97 | + 3. Dot-path keys : "users.$get" : Per-request overrides, wins on conflict |
| 98 | +``` |
82 | 99 |
|
83 | | -1. **`"*"`** — Global defaults (applied first) |
84 | | -2. **Endpoint groups** — `"/users"`, `"/users/*"` (applied second) |
85 | | -3. **Dot-path keys** — `"users.$get"` (applied last, wins on conflict) |
| 100 | +Each level preserves settings from earlier levels unless it explicitly overrides them. |
86 | 101 |
|
87 | 102 | ```typescript |
88 | 103 | const configured = sdk.$configure({ |
| 104 | + // Retry everything 3 times by default |
89 | 105 | "*": { retry: 3 }, |
| 106 | + // Cache all user-related endpoints |
90 | 107 | "/users": { cache: true }, |
| 108 | + // Deduplicate only the user list fetch |
91 | 109 | "users.$get": { deduplicate: true }, |
92 | 110 | }); |
93 | 111 |
|
94 | 112 | const request = configured.users.$get; |
95 | | -// retry: 3 (from "*") + cache: true (from "/users") + deduplicate: true (from "users.$get") |
96 | | -// All three stack. Later levels only override properties they explicitly set. |
| 113 | +// Result: retry: 3 + cache: true + deduplicate: true |
97 | 114 | ``` |
98 | 115 |
|
99 | | -Global defaults are the foundation, endpoint groups add domain-specific settings on top, and method-specific keys |
100 | | -fine-tune individual requests. Properties from earlier levels are preserved unless a later level explicitly overwrites |
101 | | -them. |
| 116 | +Set global defaults once, add domain-level policies per resource, and fine-tune individual endpoints only when needed. |
102 | 117 |
|
103 | 118 | --- |
104 | 119 |
|
105 | 120 | ## Socket SDK |
106 | 121 |
|
107 | | -The SDK pattern extends to `@hyper-fetch/sockets`. The **Socket SDK** works exactly like the HTTP SDK, but uses |
108 | | -`$listener` and `$emitter` as leaf keys instead of HTTP methods: |
| 122 | +`@hyper-fetch/sockets` uses the same pattern. Leaf keys are `$listener` and `$emitter` instead of HTTP methods: |
109 | 123 |
|
110 | 124 | ```typescript |
111 | 125 | import { createSocketSdk } from "@hyper-fetch/sockets"; |
112 | 126 |
|
113 | 127 | const sdk = createSocketSdk<typeof socket, MyChatSchema>(socket); |
114 | 128 |
|
115 | | -// Listen to events |
116 | 129 | sdk.chat.messages.$listener.listen(({ data }) => { |
117 | | - console.log(data.text, data.user); |
| 130 | + appendMessage(data.text, data.user); |
118 | 131 | }); |
119 | 132 |
|
120 | | -// Emit events |
121 | 133 | sdk.chat.messages.$emitter.emit({ payload: { text: "Hello!" } }); |
122 | | - |
123 | | -// Dynamic topics with parameters |
124 | | -sdk.chat.$roomId.$listener |
125 | | - .setParams({ roomId: "general" }) |
126 | | - .listen(({ data }) => console.log(data)); |
127 | 134 | ``` |
128 | 135 |
|
129 | | -### Socket SDK Configuration |
130 | | - |
131 | | -The Socket SDK supports the same `$configure` pattern with full parity: |
| 136 | +Configuration uses the same 3-level order and supports both objects and functions: |
132 | 137 |
|
133 | 138 | ```typescript |
134 | 139 | const configured = sdk.$configure({ |
135 | | - // Global — applies to all listeners and emitters |
| 140 | + // Auto-reconnect all socket listeners |
136 | 141 | "*": { options: { reconnect: true } }, |
137 | | - |
138 | | - // Topic group — applies to everything under chat/* |
| 142 | + // Prioritize chat topic delivery |
139 | 143 | "chat/*": { options: { priority: "high" } }, |
140 | | - |
141 | | - // Instance-specific — targets one emitter (type-narrowed, no cast needed) |
142 | | - "chat.messages.$emitter": (instance) => instance.setPayloadMapper(messageMapper), |
| 144 | + // highlight-next-line |
| 145 | + // Sanitize outgoing messages before sending |
| 146 | + "chat.messages.$emitter": (instance) => instance.setPayloadMapper(sanitizeMessage), |
143 | 147 | }); |
144 | 148 | ``` |
145 | 149 |
|
146 | | -The same 3-level application order applies: `"*"` (global) -> topic groups (`"chat/*"`) -> dot-path |
147 | | -(`"chat.messages.$listener"`). Both plain-object shorthand and function-based values work. Dot-path keys narrow |
148 | | -the callback parameter to the exact instance type — `$listener` keys receive `ListenerInstance`, `$emitter` keys |
| 150 | +Dot-path keys narrow the callback type automatically: `$listener` keys receive `ListenerInstance`, `$emitter` keys |
149 | 151 | receive `EmitterInstance`. |
150 | 152 |
|
151 | 153 | --- |
152 | 154 |
|
153 | | -## Multi-File Configuration |
| 155 | +## Splitting config across files |
154 | 156 |
|
155 | | -For large projects, split configuration by domain using `createConfiguration` (HTTP) or |
156 | | -`createSocketConfiguration` (sockets): |
| 157 | +When your app grows past a dozen endpoints, split configuration by domain: |
157 | 158 |
|
158 | 159 | ```typescript title="config/users.ts" |
159 | 160 | import { createConfiguration } from "@hyper-fetch/core"; |
160 | 161 |
|
161 | 162 | export const usersConfig = createConfiguration<ApiSchema>()({ |
| 163 | + // Normalize and cache the user list |
162 | 164 | "users.$get": (request) => |
163 | | - request.setResponseMapper(userListMapper).setCache(true), |
| 165 | + request.setResponseMapper(snakeToCamelMapper).setCache(true), |
| 166 | + // Require auth, allow stale data for 5s on profile pages |
164 | 167 | "users.$userId.$get": (request) => |
165 | 168 | request.setAuth(true).setStaleTime(5000), |
166 | 169 | }); |
167 | 170 | ``` |
168 | 171 |
|
169 | | -```typescript title="config/chat.ts" |
170 | | -import { createSocketConfiguration } from "@hyper-fetch/sockets"; |
171 | | - |
172 | | -export const chatConfig = createSocketConfiguration<ChatSchema>()({ |
173 | | - "chat/*": { options: { reconnect: true } }, |
174 | | - "chat.messages.$listener": (instance) => |
175 | | - instance.setOptions({ buffer: true }), |
| 172 | +```typescript title="config/billing.ts" |
| 173 | +export const billingConfig = createConfiguration<ApiSchema>()({ |
| 174 | + // Cache invoices for 1 minute |
| 175 | + "billing.invoices.$get": { cache: true, cacheTime: 60000 }, |
| 176 | + // Never retry invoice creation |
| 177 | + "billing.invoices.$post": { retry: 0 }, |
176 | 178 | }); |
177 | 179 | ``` |
178 | 180 |
|
179 | 181 | ```typescript title="sdk.ts" |
180 | | -export const api = createSdk(client) |
181 | | - .$configure(usersConfig); |
182 | | - |
183 | | -export const ws = createSocketSdk(socket) |
184 | | - .$configure(chatConfig); |
| 182 | +export const api = createSdk(client).$configure(usersConfig).$configure(billingConfig); |
185 | 183 | ``` |
186 | 184 |
|
187 | | -Keys are validated against your schema at compile time — typos are caught immediately. |
188 | | - |
189 | | ---- |
190 | | - |
191 | | -## How It Works Under the Hood |
192 | | - |
193 | | -The proxy that powers both SDKs tracks the **full accessor path** (e.g., `["users", "$get"]`) as you traverse the |
194 | | -chain. When a leaf node is reached, the proxy joins this path into a dot-separated string and passes it to |
195 | | -`applyDefaults`. The defaults function sorts all configuration entries into buckets by type, applies them in the |
196 | | -correct order, and returns the fully configured instance. |
197 | | - |
198 | | -For sockets, the proxy also constructs the **topic string** from the path segments (converting `$paramName` keys to |
199 | | -`:paramName` and applying camelCase-to-kebab-case), which enables topic group matching alongside dot-path matching. |
200 | | - |
201 | | -The type system uses recursive conditional types (`ExtractSdkPaths`, `ExtractSocketTopics`, `TopicPrefixes`, |
202 | | -`ResolveDotPath`) to derive the exact set of valid configuration keys from your schema and narrow callback parameters |
203 | | -to the correct instance type, providing full autocomplete and compile-time validation. |
| 185 | +Keys are validated against your schema at compile time. A typo in `"users.$gett"` fails the build, not a production |
| 186 | +request. |
204 | 187 |
|
205 | 188 | --- |
206 | 189 |
|
|
0 commit comments