@svadmin/elysia 0.10.7 → 0.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/elysia",
3
- "version": "0.10.7",
3
+ "version": "0.11.0",
4
4
  "description": "Elysia DataProvider for svadmin — CRUD convention + InferResourceMap type utility",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -20,9 +20,9 @@
20
20
  }
21
21
  },
22
22
  "peerDependencies": {
23
- "@svadmin/core": "^0.27.1",
24
- "elysia": ">=1.0.0",
25
- "@elysiajs/eden": ">=1.0.0"
23
+ "@svadmin/core": "^0.35.0",
24
+ "elysia": ">=1.4.29",
25
+ "@elysiajs/eden": ">=1.4.9"
26
26
  },
27
27
  "license": "MIT",
28
28
  "author": "zuohuadong",
@@ -41,5 +41,9 @@
41
41
  "@elysiajs/eden": {
42
42
  "optional": true
43
43
  }
44
+ },
45
+ "devDependencies": {
46
+ "elysia": ">=1.4.29",
47
+ "@elysiajs/eden": ">=1.4.9"
44
48
  }
45
49
  }
@@ -1,4 +1,3 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any */
2
1
  // Elysia DataProvider — CRUD convention compatible
3
2
  // Expects backend routes following: GET /resource, GET /resource/:id, POST /resource, PATCH /resource/:id, DELETE /resource/:id
4
3
  // Response format for lists: { items: T[], total: number } (also supports raw arrays)
@@ -8,9 +7,44 @@ import type {
8
7
  CreateParams, CreateResult, UpdateParams, UpdateResult, DeleteParams, DeleteResult,
9
8
  GetManyParams, GetManyResult, CreateManyParams, CreateManyResult,
10
9
  UpdateManyParams, UpdateManyResult, DeleteManyParams, DeleteManyResult,
11
- CustomParams, CustomResult, BaseRecord,
10
+ CustomParams, CustomResult, BaseRecord, FieldFilter, Filter, Sort, Pagination,
12
11
  } from '@svadmin/core';
13
12
 
