yaxa-svelte 2.4.0 → 2.6.0

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.
package/README.md CHANGED
@@ -295,7 +295,8 @@ export const GET = createApiDocsHandler({
295
295
  });
296
296
  ```
297
297
 
298
- - **Interactive Reference UI:** Open `/api/docs` in your browser.
298
+ - **Interactive Reference UI:** Open `/api/docs` in your browser (supports `?theme=scalar` (default), `?theme=swagger`, or `?theme=redoc`).
299
+ - **Live Demo:** Try it live on [yaxa.vercel.app/api/docs](https://yaxa.vercel.app/api/docs).
299
300
  - **Raw OpenAPI 3.1 JSON:** Request `/api/docs?format=json` or send `Accept: application/json` for SDK generation.
300
301
 
301
302
  ---
@@ -345,22 +346,117 @@ export const GET = async () => {
345
346
 
346
347
  ---
347
348
 
349
+ ## 📡 Real-Time Server-Sent Events (`yaxaSSE`, `broadcast`, & `useSSE`)
350
+
351
+ Zero-infrastructure real-time communication across serverless and Node.js without third-party subscriptions. Features declarative SSE streaming, automatic keep-alive heartbeats, multi-channel pub/sub broadcasting, and a reactive Svelte 5 client rune with exponential reconnect backoff:
352
+
353
+ ```ts
354
+ // 1. Server-Sent Events Endpoint (src/routes/api/events/+server.ts)
355
+ import { yaxaSSE, requireAuth } from 'yaxa-svelte/server';
356
+
357
+ export const GET = yaxaSSE({
358
+ guards: [requireAuth()],
359
+ channel: (event) => `org:${event.locals.orgId}`,
360
+ keepAliveIntervalMs: 15000, // Heartbeat ping prevents proxy & Vercel timeouts
361
+ onConnect: ({ send, user }) => {
362
+ send('connected', { userId: user.id });
363
+ }
364
+ });
365
+
366
+ // 2. Publish from anywhere (Endpoints, Webhooks, Background Workers)
367
+ import { broadcast } from 'yaxa-svelte/server';
368
+
369
+ await broadcast(`org:${orgId}`, 'invoice.settled', {
370
+ invoiceId: 'inv_4982',
371
+ amount: 29.0
372
+ });
373
+ ```
374
+
375
+ ```svelte
376
+ <!-- 3. Reactive Svelte 5 Client Rune -->
377
+ <script lang="ts">
378
+ import { useSSE } from 'yaxa-svelte';
379
+
380
+ const stream = useSSE('/api/events');
381
+
382
+ // Typed event listeners with automatic cleanup
383
+ stream.on('invoice.settled', (data) => {
384
+ toast.success(`Payment settled: $${data.amount}`);
385
+ });
386
+ </script>
387
+
388
+ <Badge color={stream.connected ? 'success' : 'neutral'}>
389
+ {stream.connected ? 'Live Stream' : 'Connecting...'}
390
+ </Badge>
391
+ ```
392
+
393
+ ---
394
+
395
+ ## 🔗 End-to-End Type-Safe Client RPC (`createApiClient`)
396
+
397
+ Zero-codegen end-to-end type safety bridging your server `yaxaEndpoint` routes directly into Svelte 5 components and universal `load` functions. Adds less than 500 bytes gzipped to your client bundle:
398
+
399
+ ```ts
400
+ // 1. Define Route Type Map (src/lib/api.ts)
401
+ export type AppApi = {
402
+ '/api/projects': typeof import('../routes/api/projects/+server');
403
+ '/api/projects/[id]': typeof import('../routes/api/projects/[id]/+server');
404
+ };
405
+ ```
406
+
407
+ ```svelte
408
+ <!-- 2. Type-Safe Client in Svelte 5 (+page.svelte) -->
409
+ <script lang="ts">
410
+ import { createApiClient } from 'yaxa-svelte';
411
+ import type { AppApi } from '$lib/api';
412
+
413
+ const api = createApiClient<AppApi>();
414
+
415
+ async function createProject() {
416
+ // ✨ Autocompleted URL, body, query, and response!
417
+ const { data, error, ok } = await api['/api/projects'].post({
418
+ body: { name: 'Apollo SaaS' },
419
+ query: { notify: true }
420
+ });
421
+
422
+ if (error) {
423
+ // 🛡️ Strongly typed RFC 7807 ProblemDetails
424
+ toast.error(error.title, error.detail);
425
+ return;
426
+ }
427
+
428
+ toast.success(`Created ${data.name} (#${data.id})`);
429
+ }
430
+ </script>
431
+ ```
432
+
433
+ ```ts
434
+ // 3. Universal SSR Load Function with SvelteKit fetch (src/routes/projects/+page.ts)
435
+ export const load = async ({ fetch }) => {
436
+ const api = createApiClient<AppApi>({ fetch });
437
+ const { data: projects } = await api['/api/projects'].get();
438
+ return { projects: projects ?? [] };
439
+ };
440
+ ```
441
+
442
+ ---
443
+
348
444
  ## 📦 Subpath Exports Architecture
349
445
 
350
- | Subpath | Description |
351
- | :---------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
352
- | `yaxa-svelte` | Core UI Primitives, SEO Components, `<Gate />`, Theme System & Composables (`useGate`, `useUpload`, `useToast`, `useClipboard`, etc.) |
353
- | `yaxa-svelte/admin` | Drizzle ORM Schema Introspection, `<AdminDashboard />`, `createYaxaAdminHook`, `createDrizzleAdmin`, `<DevSandbox />`, `<RecordDrawer />` |
354
- | `yaxa-svelte/server` | `yaxaEndpoint` Pipeline, Declarative Guards, OpenAPI 3.1 & Scalar Docs, RFC 7807, Background Jobs (`enqueueJob`, `createJobRunner`), `eventBus`, Server Hooks, Drizzle DB, Storage & Email |
355
- | `yaxa-svelte/auth` | Better-Auth client & server integration (`useAuth`, `<AuthCard />`, `<PasskeyUI />`, `<TwoFactorModal />`, `createYaxaAuth`) |
356
- | `yaxa-svelte/db` | Multi-dialect Drizzle ORM clients & schemas for Neon (PostgreSQL) and Turso (SQLite/LibSQL) |
357
- | `yaxa-svelte/polar` | Polar.sh billing, customer portal sessions, and webhook synchronization |
358
- | `yaxa-svelte/email` | Resend transactional email templates with automatic development console fallback |
359
- | `yaxa-svelte/storage` | AWS S3 and Cloudflare R2 presigned upload URL generators |
360
- | `yaxa-svelte/testing` | Test mocks & utilities (`createMockSiteConfig`, `createMockUser`, `createTestWrapperProps`) |
361
- | `yaxa-svelte/vite` | Turnkey Vite plugin (`yaxa()`) |
362
- | `yaxa-svelte/yaxa.css` | Standalone turnkey Tailwind CSS v4 stylesheet with base styling, `@theme`, and scrollbars |
363
- | `yaxa-svelte/theme.css` | Pure Tailwind CSS v4 design tokens, `:root` variables, and harmonic radius scale without `@import 'tailwindcss'` |
446
+ | Subpath | Description |
447
+ | :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
448
+ | `yaxa-svelte` | Core UI Primitives, SEO Components, `<Gate />`, Type-Safe Client RPC (`createApiClient`), Theme System & Composables (`useGate`, `useUpload`, `useSSE`, `useToast`, `useClipboard`, etc.) |
449
+ | `yaxa-svelte/admin` | Drizzle ORM Schema Introspection, `<AdminDashboard />`, `createYaxaAdminHook`, `createDrizzleAdmin`, `<DevSandbox />`, `<RecordDrawer />` |
450
+ | `yaxa-svelte/server` | `yaxaEndpoint` Pipeline, Declarative Guards, OpenAPI 3.1 & Scalar Docs, RFC 7807, Real-Time SSE (`yaxaSSE`, `broadcast`), Background Jobs (`enqueueJob`, `createJobRunner`), `eventBus`, Server Hooks, DB, Storage & Email |
451
+ | `yaxa-svelte/auth` | Better-Auth client & server integration (`useAuth`, `<AuthCard />`, `<PasskeyUI />`, `<TwoFactorModal />`, `createYaxaAuth`) |
452
+ | `yaxa-svelte/db` | Multi-dialect Drizzle ORM clients & schemas for Neon (PostgreSQL) and Turso (SQLite/LibSQL) |
453
+ | `yaxa-svelte/polar` | Polar.sh billing, customer portal sessions, and webhook synchronization |
454
+ | `yaxa-svelte/email` | Resend transactional email templates with automatic development console fallback |
455
+ | `yaxa-svelte/storage` | AWS S3 and Cloudflare R2 presigned upload URL generators |
456
+ | `yaxa-svelte/testing` | Test mocks & utilities (`createMockSiteConfig`, `createMockUser`, `createTestWrapperProps`) |
457
+ | `yaxa-svelte/vite` | Turnkey Vite plugin (`yaxa()`) |
458
+ | `yaxa-svelte/yaxa.css` | Standalone turnkey Tailwind CSS v4 stylesheet with base styling, `@theme`, and scrollbars |
459
+ | `yaxa-svelte/theme.css` | Pure Tailwind CSS v4 design tokens, `:root` variables, and harmonic radius scale without `@import 'tailwindcss'` |
364
460
 
365
461
  ---
366
462
 
@@ -0,0 +1,27 @@
1
+ import type { ApiClient, ApiClientConfig } from './types';
2
+ /**
3
+ * Serializes a query object into a URL search string.
4
+ * Handles arrays, booleans, numbers, and strings while ignoring undefined/null.
5
+ */
6
+ export declare function serializeQueryParams(query?: Record<string, any>): string;
7
+ /**
8
+ * Interpolates dynamic path segments (e.g. `[id]`, `[...slug]`, or `:id`).
9
+ */
10
+ export declare function interpolatePath(path: string, params?: Record<string, any>): string;
11
+ /**
12
+ * Creates an end-to-end type-safe API client for yaxaEndpoint routes.
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * import { createApiClient } from 'yaxa-svelte';
17
+ * import type { AppApi } from '$lib/api';
18
+ *
19
+ * const api = createApiClient<AppApi>();
20
+ *
21
+ * // Strong autocompletion and type-safety
22
+ * const { data, error } = await api['/api/projects'].post({
23
+ * body: { name: 'Apollo SaaS' }
24
+ * });
25
+ * ```
26
+ */
27
+ export declare function createApiClient<TRoutes = Record<string, any>>(config?: ApiClientConfig): ApiClient<TRoutes>;
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Serializes a query object into a URL search string.
3
+ * Handles arrays, booleans, numbers, and strings while ignoring undefined/null.
4
+ */
5
+ export function serializeQueryParams(query) {
6
+ if (!query || typeof query !== 'object')
7
+ return '';
8
+ const searchParams = new URLSearchParams();
9
+ for (const [key, value] of Object.entries(query)) {
10
+ if (value === undefined || value === null)
11
+ continue;
12
+ if (Array.isArray(value)) {
13
+ for (const item of value) {
14
+ if (item !== undefined && item !== null) {
15
+ searchParams.append(key, String(item));
16
+ }
17
+ }
18
+ }
19
+ else {
20
+ searchParams.set(key, String(value));
21
+ }
22
+ }
23
+ const queryString = searchParams.toString();
24
+ return queryString ? `?${queryString}` : '';
25
+ }
26
+ /**
27
+ * Interpolates dynamic path segments (e.g. `[id]`, `[...slug]`, or `:id`).
28
+ */
29
+ export function interpolatePath(path, params) {
30
+ if (!params || typeof params !== 'object')
31
+ return path;
32
+ let result = path;
33
+ for (const [key, value] of Object.entries(params)) {
34
+ if (value === undefined || value === null)
35
+ continue;
36
+ const encoded = encodeURIComponent(String(value));
37
+ // SvelteKit bracket syntax: [id] and [...rest]
38
+ result = result.replace(new RegExp(`\\[\\.\\.\\.${key}\\]`, 'g'), String(value));
39
+ result = result.replace(new RegExp(`\\[${key}\\]`, 'g'), encoded);
40
+ // Colon syntax: :id
41
+ result = result.replace(new RegExp(`:${key}\\b`, 'g'), encoded);
42
+ }
43
+ return result;
44
+ }
45
+ /**
46
+ * Core HTTP execution pipeline for apiClient.
47
+ */
48
+ async function executeRequest(config, method, rawPath, options) {
49
+ const params = options?.params;
50
+ const interpolated = interpolatePath(rawPath, params);
51
+ const queryString = serializeQueryParams(options?.query);
52
+ const baseUrl = config.baseUrl ? config.baseUrl.replace(/\/+$/, '') : '';
53
+ const cleanPath = interpolated.startsWith('/') ? interpolated : `/${interpolated}`;
54
+ const fullUrl = `${baseUrl}${cleanPath}${queryString}`;
55
+ // Resolve default headers
56
+ let defaultHeaders = {};
57
+ if (typeof config.headers === 'function') {
58
+ const resolved = await config.headers();
59
+ defaultHeaders = new Headers(resolved);
60
+ }
61
+ else if (config.headers) {
62
+ defaultHeaders = new Headers(config.headers);
63
+ }
64
+ const headers = new Headers(defaultHeaders);
65
+ if (options?.headers) {
66
+ const reqHeaders = new Headers(options.headers);
67
+ reqHeaders.forEach((val, key) => headers.set(key, val));
68
+ }
69
+ // Body serialization
70
+ let bodyPayload;
71
+ if (options?.body !== undefined && options?.body !== null) {
72
+ const rawBody = options.body;
73
+ if (typeof rawBody === 'string' ||
74
+ rawBody instanceof FormData ||
75
+ rawBody instanceof Blob ||
76
+ rawBody instanceof URLSearchParams ||
77
+ rawBody instanceof ArrayBuffer) {
78
+ bodyPayload = rawBody;
79
+ }
80
+ else {
81
+ if (!headers.has('content-type')) {
82
+ headers.set('content-type', 'application/json');
83
+ }
84
+ bodyPayload = JSON.stringify(rawBody);
85
+ }
86
+ }
87
+ const fetchFn = options?.fetch || config.fetch || globalThis.fetch;
88
+ const requestInit = {
89
+ method: method.toUpperCase(),
90
+ headers,
91
+ body: bodyPayload,
92
+ signal: options?.signal
93
+ };
94
+ if (config.onRequest) {
95
+ await config.onRequest({ url: fullUrl, options: requestInit });
96
+ }
97
+ let response;
98
+ try {
99
+ response = await fetchFn(fullUrl, requestInit);
100
+ }
101
+ catch (err) {
102
+ const networkProblem = {
103
+ type: 'https://yaxa.dev/errors/network-error',
104
+ title: 'Network Request Failed',
105
+ status: 0,
106
+ detail: err?.message || 'Could not connect to server.'
107
+ };
108
+ const fallbackResponse = typeof Response.error === 'function'
109
+ ? Response.error()
110
+ : new Response(null, { status: 503, statusText: 'Network Error' });
111
+ return {
112
+ data: null,
113
+ error: networkProblem,
114
+ response: fallbackResponse,
115
+ status: 0,
116
+ ok: false
117
+ };
118
+ }
119
+ // Parse response payload
120
+ let parsedData = null;
121
+ const contentType = response.headers.get('content-type') || '';
122
+ if (response.status !== 204) {
123
+ if (contentType.includes('application/json') || contentType.includes('problem+json')) {
124
+ try {
125
+ parsedData = await response.json();
126
+ }
127
+ catch {
128
+ parsedData = null;
129
+ }
130
+ }
131
+ else {
132
+ try {
133
+ parsedData = await response.text();
134
+ }
135
+ catch {
136
+ parsedData = null;
137
+ }
138
+ }
139
+ }
140
+ if (response.ok) {
141
+ if (config.onResponse) {
142
+ await config.onResponse({ response, data: parsedData, error: null });
143
+ }
144
+ return {
145
+ data: parsedData,
146
+ error: null,
147
+ response,
148
+ status: response.status,
149
+ ok: true
150
+ };
151
+ }
152
+ // Format RFC 7807 problem details
153
+ let errorProblem;
154
+ if (parsedData &&
155
+ typeof parsedData === 'object' &&
156
+ (parsedData.title || parsedData.type || parsedData.status)) {
157
+ errorProblem = {
158
+ type: parsedData.type || `https://yaxa.dev/errors/http-${response.status}`,
159
+ title: parsedData.title || response.statusText || 'Request Error',
160
+ status: parsedData.status || response.status,
161
+ detail: parsedData.detail,
162
+ instance: parsedData.instance,
163
+ invalidParams: parsedData.invalidParams,
164
+ ...parsedData
165
+ };
166
+ }
167
+ else {
168
+ errorProblem = {
169
+ type: `https://yaxa.dev/errors/http-${response.status}`,
170
+ title: response.statusText || 'HTTP Error',
171
+ status: response.status,
172
+ detail: typeof parsedData === 'string' ? parsedData : undefined
173
+ };
174
+ }
175
+ if (config.onResponse) {
176
+ await config.onResponse({ response, data: null, error: errorProblem });
177
+ }
178
+ return {
179
+ data: null,
180
+ error: errorProblem,
181
+ response,
182
+ status: response.status,
183
+ ok: false
184
+ };
185
+ }
186
+ /**
187
+ * Creates a route-level proxy that exposes HTTP method callers (`get`, `post`, etc.).
188
+ */
189
+ function createRouteProxy(config, path) {
190
+ const methods = ['get', 'post', 'put', 'delete', 'patch'];
191
+ const routeHandler = {};
192
+ for (const method of methods) {
193
+ routeHandler[method] = (options) => executeRequest(config, method, path, options);
194
+ }
195
+ // Allows calling the route proxy directly as a function to interpolate params:
196
+ // e.g. api['/api/projects/[id]']({ id: '123' }).get()
197
+ const callableProxy = Object.assign((params) => {
198
+ const interpolated = interpolatePath(path, params);
199
+ return createRouteProxy(config, interpolated);
200
+ }, routeHandler);
201
+ return callableProxy;
202
+ }
203
+ /**
204
+ * Creates an end-to-end type-safe API client for yaxaEndpoint routes.
205
+ *
206
+ * @example
207
+ * ```ts
208
+ * import { createApiClient } from 'yaxa-svelte';
209
+ * import type { AppApi } from '$lib/api';
210
+ *
211
+ * const api = createApiClient<AppApi>();
212
+ *
213
+ * // Strong autocompletion and type-safety
214
+ * const { data, error } = await api['/api/projects'].post({
215
+ * body: { name: 'Apollo SaaS' }
216
+ * });
217
+ * ```
218
+ */
219
+ export function createApiClient(config = {}) {
220
+ const rootTarget = {
221
+ get: (path, options) => executeRequest(config, 'get', path, options),
222
+ post: (path, options) => executeRequest(config, 'post', path, options),
223
+ put: (path, options) => executeRequest(config, 'put', path, options),
224
+ delete: (path, options) => executeRequest(config, 'delete', path, options),
225
+ patch: (path, options) => executeRequest(config, 'patch', path, options)
226
+ };
227
+ return new Proxy(rootTarget, {
228
+ get(target, prop, receiver) {
229
+ if (typeof prop === 'string') {
230
+ if (prop in target) {
231
+ return Reflect.get(target, prop, receiver);
232
+ }
233
+ // Route path access, e.g. api['/api/projects']
234
+ return createRouteProxy(config, prop);
235
+ }
236
+ return Reflect.get(target, prop, receiver);
237
+ }
238
+ });
239
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,197 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { createApiClient, serializeQueryParams, interpolatePath } from './client';
3
+ describe('serializeQueryParams', () => {
4
+ it('returns empty string for null or empty object', () => {
5
+ expect(serializeQueryParams()).toBe('');
6
+ expect(serializeQueryParams({})).toBe('');
7
+ expect(serializeQueryParams(null)).toBe('');
8
+ });
9
+ it('serializes primitives correctly', () => {
10
+ const res = serializeQueryParams({
11
+ page: 1,
12
+ search: 'hello world',
13
+ active: true
14
+ });
15
+ expect(res).toBe('?page=1&search=hello+world&active=true');
16
+ });
17
+ it('ignores undefined and null values', () => {
18
+ const res = serializeQueryParams({
19
+ query: 'test',
20
+ filter: undefined,
21
+ extra: null
22
+ });
23
+ expect(res).toBe('?query=test');
24
+ });
25
+ it('serializes array parameters into multi-keys', () => {
26
+ const res = serializeQueryParams({
27
+ tags: ['svelte', 'tailwind']
28
+ });
29
+ expect(res).toBe('?tags=svelte&tags=tailwind');
30
+ });
31
+ });
32
+ describe('interpolatePath', () => {
33
+ it('leaves static paths unchanged', () => {
34
+ expect(interpolatePath('/api/projects')).toBe('/api/projects');
35
+ });
36
+ it('interpolates SvelteKit bracket syntax [param]', () => {
37
+ expect(interpolatePath('/api/projects/[id]', { id: 'proj_123' })).toBe('/api/projects/proj_123');
38
+ });
39
+ it('interpolates colon syntax :param', () => {
40
+ expect(interpolatePath('/api/orgs/:orgId/users/:userId', { orgId: 'org_1', userId: 'usr_2' })).toBe('/api/orgs/org_1/users/usr_2');
41
+ });
42
+ it('interpolates rest parameters [...rest]', () => {
43
+ expect(interpolatePath('/api/docs/[...slug]', { slug: 'getting-started/install' })).toBe('/api/docs/getting-started/install');
44
+ });
45
+ it('url-encodes special characters', () => {
46
+ expect(interpolatePath('/api/search/[query]', { query: 'svelte & kit' })).toBe('/api/search/svelte%20%26%20kit');
47
+ });
48
+ });
49
+ describe('createApiClient', () => {
50
+ it('performs successful GET request and returns data', async () => {
51
+ const mockFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify([{ id: '1', name: 'Project 1' }]), {
52
+ status: 200,
53
+ headers: { 'content-type': 'application/json' }
54
+ }));
55
+ const api = createApiClient({ fetch: mockFetch });
56
+ const { data, error, status, ok } = await api['/api/projects'].get({
57
+ query: { limit: 10 }
58
+ });
59
+ expect(mockFetch).toHaveBeenCalledWith('/api/projects?limit=10', expect.objectContaining({
60
+ method: 'GET'
61
+ }));
62
+ expect(ok).toBe(true);
63
+ expect(status).toBe(200);
64
+ expect(error).toBeNull();
65
+ expect(data).toEqual([{ id: '1', name: 'Project 1' }]);
66
+ });
67
+ it('performs POST request with JSON body serialization', async () => {
68
+ const mockFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: 'proj_new', name: 'New Team' }), {
69
+ status: 201,
70
+ headers: { 'content-type': 'application/json' }
71
+ }));
72
+ const api = createApiClient({ baseUrl: 'https://api.yaxa.dev', fetch: mockFetch });
73
+ const result = await api['/api/projects'].post({
74
+ body: { name: 'New Team' }
75
+ });
76
+ expect(mockFetch).toHaveBeenCalledWith('https://api.yaxa.dev/api/projects', expect.objectContaining({
77
+ method: 'POST',
78
+ body: JSON.stringify({ name: 'New Team' })
79
+ }));
80
+ expect(result.ok).toBe(true);
81
+ expect(result.status).toBe(201);
82
+ expect(result.data).toEqual({ id: 'proj_new', name: 'New Team' });
83
+ });
84
+ it('interpolates path parameters via options or callable route', async () => {
85
+ const mockFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: '42' }), {
86
+ status: 200,
87
+ headers: { 'content-type': 'application/json' }
88
+ }));
89
+ const api = createApiClient({ fetch: mockFetch });
90
+ // Pattern 1: via params option
91
+ await api['/api/projects/[id]'].get({
92
+ params: { id: '42' }
93
+ });
94
+ expect(mockFetch).toHaveBeenLastCalledWith('/api/projects/42', expect.objectContaining({ method: 'GET' }));
95
+ // Pattern 2: callable route
96
+ await api['/api/projects/[id]']({ id: '99' }).get();
97
+ expect(mockFetch).toHaveBeenLastCalledWith('/api/projects/99', expect.objectContaining({ method: 'GET' }));
98
+ });
99
+ it('handles 204 No Content successfully with null data', async () => {
100
+ const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
101
+ const api = createApiClient({ fetch: mockFetch });
102
+ const result = await api['/api/projects/[id]'].delete({ params: { id: '1' } });
103
+ expect(result.ok).toBe(true);
104
+ expect(result.status).toBe(204);
105
+ expect(result.data).toBeNull();
106
+ expect(result.error).toBeNull();
107
+ });
108
+ it('parses and discriminates RFC 7807 problem details on error', async () => {
109
+ const problem = {
110
+ type: 'https://yaxa.dev/errors/validation-failed',
111
+ title: 'Validation Failed',
112
+ status: 422,
113
+ detail: 'Field "name" is required',
114
+ invalidParams: [{ name: 'name', reason: 'Required' }]
115
+ };
116
+ const mockFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify(problem), {
117
+ status: 422,
118
+ headers: { 'content-type': 'application/problem+json' }
119
+ }));
120
+ const api = createApiClient({ fetch: mockFetch });
121
+ const { data, error, ok, status } = await api['/api/projects'].post({
122
+ body: {}
123
+ });
124
+ expect(ok).toBe(false);
125
+ expect(status).toBe(422);
126
+ expect(data).toBeNull();
127
+ expect(error).toBeDefined();
128
+ expect(error?.title).toBe('Validation Failed');
129
+ expect(error?.invalidParams?.[0].name).toBe('name');
130
+ });
131
+ it('generates fallback RFC 7807 ProblemDetails for generic 500 error', async () => {
132
+ const mockFetch = vi.fn().mockResolvedValue(new Response('Internal Server Crash', {
133
+ status: 500,
134
+ statusText: 'Internal Error',
135
+ headers: { 'content-type': 'text/plain' }
136
+ }));
137
+ const api = createApiClient({ fetch: mockFetch });
138
+ const result = await api['/api/broken'].get();
139
+ expect(result.ok).toBe(false);
140
+ expect(result.status).toBe(500);
141
+ expect(result.error?.status).toBe(500);
142
+ expect(result.error?.title).toBe('Internal Error');
143
+ expect(result.error?.detail).toBe('Internal Server Crash');
144
+ });
145
+ it('gracefully handles network failures with status 0 RFC 7807 problem', async () => {
146
+ const mockFetch = vi.fn().mockRejectedValue(new Error('Failed to fetch'));
147
+ const api = createApiClient({ fetch: mockFetch });
148
+ const result = await api['/api/offline'].get();
149
+ expect(result.ok).toBe(false);
150
+ expect(result.status).toBe(0);
151
+ expect(result.error?.type).toBe('https://yaxa.dev/errors/network-error');
152
+ expect(result.error?.detail).toBe('Failed to fetch');
153
+ });
154
+ it('executes onRequest and onResponse interceptor lifecycle hooks', async () => {
155
+ const onRequest = vi.fn();
156
+ const onResponse = vi.fn();
157
+ const mockFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true }), {
158
+ status: 200,
159
+ headers: { 'content-type': 'application/json' }
160
+ }));
161
+ const api = createApiClient({
162
+ fetch: mockFetch,
163
+ onRequest,
164
+ onResponse
165
+ });
166
+ await api['/api/hooked'].get();
167
+ expect(onRequest).toHaveBeenCalledWith(expect.objectContaining({ url: '/api/hooked' }));
168
+ expect(onResponse).toHaveBeenCalledWith(expect.objectContaining({ error: null }));
169
+ });
170
+ it('supports top-level direct methods: api.get, api.post, etc.', async () => {
171
+ const mockFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ ping: 'pong' }), {
172
+ status: 200,
173
+ headers: { 'content-type': 'application/json' }
174
+ }));
175
+ const api = createApiClient({ fetch: mockFetch });
176
+ const res = await api.get('/api/health');
177
+ expect(mockFetch).toHaveBeenCalledWith('/api/health', expect.objectContaining({ method: 'GET' }));
178
+ expect(res.data).toEqual({ ping: 'pong' });
179
+ });
180
+ it('integrates with yaxaEndpoint route types seamlessly', async () => {
181
+ const mockFetch = vi.fn().mockImplementation(async (url, init) => {
182
+ const body = JSON.parse(init.body);
183
+ return new Response(JSON.stringify({ id: 'proj_abc', name: body.name, count: 1 }), {
184
+ status: 201,
185
+ headers: { 'content-type': 'application/json' }
186
+ });
187
+ });
188
+ const api = createApiClient({ fetch: mockFetch });
189
+ const { data, ok, status } = await api['/api/projects'].post({
190
+ body: { name: 'Apollo SaaS' }
191
+ });
192
+ expect(ok).toBe(true);
193
+ expect(status).toBe(201);
194
+ expect(data?.name).toBe('Apollo SaaS');
195
+ expect(data?.id).toBe('proj_abc');
196
+ });
197
+ });
@@ -0,0 +1,2 @@
1
+ export * from './types';
2
+ export * from './client';
@@ -0,0 +1,2 @@
1
+ export * from './types';
2
+ export * from './client';