@svadmin/core 0.31.0 → 0.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,33 +3,34 @@
3
3
 
4
4
  import { useQueryClient } from '@tanstack/svelte-query';
5
5
  import type { LiveProvider, LiveEvent, LiveMode } from './live.svelte';
6
- import { getAuthProvider } from './context.svelte';
6
+ import { captureAdminContext } from './context.svelte';
7
+ import type { AdminContextAccessor } from './context.svelte';
7
8
  import { notify } from './notification.svelte';
8
9
 
9
10
  // ─── Auth Error Delegate ────────────────────────────────────────
10
- // Delegate auth errors (401/403) to authProvider.onError() — refine pattern
11
+ // Delegate auth errors to authProvider.onError() — refine pattern
11
12
 
12
- export async function checkError(error: unknown): Promise<void> {
13
+ export async function checkError(
14
+ error: unknown,
15
+ adminContext: AdminContextAccessor = captureAdminContext(),
16
+ ): Promise<void> {
13
17
  try {
14
- const authProvider = getAuthProvider({ optional: true });
18
+ const authProvider = adminContext.authProvider;
15
19
  if (authProvider?.onError) {
16
20
  const result = await authProvider.onError(error);
17
21
  if (!result) return;
18
22
  if (result.logout) {
19
- try { await authProvider.logout?.(); } catch { /* logout failure should not block redirect */ }
20
- const { navigate } = await import('./router');
21
- navigate(result.redirectTo ?? '/login');
23
+ await authProvider.logout?.();
24
+ await adminContext.navigate(result.redirectTo ?? '/login');
22
25
  } else if (result.redirectTo) {
23
- const { navigate } = await import('./router');
24
- navigate(result.redirectTo);
26
+ await adminContext.navigate(result.redirectTo);
25
27
  }
26
28
  return;
27
29
  }
28
30
  if (error && typeof error === 'object' && 'statusCode' in error) {
29
31
  const status = (error as { statusCode: number }).statusCode;
30
- if (status === 401 || status === 403) {
31
- const { navigate } = await import('./router');
32
- navigate('/login');
32
+ if (status === 401) {
33
+ await adminContext.navigate('/login');
33
34
  }
34
35
  }
35
36
  } catch { /* auth check failed silently */ }
@@ -1,4 +1,3 @@
1
- /* eslint-disable svelte/prefer-svelte-reactivity */
2
1
  // @svadmin/core — Core Data and Hook APIs (Modularized for v0.2.29+)
3
2
 
4
3
  // ─── Re-exports from modular hook files ─────────────────────────────
@@ -24,7 +23,7 @@ export type { OvertimeResult, OvertimeOptions, NotificationConfig } from './hook
24
23
 
25
24
  import { createQuery, createInfiniteQuery, createMutation, useQueryClient } from '@tanstack/svelte-query';
26
25
  import { getAdminOptions } from './options.svelte';
27
- import { getDataProviderForResource, getDataProvider, getLiveProvider } from './context.svelte';
26
+ import { captureAdminContext } from './context.svelte';
28
27
  import { useParsed } from './useParsed.svelte';
29
28
  import { createOvertimeTracker, createLiveSubscription, fireSuccessNotification, fireErrorNotification, checkError } from './hook-utils.svelte';
30
29
  import { invalidateByScopes, publishLiveEvent } from './mutation-hooks.svelte';
@@ -51,12 +50,13 @@ export interface UseInfiniteListOptions<_TData extends BaseRecord = BaseRecord,
51
50
  }
52
51
 
53
52
  export function useInfiniteList<TData extends BaseRecord = BaseRecord, TError = HttpError>(options: UseInfiniteListOptions<TData, TError> = {}) {
53
+ const adminContext = captureAdminContext();
54
54
  const parsed = useParsed();
55
55
  const adminOptions = getAdminOptions();
56
56
 
57
57
  const query = createInfiniteQuery<{ data: TData[]; total: number }, TError>(() => {
58
58
  const resource = options.resource ?? parsed.resource ?? '';
59
- const provider = getDataProviderForResource(resource, options.dataProviderName);
59
+ const provider = adminContext.getDataProviderForResource(resource, options.dataProviderName);
60
60
  return {
61
61
  queryKey: [options.dataProviderName, resource, 'infiniteList', options.pagination?.pageSize, options.sorters, options.filters, options.meta],
62
62
  queryFn: async ({ pageParam = 1 }) => {
@@ -84,7 +84,7 @@ export function useInfiniteList<TData extends BaseRecord = BaseRecord, TError =
84
84
 
85
85
  createLiveSubscription((): LiveSubscriptionParams => ({
86
86
  resource: options.resource ?? parsed.resource ?? '',
87
- liveProvider: getLiveProvider(),
87
+ liveProvider: adminContext.liveProvider,
88
88
  liveMode: options.liveMode ?? adminOptions.liveMode,
89
89
  onLiveEvent: (e: LiveEvent) => {
90
90
  options.onLiveEvent?.(e);
@@ -102,7 +102,7 @@ export function useInfiniteList<TData extends BaseRecord = BaseRecord, TError =
102
102
  fireSuccessNotification(options.successNotification, '', query.data, undefined, options.resource ?? parsed.resource ?? '');
103
103
  } else if (query.isError && query.errorUpdatedAt > lastErrorAt) {
104
104
  lastErrorAt = query.errorUpdatedAt;
105
- checkError(query.error);
105
+ checkError(query.error, adminContext);
106
106
  fireErrorNotification(options.errorNotification, 'Fetch failed', query.error, options.resource ?? parsed.resource ?? '');
107
107
  }
108
108
  });
@@ -133,6 +133,7 @@ export interface UseSelectOptions<TData extends BaseRecord = BaseRecord, _TOptio
133
133
  }
134
134
 
135
135
  export function useSelect<TData extends BaseRecord = BaseRecord, TOption = { label: string; value: string | number }>(options: UseSelectOptions<TData, TOption>) {
136
+ const adminContext = captureAdminContext();
136
137
  const { resource, optionLabel = 'title', optionValue = 'id', sorters, filters, pagination, meta, dataProviderName, onSearch, debounce: debounceMs = 300 } = options;
137
138
  const adminOptions = getAdminOptions();
138
139
 
@@ -149,7 +150,7 @@ export function useSelect<TData extends BaseRecord = BaseRecord, TOption = { lab
149
150
  const effectivePageSize = options.fetchSize ?? pagination?.pageSize ?? 999;
150
151
 
151
152
  const query = createQuery<{ data: TData[]; total: number }>(() => {
152
- const provider = getDataProviderForResource(resource, dataProviderName);
153
+ const provider = adminContext.getDataProviderForResource(resource, dataProviderName);
153
154
  return {
154
155
  queryKey: [dataProviderName, resource, 'select', allFilters, sorters, pagination, meta],
155
156
  queryFn: () => provider.getList<TData>({ resource, sorters, filters: allFilters, pagination: { current: 1, pageSize: effectivePageSize }, meta }),
@@ -162,7 +163,7 @@ export function useSelect<TData extends BaseRecord = BaseRecord, TOption = { lab
162
163
  const defaultValueIds = options.defaultValue ?? [];
163
164
  const defaultValueQuery = defaultValueIds.length > 0
164
165
  ? createQuery<{ data: TData[] }>(() => {
165
- const provider = getDataProviderForResource(resource, dataProviderName);
166
+ const provider = adminContext.getDataProviderForResource(resource, dataProviderName);
166
167
  return {
167
168
  queryKey: [dataProviderName, resource, 'select-defaults', defaultValueIds],
168
169
  queryFn: async () => {
@@ -205,7 +206,7 @@ export function useSelect<TData extends BaseRecord = BaseRecord, TOption = { lab
205
206
  fireSuccessNotification(options.successNotification, '', query.data, undefined, resource);
206
207
  } else if (query.isError && query.errorUpdatedAt > lastErrorAt) {
207
208
  lastErrorAt = query.errorUpdatedAt;
208
- checkError(query.error);
209
+ checkError(query.error, adminContext);
209
210
  fireErrorNotification(options.errorNotification, 'Fetch failed', query.error, resource);
210
211
  }
211
212
  });
@@ -243,10 +244,11 @@ export interface UseCustomOptions<_TData = unknown, _TError = HttpError> {
243
244
  }
244
245
 
245
246
  export function useCustom<TData = unknown, TError = HttpError>(options: UseCustomOptions<TData, TError>) {
247
+ const adminContext = captureAdminContext();
246
248
  const adminOptions = getAdminOptions();
247
249
 
248
250
  const query = createQuery<{ data: TData }, TError>(() => {
249
- const provider = getDataProvider(options.dataProviderName);
251
+ const provider = adminContext.getDataProvider(options.dataProviderName);
250
252
  return {
251
253
  queryKey: [options.dataProviderName, 'custom', options.url, options.method, options.config, options.meta],
252
254
  queryFn: async () => {
@@ -277,7 +279,7 @@ export function useCustom<TData = unknown, TError = HttpError>(options: UseCusto
277
279
  fireSuccessNotification(options.successNotification, '', query.data);
278
280
  } else if (query.isError && query.errorUpdatedAt > lastErrorAt) {
279
281
  lastErrorAt = query.errorUpdatedAt;
280
- checkError(query.error);
282
+ checkError(query.error, adminContext);
281
283
  fireErrorNotification(options.errorNotification, 'Custom request failed', query.error, '');
282
284
  }
283
285
  });
@@ -288,11 +290,12 @@ export function useCustom<TData = unknown, TError = HttpError>(options: UseCusto
288
290
  // ─── useCustomMutation ──────────────────────────────────────────────
289
291
 
290
292
  export function useCustomMutation<TData = unknown, TError = HttpError, TVariables = unknown>(dataProviderName?: string) {
293
+ const adminContext = captureAdminContext();
291
294
  const queryClient = useQueryClient();
292
295
 
293
296
  const mutation = createMutation<{ data: TData }, TError, { url: string; method: 'get' | 'post' | 'put' | 'patch' | 'delete'; values?: TVariables; query?: Record<string, unknown>; headers?: Record<string, string>; sorters?: Sort[]; filters?: Filter[]; meta?: Record<string, unknown>; invalidates?: string[] | false; resource?: string }>(() => ({
294
297
  mutationFn: async (params) => {
295
- const provider = getDataProvider(dataProviderName);
298
+ const provider = adminContext.getDataProvider(dataProviderName);
296
299
  if (!provider.custom) throw new Error('DataProvider does not support custom method');
297
300
  return provider.custom<TData>({ url: params.url, method: params.method, payload: params.values, query: params.query, headers: params.headers, sorters: params.sorters, filters: params.filters, meta: params.meta });
298
301
  },
@@ -302,7 +305,7 @@ export function useCustomMutation<TData = unknown, TError = HttpError, TVariable
302
305
  }
303
306
  },
304
307
  onError: (error) => {
305
- checkError(error);
308
+ checkError(error, adminContext);
306
309
  },
307
310
  }));
308
311
 
@@ -312,6 +315,7 @@ export function useCustomMutation<TData = unknown, TError = HttpError, TVariable
312
315
  // ─── useCreateMany / useUpdateMany / useDeleteMany ──────────────────
313
316
 
314
317
  export function useCreateMany<TData extends BaseRecord = BaseRecord, TError = HttpError, TVariables = Record<string, unknown>>(options: { resource?: KnownResources; overtimeOptions?: OvertimeOptions } = {}) {
318
+ const adminContext = captureAdminContext();
315
319
  const parsed = useParsed();
316
320
  const resource = options.resource ?? parsed.resource ?? '';
317
321
  const adminOptions = getAdminOptions();
@@ -320,7 +324,7 @@ export function useCreateMany<TData extends BaseRecord = BaseRecord, TError = Ht
320
324
  const mutation = createMutation<{ data: TData[] }, TError, { resource?: KnownResources; variables: TVariables[]; meta?: Record<string, unknown>; dataProviderName?: string }>(() => ({
321
325
  mutationFn: async (params) => {
322
326
  const resName = params.resource ?? resource;
323
- const provider = getDataProviderForResource(resName, params.dataProviderName);
327
+ const provider = adminContext.getDataProviderForResource(resName, params.dataProviderName);
324
328
  if (provider.createMany) return await provider.createMany<TData, TVariables>({ resource: resName, variables: params.variables, meta: params.meta });
325
329
  const results = await Promise.all(params.variables.map(v => provider.create<TData, TVariables>({ resource: resName, variables: v, meta: params.meta })));
326
330
  return { data: results.map(r => r.data) };
@@ -329,10 +333,10 @@ export function useCreateMany<TData extends BaseRecord = BaseRecord, TError = Ht
329
333
  const resName = params.resource ?? resource;
330
334
  fireSuccessNotification(undefined, 'Created successfully', data.data, params.variables, resName);
331
335
  audit({ action: 'create', resource: resName });
332
- publishLiveEvent(resName, 'created');
336
+ publishLiveEvent(resName, 'created', undefined, adminContext);
333
337
  },
334
338
  onError: (error, params) => {
335
- checkError(error);
339
+ checkError(error, adminContext);
336
340
  fireErrorNotification(undefined, 'Create many failed', error, params.resource ?? resource);
337
341
  },
338
342
  onSettled: (_d, _e, params) => {
@@ -345,6 +349,7 @@ export function useCreateMany<TData extends BaseRecord = BaseRecord, TError = Ht
345
349
  }
346
350
 
347
351
  export function useUpdateMany<TData extends BaseRecord = BaseRecord, TError = HttpError, TVariables = Record<string, unknown>>(options: { resource?: KnownResources; overtimeOptions?: OvertimeOptions } = {}) {
352
+ const adminContext = captureAdminContext();
348
353
  const parsed = useParsed();
349
354
  const resource = options.resource ?? parsed.resource ?? '';
350
355
  const adminOptions = getAdminOptions();
@@ -353,7 +358,7 @@ export function useUpdateMany<TData extends BaseRecord = BaseRecord, TError = Ht
353
358
  const mutation = createMutation<{ data: TData[] }, TError, { resource?: KnownResources; ids: (string | number)[]; variables: TVariables; meta?: Record<string, unknown>; dataProviderName?: string }>(() => ({
354
359
  mutationFn: async (params) => {
355
360
  const resName = params.resource ?? resource;
356
- const provider = getDataProviderForResource(resName, params.dataProviderName);
361
+ const provider = adminContext.getDataProviderForResource(resName, params.dataProviderName);
357
362
  if (provider.updateMany) return await provider.updateMany<TData, TVariables>({ resource: resName, ids: params.ids, variables: params.variables, meta: params.meta });
358
363
  const results = await Promise.all(params.ids.map(id => provider.update<TData, TVariables>({ resource: resName, id, variables: params.variables, meta: params.meta })));
359
364
  return { data: results.map(r => r.data) };
@@ -362,10 +367,10 @@ export function useUpdateMany<TData extends BaseRecord = BaseRecord, TError = Ht
362
367
  const resName = params.resource ?? resource;
363
368
  fireSuccessNotification(undefined, 'Updated successfully', data.data, params.variables, resName);
364
369
  audit({ action: 'update', resource: resName });
365
- publishLiveEvent(resName, 'updated', params.ids);
370
+ publishLiveEvent(resName, 'updated', params.ids, adminContext);
366
371
  },
367
372
  onError: (error, params) => {
368
- checkError(error);
373
+ checkError(error, adminContext);
369
374
  fireErrorNotification(undefined, 'Update many failed', error, params.resource ?? resource);
370
375
  },
371
376
  onSettled: (_d, _e, params) => {
@@ -381,6 +386,7 @@ export function useUpdateMany<TData extends BaseRecord = BaseRecord, TError = Ht
381
386
  }
382
387
 
383
388
  export function useDeleteMany<TData extends BaseRecord = BaseRecord, TError = HttpError>(options: { resource?: KnownResources; overtimeOptions?: OvertimeOptions } = {}) {
389
+ const adminContext = captureAdminContext();
384
390
  const parsed = useParsed();
385
391
  const resource = options.resource ?? parsed.resource ?? '';
386
392
  const adminOptions = getAdminOptions();
@@ -389,7 +395,7 @@ export function useDeleteMany<TData extends BaseRecord = BaseRecord, TError = Ht
389
395
  const mutation = createMutation<{ data: TData[] }, TError, { resource?: KnownResources; ids: (string | number)[]; meta?: Record<string, unknown>; dataProviderName?: string }>(() => ({
390
396
  mutationFn: async (params) => {
391
397
  const resName = params.resource ?? resource;
392
- const provider = getDataProviderForResource(resName, params.dataProviderName);
398
+ const provider = adminContext.getDataProviderForResource(resName, params.dataProviderName);
393
399
  if (provider.deleteMany) return await provider.deleteMany<TData>({ resource: resName, ids: params.ids, meta: params.meta });
394
400
  const results = await Promise.all(params.ids.map(id => provider.deleteOne<TData>({ resource: resName, id, meta: params.meta })));
395
401
  return { data: results.map(r => r.data) };
@@ -398,10 +404,10 @@ export function useDeleteMany<TData extends BaseRecord = BaseRecord, TError = Ht
398
404
  const resName = params.resource ?? resource;
399
405
  fireSuccessNotification(undefined, 'Deleted successfully', data.data, undefined, resName);
400
406
  audit({ action: 'delete', resource: resName });
401
- publishLiveEvent(resName, 'deleted', params.ids);
407
+ publishLiveEvent(resName, 'deleted', params.ids, adminContext);
402
408
  },
403
409
  onError: (error, params) => {
404
- checkError(error);
410
+ checkError(error, adminContext);
405
411
  fireErrorNotification(undefined, 'Delete many failed', error, params.resource ?? resource);
406
412
  },
407
413
  onSettled: (_d, _e, params) => {
@@ -454,7 +460,8 @@ export function useOvertime(options?: OvertimeOptions) {
454
460
  // ─── useDataProvider ────────────────────────────────────────────────
455
461
 
456
462
  export function useDataProvider(): (dataProviderName?: string) => DataProvider {
457
- return (name?: string) => getDataProvider(name);
463
+ const adminContext = captureAdminContext();
464
+ return (name?: string) => adminContext.getDataProvider(name);
458
465
  }
459
466
 
460
467
  // ─── useThemedLayoutContext ─────────────────────────────────────────
@@ -470,4 +477,3 @@ export function useThemedLayoutContext() {
470
477
  toggleSidebar() { _sidebarCollapsed = !_sidebarCollapsed; },
471
478
  };
472
479
  }
473
-
package/src/http.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  /**
2
- * HTTP fetch 工具 — 带 CSRF 防护、401 自动跳转、403 拦截的 fetch wrapper。
2
+ * HTTP fetch 工具 — 带 CSRF 防护、401 自动跳转和结构化错误语义。
3
3
  */
4
4
 
5
+ import { HttpError, type ValidationErrors } from './types';
6
+
5
7
  export interface FetchWithInterceptorOptions {
6
8
  /** 401 时跳转的登录页路径 */
7
9
  loginPath?: string;
8
- /** 403 时抛出的错误消息 */
10
+ /** 403 且服务端未返回消息时使用的错误消息 */
9
11
  forbiddenMessage?: string;
10
12
  /** 是否自动添加 X-Requested-With header (CSRF 防护) */
11
13
  csrfProtection?: boolean;
@@ -21,32 +23,103 @@ const DEFAULT_OPTIONS: Required<Omit<FetchWithInterceptorOptions, 'fetchImpl' |
21
23
  fetchImpl: FetchWithInterceptor;
22
24
  onUnauthorized: (loginPath: string, returnTo: string) => void;
23
25
  } = {
24
- loginPath: "/auth/login",
25
- forbiddenMessage: "Forbidden: 该操作已被安全策略拒绝",
26
+ loginPath: '/auth/login',
27
+ forbiddenMessage: 'Forbidden: 该操作已被安全策略拒绝',
26
28
  csrfProtection: true,
27
29
  get fetchImpl() {
28
- if (typeof globalThis !== "undefined" && typeof globalThis.fetch === "function") {
30
+ if (typeof globalThis !== 'undefined' && typeof globalThis.fetch === 'function') {
29
31
  return (url: string, init?: RequestInit) => globalThis.fetch(url, init);
30
32
  }
31
33
  return async () => {
32
- throw new Error("No fetch implementation found. Pass fetchImpl in options.");
34
+ throw new Error('No fetch implementation found. Pass fetchImpl in options.');
33
35
  };
34
36
  },
35
37
  onUnauthorized: (loginPath, returnTo) => {
36
- if (typeof window !== "undefined") {
38
+ if (typeof window !== 'undefined') {
37
39
  window.location.href = `${loginPath}?returnTo=${returnTo}`;
38
40
  }
39
41
  },
40
42
  };
41
43
 
44
+ function asRecord(value: unknown): Record<string, unknown> | null {
45
+ return typeof value === 'object' && value !== null
46
+ ? value as Record<string, unknown>
47
+ : null;
48
+ }
49
+
50
+ function asStructuredHttpError(error: unknown): HttpError | null {
51
+ const record = asRecord(error);
52
+ if (!record || typeof record.statusCode !== 'number') return null;
53
+ const message = typeof record.message === 'string' ? record.message : 'HTTP request failed';
54
+ const errors = asValidationErrors(record.errors);
55
+ return new HttpError(message, record.statusCode, errors, {
56
+ code: typeof record.code === 'string' ? record.code : undefined,
57
+ details: record.details,
58
+ body: record.body,
59
+ cause: error,
60
+ });
61
+ }
62
+
63
+ function firstString(...values: unknown[]): string | undefined {
64
+ return values.find((value): value is string => typeof value === 'string');
65
+ }
66
+
67
+ function asValidationErrors(value: unknown): ValidationErrors | undefined {
68
+ const record = asRecord(value);
69
+ if (!record) return undefined;
70
+
71
+ const valid = Object.values(record).every((entry) => (
72
+ typeof entry === 'string'
73
+ || (Array.isArray(entry) && entry.every((item) => typeof item === 'string'))
74
+ ));
75
+ return valid ? record as ValidationErrors : undefined;
76
+ }
77
+
78
+ async function readErrorBody(response: Response): Promise<unknown> {
79
+ const text = await response.text();
80
+ if (!text) return undefined;
81
+ try {
82
+ return JSON.parse(text) as unknown;
83
+ } catch {
84
+ return text;
85
+ }
86
+ }
87
+
88
+ async function createResponseError(
89
+ response: Response,
90
+ forbiddenMessage: string,
91
+ ): Promise<HttpError> {
92
+ const body = await readErrorBody(response);
93
+ const record = asRecord(body);
94
+ const nestedError = asRecord(record?.error);
95
+ const code = response.headers.get('X-Svadmin-Auth-Retry') === 'exhausted'
96
+ ? 'auth_retry_exhausted'
97
+ : firstString(
98
+ record?.code,
99
+ record?.error_code,
100
+ nestedError?.code,
101
+ nestedError?.error_code,
102
+ record?.error,
103
+ );
104
+ const details = record?.details ?? nestedError?.details;
105
+ const message = firstString(
106
+ record?.message,
107
+ nestedError?.message,
108
+ record?.error_description,
109
+ nestedError?.error_description,
110
+ body,
111
+ ) ?? (response.status === 401 ? 'Unauthorized' : forbiddenMessage);
112
+
113
+ return new HttpError(
114
+ message,
115
+ response.status,
116
+ asValidationErrors(record?.errors ?? nestedError?.errors),
117
+ { code, details, body },
118
+ );
119
+ }
120
+
42
121
  /**
43
- * 带拦截器的 fetch — 自动注入 CSRF header,401 跳转登录,403 抛异常。
44
- *
45
- * @example
46
- * ```ts
47
- * const fetcher = createFetchWithInterceptor({ loginPath: '/login' });
48
- * const res = await fetcher('/api/users');
49
- * ```
122
+ * 带拦截器的 fetch。Core 只保留错误语义;认证刷新和请求重放由认证提供方处理。
50
123
  */
51
124
  export function createFetchWithInterceptor(
52
125
  options: FetchWithInterceptorOptions = {},
@@ -55,34 +128,42 @@ export function createFetchWithInterceptor(
55
128
 
56
129
  return async (url: string, init: RequestInit = {}): Promise<Response> => {
57
130
  const headers = new Headers(init.headers || {});
58
-
59
131
  if (
60
- opts.csrfProtection &&
61
- init.method &&
62
- ["POST", "PUT", "DELETE", "PATCH"].includes(init.method.toUpperCase())
132
+ opts.csrfProtection
133
+ && init.method
134
+ && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(init.method.toUpperCase())
63
135
  ) {
64
- headers.set("X-Requested-With", "XMLHttpRequest");
136
+ headers.set('X-Requested-With', 'XMLHttpRequest');
65
137
  }
66
138
 
67
- const res = await opts.fetchImpl(url, { ...init, headers });
139
+ let response: Response;
140
+ try {
141
+ response = await opts.fetchImpl(url, { ...init, headers });
142
+ } catch (error) {
143
+ if (error instanceof HttpError) throw error;
144
+ const structuredError = asStructuredHttpError(error);
145
+ if (structuredError) throw structuredError;
146
+ throw error;
147
+ }
68
148
 
69
- if (!res.ok) {
70
- if (res.status === 401) {
71
- const returnTo = typeof window !== "undefined"
72
- ? encodeURIComponent(window.location.pathname)
73
- : "";
149
+ if (response.ok || (response.status !== 401 && response.status !== 403)) {
150
+ return response;
151
+ }
152
+
153
+ const httpError = await createResponseError(response, opts.forbiddenMessage);
154
+ if (response.status === 401) {
155
+ const returnTo = typeof window !== 'undefined'
156
+ ? encodeURIComponent(window.location.pathname)
157
+ : '';
158
+ try {
74
159
  opts.onUnauthorized(opts.loginPath, returnTo);
75
- throw new Error("Unauthorized");
76
- }
77
- if (res.status === 403) {
78
- throw new Error(opts.forbiddenMessage);
160
+ } catch {
161
+ // 重定向适配器失败不能覆盖服务端的结构化 401 错误。
79
162
  }
80
163
  }
81
- return res;
164
+ throw httpError;
82
165
  };
83
166
  }
84
167
 
85
- /**
86
- * 便捷全局实例 — 使用默认配置。
87
- */
168
+ /** 便捷全局实例 — 使用默认配置。 */
88
169
  export const fetchWithInterceptor = createFetchWithInterceptor();