13
+ export interface ElysiaResourceContext {
14
+ apiUrl: string;
15
+ resource: string;
16
+ meta?: Record<string, unknown>;
17
+ }
18
+
19
+ export interface ElysiaListContext extends ElysiaResourceContext {
20
+ pagination: {
21
+ current: number;
22
+ pageSize: number;
23
+ mode?: Pagination['mode'];
24
+ };
25
+ sorters: Sort[];
26
+ filters: Filter[];
27
+ }
28
+
29
+ export type ElysiaResourceMatcher = string | RegExp | ((resource: string) => boolean);
30
+
31
+ /** Per-resource transport overrides for APIs with mixed URL/query/envelope dialects. */
32
+ export interface ElysiaResourceAdapter {
33
+ match: ElysiaResourceMatcher;
34
+ /**
35
+ * Path appended to `apiUrl`. Encode dynamic path segments inside the resolver;
36
+ * the provider intentionally does not encode the complete resource path.
37
+ */
38
+ resourcePath?: string | ((context: ElysiaResourceContext) => string);
39
+ /** Build the complete query string for list requests. */
40
+ buildListSearchParams?: (context: ElysiaListContext) => URLSearchParams;
41
+ /** Normalize a resource-specific list envelope while retaining extra result metadata. */
42
+ parseListResponse?: <TData extends BaseRecord = BaseRecord>(
43
+ json: unknown,
44
+ context: ElysiaListContext,
45
+ ) => GetListResult<TData>;
46
+ }
47
+
14
48
  export interface ElysiaDataProviderOptions {
15
49
  /** Base API URL, e.g. 'http://localhost:3000' */
16
50
  apiUrl: string;
@@ -41,22 +75,195 @@ export interface ElysiaDataProviderOptions {
41
75
  *
42
76
  * @default Handles `{ items, total }` and raw arrays automatically
43
77
  */
44
- parseListResponse?: <T>(json: unknown, resource: string) => { data: T[]; total: number };
78
+ parseListResponse?: <TData extends BaseRecord = BaseRecord>(
79
+ json: unknown,
80
+ resource: string,
81
+ ) => GetListResult<TData>;
82
+ /**
83
+ * Ordered per-resource transport overrides. The first matching adapter wins.
84
+ * Existing global options remain the fallback for unmatched resources.
85
+ */
86
+ resourceAdapters?: readonly ElysiaResourceAdapter[];
87
+ }
88
+
89
+ const DEFAULT_JSON_HEADERS: Record<string, string> = { 'Content-Type': 'application/json' };
90
+
91
+ type RequestOptions = Omit<RequestInit, 'headers'> & {
92
+ headers?: Record<string, string>;
93
+ };
94
+
95
+ function mergeHeaders(...sources: Array<Record<string, string> | undefined>): Record<string, string> {
96
+ const merged = new Map<string, readonly [name: string, value: string]>();
97
+
98
+ for (const source of sources) {
99
+ if (!source) continue;
100
+ for (const [name, value] of Object.entries(source)) {
101
+ merged.set(name.toLowerCase(), [name, value]);
102
+ }
103
+ }
104
+
105
+ return Object.fromEntries(merged.values());
45
106
  }
46
107
 
47
108
  function resolveHeaders(opts: ElysiaDataProviderOptions): Record<string, string> {
48
- const base: Record<string, string> = { 'Content-Type': 'application/json' };
49
109
  const extra = typeof opts.headers === 'function' ? opts.headers() : (opts.headers ?? {});
50
- return { ...base, ...extra };
110
+ return mergeHeaders(DEFAULT_JSON_HEADERS, extra);
51
111
  }
52
112
 
53
- function resolveResourceUrl(opts: ElysiaDataProviderOptions, resource: string): string {
54
- const segment = opts.resourceUrlMap?.[resource] ?? resource;
55
- return `${opts.apiUrl}/${segment}`;
113
+ function matchesResource(matcher: ElysiaResourceMatcher, resource: string): boolean {
114
+ if (typeof matcher === 'string') return matcher === resource;
115
+ if (typeof matcher === 'function') return matcher(resource);
116
+ matcher.lastIndex = 0;
117
+ return matcher.test(resource);
118
+ }
119
+
120
+ function resolveResourceAdapter(
121
+ opts: ElysiaDataProviderOptions,
122
+ resource: string,
123
+ ): ElysiaResourceAdapter | undefined {
124
+ return opts.resourceAdapters?.find(adapter => matchesResource(adapter.match, resource));
125
+ }
126
+
127
+ function resolveResourceUrlWithAdapter(
128
+ opts: ElysiaDataProviderOptions,
129
+ context: ElysiaResourceContext,
130
+ adapter: ElysiaResourceAdapter | undefined,
131
+ ): string {
132
+ const configuredPath = typeof adapter?.resourcePath === 'function'
133
+ ? adapter.resourcePath(context)
134
+ : adapter?.resourcePath;
135
+ const path = configuredPath ?? opts.resourceUrlMap?.[context.resource] ?? context.resource;
136
+ return `${opts.apiUrl.replace(/\/$/, '')}/${path.replace(/^\//, '')}`;
137
+ }
138
+
139
+ function resolveResourceUrl(
140
+ opts: ElysiaDataProviderOptions,
141
+ resource: string,
142
+ meta?: Record<string, unknown>,
143
+ ): string {
144
+ const context: ElysiaResourceContext = { apiUrl: opts.apiUrl, resource, meta };
145
+ return resolveResourceUrlWithAdapter(opts, context, resolveResourceAdapter(opts, resource));
146
+ }
147
+
148
+ function encodeIdPathSegment(id: string | number): string {
149
+ return encodeURIComponent(String(id));
150
+ }
151
+
152
+ function isSameOrigin(apiUrl: string, targetUrl: string): boolean {
153
+ try {
154
+ const api = new URL(apiUrl);
155
+ const target = new URL(targetUrl, apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`);
156
+ return api.origin === target.origin;
157
+ } catch {
158
+ return false;
159
+ }
160
+ }
161
+
162
+ function serializeQueryValue(value: unknown): string {
163
+ if (value === null) return 'null';
164
+ if (typeof value === 'string') return value;
165
+ if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
166
+ return String(value);
167
+ }
168
+
169
+ const serialized = JSON.stringify(value);
170
+ return serialized === undefined ? String(value) : serialized;
171
+ }
172
+
173
+ function appendQuery(params: URLSearchParams, query?: Record<string, unknown>): void {
174
+ if (!query) return;
175
+
176
+ for (const [name, value] of Object.entries(query)) {
177
+ if (value === undefined) continue;
178
+ if (Array.isArray(value)) {
179
+ for (const item of value) params.append(name, serializeQueryValue(item));
180
+ continue;
181
+ }
182
+ params.set(name, serializeQueryValue(value));
183
+ }
56
184
  }
57
185
 
58
- async function request<T>(url: string, headers: Record<string, string>, init?: RequestInit, withCredentials?: boolean): Promise<T> {
59
- const fetchInit: RequestInit = { ...init, headers: { ...headers, ...init?.headers } };
186
+ function appendSorters(params: URLSearchParams, sorters?: Sort[]): void {
187
+ if (!sorters?.length) return;
188
+ params.set('_sort', sorters.map(sorter => sorter.field).join(','));
189
+ params.set('_order', sorters.map(sorter => sorter.order).join(','));
190
+ }
191
+
192
+ function filterParamName(filter: FieldFilter): string {
193
+ if (filter.operator === 'eq') return filter.field;
194
+ if (filter.operator === 'contains') return `${filter.field}_like`;
195
+ return `${filter.field}_${filter.operator}`;
196
+ }
197
+
198
+ function filterParamValue(filter: FieldFilter): string {
199
+ if (filter.operator === 'null' || filter.operator === 'nnull') return 'true';
200
+ if (Array.isArray(filter.value)) {
201
+ return filter.value.map(serializeQueryValue).join(',');
202
+ }
203
+ return serializeQueryValue(filter.value);
204
+ }
205
+
206
+ function appendFilters(params: URLSearchParams, filters?: Filter[]): void {
207
+ if (!filters?.length) return;
208
+
209
+ let hasLogicalFilter = false;
210
+ for (const filter of filters) {
211
+ if ('field' in filter) {
212
+ params.append(filterParamName(filter), filterParamValue(filter));
213
+ } else {
214
+ hasLogicalFilter = true;
215
+ }
216
+ }
217
+
218
+ // Flat filters keep the established field_operator convention. A canonical
219
+ // JSON copy is added only when a logical group is present, because OR/AND
220
+ // cannot be represented without losing nesting in flat query parameters.
221
+ if (hasLogicalFilter) params.set('_filters', JSON.stringify(filters));
222
+ }
223
+
224
+ function buildDefaultListSearchParams(context: ElysiaListContext): URLSearchParams {
225
+ const params = new URLSearchParams();
226
+ params.set('_page', String(context.pagination.current));
227
+ params.set('_limit', String(context.pagination.pageSize));
228
+ appendSorters(params, context.sorters);
229
+ appendFilters(params, context.filters);
230
+ return params;
231
+ }
232
+
233
+ function buildCustomUrl(
234
+ url: string,
235
+ apiUrl: string,
236
+ query?: Record<string, unknown>,
237
+ sorters?: Sort[],
238
+ filters?: Filter[],
239
+ ): string {
240
+ const parsed = new URL(url, apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`);
241
+ appendQuery(parsed.searchParams, query);
242
+ appendSorters(parsed.searchParams, sorters);
243
+ appendFilters(parsed.searchParams, filters);
244
+ return parsed.toString();
245
+ }
246
+
247
+ async function parseResponse<T>(response: Response): Promise<T> {
248
+ if (response.status === 204 || response.status === 205) {
249
+ return undefined as unknown as T;
250
+ }
251
+
252
+ const contentLength = response.headers?.get('content-length');
253
+ if (contentLength?.trim() === '0') {
254
+ return undefined as unknown as T;
255
+ }
256
+
257
+ const body = await response.text();
258
+ if (!body || body.trim() === '') {
259
+ return undefined as unknown as T;
260
+ }
261
+
262
+ return JSON.parse(body) as T;
263
+ }
264
+
265
+ async function request<T>(url: string, headers: Record<string, string>, init?: RequestOptions, withCredentials?: boolean): Promise<T> {
266
+ const fetchInit: RequestInit = { ...init, headers: mergeHeaders(headers, init?.headers) };
60
267
  if (withCredentials) {
61
268
  fetchInit.credentials = 'include';
62
269
  }
@@ -65,7 +272,7 @@ async function request<T>(url: string, headers: Record<string, string>, init?: R
65
272
  const body = await response.text().catch(() => '');
66
273
  throw new Error(`HTTP ${response.status}: ${response.statusText}${body ? ` — ${body}` : ''}`);
67
274
  }
68
- return response.json();
275
+ return parseResponse<T>(response);
69
276
  }
70
277
 
71
278
  /**
@@ -75,16 +282,29 @@ async function request<T>(url: string, headers: Record<string, string>, init?: R
75
282
  * - `{ data: T[], total: number }` (common alternative)
76
283
  * - `T[]` (raw array — total is inferred from array length)
77
284
  */
78
- function defaultParseListResponse<T>(json: unknown): { data: T[]; total: number } {
285
+ function normalizeObjectListResponse<TData extends BaseRecord>(
286
+ response: Record<string, unknown>,
287
+ recordsKey: 'items' | 'data',
288
+ ): GetListResult<TData> {
289
+ const { [recordsKey]: rawRecords, ...metadata } = response;
290
+ const records = rawRecords as TData[];
291
+ return {
292
+ ...metadata,
293
+ data: records,
294
+ total: response.total !== undefined ? Number(response.total) : records.length,
295
+ };
296
+ }
297
+
298
+ function defaultParseListResponse<TData extends BaseRecord>(json: unknown): GetListResult<TData> {
79
299
  if (Array.isArray(json)) {
80
- return { data: json as unknown as T[], total: json.length };
300
+ return { data: json as TData[], total: json.length };
81
301
  }
82
302
  const obj = json as Record<string, unknown>;
83
303
  if (Array.isArray(obj.items)) {
84
- return { data: obj.items as unknown as T[], total: obj.total !== undefined ? Number(obj.total) : obj.items.length };
304
+ return normalizeObjectListResponse<TData>(obj, 'items');
85
305
  }
86
306
  if (Array.isArray(obj.data)) {
87
- return { data: obj.data as unknown as T[], total: obj.total !== undefined ? Number(obj.total) : obj.data.length };
307
+ return normalizeObjectListResponse<TData>(obj, 'data');
88
308
  }
89
309
  throw new Error('Unrecognized list response format. Expected { items, total }, { data, total }, or an array.');
90
310
  }
@@ -115,44 +335,41 @@ export function createElysiaDataProvider(opts: ElysiaDataProviderOptions): DataP
115
335
  return {
116
336
  getApiUrl: () => apiUrl,
117
337
 
118
- async getList<TData extends BaseRecord = BaseRecord>({ resource, pagination, sorters, filters }: GetListParams): Promise<GetListResult<TData>> {
119
- const params = new URLSearchParams();
338
+ async getList<TData extends BaseRecord = BaseRecord>({ resource, pagination, sorters, filters, meta }: GetListParams): Promise<GetListResult<TData>> {
120
339
  const { current = 1, pageSize = 10 } = pagination ?? {};
121
- params.set('_page', String(current));
122
- params.set('_limit', String(pageSize));
123
-
124
- if (sorters?.length) {
125
- params.set('_sort', sorters.map(s => s.field).join(','));
126
- params.set('_order', sorters.map(s => s.order).join(','));
127
- }
128
-
129
- if (filters?.length) {
130
- for (const f of filters) {
131
- if ((f as any).operator === 'eq') params.set((f as any).field, String((f as any).value));
132
- else if ((f as any).operator === 'contains') params.set(`${(f as any).field}_like`, String((f as any).value));
133
- else params.set(`${(f as any).field}_${(f as any).operator}`, String((f as any).value));
134
- }
135
- }
136
-
137
- const baseUrl = resolveResourceUrl(opts, resource);
138
- const url = `${baseUrl}?${params.toString()}`;
340
+ const context: ElysiaListContext = {
341
+ apiUrl,
342
+ resource,
343
+ meta,
344
+ pagination: { current, pageSize, mode: pagination?.mode },
345
+ sorters: sorters ?? [],
346
+ filters: filters ?? [],
347
+ };
348
+ const adapter = resolveResourceAdapter(opts, resource);
349
+ const params = adapter?.buildListSearchParams?.(context) ?? buildDefaultListSearchParams(context);
350
+ const baseUrl = resolveResourceUrlWithAdapter(opts, context, adapter);
351
+ const query = params.toString();
352
+ const url = query ? `${baseUrl}?${query}` : baseUrl;
139
353
  const headers = resolveHeaders(opts);
140
354
  const json = await request<unknown>(url, headers, undefined, withCredentials);
141
355
 
356
+ if (adapter?.parseListResponse) {
357
+ return adapter.parseListResponse<TData>(json, context);
358
+ }
142
359
  if (opts.parseListResponse) {
143
360
  return opts.parseListResponse<TData>(json, resource);
144
361
  }
145
362
  return defaultParseListResponse<TData>(json);
146
363
  },
147
364
 
148
- async getOne<TData extends BaseRecord = BaseRecord>({ resource, id }: GetOneParams): Promise<GetOneResult<TData>> {
149
- const baseUrl = resolveResourceUrl(opts, resource);
150
- const data = await request<TData>(`${baseUrl}/${id}`, resolveHeaders(opts), undefined, withCredentials);
365
+ async getOne<TData extends BaseRecord = BaseRecord>({ resource, id, meta }: GetOneParams): Promise<GetOneResult<TData>> {
366
+ const baseUrl = resolveResourceUrl(opts, resource, meta);
367
+ const data = await request<TData>(`${baseUrl}/${encodeIdPathSegment(id)}`, resolveHeaders(opts), undefined, withCredentials);
151
368
  return { data };
152
369
  },
153
370
 
154
- async create<TData extends BaseRecord = BaseRecord, TVariables = unknown>({ resource, variables }: CreateParams<TVariables>): Promise<CreateResult<TData>> {
155
- const baseUrl = resolveResourceUrl(opts, resource);
371
+ async create<TData extends BaseRecord = BaseRecord, TVariables = unknown>({ resource, variables, meta }: CreateParams<TVariables>): Promise<CreateResult<TData>> {
372
+ const baseUrl = resolveResourceUrl(opts, resource, meta);
156
373
  const data = await request<TData>(baseUrl, resolveHeaders(opts), {
157
374
  method: 'POST',
158
375
  body: JSON.stringify(variables),
@@ -160,32 +377,32 @@ export function createElysiaDataProvider(opts: ElysiaDataProviderOptions): DataP
160
377
  return { data };
161
378
  },
162
379
 
163
- async update<TData extends BaseRecord = BaseRecord, TVariables = unknown>({ resource, id, variables }: UpdateParams<TVariables>): Promise<UpdateResult<TData>> {
164
- const baseUrl = resolveResourceUrl(opts, resource);
165
- const data = await request<TData>(`${baseUrl}/${id}`, resolveHeaders(opts), {
380
+ async update<TData extends BaseRecord = BaseRecord, TVariables = unknown>({ resource, id, variables, meta }: UpdateParams<TVariables>): Promise<UpdateResult<TData>> {
381
+ const baseUrl = resolveResourceUrl(opts, resource, meta);
382
+ const data = await request<TData>(`${baseUrl}/${encodeIdPathSegment(id)}`, resolveHeaders(opts), {
166
383
  method: updateMethod,
167
384
  body: JSON.stringify(variables),
168
385
  }, withCredentials);
169
386
  return { data };
170
387
  },
171
388
 
172
- async deleteOne<TData extends BaseRecord = BaseRecord, TVariables = unknown>({ resource, id }: DeleteParams<TVariables>): Promise<DeleteResult<TData>> {
173
- const baseUrl = resolveResourceUrl(opts, resource);
174
- const data = await request<TData>(`${baseUrl}/${id}`, resolveHeaders(opts), {
389
+ async deleteOne<TData extends BaseRecord = BaseRecord, TVariables = unknown>({ resource, id, meta }: DeleteParams<TVariables>): Promise<DeleteResult<TData>> {
390
+ const baseUrl = resolveResourceUrl(opts, resource, meta);
391
+ const data = await request<TData | undefined>(`${baseUrl}/${encodeIdPathSegment(id)}`, resolveHeaders(opts), {
175
392
  method: 'DELETE',
176
393
  }, withCredentials);
177
- return { data };
394
+ return { data: data === undefined ? { id } as unknown as TData : data };
178
395
  },
179
396
 
180
- async getMany<TData extends BaseRecord = BaseRecord>({ resource, ids }: GetManyParams): Promise<GetManyResult<TData>> {
181
- const baseUrl = resolveResourceUrl(opts, resource);
397
+ async getMany<TData extends BaseRecord = BaseRecord>({ resource, ids, meta }: GetManyParams): Promise<GetManyResult<TData>> {
398
+ const baseUrl = resolveResourceUrl(opts, resource, meta);
182
399
  const params = ids.map(id => `id=${encodeURIComponent(String(id))}`).join('&');
183
400
  const data = await request<TData[]>(`${baseUrl}?${params}`, resolveHeaders(opts), undefined, withCredentials);
184
401
  return { data };
185
402
  },
186
403
 
187
- async createMany<TData extends BaseRecord = BaseRecord, TVariables = unknown>({ resource, variables }: CreateManyParams<TVariables>): Promise<CreateManyResult<TData>> {
188
- const baseUrl = resolveResourceUrl(opts, resource);
404
+ async createMany<TData extends BaseRecord = BaseRecord, TVariables = unknown>({ resource, variables, meta }: CreateManyParams<TVariables>): Promise<CreateManyResult<TData>> {
405
+ const baseUrl = resolveResourceUrl(opts, resource, meta);
189
406
  const results = await Promise.all(
190
407
  variables.map(vars =>
191
408
  request<TData>(baseUrl, resolveHeaders(opts), {
@@ -197,11 +414,11 @@ export function createElysiaDataProvider(opts: ElysiaDataProviderOptions): DataP
197
414
  return { data: results };
198
415
  },
199
416
 
200
- async updateMany<TData extends BaseRecord = BaseRecord, TVariables = unknown>({ resource, ids, variables }: UpdateManyParams<TVariables>): Promise<UpdateManyResult<TData>> {
201
- const baseUrl = resolveResourceUrl(opts, resource);
417
+ async updateMany<TData extends BaseRecord = BaseRecord, TVariables = unknown>({ resource, ids, variables, meta }: UpdateManyParams<TVariables>): Promise<UpdateManyResult<TData>> {
418
+ const baseUrl = resolveResourceUrl(opts, resource, meta);
202
419
  const results = await Promise.all(
203
420
  ids.map(id =>
204
- request<TData>(`${baseUrl}/${id}`, resolveHeaders(opts), {
421
+ request<TData>(`${baseUrl}/${encodeIdPathSegment(id)}`, resolveHeaders(opts), {
205
422
  method: updateMethod,
206
423
  body: JSON.stringify(variables),
207
424
  }, withCredentials)
@@ -210,23 +427,33 @@ export function createElysiaDataProvider(opts: ElysiaDataProviderOptions): DataP
210
427
  return { data: results };
211
428
  },
212
429
 
213
- async deleteMany<TData extends BaseRecord = BaseRecord, TVariables = unknown>({ resource, ids }: DeleteManyParams<TVariables>): Promise<DeleteManyResult<TData>> {
214
- const baseUrl = resolveResourceUrl(opts, resource);
430
+ async deleteMany<TData extends BaseRecord = BaseRecord, TVariables = unknown>({ resource, ids, meta }: DeleteManyParams<TVariables>): Promise<DeleteManyResult<TData>> {
431
+ const baseUrl = resolveResourceUrl(opts, resource, meta);
215
432
  const results = await Promise.all(
216
433
  ids.map(id =>
217
- request<TData>(`${baseUrl}/${id}`, resolveHeaders(opts), {
434
+ request<TData | undefined>(`${baseUrl}/${encodeIdPathSegment(id)}`, resolveHeaders(opts), {
218
435
  method: 'DELETE',
219
- }, withCredentials)
436
+ }, withCredentials).then(data => data === undefined ? { id } as unknown as TData : data)
220
437
  )
221
438
  );
222
439
  return { data: results };
223
440
  },
224
441
 
225
- async custom<TData = unknown, TVariables = unknown>({ url, method, payload, headers }: CustomParams<TVariables>): Promise<CustomResult<TData>> {
226
- const data = await request<TData>(url, { ...resolveHeaders(opts), ...headers }, {
442
+ async custom<TData = unknown, TVariables = unknown>({ url, method, payload, query, headers, sorters, filters }: CustomParams<TVariables>): Promise<CustomResult<TData>> {
443
+ const requestUrl = buildCustomUrl(url, apiUrl, query, sorters, filters);
444
+ const sameOrigin = isSameOrigin(apiUrl, requestUrl);
445
+ const providerHeaders = resolveHeaders(opts);
446
+ const requestHeaders = mergeHeaders(
447
+ // Provider-level headers often contain credentials under application-specific
448
+ // names. Never inherit them across origins; callers must opt in explicitly via
449
+ // custom.headers for the target service.
450
+ sameOrigin ? providerHeaders : DEFAULT_JSON_HEADERS,
451
+ headers,
452
+ );
453
+ const data = await request<TData>(requestUrl, requestHeaders, {
227
454
  method: method.toUpperCase(),
228
- body: payload ? JSON.stringify(payload) : undefined,
229
- }, withCredentials);
455
+ body: payload === undefined ? undefined : JSON.stringify(payload),
456
+ }, withCredentials && sameOrigin);
230
457
  return { data };
231
458
  },
232
459
  };
package/src/index.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  // @svadmin/elysia — Elysia DataProvider + type utilities
2
2
 
3
3
  export { createElysiaDataProvider } from './data-provider';
4
- export type { ElysiaDataProviderOptions } from './data-provider';
4
+ export type {
5
+ ElysiaDataProviderOptions,
6
+ ElysiaListContext,
7
+ ElysiaResourceAdapter,
8
+ ElysiaResourceContext,
9
+ ElysiaResourceMatcher,
10
+ } from './data-provider';
5
11
  export type { InferResourceMap } from './types';
package/src/types.ts CHANGED
@@ -39,15 +39,46 @@
39
39
  * }
40
40
  * ```
41
41
  */
42
- export type InferResourceMap<App> = App extends { _routes: infer Routes }
43
- ? {
44
- [K in keyof Routes as K extends `/${infer Resource}`
45
- ? Resource extends `${string}/${string}` ? never : Resource
46
- : never
47
- ]: Routes[K] extends { get: { response: { 200: infer Res } } }
48
- ? Res extends { items: (infer Item)[] }
49
- ? Item
50
- : Res
42
+ type SuccessfulResponse<Response> = Response extends { 200: infer Payload }
43
+ ? Payload
44
+ : Response extends { '200': infer Payload }
45
+ ? Payload
46
+ : Response;
47
+
48
+ type ResourceItem<Response> = SuccessfulResponse<Response> extends infer Payload
49
+ ? Payload extends { items: readonly (infer Item)[] }
50
+ ? Item
51
+ : Payload extends { data: readonly (infer Item)[] }
52
+ ? Item
53
+ : Payload extends readonly (infer Item)[]
54
+ ? Item
51
55
  : never
52
- }
53
- : Record<string, never>;
56
+ : never;
57
+
58
+ type RouteResource<Route> = Route extends { get: { response: infer Response } }
59
+ ? ResourceItem<Response>
60
+ : never;
61
+
62
+ type InferEdenResourceMap<Routes> = {
63
+ [Resource in keyof Routes as Resource extends string
64
+ ? Routes[Resource] extends { get: { response: unknown } }
65
+ ? [RouteResource<Routes[Resource]>] extends [never] ? never : Resource
66
+ : never
67
+ : never
68
+ ]: RouteResource<Routes[Resource]>
69
+ };
70
+
71
+ type InferLegacyResourceMap<Routes> = {
72
+ [Path in keyof Routes as Path extends `/${infer Resource}`
73
+ ? Resource extends `${string}/${string}`
74
+ ? never
75
+ : [RouteResource<Routes[Path]>] extends [never] ? never : Resource
76
+ : never
77
+ ]: RouteResource<Routes[Path]>
78
+ };
79
+
80
+ export type InferResourceMap<App> = App extends { '~Routes': infer Routes }
81
+ ? InferEdenResourceMap<Routes>
82
+ : App extends { _routes: infer Routes }
83
+ ? InferLegacyResourceMap<Routes>
84
+ : Record<string, never>;