wp-native-client 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +41 -0
  3. package/dist/abilities/catalog.d.ts +49 -0
  4. package/dist/abilities/catalog.d.ts.map +1 -0
  5. package/dist/abilities/catalog.js +65 -0
  6. package/dist/abilities/catalog.js.map +1 -0
  7. package/dist/abilities/discovery.d.ts +43 -0
  8. package/dist/abilities/discovery.d.ts.map +1 -0
  9. package/dist/abilities/discovery.js +82 -0
  10. package/dist/abilities/discovery.js.map +1 -0
  11. package/dist/abilities/index.d.ts +7 -0
  12. package/dist/abilities/index.d.ts.map +1 -0
  13. package/dist/abilities/index.js +6 -0
  14. package/dist/abilities/index.js.map +1 -0
  15. package/dist/abilities/types.d.ts +88 -0
  16. package/dist/abilities/types.d.ts.map +1 -0
  17. package/dist/abilities/types.js +15 -0
  18. package/dist/abilities/types.js.map +1 -0
  19. package/dist/client.d.ts +110 -0
  20. package/dist/client.d.ts.map +1 -0
  21. package/dist/client.js +142 -0
  22. package/dist/client.js.map +1 -0
  23. package/dist/index.d.ts +16 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +13 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/transports/auth-fetch.d.ts +132 -0
  28. package/dist/transports/auth-fetch.d.ts.map +1 -0
  29. package/dist/transports/auth-fetch.js +217 -0
  30. package/dist/transports/auth-fetch.js.map +1 -0
  31. package/dist/transports/fetch.d.ts +42 -0
  32. package/dist/transports/fetch.d.ts.map +1 -0
  33. package/dist/transports/fetch.js +68 -0
  34. package/dist/transports/fetch.js.map +1 -0
  35. package/dist/transports/types.d.ts +22 -0
  36. package/dist/transports/types.d.ts.map +1 -0
  37. package/dist/transports/types.js +9 -0
  38. package/dist/transports/types.js.map +1 -0
  39. package/dist/transports/wp-api-fetch.d.ts +25 -0
  40. package/dist/transports/wp-api-fetch.d.ts.map +1 -0
  41. package/dist/transports/wp-api-fetch.js +40 -0
  42. package/dist/transports/wp-api-fetch.js.map +1 -0
  43. package/dist/wordpress.d.ts +8 -0
  44. package/dist/wordpress.d.ts.map +1 -0
  45. package/dist/wordpress.js +8 -0
  46. package/dist/wordpress.js.map +1 -0
  47. package/package.json +45 -0
  48. package/src/__smoke__/discovery.smoke.ts +66 -0
  49. package/src/abilities/catalog.ts +75 -0
  50. package/src/abilities/discovery.ts +111 -0
  51. package/src/abilities/index.ts +18 -0
  52. package/src/abilities/types.ts +98 -0
  53. package/src/client.ts +191 -0
  54. package/src/index.ts +32 -0
  55. package/src/transports/auth-fetch.ts +310 -0
  56. package/src/transports/fetch.ts +107 -0
  57. package/src/transports/types.ts +24 -0
  58. package/src/transports/wp-api-fetch.ts +58 -0
  59. package/src/wordpress.ts +8 -0
