yaxa-svelte 2.5.0 → 2.7.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
@@ -5,6 +5,7 @@
5
5
  **The Intuitive Svelte UI & Solo SaaS Library**
6
6
  _Nuxt UI v4 & Nuxt UI Pro Equivalent for SvelteKit 2 & 3 & Svelte 5 with Built-in SEO & SaaS Parity._
7
7
 
8
+ [![Live Demo](https://img.shields.io/badge/Live_Demo-yaxa.vercel.app-000000?style=flat&logo=vercel&logoColor=white)](https://yaxa.vercel.app)
8
9
  [![npm version](https://img.shields.io/npm/v/yaxa-svelte.svg?color=CB3837&logo=npm)](https://www.npmjs.com/package/yaxa-svelte)
9
10
  [![npm downloads](https://img.shields.io/npm/dm/yaxa-svelte.svg?color=blue)](https://www.npmjs.com/package/yaxa-svelte)
10
11
  [![Svelte 5](https://img.shields.io/badge/Svelte-5.0+-FF3E00?style=flat&logo=svelte&logoColor=white)](https://svelte.dev)
@@ -15,7 +16,13 @@ _Nuxt UI v4 & Nuxt UI Pro Equivalent for SvelteKit 2 & 3 & Svelte 5 with Built-i
15
16
  [![Security Policy](https://img.shields.io/badge/Security-Policy-blue.svg)](SECURITY.md)
16
17
  [![Contributing](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md)
17
18
 
18
- [**Live Documentation & Playground →**](https://yaxa.vercel.app)
19
+ <br />
20
+
21
+ [**🚀 Live Demo**](https://yaxa.vercel.app) &nbsp;•&nbsp;
22
+ [**📚 Documentation**](https://yaxa.vercel.app/docs) &nbsp;•&nbsp;
23
+ [**🎨 Theme Studio**](https://yaxa.vercel.app/theme) &nbsp;•&nbsp;
24
+ [**💼 SaaS Showcase**](https://yaxa.vercel.app/saas) &nbsp;•&nbsp;
25
+ [**⚡ Scalar API Reference**](https://yaxa.vercel.app/api/docs)
19
26
 
20
27
  </div>
21
28
 
@@ -276,7 +283,7 @@ export const POST = yaxaEndpoint({
276
283
 
277
284
  ## 📜 Auto-Generated OpenAPI 3.1 & Interactive Docs (`/api/docs`)
278
285
 
279
- Instantly generate an OpenAPI 3.1 specification and host interactive documentation ([Scalar](https://scalar.com/), Swagger UI, or Redoc) directly from your `yaxaEndpoint` routes:
286
+ Instantly generate an OpenAPI 3.1 specification and host interactive documentation ([Scalar](https://scalar.com/) or Swagger UI) directly from your `yaxaEndpoint` routes:
280
287
 
281
288
  ```ts
282
289
  // src/routes/api/docs/+server.ts
@@ -286,7 +293,7 @@ import { POST as createProject } from '../projects/+server';
286
293
  export const GET = createApiDocsHandler({
287
294
  title: 'Acme SaaS API',
288
295
  version: '1.0.0',
289
- theme: 'scalar', // 'scalar' (default) | 'swagger' | 'redoc'
296
+ theme: 'scalar', // 'scalar' (default) | 'swagger'
290
297
  endpoints: {
291
298
  '/api/projects': {
292
299
  POST: createProject
@@ -295,7 +302,7 @@ export const GET = createApiDocsHandler({
295
302
  });
296
303
  ```
297
304
 
298
- - **Interactive Reference UI:** Open `/api/docs` in your browser (supports `?theme=scalar` (default), `?theme=swagger`, or `?theme=redoc`).
305
+ - **Interactive Reference UI:** Open `/api/docs` in your browser (supports `?theme=scalar` (default) or `?theme=swagger`).
299
306
  - **Live Demo:** Try it live on [yaxa.vercel.app/api/docs](https://yaxa.vercel.app/api/docs).
300
307
  - **Raw OpenAPI 3.1 JSON:** Request `/api/docs?format=json` or send `Accept: application/json` for SDK generation.
301
308
 
@@ -392,22 +399,130 @@ await broadcast(`org:${orgId}`, 'invoice.settled', {
392
399
 
393
400
  ---
394
401
 
402
+ ## 🔗 End-to-End Type-Safe Client RPC (`createApiClient`)
403
+
404
+ 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:
405
+
406
+ ```ts
407
+ // 1. Define Route Type Map (src/lib/api.ts)
408
+ export type AppApi = {
409
+ '/api/projects': typeof import('../routes/api/projects/+server');
410
+ '/api/projects/[id]': typeof import('../routes/api/projects/[id]/+server');
411
+ };
412
+ ```
413
+
414
+ ```svelte
415
+ <!-- 2. Type-Safe Client in Svelte 5 (+page.svelte) -->
416
+ <script lang="ts">
417
+ import { createApiClient } from 'yaxa-svelte';
418
+ import type { AppApi } from '$lib/api';
419
+
420
+ const api = createApiClient<AppApi>();
421
+
422
+ async function createProject() {
423
+ // ✨ Autocompleted URL, body, query, and response!
424
+ const { data, error, ok } = await api['/api/projects'].post({
425
+ body: { name: 'Apollo SaaS' },
426
+ query: { notify: true }
427
+ });
428
+
429
+ if (error) {
430
+ // 🛡️ Strongly typed RFC 7807 ProblemDetails
431
+ toast.error(error.title, error.detail);
432
+ return;
433
+ }
434
+
435
+ toast.success(`Created ${data.name} (#${data.id})`);
436
+ }
437
+ </script>
438
+ ```
439
+
440
+ ```ts
441
+ // 3. Universal SSR Load Function with SvelteKit fetch (src/routes/projects/+page.ts)
442
+ export const load = async ({ fetch }) => {
443
+ const api = createApiClient<AppApi>({ fetch });
444
+ const { data: projects } = await api['/api/projects'].get();
445
+ return { projects: projects ?? [] };
446
+ };
447
+ ```
448
+
449
+ ---
450
+
451
+ ## ⚡ Runes-Native Reactive Query & Realtime RPC (`createQuery`, `createMutation`)
452
+
453
+ A zero-overhead, runes-native query and mutation cache built specifically for Svelte 5. Features request coalescing, stale-while-revalidate, window focus revalidation, optimistic updates with automatic rollback, and live SSE cache invalidation:
454
+
455
+ ```svelte
456
+ <!-- src/routes/projects/+page.svelte -->
457
+ <script lang="ts">
458
+ import { api } from '$lib/api';
459
+ import { createQuery, createMutation } from 'yaxa-svelte';
460
+
461
+ let activeOrg = $state('org_123');
462
+
463
+ // 1. Reactive Query: auto-refetches when activeOrg changes
464
+ const projects = createQuery(() => api['/api/projects'].get({ query: { orgId: activeOrg } }), {
465
+ staleTimeMs: 15_000,
466
+ refetchOnWindowFocus: true,
467
+ subscribeToSse: `org:${activeOrg}/projects` // ⚡ Live SSE invalidation
468
+ });
469
+
470
+ // 2. Optimistic Mutation with Automatic Rollback on RFC 7807 Error
471
+ const createProject = createMutation(
472
+ (name: string) => api['/api/projects'].post({ body: { name, orgId: activeOrg } }),
473
+ {
474
+ onMutate: (name) => {
475
+ const previous = projects.data;
476
+ projects.optimisticUpdate((curr) => [
477
+ { id: 'temp-' + Date.now(), name, status: 'creating' },
478
+ ...(curr ?? [])
479
+ ]);
480
+ return { previous }; // Snapshot for rollback
481
+ },
482
+ onError: (err, _name, context) => {
483
+ if (context?.previous) projects.setData(context.previous);
484
+ toast.error(err.title, err.detail);
485
+ },
486
+ onSettled: () => projects.invalidate()
487
+ }
488
+ );
489
+ </script>
490
+
491
+ {#if projects.isLoading}
492
+ <Skeleton count={3} class="h-10 w-full" />
493
+ {:else if projects.data}
494
+ <ul>
495
+ {#each projects.data as project}
496
+ <li class={project.id.startsWith('temp-') ? 'opacity-50' : ''}>
497
+ {project.name}
498
+ </li>
499
+ {/each}
500
+ </ul>
501
+ {/if}
502
+
503
+ <Button onclick={() => createProject.mutate('New Project')} loading={createProject.isPending}>
504
+ Create Project
505
+ </Button>
506
+ ```
507
+
508
+ ---
509
+
395
510
  ## 📦 Subpath Exports Architecture
396
511
 
397
- | Subpath | Description |
398
- | :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
399
- | `yaxa-svelte` | Core UI Primitives, SEO Components, `<Gate />`, Theme System & Composables (`useGate`, `useUpload`, `useSSE`, `useToast`, `useClipboard`, etc.) |
400
- | `yaxa-svelte/admin` | Drizzle ORM Schema Introspection, `<AdminDashboard />`, `createYaxaAdminHook`, `createDrizzleAdmin`, `<DevSandbox />`, `<RecordDrawer />` |
401
- | `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 |
402
- | `yaxa-svelte/auth` | Better-Auth client & server integration (`useAuth`, `<AuthCard />`, `<PasskeyUI />`, `<TwoFactorModal />`, `createYaxaAuth`) |
403
- | `yaxa-svelte/db` | Multi-dialect Drizzle ORM clients & schemas for Neon (PostgreSQL) and Turso (SQLite/LibSQL) |
404
- | `yaxa-svelte/polar` | Polar.sh billing, customer portal sessions, and webhook synchronization |
405
- | `yaxa-svelte/email` | Resend transactional email templates with automatic development console fallback |
406
- | `yaxa-svelte/storage` | AWS S3 and Cloudflare R2 presigned upload URL generators |
407
- | `yaxa-svelte/testing` | Test mocks & utilities (`createMockSiteConfig`, `createMockUser`, `createTestWrapperProps`) |
408
- | `yaxa-svelte/vite` | Turnkey Vite plugin (`yaxa()`) |
409
- | `yaxa-svelte/yaxa.css` | Standalone turnkey Tailwind CSS v4 stylesheet with base styling, `@theme`, and scrollbars |
410
- | `yaxa-svelte/theme.css` | Pure Tailwind CSS v4 design tokens, `:root` variables, and harmonic radius scale without `@import 'tailwindcss'` |
512
+ | Subpath | Description |
513
+ | :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
514
+ | `yaxa-svelte` | Core UI Primitives, SEO Components, `<Gate />`, Type-Safe Client RPC (`createApiClient`), Runes Query Engine (`createQuery`, `createMutation`, `QueryClient`), Theme System & Composables (`useGate`, `useUpload`, `useSSE`, `useToast`, `useClipboard`, etc.) |
515
+ | `yaxa-svelte/admin` | Drizzle ORM Schema Introspection, `<AdminDashboard />`, `createYaxaAdminHook`, `createDrizzleAdmin`, `<DevSandbox />`, `<RecordDrawer />` |
516
+ | `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 |
517
+ | `yaxa-svelte/auth` | Better-Auth client & server integration (`useAuth`, `<AuthCard />`, `<PasskeyUI />`, `<TwoFactorModal />`, `createYaxaAuth`) |
518
+ | `yaxa-svelte/db` | Multi-dialect Drizzle ORM clients & schemas for Neon (PostgreSQL) and Turso (SQLite/LibSQL) |
519
+ | `yaxa-svelte/polar` | Polar.sh billing, customer portal sessions, and webhook synchronization |
520
+ | `yaxa-svelte/email` | Resend transactional email templates with automatic development console fallback |
521
+ | `yaxa-svelte/storage` | AWS S3 and Cloudflare R2 presigned upload URL generators |
522
+ | `yaxa-svelte/testing` | Test mocks & utilities (`createMockSiteConfig`, `createMockUser`, `createTestWrapperProps`) |
523
+ | `yaxa-svelte/vite` | Turnkey Vite plugin (`yaxa()`) |
524
+ | `yaxa-svelte/yaxa.css` | Standalone turnkey Tailwind CSS v4 stylesheet with base styling, `@theme`, and scrollbars |
525
+ | `yaxa-svelte/theme.css` | Pure Tailwind CSS v4 design tokens, `:root` variables, and harmonic radius scale without `@import 'tailwindcss'` |
411
526
 
412
527
  ---
413
528
 
@@ -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,5 @@
1
+ export * from './types';
2
+ export * from './client';
3
+ export * from './query-cache.svelte';
4
+ export * from './query.svelte';
5
+ export * from './mutation.svelte';