yaxa-svelte 2.5.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
@@ -392,11 +392,60 @@ await broadcast(`org:${orgId}`, 'invoice.settled', {
392
392
 
393
393
  ---
394
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
+
395
444
  ## 📦 Subpath Exports Architecture
396
445
 
397
446
  | Subpath | Description |
398
447
  | :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
399
- | `yaxa-svelte` | Core UI Primitives, SEO Components, `<Gate />`, Theme System & Composables (`useGate`, `useUpload`, `useSSE`, `useToast`, `useClipboard`, etc.) |
448
+ | `yaxa-svelte` | Core UI Primitives, SEO Components, `<Gate />`, Type-Safe Client RPC (`createApiClient`), Theme System & Composables (`useGate`, `useUpload`, `useSSE`, `useToast`, `useClipboard`, etc.) |
400
449
  | `yaxa-svelte/admin` | Drizzle ORM Schema Introspection, `<AdminDashboard />`, `createYaxaAdminHook`, `createDrizzleAdmin`, `<DevSandbox />`, `<RecordDrawer />` |
401
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 |
402
451
  | `yaxa-svelte/auth` | Better-Auth client & server integration (`useAuth`, `<AuthCard />`, `<PasskeyUI />`, `<TwoFactorModal />`, `createYaxaAuth`) |
@@ -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';
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Standard RFC 7807 Problem Details representation.
3
+ * @see https://datatracker.ietf.org/doc/html/rfc7807
4
+ */
5
+ export interface ProblemDetails {
6
+ type: string;
7
+ title: string;
8
+ status: number;
9
+ detail?: string;
10
+ instance?: string;
11
+ invalidParams?: Array<{
12
+ name: string;
13
+ reason: string;
14
+ }>;
15
+ [key: string]: unknown;
16
+ }
17
+ /**
18
+ * Standard Schema or validator representation.
19
+ */
20
+ export type AnyValidator<T = any> = {
21
+ '~standard': {
22
+ validate: (value: unknown) => Promise<{
23
+ value?: T;
24
+ issues?: ReadonlyArray<any>;
25
+ }> | {
26
+ value?: T;
27
+ issues?: ReadonlyArray<any>;
28
+ };
29
+ };
30
+ } | {
31
+ safeParse: (value: unknown) => {
32
+ success: true;
33
+ data: T;
34
+ } | {
35
+ success: false;
36
+ error: any;
37
+ };
38
+ } | {
39
+ parse: (value: unknown) => T;
40
+ } | ((value: unknown) => {
41
+ success: boolean;
42
+ data?: T;
43
+ error?: any;
44
+ });
45
+ /**
46
+ * Infer output type from a schema validator.
47
+ */
48
+ export type InferValidatorOutput<V> = V extends {
49
+ '~standard': {
50
+ validate: (...args: any[]) => Promise<{
51
+ value?: infer T;
52
+ }> | {
53
+ value?: infer T;
54
+ };
55
+ };
56
+ } ? T : V extends {
57
+ safeParse: (...args: any[]) => {
58
+ success: true;
59
+ data: infer T;
60
+ } | {
61
+ success: false;
62
+ error: any;
63
+ };
64
+ } ? T : V extends {
65
+ parse: (...args: any[]) => infer T;
66
+ } ? T : V extends (value: unknown) => {
67
+ success: true;
68
+ data: infer T;
69
+ } ? T : any;
70
+ /**
71
+ * Infer request payload and response types from a yaxaEndpoint handler or config.
72
+ */
73
+ export type InferEndpoint<T> = T extends {
74
+ _yaxaConfig: {
75
+ schema?: {
76
+ body?: infer B;
77
+ query?: infer Q;
78
+ params?: infer P;
79
+ headers?: infer H;
80
+ };
81
+ handle: (...args: any[]) => infer R;
82
+ };
83
+ } ? {
84
+ body: [B] extends [never] ? undefined : InferValidatorOutput<B>;
85
+ query: [Q] extends [never] ? undefined : InferValidatorOutput<Q>;
86
+ params: [P] extends [never] ? undefined : InferValidatorOutput<P>;
87
+ headers: [H] extends [never] ? undefined : InferValidatorOutput<H>;
88
+ response: Awaited<R> extends Response ? unknown : Awaited<R>;
89
+ } : T extends (...args: any[]) => Promise<infer R> | infer R ? {
90
+ body: any;
91
+ query: any;
92
+ params: any;
93
+ headers: any;
94
+ response: Awaited<R> extends Response ? unknown : Awaited<R>;
95
+ } : {
96
+ body: any;
97
+ query: any;
98
+ params: any;
99
+ headers: any;
100
+ response: any;
101
+ };
102
+ /**
103
+ * Strong discriminated union result returned by apiClient calls.
104
+ */
105
+ export type ClientResponse<TData> = {
106
+ data: TData;
107
+ error: null;
108
+ response: Response;
109
+ status: number;
110
+ ok: true;
111
+ } | {
112
+ data: null;
113
+ error: ProblemDetails;
114
+ response: Response;
115
+ status: number;
116
+ ok: false;
117
+ };
118
+ /**
119
+ * Client request options passed to an endpoint method.
120
+ */
121
+ export interface ClientRequestOptions<TBody = unknown, TQuery = Record<string, any>, TParams = Record<string, any>> {
122
+ body?: TBody;
123
+ query?: TQuery;
124
+ params?: TParams;
125
+ headers?: HeadersInit;
126
+ signal?: AbortSignal;
127
+ fetch?: typeof fetch;
128
+ }
129
+ /**
130
+ * Helper to determine if an option object is required based on schema properties.
131
+ */
132
+ export type RequiredKeys<T> = {
133
+ [K in keyof T]-?: Record<string, never> extends Pick<T, K> ? never : K;
134
+ }[keyof T];
135
+ export type ClientMethodFn<TEndpoint> = TEndpoint extends never ? (options?: ClientRequestOptions) => Promise<ClientResponse<any>> : InferEndpoint<TEndpoint> extends infer Inferred ? Inferred extends {
136
+ body: infer B;
137
+ query: infer Q;
138
+ params: infer P;
139
+ response: infer R;
140
+ } ? (options?: {
141
+ body?: B;
142
+ query?: Q;
143
+ params?: P;
144
+ headers?: HeadersInit;
145
+ signal?: AbortSignal;
146
+ fetch?: typeof fetch;
147
+ }) => Promise<ClientResponse<R>> : (options?: ClientRequestOptions) => Promise<ClientResponse<any>> : (options?: ClientRequestOptions) => Promise<ClientResponse<any>>;
148
+ /**
149
+ * HTTP methods supported by a route definition.
150
+ */
151
+ export type RouteMethods<TRoute> = {
152
+ get: TRoute extends {
153
+ GET: infer H;
154
+ } ? ClientMethodFn<H> : (options?: ClientRequestOptions) => Promise<ClientResponse<any>>;
155
+ post: TRoute extends {
156
+ POST: infer H;
157
+ } ? ClientMethodFn<H> : (options?: ClientRequestOptions) => Promise<ClientResponse<any>>;
158
+ put: TRoute extends {
159
+ PUT: infer H;
160
+ } ? ClientMethodFn<H> : (options?: ClientRequestOptions) => Promise<ClientResponse<any>>;
161
+ delete: TRoute extends {
162
+ DELETE: infer H;
163
+ } ? ClientMethodFn<H> : (options?: ClientRequestOptions) => Promise<ClientResponse<any>>;
164
+ patch: TRoute extends {
165
+ PATCH: infer H;
166
+ } ? ClientMethodFn<H> : (options?: ClientRequestOptions) => Promise<ClientResponse<any>>;
167
+ };
168
+ /**
169
+ * Callable route proxy supporting path parameter interpolation.
170
+ * e.g. api['/api/projects/[id]']({ id: '123' }).get()
171
+ */
172
+ export type RouteProxy<TRoute> = RouteMethods<TRoute> & {
173
+ (params: Record<string, any>): RouteMethods<TRoute>;
174
+ };
175
+ /**
176
+ * Global configuration options for creating an API client.
177
+ */
178
+ export interface ApiClientConfig {
179
+ /**
180
+ * Base URL prefix for all requests (e.g. 'https://api.example.com' or '').
181
+ * Defaults to empty string (relative to current origin).
182
+ */
183
+ baseUrl?: string;
184
+ /**
185
+ * Custom fetch implementation (e.g. SvelteKit's event.fetch in load functions).
186
+ * Defaults to globalThis.fetch.
187
+ */
188
+ fetch?: typeof fetch;
189
+ /**
190
+ * Default headers to attach to every request.
191
+ */
192
+ headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
193
+ /**
194
+ * Interceptor hook called before every request.
195
+ */
196
+ onRequest?: (context: {
197
+ url: string;
198
+ options: RequestInit;
199
+ }) => void | Promise<void>;
200
+ /**
201
+ * Interceptor hook called on every response.
202
+ */
203
+ onResponse?: (context: {
204
+ response: Response;
205
+ data: any;
206
+ error: ProblemDetails | null;
207
+ }) => void | Promise<void>;
208
+ }
209
+ /**
210
+ * Dynamic Proxy type mapping paths to methods.
211
+ */
212
+ export type ApiClient<TRoutes = Record<string, any>> = {
213
+ [K in keyof TRoutes]: RouteProxy<TRoutes[K]>;
214
+ } & {
215
+ [path: string]: RouteProxy<any>;
216
+ } & {
217
+ get: <TData = any>(path: string, options?: ClientRequestOptions) => Promise<ClientResponse<TData>>;
218
+ post: <TData = any>(path: string, options?: ClientRequestOptions) => Promise<ClientResponse<TData>>;
219
+ put: <TData = any>(path: string, options?: ClientRequestOptions) => Promise<ClientResponse<TData>>;
220
+ delete: <TData = any>(path: string, options?: ClientRequestOptions) => Promise<ClientResponse<TData>>;
221
+ patch: <TData = any>(path: string, options?: ClientRequestOptions) => Promise<ClientResponse<TData>>;
222
+ };
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.d.ts CHANGED
@@ -207,5 +207,7 @@ export { default as FeatureGrid, featureGridVariants } from './components/blocks
207
207
  export type { FeatureGridProps, FeatureItem } from './components/blocks/FeatureGrid.svelte';
208
208
  export { createMockSiteConfig, createMockUser, createTestWrapperProps } from './testing';
209
209
  export type { YaxaTestOptions } from './testing';
210
+ export { createApiClient, interpolatePath, serializeQueryParams } from './client';
211
+ export type { ApiClient, ApiClientConfig, ClientRequestOptions, ClientResponse, InferEndpoint, RouteMethods, ProblemDetails as ClientProblemDetails } from './client';
210
212
  export { cn, tv } from './utils/cn';
211
213
  export type { VariantProps } from './utils/cn';
package/dist/index.js CHANGED
@@ -138,5 +138,7 @@ export { default as FAQ, faqVariants } from './components/blocks/FAQ.svelte';
138
138
  export { default as FeatureGrid, featureGridVariants } from './components/blocks/FeatureGrid.svelte';
139
139
  // Testing Utilities
140
140
  export { createMockSiteConfig, createMockUser, createTestWrapperProps } from './testing';
141
+ // Type-Safe Client RPC
142
+ export { createApiClient, interpolatePath, serializeQueryParams } from './client';
141
143
  // Utilities
142
144
  export { cn, tv } from './utils/cn';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yaxa-svelte",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "The Intuitive Svelte UI Library — Nuxt UI v4 equivalent for SvelteKit 2 & 3 & Svelte 5 with automated SEO, dynamic OpenGraph cards, sitemaps, robots.txt, and Tailwind CSS v4.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://yaxa.vercel.app",