package/src/client.ts ADDED
@@ -0,0 +1,191 @@
1
+ /**
2
+ * WPNativeClient — universal WordPress client built on the Abilities API.
3
+ *
4
+ * One client. No subclasses. No per-site wrappers.
5
+ *
6
+ * The client wraps a Transport (FetchTransport, AuthFetchTransport, or
7
+ * WpApiFetchTransport) and exposes two surfaces:
8
+ *
9
+ * 1. Discovery — fetch the site's ability catalog at startup
10
+ * 2. Execution — call abilities by name with typed input/output
11
+ *
12
+ * Site-specific concerns (extrachill/* abilities, woocommerce/* abilities,
13
+ * etc.) are addressed via ability namespacing on the server side. The
14
+ * client itself never knows the difference between a core WP ability and
15
+ * a plugin-registered one — they're all just strings + JSON Schemas.
16
+ *
17
+ * Example:
18
+ *
19
+ * import { WPNativeClient, AuthFetchTransport } from 'wp-native-client';
20
+ *
21
+ * const client = new WPNativeClient(
22
+ * new AuthFetchTransport({ baseUrl: 'https://example.com/wp-json', ... })
23
+ * );
24
+ *
25
+ * await client.discover();
26
+ *
27
+ * const posts = await client.execute<Post[]>('wp/post.list', { per_page: 20 });
28
+ * const me = await client.execute<User>('wp-native/user.me');
29
+ */
30
+
31
+ import type { Transport } from './transports/types';
32
+ import { ApiError } from './transports/fetch';
33
+ import { AbilityCatalog } from './abilities/catalog';
34
+ import { discoverAbilities } from './abilities/discovery';
35
+ import type {
36
+ AbilityDescriptor,
37
+ AbilityExecutionResponse,
38
+ } from './abilities/types';
39
+
40
+ const RUN_PATH_PREFIX = 'wp-abilities/v1/abilities';
41
+
42
+ export interface WPNativeClientConfig {
43
+ /**
44
+ * Auto-fail execute() calls for abilities not present in the catalog.
45
+ *
46
+ * When true (default), execute() throws synchronously if the ability
47
+ * name is not registered on the site, before making the HTTP request.
48
+ * This catches typos and missing-plugin scenarios at the call site.
49
+ *
50
+ * Set to false if you want to call abilities before discover() has
51
+ * run (auth bootstrap, for example) — but prefer executeUnchecked()
52
+ * for that case so the intent is explicit.
53
+ */
54
+ validateAbilityNames?: boolean;
55
+ }
56
+
57
+ export class WPNativeClient {
58
+ private readonly transport: Transport;
59
+ private readonly config: Required<WPNativeClientConfig>;
60
+ private _catalog: AbilityCatalog | null = null;
61
+
62
+ constructor(transport: Transport, config: WPNativeClientConfig = {}) {
63
+ this.transport = transport;
64
+ this.config = {
65
+ validateAbilityNames: config.validateAbilityNames ?? true,
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Walk the Abilities API and populate the in-memory catalog.
71
+ *
72
+ * Call once at app startup, after auth is established. Subsequent
73
+ * calls replace the catalog (cheap to re-run if the server registers
74
+ * new abilities at runtime).
75
+ *
76
+ * Optionally filter to a subset by category — useful when bootstrapping
77
+ * a minimal client that only needs auth abilities.
78
+ */
79
+ async discover(options: { category?: string } = {}): Promise<AbilityCatalog> {
80
+ const filter: { category?: string } = {};
81
+ if (options.category !== undefined) {
82
+ filter.category = options.category;
83
+ }
84
+ const catalog = await discoverAbilities(this.transport, filter);
85
+ this._catalog = catalog;
86
+ return catalog;
87
+ }
88
+
89
+ /**
90
+ * The current catalog. Throws if discover() has not been called.
91
+ *
92
+ * Use catalogOrNull() if you want to feature-detect without throwing.
93
+ */
94
+ get catalog(): AbilityCatalog {
95
+ if (!this._catalog) {
96
+ throw new Error(
97
+ 'WPNativeClient: catalog not loaded. Call discover() before accessing catalog.',
98
+ );
99
+ }
100
+ return this._catalog;
101
+ }
102
+
103
+ /**
104
+ * Non-throwing accessor for the catalog. Returns null if discover()
105
+ * has not been called.
106
+ */
107
+ catalogOrNull(): AbilityCatalog | null {
108
+ return this._catalog;
109
+ }
110
+
111
+ /**
112
+ * Whether discover() has populated the catalog.
113
+ */
114
+ hasCatalog(): boolean {
115
+ return this._catalog !== null;
116
+ }
117
+
118
+ /**
119
+ * Execute an ability by name.
120
+ *
121
+ * The ability is looked up in the catalog (when validateAbilityNames is
122
+ * enabled), then POSTed to /wp-abilities/v1/abilities/{name}/run with
123
+ * the given input as the request body's `input` field.
124
+ *
125
+ * The response shape `{ result: TResult }` is unwrapped — callers receive
126
+ * the result value directly.
127
+ *
128
+ * Throws:
129
+ * - Error if the ability is not in the catalog (and validation enabled)
130
+ * - ApiError if the server returns a non-2xx response
131
+ */
132
+ async execute<TResult = unknown, TInput = unknown>(
133
+ name: string,
134
+ input?: TInput,
135
+ ): Promise<TResult> {
136
+ if (this.config.validateAbilityNames && this._catalog) {
137
+ if (!this._catalog.has(name)) {
138
+ throw new Error(
139
+ `WPNativeClient: ability "${name}" is not registered on this site. ` +
140
+ `Run discover() to refresh the catalog, or use executeUnchecked() ` +
141
+ `to bypass validation.`,
142
+ );
143
+ }
144
+ }
145
+
146
+ return this.executeUnchecked<TResult, TInput>(name, input);
147
+ }
148
+
149
+ /**
150
+ * Execute an ability without checking the catalog.
151
+ *
152
+ * Use this for the auth bootstrap path (login → discover) where the
153
+ * client must call abilities before the catalog exists. For all other
154
+ * call sites, prefer execute() so missing abilities fail loudly at
155
+ * the call site.
156
+ */
157
+ async executeUnchecked<TResult = unknown, TInput = unknown>(
158
+ name: string,
159
+ input?: TInput,
160
+ ): Promise<TResult> {
161
+ const path = `${RUN_PATH_PREFIX}/${encodeURIComponent(name)}/run`;
162
+ const body: { input: TInput | null } = {
163
+ input: input === undefined ? null : input,
164
+ };
165
+
166
+ const response = await this.transport.request<AbilityExecutionResponse<TResult>>({
167
+ path,
168
+ method: 'POST',
169
+ body: body as Record<string, unknown>,
170
+ });
171
+
172
+ if (!response || typeof response !== 'object' || !('result' in response)) {
173
+ throw new ApiError(
174
+ `WPNativeClient: malformed ability response for "${name}". ` +
175
+ `Expected { result: ... }.`,
176
+ 'malformed_ability_response',
177
+ 500,
178
+ );
179
+ }
180
+
181
+ return response.result;
182
+ }
183
+
184
+ /**
185
+ * Get a single ability descriptor from the catalog.
186
+ * Returns undefined if not registered or catalog not loaded.
187
+ */
188
+ describe(name: string): AbilityDescriptor | undefined {
189
+ return this._catalog?.get(name);
190
+ }
191
+ }
package/src/index.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * wp-native-client — universal WordPress client built on the Abilities API.
3
+ *
4
+ * One client. Discovery + execution. No per-site wrappers.
5
+ */
6
+
7
+ // Client
8
+ export { WPNativeClient } from './client';
9
+ export type { WPNativeClientConfig } from './client';
10
+
11
+ // Abilities subsystem
12
+ export { AbilityCatalog } from './abilities/catalog';
13
+ export {
14
+ discoverAbilities,
15
+ fetchAbilitiesPage,
16
+ fetchAbility,
17
+ fetchAbilityCategories,
18
+ } from './abilities/discovery';
19
+ export type {
20
+ AbilityDescriptor,
21
+ AbilityCategory,
22
+ AbilityExecutionResponse,
23
+ AbilityListPage,
24
+ AbilityListParams,
25
+ } from './abilities/types';
26
+
27
+ // Transports
28
+ export type { Transport, TransportRequest, TransportResponse } from './transports/types';
29
+ export { FetchTransport, ApiError } from './transports/fetch';
30
+ export type { FetchTransportConfig } from './transports/fetch';
31
+ export { AuthFetchTransport } from './transports/auth-fetch';
32
+ export type { AuthFetchTransportConfig, StoredTokens } from './transports/auth-fetch';
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Authenticated fetch transport with token lifecycle.
3
+ *
4
+ * Wraps FetchTransport and adds:
5
+ * - Bearer token injection via getAuthHeaders
6
+ * - Proactive token refresh before expiry
7
+ * - 401 retry with refresh lock (prevents thundering herd)
8
+ * - Auth failure callback for logout/navigation
9
+ *
10
+ * Platform-specific storage is injected via callbacks:
11
+ * - loadTokens: read persisted tokens on init
12
+ * - saveTokens: persist after login/refresh
13
+ * - clearTokens: wipe on logout/auth failure
14
+ *
15
+ * Usage:
16
+ *
17
+ * const transport = new AuthFetchTransport({
18
+ * baseUrl: 'https://wordpress.test/wp-json',
19
+ * refreshPath: 'wp-native/v1/auth/refresh',
20
+ * getDeviceId: () => getSecureItem('device_id'),
21
+ * loadTokens: () => getSecureItem('tokens').then(JSON.parse),
22
+ * saveTokens: (t) => setSecureItem('tokens', JSON.stringify(t)),
23
+ * clearTokens: () => deleteSecureItem('tokens'),
24
+ * onAuthFailure: () => router.replace('/login'),
25
+ * });
26
+ *
27
+ * // Inject your platform's secure storage via the callbacks above.
28
+ * await transport.initialize(); // loads stored tokens
29
+ */
30
+
31
+ import type { Transport, TransportRequest } from './types';
32
+ import { ApiError } from './fetch';
33
+
34
+ /** Token data stored by consumers. */
35
+ export interface StoredTokens {
36
+ accessToken: string;
37
+ refreshToken: string;
38
+ /** Unix timestamp (seconds) when the access token expires. */
39
+ accessExpiresAt: number;
40
+ }
41
+
42
+ export interface AuthFetchTransportConfig {
43
+ /** Base URL for the REST API, e.g. "https://example.com/wp-json" */
44
+ baseUrl: string;
45
+
46
+ /**
47
+ * REST path for the token refresh endpoint.
48
+ * @default 'wp-native/v1/auth/refresh'
49
+ */
50
+ refreshPath?: string;
51
+
52
+ /**
53
+ * How many milliseconds before expiry to proactively refresh.
54
+ * @default 60000 (1 minute)
55
+ */
56
+ refreshBufferMs?: number;
57
+
58
+ /**
59
+ * Return a device ID for refresh requests.
60
+ * Mobile apps generate a UUID and persist it; Node scripts can return a fixed string.
61
+ */
62
+ getDeviceId: () => string | Promise<string>;
63
+
64
+ /**
65
+ * Load persisted tokens on initialize().
66
+ * Return null if no tokens are stored.
67
+ */
68
+ loadTokens: () => StoredTokens | null | Promise<StoredTokens | null>;
69
+
70
+ /**
71
+ * Persist tokens after login, register, or refresh.
72
+ */
73
+ saveTokens: (tokens: StoredTokens) => void | Promise<void>;
74
+
75
+ /**
76
+ * Clear persisted tokens on logout or auth failure.
77
+ */
78
+ clearTokens: () => void | Promise<void>;
79
+
80
+ /**
81
+ * Called when authentication cannot be recovered (refresh failed, no tokens).
82
+ * Use this to navigate to a login screen or reset app state.
83
+ */
84
+ onAuthFailure?: () => void;
85
+
86
+ /**
87
+ * Extra headers to include on every request.
88
+ * Useful for custom client identification headers.
89
+ */
90
+ defaultHeaders?: Record<string, string>;
91
+ }
92
+
93
+ export class AuthFetchTransport implements Transport {
94
+ private config: AuthFetchTransportConfig;
95
+ private accessToken: string | null = null;
96
+ private refreshToken: string | null = null;
97
+ private accessExpiresAt: number | null = null;
98
+ private refreshPromise: Promise<boolean> | null = null;
99
+ private initialized = false;
100
+
101
+ constructor(config: AuthFetchTransportConfig) {
102
+ this.config = config;
103
+ }
104
+
105
+ /**
106
+ * Load stored tokens into memory. Call once at app startup.
107
+ */
108
+ async initialize(): Promise<void> {
109
+ const tokens = await this.config.loadTokens();
110
+ if (tokens) {
111
+ this.accessToken = tokens.accessToken;
112
+ this.refreshToken = tokens.refreshToken;
113
+ this.accessExpiresAt = tokens.accessExpiresAt;
114
+ }
115
+ this.initialized = true;
116
+ }
117
+
118
+ /**
119
+ * Whether initialize() has been called.
120
+ */
121
+ isInitialized(): boolean {
122
+ return this.initialized;
123
+ }
124
+
125
+ /**
126
+ * Whether tokens are currently loaded in memory.
127
+ */
128
+ hasTokens(): boolean {
129
+ return this.accessToken !== null && this.refreshToken !== null;
130
+ }
131
+
132
+ /**
133
+ * Store new tokens in memory and persist via saveTokens callback.
134
+ * Call after login, register, or any auth flow that returns tokens.
135
+ */
136
+ async setTokens(tokens: StoredTokens): Promise<void> {
137
+ this.accessToken = tokens.accessToken;
138
+ this.refreshToken = tokens.refreshToken;
139
+ this.accessExpiresAt = tokens.accessExpiresAt;
140
+ await this.config.saveTokens(tokens);
141
+ }
142
+
143
+ /**
144
+ * Set or replace the auth failure callback.
145
+ * Useful when the callback depends on framework state or navigation
146
+ * and must be wired after transport construction.
147
+ */
148
+ setOnAuthFailure(callback: () => void): void {
149
+ this.config.onAuthFailure = callback;
150
+ }
151
+
152
+ /**
153
+ * Clear tokens from memory and storage. Call on logout.
154
+ */
155
+ async clearAuth(): Promise<void> {
156
+ this.accessToken = null;
157
+ this.refreshToken = null;
158
+ this.accessExpiresAt = null;
159
+ await this.config.clearTokens();
160
+ }
161
+
162
+ /**
163
+ * Execute an HTTP request with automatic auth handling.
164
+ *
165
+ * - Injects Bearer token if available
166
+ * - Proactively refreshes before expiry
167
+ * - Retries once on 401 after refreshing
168
+ */
169
+ async request<T>(req: TransportRequest): Promise<T> {
170
+ // Proactive refresh before the request
171
+ if (this.hasTokens() && this.isAccessExpiringSoon()) {
172
+ const refreshed = await this.refreshAccessToken();
173
+ if (!refreshed) {
174
+ await this.handleAuthFailure();
175
+ throw new ApiError('Session expired', 'session_expired', 401);
176
+ }
177
+ }
178
+
179
+ try {
180
+ return await this.executeRequest<T>(req);
181
+ } catch (error) {
182
+ // Retry once on 401 after refresh
183
+ if (error instanceof ApiError && error.status === 401 && this.refreshToken) {
184
+ const refreshed = await this.refreshAccessToken();
185
+ if (!refreshed) {
186
+ await this.handleAuthFailure();
187
+ throw new ApiError('Session expired', 'session_expired', 401);
188
+ }
189
+ return await this.executeRequest<T>(req);
190
+ }
191
+ throw error;
192
+ }
193
+ }
194
+
195
+ // ─── Private ───────────────────────────────────────────────────────────
196
+
197
+ private async executeRequest<T>(req: TransportRequest): Promise<T> {
198
+ const url = `${this.config.baseUrl}/${req.path}`;
199
+
200
+ const isFormData = typeof FormData !== 'undefined' && req.body instanceof FormData;
201
+
202
+ const headers: Record<string, string> = {
203
+ ...(!isFormData ? { 'Content-Type': 'application/json' } : {}),
204
+ ...(this.config.defaultHeaders ?? {}),
205
+ ...(this.accessToken ? { Authorization: `Bearer ${this.accessToken}` } : {}),
206
+ ...req.headers,
207
+ };
208
+
209
+ const response = await fetch(url, {
210
+ method: req.method,
211
+ headers,
212
+ body: req.body
213
+ ? isFormData
214
+ ? (req.body as BodyInit)
215
+ : JSON.stringify(req.body)
216
+ : null,
217
+ });
218
+
219
+ if (response.status === 401) {
220
+ throw new ApiError('Unauthorized', 'unauthorized', 401);
221
+ }
222
+
223
+ if (!response.ok) {
224
+ let errorData: { code?: string; message?: string } = {};
225
+ try {
226
+ errorData = await response.json();
227
+ } catch {
228
+ // Response wasn't JSON
229
+ }
230
+ throw new ApiError(
231
+ errorData.message || `Request failed with status ${response.status}`,
232
+ errorData.code || 'request_failed',
233
+ response.status,
234
+ );
235
+ }
236
+
237
+ if (response.status === 204) {
238
+ return undefined as T;
239
+ }
240
+
241
+ return (await response.json()) as T;
242
+ }
243
+
244
+ private isAccessExpiringSoon(): boolean {
245
+ if (!this.accessExpiresAt) return true;
246
+ const bufferMs = this.config.refreshBufferMs ?? 60_000;
247
+ return Date.now() >= this.accessExpiresAt * 1000 - bufferMs;
248
+ }
249
+
250
+ /**
251
+ * Refresh the access token. Uses a lock to deduplicate concurrent refreshes.
252
+ */
253
+ private async refreshAccessToken(): Promise<boolean> {
254
+ if (this.refreshPromise) {
255
+ return this.refreshPromise;
256
+ }
257
+
258
+ this.refreshPromise = this.executeRefresh();
259
+
260
+ try {
261
+ return await this.refreshPromise;
262
+ } finally {
263
+ this.refreshPromise = null;
264
+ }
265
+ }
266
+
267
+ private async executeRefresh(): Promise<boolean> {
268
+ if (!this.refreshToken) return false;
269
+
270
+ try {
271
+ const deviceId = await this.config.getDeviceId();
272
+ const refreshPath = this.config.refreshPath ?? 'wp-native/v1/auth/refresh';
273
+ const url = `${this.config.baseUrl}/${refreshPath}`;
274
+
275
+ const response = await fetch(url, {
276
+ method: 'POST',
277
+ headers: { 'Content-Type': 'application/json' },
278
+ body: JSON.stringify({
279
+ refresh_token: this.refreshToken,
280
+ device_id: deviceId,
281
+ }),
282
+ });
283
+
284
+ if (!response.ok) return false;
285
+
286
+ const data = (await response.json()) as {
287
+ access_token: string;
288
+ access_expires_at: string;
289
+ refresh_token: string;
290
+ };
291
+
292
+ const expiresAt = Math.floor(new Date(data.access_expires_at).getTime() / 1000);
293
+
294
+ await this.setTokens({
295
+ accessToken: data.access_token,
296
+ refreshToken: data.refresh_token,
297
+ accessExpiresAt: expiresAt,
298
+ });
299
+
300
+ return true;
301
+ } catch {
302
+ return false;
303
+ }
304
+ }
305
+
306
+ private async handleAuthFailure(): Promise<void> {
307
+ await this.clearAuth();
308
+ this.config.onAuthFailure?.();
309
+ }
310
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Native fetch transport.
3
+ *
4
+ * Works in:
5
+ * - React Native (built-in fetch)
6
+ * - Modern browsers (without @wordpress/api-fetch)
7
+ * - Node 18+ (built-in fetch)
8
+ *
9
+ * Auth is handled via a configurable header function.
10
+ * Mobile apps pass Bearer tokens, Node scripts pass Basic auth, etc.
11
+ */
12
+
13
+ import type { Transport, TransportRequest } from './types';
14
+
15
+ export interface FetchTransportConfig {
16
+ /** Base URL for the REST API, e.g. "https://example.com/wp-json" */
17
+ baseUrl: string;
18
+
19
+ /**
20
+ * Return auth headers for each request.
21
+ * Return empty object for public endpoints.
22
+ *
23
+ * Examples:
24
+ * Bearer: () => ({ Authorization: `Bearer ${token}` })
25
+ * Basic: () => ({ Authorization: `Basic ${btoa('user:pass')}` })
26
+ * Nonce: () => ({ 'X-WP-Nonce': nonce })
27
+ */
28
+ getAuthHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
29
+
30
+ /**
31
+ * Called when a request returns 401.
32
+ * Use this to trigger token refresh or logout.
33
+ */
34
+ onUnauthorized?: () => void | Promise<void>;
35
+ }
36
+
37
+ export class FetchTransport implements Transport {
38
+ private config: FetchTransportConfig;
39
+
40
+ constructor(config: FetchTransportConfig) {
41
+ this.config = config;
42
+ }
43
+
44
+ async request<T>(req: TransportRequest): Promise<T> {
45
+ const url = `${this.config.baseUrl}/${req.path}`;
46
+
47
+ const authHeaders = this.config.getAuthHeaders
48
+ ? await this.config.getAuthHeaders()
49
+ : {};
50
+
51
+ const isFormData = typeof FormData !== 'undefined' && req.body instanceof FormData;
52
+
53
+ const headers: Record<string, string> = {
54
+ ...(!isFormData ? { 'Content-Type': 'application/json' } : {}),
55
+ ...authHeaders,
56
+ ...req.headers,
57
+ };
58
+
59
+ const response = await fetch(url, {
60
+ method: req.method,
61
+ headers,
62
+ body: req.body
63
+ ? isFormData
64
+ ? (req.body as BodyInit)
65
+ : JSON.stringify(req.body)
66
+ : null,
67
+ });
68
+
69
+ if (response.status === 401) {
70
+ await this.config.onUnauthorized?.();
71
+ throw new ApiError('Unauthorized', 'unauthorized', 401);
72
+ }
73
+
74
+ if (!response.ok) {
75
+ let errorData: { code?: string; message?: string } = {};
76
+ try {
77
+ errorData = await response.json();
78
+ } catch {
79
+ // Response wasn't JSON
80
+ }
81
+ throw new ApiError(
82
+ errorData.message || `Request failed with status ${response.status}`,
83
+ errorData.code || 'request_failed',
84
+ response.status,
85
+ );
86
+ }
87
+
88
+ // Handle 204 No Content
89
+ if (response.status === 204) {
90
+ return undefined as T;
91
+ }
92
+
93
+ return (await response.json()) as T;
94
+ }
95
+ }
96
+
97
+ export class ApiError extends Error {
98
+ code: string;
99
+ status: number;
100
+
101
+ constructor(message: string, code: string, status: number) {
102
+ super(message);
103
+ this.name = 'ApiError';
104
+ this.code = code;
105
+ this.status = status;
106
+ }
107
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Transport layer abstraction.
3
+ *
4
+ * A Transport is the only platform-specific piece in the client.
5
+ * It knows how to send HTTP requests and handle auth headers.
6
+ * Everything above it is pure TypeScript — platform agnostic.
7
+ */
8
+
9
+ export interface TransportRequest {
10
+ path: string;
11
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
12
+ body?: Record<string, unknown> | FormData;
13
+ headers?: Record<string, string>;
14
+ }
15
+
16
+ export interface TransportResponse<T = unknown> {
17
+ data: T;
18
+ status: number;
19
+ headers: Record<string, string>;
20
+ }
21
+
22
+ export interface Transport {
23
+ request<T>(req: TransportRequest): Promise<T>;
24
+ }