webspresso 0.0.108 → 0.0.110
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/LICENSE +21 -0
- package/README.md +3 -3
- package/bin/commands/add-esbuild.js +7 -3
- package/bin/commands/add-tailwind.js +9 -5
- package/bin/commands/build.js +20 -4
- package/bin/commands/dev.js +57 -7
- package/bin/commands/doctor.js +42 -1
- package/bin/commands/favicon-generate.js +10 -3
- package/bin/commands/new.js +8 -3
- package/bin/commands/start.js +3 -1
- package/bin/utils/add-command.js +16 -0
- package/core/app.js +45 -3
- package/core/auth/authenticate.js +3 -3
- package/core/auth/hash.js +167 -43
- package/core/auth/index.js +13 -1
- package/core/auth/jwt.js +21 -2
- package/core/auth/manager.js +63 -14
- package/core/auth/middleware.js +4 -2
- package/core/auth/oauth.js +5 -1
- package/core/auth/tokens.js +7 -2
- package/core/auth/totp.js +42 -6
- package/core/auth/user-agent.js +2 -0
- package/core/client/adapter/datastar.js +1401 -0
- package/core/client/api.js +1337 -0
- package/core/client/controller.js +1706 -0
- package/core/client/debug.js +367 -0
- package/core/client/devtool.js +1134 -0
- package/core/client/forms.js +1164 -0
- package/core/client/fragments.js +894 -0
- package/core/client/index.d.ts +59 -0
- package/core/client/index.js +391 -0
- package/core/client/lite.js +175 -0
- package/core/client/server-fragments.js +868 -0
- package/core/client/storage.js +339 -0
- package/core/client/utils.js +281 -0
- package/core/compression/middleware.js +277 -26
- package/core/context/app-context.js +25 -3
- package/core/context/request.js +32 -4
- package/core/errors/middleware.js +14 -0
- package/core/events/dispatcher.js +33 -0
- package/core/forms/flash.js +136 -102
- package/core/health/middleware.js +36 -6
- package/core/http/pipeline.js +45 -6
- package/core/index.js +25 -7
- package/core/kernel/view.js +41 -10
- package/core/metrics/middleware.js +30 -6
- package/core/metrics/registry.js +13 -5
- package/core/orm/cache/fingerprint.js +32 -11
- package/core/orm/cache/layer.js +19 -2
- package/core/orm/hidden.js +145 -0
- package/core/orm/model.js +48 -2
- package/core/orm/query-builder.js +343 -6
- package/core/orm/query-filters.js +14 -2
- package/core/orm/repository.js +64 -13
- package/core/orm/schema-helpers.js +9 -6
- package/core/orm/transaction.js +50 -6
- package/core/orm/types.js +4 -0
- package/core/queue/adapters/database.js +184 -2
- package/core/queue/adapters/memory.js +153 -5
- package/core/queue/adapters/redis.js +59 -4
- package/core/queue/job.js +14 -1
- package/core/queue/manager.js +115 -0
- package/core/resilience/circuit-breaker.js +27 -8
- package/core/services/builtins/exchange.js +174 -7
- package/core/services/builtins/file-manager.js +36 -0
- package/core/services/builtins/mail.js +27 -0
- package/core/services/builtins/queue.js +144 -0
- package/core/services/builtins/security.js +118 -0
- package/core/services/builtins/webhook.js +110 -0
- package/core/services/executor.js +38 -16
- package/core/services/index.js +4 -1
- package/core/sse/stream.js +28 -5
- package/core/utils/timeout.js +8 -1
- package/core/validation/compiler.js +10 -7
- package/index.d.ts +1320 -74
- package/index.js +128 -8
- package/package.json +75 -27
- package/plugins/admin-panel/client/parts/08-record-list.js +717 -70
- package/plugins/admin-panel/index.js +19 -8
- package/plugins/csrf/index.js +3 -3
- package/plugins/data-exchange/export.js +214 -0
- package/plugins/data-exchange/import.js +48 -5
- package/plugins/data-exchange/index.js +70 -28
- package/plugins/data-exchange/preview.js +266 -0
- package/plugins/data-exchange/template.js +242 -0
- package/plugins/email/admin-component.js +783 -141
- package/plugins/email/api-handlers.js +51 -1
- package/plugins/email/index.js +5 -0
- package/plugins/email/service.js +48 -0
- package/plugins/file-manager/admin-component.js +1546 -70
- package/plugins/file-manager/api-handlers.js +166 -1
- package/plugins/file-manager/core.js +598 -8
- package/plugins/file-manager/index.js +8 -0
- package/plugins/index.js +7 -0
- package/plugins/mcp/auth.js +3 -3
- package/plugins/oauth/index.js +36 -3
- package/plugins/queue/admin-component.js +1092 -0
- package/plugins/queue/api-handlers.js +248 -0
- package/plugins/queue/index.js +69 -0
- package/plugins/rest-resources/index.js +7 -2
- package/plugins/security/admin-component.js +689 -0
- package/plugins/security/api-handlers.js +169 -0
- package/plugins/security/audit.js +153 -0
- package/plugins/security/bruteforce.js +244 -0
- package/plugins/security/firewall.js +459 -0
- package/plugins/security/headers.js +214 -0
- package/plugins/security/honeypot.js +234 -0
- package/plugins/security/incident-recorder.js +137 -0
- package/plugins/security/index.js +211 -0
- package/plugins/seo-checker/analyzer.js +20 -7
- package/plugins/upload/index.js +36 -0
- package/plugins/webhooks/admin-component.js +702 -175
- package/plugins/webhooks/api-handlers.js +75 -1
- package/plugins/webhooks/dispatcher.js +290 -11
- package/plugins/webhooks/index.js +37 -2
- package/plugins/webhooks/sender.js +56 -0
- package/plugins/xlsx/engine.js +19 -4
- package/src/api/api-loader.js +19 -1
- package/src/bundle/index.js +443 -17
- package/src/client-runtime/bootstrap-swup.js +9 -0
- package/src/client-runtime/mount.js +15 -20
- package/src/client-runtime/resolve.js +3 -2
- package/src/dev/api-typegen.js +1300 -0
- package/src/dev/banner.js +10 -1
- package/src/dev/colors.js +1 -1
- package/src/dev/index.js +7 -1
- package/src/dev/typegen.js +173 -17
- package/src/dev-reload/index.js +1 -1
- package/src/discovery/file-route-parser.js +17 -3
- package/src/discovery/scan-routes.js +4 -1
- package/src/file-router.js +253 -69
- package/src/helpers.js +46 -26
- package/src/njk-frontmatter.js +30 -4
- package/src/routing/cascading.js +26 -8
- package/src/routing/mount-discovered-routes.js +76 -0
- package/src/server.js +231 -70
- package/views/partials/webspresso-client-runtime.njk +1 -9
- package/.agents/ADMIN.md +0 -426
- package/.agents/AGENTS.md +0 -96
- package/.agents/ARCHITECTURE.md +0 -163
- package/.agents/AUTH.md +0 -581
- package/.agents/CACHE.md +0 -148
- package/.agents/CLI.md +0 -649
- package/.agents/COMPRESSION.md +0 -59
- package/.agents/CONTEXT.md +0 -152
- package/.agents/CONVENTIONS.md +0 -50
- package/.agents/EARLY_HINTS.md +0 -120
- package/.agents/EMAIL.md +0 -223
- package/.agents/ERRORS.md +0 -260
- package/.agents/EVENTS.md +0 -179
- package/.agents/FLAGS.md +0 -118
- package/.agents/HEALTH.md +0 -118
- package/.agents/LIMITS.md +0 -109
- package/.agents/LOGGING.md +0 -93
- package/.agents/MCP.md +0 -103
- package/.agents/METRICS.md +0 -127
- package/.agents/MIGRATION_JOBS.md +0 -95
- package/.agents/ORM.md +0 -700
- package/.agents/PLUGINS.md +0 -729
- package/.agents/QUEUE.md +0 -290
- package/.agents/RATE_LIMIT.md +0 -147
- package/.agents/REALTIME.md +0 -213
- package/.agents/RESILIENCE.md +0 -235
- package/.agents/ROUTING.md +0 -605
- package/.agents/SCALE_OUT.md +0 -109
- package/.agents/SECRETS.md +0 -117
- package/.agents/SERVICES.md +0 -341
- package/.agents/SHUTDOWN.md +0 -74
- package/.agents/STREAMING.md +0 -108
- package/.agents/WORKFLOW.md +0 -27
- package/.agents/internal/DOCUMENTATION_REVIEW.md +0 -485
- package/.agents/internal/TECHNICAL_REVIEW.md +0 -428
- package/.agents/skills/webspresso-admin-and-plugins/SKILL.md +0 -122
- package/.agents/skills/webspresso-framework/SKILL.md +0 -89
- package/.agents/skills/webspresso-orm-and-database/SKILL.md +0 -127
- package/.agents/skills/webspresso-routing-and-api/SKILL.md +0 -130
- package/.agents/skills/webspresso-services/SKILL.md +0 -134
- package/.cursorrules +0 -14
- package/AGENTS.md +0 -203
- package/CLAUDE.md +0 -54
- package/core/kernel/run-demo.js +0 -58
- package/plugins/admin-panel/styles.js +0 -9
- package/plugins/data-exchange/export-xlsx.js +0 -119
- package/src/client-runtime/bootstrap-alpine-swup.js +0 -34
|
@@ -0,0 +1,1337 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Webspresso Typed API Client & Request Coordinator
|
|
5
|
+
*
|
|
6
|
+
* Provides:
|
|
7
|
+
* - Explicit concurrency policies per request key: 'latest' | 'drop' | 'queue' | 'parallel'
|
|
8
|
+
* - Zero automatic retry on mutations (POST, PUT, PATCH, DELETE)
|
|
9
|
+
* - Structured error taxonomy: 'http' | 'network' | 'validation' | 'canceled'
|
|
10
|
+
* - Wire format modeling (Date -> ISO, undefined query stripping, 204/empty -> undefined, File/Blob/FormData)
|
|
11
|
+
* - Automatic CSRF token & same-origin credential attachment
|
|
12
|
+
* - Two-phase mutation -> invalidation dispatch where refresh failure never fails a succeeded mutation
|
|
13
|
+
* - Raw & streaming escape hatches (`api.raw`, `api.stream`)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const VALID_POLICIES = new Set(['latest', 'drop', 'queue', 'parallel']);
|
|
17
|
+
const MUTATION_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
18
|
+
const RESERVED_OPTION_KEYS = new Set([
|
|
19
|
+
'query',
|
|
20
|
+
'params',
|
|
21
|
+
'headers',
|
|
22
|
+
'signal',
|
|
23
|
+
'key',
|
|
24
|
+
'policy',
|
|
25
|
+
'concurrency',
|
|
26
|
+
'credentials',
|
|
27
|
+
'csrfToken',
|
|
28
|
+
'parseResponse',
|
|
29
|
+
'onRefreshError',
|
|
30
|
+
'timeout',
|
|
31
|
+
'retry',
|
|
32
|
+
'retryMutations',
|
|
33
|
+
'raw',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Structured Client Request Error
|
|
38
|
+
* Distinguishes 'http', 'network', 'validation', 'canceled', and 'timeout' failure modes.
|
|
39
|
+
*/
|
|
40
|
+
class ClientRequestError extends Error {
|
|
41
|
+
/**
|
|
42
|
+
* @param {string} message
|
|
43
|
+
* @param {object} details
|
|
44
|
+
* @param {'http'|'network'|'validation'|'canceled'|'timeout'} details.kind
|
|
45
|
+
* @param {number|null} [details.status]
|
|
46
|
+
* @param {string} [details.code]
|
|
47
|
+
* @param {string} [details.method]
|
|
48
|
+
* @param {string} [details.url]
|
|
49
|
+
* @param {string} [details.key]
|
|
50
|
+
* @param {Record<string, string>} [details.fieldErrors]
|
|
51
|
+
* @param {Record<string, string[]>} [details.fieldErrorList]
|
|
52
|
+
* @param {Array<{path: string, message: string, code?: string}>} [details.issues]
|
|
53
|
+
* @param {string|null} [details.formError]
|
|
54
|
+
* @param {unknown} [details.data]
|
|
55
|
+
* @param {boolean} [details.isDropped]
|
|
56
|
+
* @param {boolean} [details.isSuperseded]
|
|
57
|
+
* @param {boolean} [details.isTimedOut]
|
|
58
|
+
* @param {Error} [details.cause]
|
|
59
|
+
*/
|
|
60
|
+
constructor(message, details = {}) {
|
|
61
|
+
super(message);
|
|
62
|
+
this.name = 'ClientRequestError';
|
|
63
|
+
this.kind = details.kind || 'http';
|
|
64
|
+
this.status = details.status !== undefined ? details.status : null;
|
|
65
|
+
this.code = details.code || defaultCodeForKind(this.kind, this.status);
|
|
66
|
+
this.method = details.method || 'GET';
|
|
67
|
+
this.url = details.url || '';
|
|
68
|
+
this.key = details.key || '';
|
|
69
|
+
this.fieldErrors = details.fieldErrors || {};
|
|
70
|
+
this.fieldErrorList = details.fieldErrorList || {};
|
|
71
|
+
this.issues = Array.isArray(details.issues) ? details.issues : [];
|
|
72
|
+
this.formError = details.formError || null;
|
|
73
|
+
this.data = details.data !== undefined ? details.data : null;
|
|
74
|
+
this.isCanceled = this.kind === 'canceled';
|
|
75
|
+
this.isValidation = this.kind === 'validation';
|
|
76
|
+
this.isNetwork = this.kind === 'network';
|
|
77
|
+
this.isHttp = this.kind === 'http';
|
|
78
|
+
this.isTimedOut =
|
|
79
|
+
this.kind === 'timeout' ||
|
|
80
|
+
Boolean(details.isTimedOut) ||
|
|
81
|
+
this.code === 'REQUEST_TIMED_OUT' ||
|
|
82
|
+
this.status === 408;
|
|
83
|
+
this.isDropped = Boolean(details.isDropped);
|
|
84
|
+
this.isSuperseded = Boolean(details.isSuperseded);
|
|
85
|
+
this.serverRollbackGuaranteed = false;
|
|
86
|
+
if (this.isCanceled) {
|
|
87
|
+
this.note = 'Client request cancellation/abort does not roll back server-side execution.';
|
|
88
|
+
}
|
|
89
|
+
if (details.cause) {
|
|
90
|
+
this.cause = details.cause;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function defaultCodeForKind(kind, status) {
|
|
96
|
+
if (kind === 'timeout' || status === 408) return 'REQUEST_TIMED_OUT';
|
|
97
|
+
if (kind === 'validation') return 'VALIDATION_ERROR';
|
|
98
|
+
if (kind === 'canceled') return 'REQUEST_CANCELED';
|
|
99
|
+
if (kind === 'network') return 'NETWORK_ERROR';
|
|
100
|
+
if (status === 401) return 'UNAUTHORIZED';
|
|
101
|
+
if (status === 403) return 'FORBIDDEN';
|
|
102
|
+
if (status === 404) return 'NOT_FOUND';
|
|
103
|
+
if (status === 409) return 'CONFLICT';
|
|
104
|
+
if (status === 429) return 'RATE_LIMITED';
|
|
105
|
+
return 'HTTP_ERROR';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Discover CSRF token from DOM meta tag, hidden `_csrf` input, or cookies.
|
|
110
|
+
*
|
|
111
|
+
* @param {Document} [doc]
|
|
112
|
+
* @returns {string|null}
|
|
113
|
+
*/
|
|
114
|
+
function discoverCsrfToken(doc) {
|
|
115
|
+
const targetDoc = doc || (typeof document !== 'undefined' ? document : null);
|
|
116
|
+
if (!targetDoc) return null;
|
|
117
|
+
|
|
118
|
+
if (typeof targetDoc.querySelector === 'function') {
|
|
119
|
+
const meta =
|
|
120
|
+
targetDoc.querySelector('meta[name="csrf-token"]') ||
|
|
121
|
+
targetDoc.querySelector('meta[name="xsrf-token"]');
|
|
122
|
+
if (meta && typeof meta.getAttribute === 'function') {
|
|
123
|
+
const content = meta.getAttribute('content');
|
|
124
|
+
if (content) return content.trim();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const input = targetDoc.querySelector('input[name="_csrf"]');
|
|
128
|
+
if (input && input.value) {
|
|
129
|
+
return String(input.value).trim();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (typeof targetDoc.cookie === 'string' && targetDoc.cookie) {
|
|
134
|
+
const cookies = targetDoc.cookie.split(';');
|
|
135
|
+
for (const raw of cookies) {
|
|
136
|
+
const [rawName, ...rest] = raw.trim().split('=');
|
|
137
|
+
if (rawName === 'XSRF-TOKEN' || rawName === '_csrf' || rawName === 'csrf_token') {
|
|
138
|
+
try {
|
|
139
|
+
return decodeURIComponent(rest.join('='));
|
|
140
|
+
} catch (_e) {
|
|
141
|
+
return rest.join('=');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Interpolate `:param` or `[param]` segments in URL paths and append query parameters.
|
|
152
|
+
* Strips `undefined` values and formats `Date` instances as ISO strings.
|
|
153
|
+
*
|
|
154
|
+
* @param {string} urlTemplate
|
|
155
|
+
* @param {Record<string, unknown>} [params]
|
|
156
|
+
* @param {Record<string, unknown>} [query]
|
|
157
|
+
* @returns {string}
|
|
158
|
+
*/
|
|
159
|
+
function buildRequestUrl(urlTemplate, params, query) {
|
|
160
|
+
let resolvedPath = String(urlTemplate || '');
|
|
161
|
+
|
|
162
|
+
if (params && typeof params === 'object') {
|
|
163
|
+
for (const [key, val] of Object.entries(params)) {
|
|
164
|
+
if (val === undefined || val === null) continue;
|
|
165
|
+
const encoded = encodeURIComponent(val instanceof Date ? val.toISOString() : String(val));
|
|
166
|
+
resolvedPath = resolvedPath
|
|
167
|
+
.replace(new RegExp(`:${key}(?=/|$|\\?)`, 'g'), encoded)
|
|
168
|
+
.replace(new RegExp(`\\[${key}\\]`, 'g'), encoded);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!query || typeof query !== 'object') {
|
|
173
|
+
return resolvedPath;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const searchParams = new URLSearchParams();
|
|
177
|
+
for (const [key, rawVal] of Object.entries(query)) {
|
|
178
|
+
if (rawVal === undefined) continue;
|
|
179
|
+
if (Array.isArray(rawVal)) {
|
|
180
|
+
for (const item of rawVal) {
|
|
181
|
+
if (item === undefined) continue;
|
|
182
|
+
searchParams.append(key, item instanceof Date ? item.toISOString() : String(item));
|
|
183
|
+
}
|
|
184
|
+
} else if (rawVal instanceof Date) {
|
|
185
|
+
searchParams.append(key, rawVal.toISOString());
|
|
186
|
+
} else if (rawVal === null) {
|
|
187
|
+
searchParams.append(key, '');
|
|
188
|
+
} else if (typeof rawVal === 'object') {
|
|
189
|
+
searchParams.append(key, JSON.stringify(rawVal));
|
|
190
|
+
} else {
|
|
191
|
+
searchParams.append(key, String(rawVal));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const qs = searchParams.toString();
|
|
196
|
+
if (!qs) return resolvedPath;
|
|
197
|
+
return resolvedPath.includes('?') ? `${resolvedPath}&${qs}` : `${resolvedPath}?${qs}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Check whether an object contains binary File or Blob instances that require FormData transport.
|
|
202
|
+
*/
|
|
203
|
+
function containsBinaryValue(value) {
|
|
204
|
+
if (!value || typeof value !== 'object') return false;
|
|
205
|
+
if (typeof Blob !== 'undefined' && value instanceof Blob) return true;
|
|
206
|
+
if (typeof File !== 'undefined' && value instanceof File) return true;
|
|
207
|
+
if (typeof FormData !== 'undefined' && value instanceof FormData) return true;
|
|
208
|
+
if (Array.isArray(value)) {
|
|
209
|
+
return value.some(containsBinaryValue);
|
|
210
|
+
}
|
|
211
|
+
for (const v of Object.values(value)) {
|
|
212
|
+
if (containsBinaryValue(v)) return true;
|
|
213
|
+
}
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Convert a plain object containing File/Blob values into FormData.
|
|
219
|
+
*/
|
|
220
|
+
function objectToFormData(obj) {
|
|
221
|
+
const fd = new FormData();
|
|
222
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
223
|
+
if (val === undefined) continue;
|
|
224
|
+
if (Array.isArray(val)) {
|
|
225
|
+
for (const item of val) {
|
|
226
|
+
if (item === undefined) continue;
|
|
227
|
+
if (typeof Blob !== 'undefined' && item instanceof Blob) {
|
|
228
|
+
fd.append(key, item);
|
|
229
|
+
} else if (item instanceof Date) {
|
|
230
|
+
fd.append(key, item.toISOString());
|
|
231
|
+
} else if (item !== null) {
|
|
232
|
+
fd.append(key, String(item));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
} else if (typeof Blob !== 'undefined' && val instanceof Blob) {
|
|
236
|
+
fd.append(key, val);
|
|
237
|
+
} else if (val instanceof Date) {
|
|
238
|
+
fd.append(key, val.toISOString());
|
|
239
|
+
} else if (val === null) {
|
|
240
|
+
fd.append(key, '');
|
|
241
|
+
} else if (typeof val === 'object') {
|
|
242
|
+
fd.append(key, JSON.stringify(val));
|
|
243
|
+
} else {
|
|
244
|
+
fd.append(key, String(val));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return fd;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Serialize JSON payload while converting Date objects to ISO strings.
|
|
252
|
+
*/
|
|
253
|
+
function serializeRequestBody(body, headers) {
|
|
254
|
+
if (body === undefined || body === null) {
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
if (typeof FormData !== 'undefined' && body instanceof FormData) {
|
|
258
|
+
return body;
|
|
259
|
+
}
|
|
260
|
+
if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) {
|
|
261
|
+
if (!headers['Content-Type'] && !headers['content-type']) {
|
|
262
|
+
headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
|
|
263
|
+
}
|
|
264
|
+
return body.toString();
|
|
265
|
+
}
|
|
266
|
+
if (typeof Blob !== 'undefined' && body instanceof Blob) {
|
|
267
|
+
return body;
|
|
268
|
+
}
|
|
269
|
+
if (typeof body === 'string') {
|
|
270
|
+
if (!headers['Content-Type'] && !headers['content-type']) {
|
|
271
|
+
headers['Content-Type'] = 'text/plain;charset=UTF-8';
|
|
272
|
+
}
|
|
273
|
+
return body;
|
|
274
|
+
}
|
|
275
|
+
if (containsBinaryValue(body) && typeof FormData !== 'undefined') {
|
|
276
|
+
return objectToFormData(body);
|
|
277
|
+
}
|
|
278
|
+
if (!headers['Content-Type'] && !headers['content-type']) {
|
|
279
|
+
headers['Content-Type'] = 'application/json';
|
|
280
|
+
}
|
|
281
|
+
return JSON.stringify(body);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Extract structured validation field errors from Webspresso / Zod error payloads.
|
|
286
|
+
*
|
|
287
|
+
* @param {unknown} payload
|
|
288
|
+
* @returns {{ fieldErrors: Record<string, string>, fieldErrorList: Record<string, string[]>, issues: Array<{path: string, message: string, code?: string}>, formError: string|null }}
|
|
289
|
+
*/
|
|
290
|
+
function extractValidationErrors(payload) {
|
|
291
|
+
const fieldErrors = {};
|
|
292
|
+
const fieldErrorList = {};
|
|
293
|
+
const issues = [];
|
|
294
|
+
let formError = null;
|
|
295
|
+
|
|
296
|
+
if (!payload || typeof payload !== 'object') {
|
|
297
|
+
return { fieldErrors, fieldErrorList, issues, formError };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const errObj = payload.error && typeof payload.error === 'object' ? payload.error : payload;
|
|
301
|
+
|
|
302
|
+
if (typeof errObj.formError === 'string' && errObj.formError) {
|
|
303
|
+
formError = errObj.formError;
|
|
304
|
+
} else if (typeof payload.formError === 'string' && payload.formError) {
|
|
305
|
+
formError = payload.formError;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// 1. Direct fieldErrors map (e.g. { title: 'Required' } or { title: ['Required'] })
|
|
309
|
+
const rawFieldErrors = errObj.fieldErrors || payload.fieldErrors || errObj.fields || null;
|
|
310
|
+
if (rawFieldErrors && typeof rawFieldErrors === 'object' && !Array.isArray(rawFieldErrors)) {
|
|
311
|
+
for (const [field, msgOrArr] of Object.entries(rawFieldErrors)) {
|
|
312
|
+
const list = Array.isArray(msgOrArr) ? msgOrArr.map(String) : [String(msgOrArr)];
|
|
313
|
+
if (list.length > 0) {
|
|
314
|
+
fieldErrors[field] = list[0];
|
|
315
|
+
fieldErrorList[field] = list;
|
|
316
|
+
issues.push({ path: field, message: list[0] });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// 2. Zod / Webspresso `issues` or `details` array
|
|
322
|
+
const rawIssues =
|
|
323
|
+
(Array.isArray(errObj.issues) && errObj.issues) ||
|
|
324
|
+
(Array.isArray(payload.issues) && payload.issues) ||
|
|
325
|
+
(Array.isArray(errObj.details) && errObj.details) ||
|
|
326
|
+
(Array.isArray(payload.details) && payload.details) ||
|
|
327
|
+
(Array.isArray(errObj.errors) && errObj.errors) ||
|
|
328
|
+
[];
|
|
329
|
+
|
|
330
|
+
for (const issue of rawIssues) {
|
|
331
|
+
if (!issue || typeof issue !== 'object') continue;
|
|
332
|
+
let rawPath = '';
|
|
333
|
+
if (Array.isArray(issue.path)) {
|
|
334
|
+
// Strip leading 'body' / 'query' / 'params' prefix from route validation paths
|
|
335
|
+
const cleanParts =
|
|
336
|
+
issue.path.length > 1 &&
|
|
337
|
+
(issue.path[0] === 'body' || issue.path[0] === 'query' || issue.path[0] === 'params')
|
|
338
|
+
? issue.path.slice(1)
|
|
339
|
+
: issue.path;
|
|
340
|
+
rawPath = cleanParts.join('.');
|
|
341
|
+
} else if (typeof issue.path === 'string') {
|
|
342
|
+
rawPath = issue.path.replace(/^(body|query|params)\./, '');
|
|
343
|
+
} else if (typeof issue.field === 'string') {
|
|
344
|
+
rawPath = issue.field.replace(/^(body|query|params)\./, '');
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const message = issue.message ? String(issue.message) : 'Invalid value';
|
|
348
|
+
issues.push({ path: rawPath, message, code: issue.code });
|
|
349
|
+
|
|
350
|
+
if (rawPath) {
|
|
351
|
+
if (!fieldErrors[rawPath]) {
|
|
352
|
+
fieldErrors[rawPath] = message;
|
|
353
|
+
}
|
|
354
|
+
if (!fieldErrorList[rawPath]) {
|
|
355
|
+
fieldErrorList[rawPath] = [];
|
|
356
|
+
}
|
|
357
|
+
if (!fieldErrorList[rawPath].includes(message)) {
|
|
358
|
+
fieldErrorList[rawPath].push(message);
|
|
359
|
+
}
|
|
360
|
+
} else if (!formError) {
|
|
361
|
+
formError = message;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (!formError && typeof errObj.message === 'string' && Object.keys(fieldErrors).length === 0) {
|
|
366
|
+
formError = errObj.message;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return { fieldErrors, fieldErrorList, issues, formError };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Separate invalidation infrastructure metadata from application data.
|
|
374
|
+
* Supports:
|
|
375
|
+
* - `X-Webspresso-Invalidate` response header
|
|
376
|
+
* - `{ data, invalidate }` mutation result envelope
|
|
377
|
+
*/
|
|
378
|
+
function unwrapApiResultAndInvalidate(parsedBody, responseHeaders) {
|
|
379
|
+
const headerTags = [];
|
|
380
|
+
if (responseHeaders && typeof responseHeaders.get === 'function') {
|
|
381
|
+
const rawHeader = responseHeaders.get('x-webspresso-invalidate');
|
|
382
|
+
if (rawHeader) {
|
|
383
|
+
for (const tag of rawHeader.split(',')) {
|
|
384
|
+
const trimmed = tag.trim();
|
|
385
|
+
if (trimmed) headerTags.push(trimmed);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
let data = parsedBody;
|
|
391
|
+
const bodyTags = [];
|
|
392
|
+
|
|
393
|
+
if (
|
|
394
|
+
parsedBody &&
|
|
395
|
+
typeof parsedBody === 'object' &&
|
|
396
|
+
!Array.isArray(parsedBody) &&
|
|
397
|
+
Array.isArray(parsedBody.invalidate) &&
|
|
398
|
+
'data' in parsedBody
|
|
399
|
+
) {
|
|
400
|
+
for (const tag of parsedBody.invalidate) {
|
|
401
|
+
if (typeof tag === 'string' && tag.trim()) {
|
|
402
|
+
bodyTags.push(tag.trim());
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
data = parsedBody.data;
|
|
406
|
+
// Provide non-enumerable `.data` self-reference on object payloads so both
|
|
407
|
+
// `result.id` and `result.data.id` work seamlessly.
|
|
408
|
+
if (data && typeof data === 'object' && !('data' in data)) {
|
|
409
|
+
Object.defineProperty(data, 'data', {
|
|
410
|
+
value: data,
|
|
411
|
+
enumerable: false,
|
|
412
|
+
configurable: true,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const invalidate = Array.from(new Set([...headerTags, ...bodyTags]));
|
|
418
|
+
return { data, invalidate };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Normalize arguments for `api.get(url, inputOrOptions?, options?)`.
|
|
423
|
+
*/
|
|
424
|
+
function normalizeGetArgs(inputOrOptions, explicitOptions) {
|
|
425
|
+
if (explicitOptions && typeof explicitOptions === 'object') {
|
|
426
|
+
const inp = inputOrOptions && typeof inputOrOptions === 'object' ? inputOrOptions : {};
|
|
427
|
+
const query = inp.query && typeof inp.query === 'object' ? inp.query : inp;
|
|
428
|
+
const params = explicitOptions.params || inp.params;
|
|
429
|
+
return {
|
|
430
|
+
...explicitOptions,
|
|
431
|
+
query,
|
|
432
|
+
params,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (!inputOrOptions || typeof inputOrOptions !== 'object') {
|
|
437
|
+
return {};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const keys = Object.keys(inputOrOptions);
|
|
441
|
+
const hasExplicitWrapper =
|
|
442
|
+
'query' in inputOrOptions ||
|
|
443
|
+
'params' in inputOrOptions ||
|
|
444
|
+
'policy' in inputOrOptions ||
|
|
445
|
+
'concurrency' in inputOrOptions ||
|
|
446
|
+
'key' in inputOrOptions ||
|
|
447
|
+
'signal' in inputOrOptions ||
|
|
448
|
+
'headers' in inputOrOptions ||
|
|
449
|
+
'timeout' in inputOrOptions ||
|
|
450
|
+
'retry' in inputOrOptions ||
|
|
451
|
+
'retryMutations' in inputOrOptions;
|
|
452
|
+
|
|
453
|
+
if (hasExplicitWrapper && keys.every((k) => RESERVED_OPTION_KEYS.has(k))) {
|
|
454
|
+
return { ...inputOrOptions };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Mix of query keys and reserved options or pure query object
|
|
458
|
+
const query = {};
|
|
459
|
+
const opts = {};
|
|
460
|
+
for (const [k, v] of Object.entries(inputOrOptions)) {
|
|
461
|
+
if (RESERVED_OPTION_KEYS.has(k)) {
|
|
462
|
+
opts[k] = v;
|
|
463
|
+
} else {
|
|
464
|
+
query[k] = v;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (opts.query && typeof opts.query === 'object') {
|
|
468
|
+
Object.assign(query, opts.query);
|
|
469
|
+
}
|
|
470
|
+
opts.query = query;
|
|
471
|
+
return opts;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Normalize retry options into an actionable execution descriptor.
|
|
476
|
+
* Never allows retries on mutations unless `retryMutations: true` is explicitly opted into.
|
|
477
|
+
*
|
|
478
|
+
* @param {boolean|number|object} [retryOpt]
|
|
479
|
+
* @param {string} upperMethod
|
|
480
|
+
* @param {boolean} [retryMutations=false]
|
|
481
|
+
* @returns {object|null}
|
|
482
|
+
*/
|
|
483
|
+
function normalizeRetryConfig(retryOpt, upperMethod, retryMutations = false) {
|
|
484
|
+
if (!retryOpt) return null;
|
|
485
|
+
|
|
486
|
+
// Strict invariant: never automatically retry mutation methods (POST, PUT, PATCH, DELETE)
|
|
487
|
+
if (MUTATION_METHODS.has(upperMethod) && !retryMutations) {
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
let count = 0;
|
|
492
|
+
let delay = 300;
|
|
493
|
+
let maxDelay = 5000;
|
|
494
|
+
let backoff = 'exponential';
|
|
495
|
+
let statusCodes = new Set([408, 429, 502, 503, 504]);
|
|
496
|
+
let onRetry = null;
|
|
497
|
+
|
|
498
|
+
if (typeof retryOpt === 'number') {
|
|
499
|
+
count = Math.max(0, Math.floor(retryOpt));
|
|
500
|
+
} else if (retryOpt === true) {
|
|
501
|
+
count = 2;
|
|
502
|
+
} else if (typeof retryOpt === 'object' && retryOpt !== null) {
|
|
503
|
+
count = typeof retryOpt.count === 'number' ? Math.max(0, Math.floor(retryOpt.count)) : 2;
|
|
504
|
+
if (typeof retryOpt.delay === 'number') delay = Math.max(0, retryOpt.delay);
|
|
505
|
+
if (typeof retryOpt.maxDelay === 'number') maxDelay = Math.max(delay, retryOpt.maxDelay);
|
|
506
|
+
if (retryOpt.backoff === 'linear' || retryOpt.backoff === 'exponential') {
|
|
507
|
+
backoff = retryOpt.backoff;
|
|
508
|
+
}
|
|
509
|
+
if (Array.isArray(retryOpt.statusCodes)) {
|
|
510
|
+
statusCodes = new Set(retryOpt.statusCodes);
|
|
511
|
+
}
|
|
512
|
+
if (typeof retryOpt.onRetry === 'function') {
|
|
513
|
+
onRetry = retryOpt.onRetry;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (count <= 0) return null;
|
|
518
|
+
|
|
519
|
+
return {
|
|
520
|
+
count,
|
|
521
|
+
delay,
|
|
522
|
+
maxDelay,
|
|
523
|
+
backoff,
|
|
524
|
+
statusCodes,
|
|
525
|
+
onRetry,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Determine whether an error is transient and eligible for retry.
|
|
531
|
+
*/
|
|
532
|
+
function isRetryableError(err, statusCodes) {
|
|
533
|
+
if (!err) return false;
|
|
534
|
+
// Canceled, dropped, or superseded requests must never retry
|
|
535
|
+
if (err.isCanceled || err.isDropped || err.isSuperseded) return false;
|
|
536
|
+
// Network failures and timeouts are retryable
|
|
537
|
+
if (err.kind === 'network' || err.kind === 'timeout' || err.isTimedOut) return true;
|
|
538
|
+
// Transient HTTP status codes
|
|
539
|
+
if (typeof err.status === 'number' && statusCodes && statusCodes.has(err.status)) {
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
return false;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Calculate retry delay with jitter.
|
|
547
|
+
*/
|
|
548
|
+
function calculateRetryDelay(attempt, config) {
|
|
549
|
+
const base = config.delay;
|
|
550
|
+
const max = config.maxDelay;
|
|
551
|
+
let calculated;
|
|
552
|
+
if (config.backoff === 'linear') {
|
|
553
|
+
calculated = base * attempt;
|
|
554
|
+
} else {
|
|
555
|
+
calculated = base * Math.pow(2, attempt - 1);
|
|
556
|
+
}
|
|
557
|
+
const jitter = Math.random() * 50;
|
|
558
|
+
return Math.min(max, calculated + jitter);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Abortable delay helper that rejects immediately if any signal aborts during wait.
|
|
563
|
+
*/
|
|
564
|
+
function abortableSleep(ms, signals = []) {
|
|
565
|
+
return new Promise((resolve, reject) => {
|
|
566
|
+
for (const sig of signals) {
|
|
567
|
+
if (sig && sig.aborted) {
|
|
568
|
+
return reject(new Error('Sleep aborted'));
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
let timer = null;
|
|
573
|
+
const cleanup = () => {
|
|
574
|
+
if (timer) clearTimeout(timer);
|
|
575
|
+
for (const sig of signals) {
|
|
576
|
+
if (sig && typeof sig.removeEventListener === 'function') {
|
|
577
|
+
sig.removeEventListener('abort', onAbort);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
const onAbort = () => {
|
|
583
|
+
cleanup();
|
|
584
|
+
reject(new Error('Sleep aborted'));
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
for (const sig of signals) {
|
|
588
|
+
if (sig && typeof sig.addEventListener === 'function') {
|
|
589
|
+
sig.addEventListener('abort', onAbort, { once: true });
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
timer = setTimeout(() => {
|
|
594
|
+
cleanup();
|
|
595
|
+
resolve();
|
|
596
|
+
}, ms);
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Create a Typed API Client instance.
|
|
602
|
+
*
|
|
603
|
+
* @param {object} [config]
|
|
604
|
+
* @param {typeof fetch} [config.fetch] - Custom fetch implementation
|
|
605
|
+
* @param {string} [config.baseUrl=''] - Optional base URL prefix
|
|
606
|
+
* @param {() => string|null} [config.getCsrfToken] - Custom CSRF token provider
|
|
607
|
+
* @param {(tags: string[], meta: object) => Promise<unknown>|unknown} [config.onInvalidate] - Fragment invalidation callback
|
|
608
|
+
* @param {(err: Error, meta: object) => void} [config.onRefreshError] - Callback if background fragment refresh fails after mutation
|
|
609
|
+
* @param {AbortSignal} [config.lifecycleSignal] - Controller lifecycle signal (aborts in-flight requests on unmount)
|
|
610
|
+
* @param {() => boolean} [config.isConnected] - Controller connection check
|
|
611
|
+
* @param {object} [config.debug] - Client debugger instance
|
|
612
|
+
*/
|
|
613
|
+
function trimTrailingSlashes(str) {
|
|
614
|
+
if (!str) return '';
|
|
615
|
+
let end = str.length;
|
|
616
|
+
while (end > 0 && str.charCodeAt(end - 1) === 47) {
|
|
617
|
+
end--;
|
|
618
|
+
}
|
|
619
|
+
return end === str.length ? str : str.slice(0, end);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function createApiClient(config = {}) {
|
|
623
|
+
const fetchImpl =
|
|
624
|
+
config.fetch || (typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : null);
|
|
625
|
+
const baseUrl = config.baseUrl ? trimTrailingSlashes(String(config.baseUrl)) : '';
|
|
626
|
+
const lifecycleSignal = config.lifecycleSignal || null;
|
|
627
|
+
const isConnected = typeof config.isConnected === 'function' ? config.isConnected : () => true;
|
|
628
|
+
const debug = config.debug || null;
|
|
629
|
+
const onInvalidate = typeof config.onInvalidate === 'function' ? config.onInvalidate : null;
|
|
630
|
+
const onRefreshError = typeof config.onRefreshError === 'function' ? config.onRefreshError : null;
|
|
631
|
+
|
|
632
|
+
// Concurrency state maps per request key
|
|
633
|
+
const latestMap = new Map(); // key -> { seq: number, controller: AbortController, requestId: string }
|
|
634
|
+
const inFlightMap = new Map(); // key -> Set<string> (active requestIds)
|
|
635
|
+
const queueMap = new Map(); // key -> Promise<unknown>
|
|
636
|
+
let reqCounter = 0;
|
|
637
|
+
let lastRefreshPromise = null;
|
|
638
|
+
|
|
639
|
+
function resolvePolicy(policy) {
|
|
640
|
+
if (!policy) return 'parallel';
|
|
641
|
+
const normalized = String(policy).toLowerCase();
|
|
642
|
+
if (!VALID_POLICIES.has(normalized)) {
|
|
643
|
+
throw new Error(
|
|
644
|
+
`Invalid request concurrency policy "${policy}". Expected one of: latest, drop, queue, parallel.`
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
return normalized;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Core request executor supporting concurrency policies.
|
|
652
|
+
*/
|
|
653
|
+
async function executeRequest(method, urlTemplate, body, options = {}) {
|
|
654
|
+
if (!fetchImpl) {
|
|
655
|
+
throw new Error('Global fetch is not available in this environment.');
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const upperMethod = String(method || 'GET').toUpperCase();
|
|
659
|
+
const policy = resolvePolicy(options.policy || options.concurrency);
|
|
660
|
+
const requestUrl = buildRequestUrl(
|
|
661
|
+
baseUrl ? `${baseUrl}${urlTemplate}` : urlTemplate,
|
|
662
|
+
options.params,
|
|
663
|
+
options.query
|
|
664
|
+
);
|
|
665
|
+
const baseKeyPath = String(urlTemplate || '').split('?')[0];
|
|
666
|
+
const requestKey = options.key ? String(options.key) : `${upperMethod}:${baseKeyPath}`;
|
|
667
|
+
const requestId = `req_${++reqCounter}_${Date.now()}`;
|
|
668
|
+
|
|
669
|
+
// Check controller lifecycle before starting
|
|
670
|
+
if (!isConnected() || (lifecycleSignal && lifecycleSignal.aborted)) {
|
|
671
|
+
throw new ClientRequestError(`Request canceled: controller is disconnected (${upperMethod} ${requestUrl})`, {
|
|
672
|
+
kind: 'canceled',
|
|
673
|
+
method: upperMethod,
|
|
674
|
+
url: requestUrl,
|
|
675
|
+
key: requestKey,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Policy: 'drop' -> Do not start if another request with the same key is currently in flight
|
|
680
|
+
const activeSet = inFlightMap.get(requestKey);
|
|
681
|
+
if (policy === 'drop' && activeSet && activeSet.size > 0) {
|
|
682
|
+
if (debug) {
|
|
683
|
+
debug.trackDiscardedResponse({
|
|
684
|
+
source: 'api',
|
|
685
|
+
key: requestKey,
|
|
686
|
+
url: requestUrl,
|
|
687
|
+
reason: 'dropped-by-policy',
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
throw new ClientRequestError(
|
|
691
|
+
`Request dropped by 'drop' concurrency policy: "${requestKey}" is already in flight.`,
|
|
692
|
+
{
|
|
693
|
+
kind: 'canceled',
|
|
694
|
+
code: 'REQUEST_DROPPED',
|
|
695
|
+
method: upperMethod,
|
|
696
|
+
url: requestUrl,
|
|
697
|
+
key: requestKey,
|
|
698
|
+
isDropped: true,
|
|
699
|
+
}
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Policy: 'queue' -> Chain onto existing queue for this key
|
|
704
|
+
const retryConfig = normalizeRetryConfig(
|
|
705
|
+
options.retry,
|
|
706
|
+
upperMethod,
|
|
707
|
+
Boolean(options.retryMutations)
|
|
708
|
+
);
|
|
709
|
+
|
|
710
|
+
async function executeWithRetry(runFn) {
|
|
711
|
+
if (!retryConfig) {
|
|
712
|
+
return runFn(requestId);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
let attempt = 0;
|
|
716
|
+
while (true) {
|
|
717
|
+
attempt++;
|
|
718
|
+
const currentReqId = attempt === 1 ? requestId : `req_${++reqCounter}_${Date.now()}`;
|
|
719
|
+
try {
|
|
720
|
+
return await runFn(currentReqId);
|
|
721
|
+
} catch (err) {
|
|
722
|
+
const canRetry =
|
|
723
|
+
attempt <= retryConfig.count &&
|
|
724
|
+
isConnected() &&
|
|
725
|
+
(!lifecycleSignal || !lifecycleSignal.aborted) &&
|
|
726
|
+
(!options.signal || !options.signal.aborted) &&
|
|
727
|
+
isRetryableError(err, retryConfig.statusCodes);
|
|
728
|
+
|
|
729
|
+
if (!canRetry) {
|
|
730
|
+
throw err;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const delayMs = calculateRetryDelay(attempt, retryConfig);
|
|
734
|
+
|
|
735
|
+
if (typeof retryConfig.onRetry === 'function') {
|
|
736
|
+
try {
|
|
737
|
+
retryConfig.onRetry(err, attempt, delayMs);
|
|
738
|
+
} catch (_cbErr) {
|
|
739
|
+
// Ignore callback error
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (debug && typeof debug.trackRequestRetry === 'function') {
|
|
744
|
+
debug.trackRequestRetry({
|
|
745
|
+
requestId: currentReqId,
|
|
746
|
+
key: requestKey,
|
|
747
|
+
attempt,
|
|
748
|
+
delay: delayMs,
|
|
749
|
+
error: err,
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const activeSignals = [options.signal, lifecycleSignal].filter(Boolean);
|
|
754
|
+
try {
|
|
755
|
+
await abortableSleep(delayMs, activeSignals);
|
|
756
|
+
} catch (_sleepErr) {
|
|
757
|
+
throw new ClientRequestError(
|
|
758
|
+
`Request canceled during retry backoff (${upperMethod} ${requestUrl}).`,
|
|
759
|
+
{
|
|
760
|
+
kind: 'canceled',
|
|
761
|
+
code: 'REQUEST_CANCELED',
|
|
762
|
+
method: upperMethod,
|
|
763
|
+
url: requestUrl,
|
|
764
|
+
key: requestKey,
|
|
765
|
+
}
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if (policy === 'queue') {
|
|
773
|
+
const prevTail = queueMap.get(requestKey) || Promise.resolve();
|
|
774
|
+
const nextTask = prevTail
|
|
775
|
+
.catch(() => {})
|
|
776
|
+
.then(() =>
|
|
777
|
+
executeWithRetry((subReqId) =>
|
|
778
|
+
runSingleRequest(upperMethod, requestUrl, body, options, requestKey, subReqId, 'queue')
|
|
779
|
+
)
|
|
780
|
+
);
|
|
781
|
+
queueMap.set(
|
|
782
|
+
requestKey,
|
|
783
|
+
nextTask.catch(() => {})
|
|
784
|
+
);
|
|
785
|
+
return nextTask;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
return executeWithRetry((subReqId) =>
|
|
789
|
+
runSingleRequest(upperMethod, requestUrl, body, options, requestKey, subReqId, policy)
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
async function runSingleRequest(upperMethod, requestUrl, body, options, requestKey, requestId, policy) {
|
|
794
|
+
const abortController = new AbortController();
|
|
795
|
+
let superseded = false;
|
|
796
|
+
let timedOut = false;
|
|
797
|
+
let timeoutTimer = null;
|
|
798
|
+
let currentSeq = 1;
|
|
799
|
+
|
|
800
|
+
if (typeof options.timeout === 'number' && options.timeout > 0) {
|
|
801
|
+
timeoutTimer = setTimeout(() => {
|
|
802
|
+
timedOut = true;
|
|
803
|
+
abortController.abort();
|
|
804
|
+
}, options.timeout);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Policy: 'latest' -> Abort previous in-flight request for this key and increment sequence
|
|
808
|
+
if (policy === 'latest') {
|
|
809
|
+
const prev = latestMap.get(requestKey);
|
|
810
|
+
if (prev) {
|
|
811
|
+
currentSeq = prev.seq + 1;
|
|
812
|
+
prev.superseded = true;
|
|
813
|
+
try {
|
|
814
|
+
prev.controller.abort();
|
|
815
|
+
} catch (_e) {
|
|
816
|
+
// Ignore
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
latestMap.set(requestKey, {
|
|
820
|
+
seq: currentSeq,
|
|
821
|
+
controller: abortController,
|
|
822
|
+
requestId,
|
|
823
|
+
superseded: false,
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Link caller AbortSignal and controller lifecycleSignal
|
|
828
|
+
const onCallerAbort = () => abortController.abort();
|
|
829
|
+
if (options.signal) {
|
|
830
|
+
if (options.signal.aborted) {
|
|
831
|
+
abortController.abort();
|
|
832
|
+
} else {
|
|
833
|
+
options.signal.addEventListener('abort', onCallerAbort, { once: true });
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
if (lifecycleSignal) {
|
|
837
|
+
if (lifecycleSignal.aborted) {
|
|
838
|
+
abortController.abort();
|
|
839
|
+
} else {
|
|
840
|
+
lifecycleSignal.addEventListener('abort', onCallerAbort, { once: true });
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
if (!inFlightMap.has(requestKey)) {
|
|
845
|
+
inFlightMap.set(requestKey, new Set());
|
|
846
|
+
}
|
|
847
|
+
inFlightMap.get(requestKey).add(requestId);
|
|
848
|
+
|
|
849
|
+
if (debug) {
|
|
850
|
+
debug.trackRequestStart({
|
|
851
|
+
requestId,
|
|
852
|
+
key: requestKey,
|
|
853
|
+
method: upperMethod,
|
|
854
|
+
url: requestUrl,
|
|
855
|
+
policy,
|
|
856
|
+
payload: body,
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const headers = {
|
|
861
|
+
Accept: options.raw ? '*/*' : 'application/json',
|
|
862
|
+
'X-Requested-With': 'XMLHttpRequest',
|
|
863
|
+
...(options.headers || {}),
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
// Attach CSRF token on state-changing requests
|
|
867
|
+
if (MUTATION_METHODS.has(upperMethod)) {
|
|
868
|
+
const csrfToken =
|
|
869
|
+
options.csrfToken ||
|
|
870
|
+
(typeof config.getCsrfToken === 'function' ? config.getCsrfToken() : discoverCsrfToken());
|
|
871
|
+
if (csrfToken && !headers['X-CSRF-Token'] && !headers['x-csrf-token']) {
|
|
872
|
+
headers['X-CSRF-Token'] = csrfToken;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const serializedBody =
|
|
877
|
+
upperMethod === 'GET' || upperMethod === 'HEAD'
|
|
878
|
+
? undefined
|
|
879
|
+
: serializeRequestBody(body, headers);
|
|
880
|
+
|
|
881
|
+
try {
|
|
882
|
+
let response;
|
|
883
|
+
try {
|
|
884
|
+
response = await fetchImpl(requestUrl, {
|
|
885
|
+
method: upperMethod,
|
|
886
|
+
headers,
|
|
887
|
+
body: serializedBody,
|
|
888
|
+
credentials: options.credentials || 'same-origin',
|
|
889
|
+
signal: abortController.signal,
|
|
890
|
+
});
|
|
891
|
+
} catch (fetchErr) {
|
|
892
|
+
if (timeoutTimer) {
|
|
893
|
+
clearTimeout(timeoutTimer);
|
|
894
|
+
timeoutTimer = null;
|
|
895
|
+
}
|
|
896
|
+
const latestEntry = policy === 'latest' ? latestMap.get(requestKey) : null;
|
|
897
|
+
if (latestEntry && (latestEntry.seq !== currentSeq || latestEntry.superseded)) {
|
|
898
|
+
superseded = true;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
if (timedOut) {
|
|
902
|
+
if (debug) {
|
|
903
|
+
debug.trackRequestEnd(requestId, { status: 'failed', errorKind: 'timeout' });
|
|
904
|
+
}
|
|
905
|
+
throw new ClientRequestError(
|
|
906
|
+
`Request timed out after ${options.timeout}ms (${upperMethod} ${requestUrl}).`,
|
|
907
|
+
{
|
|
908
|
+
kind: 'timeout',
|
|
909
|
+
status: 408,
|
|
910
|
+
code: 'REQUEST_TIMED_OUT',
|
|
911
|
+
method: upperMethod,
|
|
912
|
+
url: requestUrl,
|
|
913
|
+
key: requestKey,
|
|
914
|
+
isTimedOut: true,
|
|
915
|
+
cause: fetchErr,
|
|
916
|
+
}
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const isAbort =
|
|
921
|
+
superseded ||
|
|
922
|
+
abortController.signal.aborted ||
|
|
923
|
+
(fetchErr && (fetchErr.name === 'AbortError' || fetchErr.code === 'ABORT_ERR'));
|
|
924
|
+
|
|
925
|
+
if (isAbort) {
|
|
926
|
+
if (debug) {
|
|
927
|
+
debug.trackDiscardedResponse({
|
|
928
|
+
source: 'api',
|
|
929
|
+
key: requestKey,
|
|
930
|
+
url: requestUrl,
|
|
931
|
+
reason: superseded ? 'superseded-by-latest' : 'aborted',
|
|
932
|
+
version: currentSeq,
|
|
933
|
+
currentVersion: latestEntry ? latestEntry.seq : currentSeq,
|
|
934
|
+
});
|
|
935
|
+
debug.trackRequestEnd(requestId, { status: 'canceled', errorKind: 'canceled' });
|
|
936
|
+
}
|
|
937
|
+
throw new ClientRequestError(
|
|
938
|
+
superseded
|
|
939
|
+
? `Request superseded by newer '${requestKey}' request.`
|
|
940
|
+
: `Request canceled (${upperMethod} ${requestUrl}).`,
|
|
941
|
+
{
|
|
942
|
+
kind: 'canceled',
|
|
943
|
+
code: superseded ? 'REQUEST_SUPERSEDED' : 'REQUEST_CANCELED',
|
|
944
|
+
method: upperMethod,
|
|
945
|
+
url: requestUrl,
|
|
946
|
+
key: requestKey,
|
|
947
|
+
isSuperseded: superseded,
|
|
948
|
+
cause: fetchErr,
|
|
949
|
+
}
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
if (debug) {
|
|
954
|
+
debug.trackRequestEnd(requestId, { status: 'failed', errorKind: 'network' });
|
|
955
|
+
}
|
|
956
|
+
throw new ClientRequestError(
|
|
957
|
+
fetchErr && fetchErr.message ? fetchErr.message : `Network error (${upperMethod} ${requestUrl})`,
|
|
958
|
+
{
|
|
959
|
+
kind: 'network',
|
|
960
|
+
method: upperMethod,
|
|
961
|
+
url: requestUrl,
|
|
962
|
+
key: requestKey,
|
|
963
|
+
cause: fetchErr,
|
|
964
|
+
}
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Check if superseded by a newer 'latest' request while awaiting response/body
|
|
969
|
+
const checkSupersededOrDisconnected = () => {
|
|
970
|
+
if (policy === 'latest') {
|
|
971
|
+
const latestEntry = latestMap.get(requestKey);
|
|
972
|
+
if (latestEntry && (latestEntry.seq !== currentSeq || latestEntry.superseded)) {
|
|
973
|
+
if (debug) {
|
|
974
|
+
debug.trackDiscardedResponse({
|
|
975
|
+
source: 'api',
|
|
976
|
+
key: requestKey,
|
|
977
|
+
url: requestUrl,
|
|
978
|
+
reason: 'superseded-by-latest',
|
|
979
|
+
version: currentSeq,
|
|
980
|
+
currentVersion: latestEntry.seq,
|
|
981
|
+
});
|
|
982
|
+
debug.trackRequestEnd(requestId, {
|
|
983
|
+
status: 'discarded',
|
|
984
|
+
httpStatus: response.status,
|
|
985
|
+
errorKind: 'canceled',
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
throw new ClientRequestError(`Stale response discarded for '${requestKey}'.`, {
|
|
989
|
+
kind: 'canceled',
|
|
990
|
+
code: 'REQUEST_SUPERSEDED',
|
|
991
|
+
status: response.status,
|
|
992
|
+
method: upperMethod,
|
|
993
|
+
url: requestUrl,
|
|
994
|
+
key: requestKey,
|
|
995
|
+
isSuperseded: true,
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
if (!isConnected()) {
|
|
1000
|
+
if (debug) {
|
|
1001
|
+
debug.trackDiscardedResponse({
|
|
1002
|
+
source: 'api',
|
|
1003
|
+
key: requestKey,
|
|
1004
|
+
url: requestUrl,
|
|
1005
|
+
reason: 'controller-disconnected',
|
|
1006
|
+
});
|
|
1007
|
+
debug.trackRequestEnd(requestId, {
|
|
1008
|
+
status: 'discarded',
|
|
1009
|
+
httpStatus: response.status,
|
|
1010
|
+
errorKind: 'canceled',
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
throw new ClientRequestError(
|
|
1014
|
+
`Response discarded: controller disconnected (${upperMethod} ${requestUrl}).`,
|
|
1015
|
+
{
|
|
1016
|
+
kind: 'canceled',
|
|
1017
|
+
code: 'CONTROLLER_DISCONNECTED',
|
|
1018
|
+
status: response.status,
|
|
1019
|
+
method: upperMethod,
|
|
1020
|
+
url: requestUrl,
|
|
1021
|
+
key: requestKey,
|
|
1022
|
+
}
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
};
|
|
1026
|
+
|
|
1027
|
+
checkSupersededOrDisconnected();
|
|
1028
|
+
|
|
1029
|
+
// Escape hatch: return raw Response without body consumption
|
|
1030
|
+
if (options.raw) {
|
|
1031
|
+
if (!response.ok) {
|
|
1032
|
+
throw new ClientRequestError(`HTTP ${response.status} (${upperMethod} ${requestUrl})`, {
|
|
1033
|
+
kind: 'http',
|
|
1034
|
+
status: response.status,
|
|
1035
|
+
method: upperMethod,
|
|
1036
|
+
url: requestUrl,
|
|
1037
|
+
key: requestKey,
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
if (debug) {
|
|
1041
|
+
debug.trackRequestEnd(requestId, { status: 'completed', httpStatus: response.status });
|
|
1042
|
+
}
|
|
1043
|
+
return response;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// Parse response body according to wire format
|
|
1047
|
+
let parsedBody;
|
|
1048
|
+
const status = response.status;
|
|
1049
|
+
const contentType =
|
|
1050
|
+
response.headers && typeof response.headers.get === 'function'
|
|
1051
|
+
? response.headers.get('content-type') || ''
|
|
1052
|
+
: '';
|
|
1053
|
+
|
|
1054
|
+
if (status === 204 || status === 205) {
|
|
1055
|
+
parsedBody = undefined;
|
|
1056
|
+
} else if (typeof response.text === 'function') {
|
|
1057
|
+
let rawText;
|
|
1058
|
+
try {
|
|
1059
|
+
rawText = await response.text();
|
|
1060
|
+
} catch (bodyErr) {
|
|
1061
|
+
if (timedOut) {
|
|
1062
|
+
throw new ClientRequestError(
|
|
1063
|
+
`Request timed out after ${options.timeout}ms (${upperMethod} ${requestUrl}).`,
|
|
1064
|
+
{
|
|
1065
|
+
kind: 'timeout',
|
|
1066
|
+
status: 408,
|
|
1067
|
+
code: 'REQUEST_TIMED_OUT',
|
|
1068
|
+
method: upperMethod,
|
|
1069
|
+
url: requestUrl,
|
|
1070
|
+
key: requestKey,
|
|
1071
|
+
isTimedOut: true,
|
|
1072
|
+
cause: bodyErr,
|
|
1073
|
+
}
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
throw bodyErr;
|
|
1077
|
+
}
|
|
1078
|
+
checkSupersededOrDisconnected();
|
|
1079
|
+
if (!rawText || !rawText.trim()) {
|
|
1080
|
+
parsedBody = undefined;
|
|
1081
|
+
} else if (contentType.includes('application/json') || rawText.trim().startsWith('{') || rawText.trim().startsWith('[')) {
|
|
1082
|
+
try {
|
|
1083
|
+
parsedBody = JSON.parse(rawText);
|
|
1084
|
+
} catch (_jsonErr) {
|
|
1085
|
+
parsedBody = rawText;
|
|
1086
|
+
}
|
|
1087
|
+
} else {
|
|
1088
|
+
parsedBody = rawText;
|
|
1089
|
+
}
|
|
1090
|
+
} else if (typeof response.json === 'function') {
|
|
1091
|
+
try {
|
|
1092
|
+
parsedBody = await response.json();
|
|
1093
|
+
} catch (bodyErr) {
|
|
1094
|
+
if (timedOut) {
|
|
1095
|
+
throw new ClientRequestError(
|
|
1096
|
+
`Request timed out after ${options.timeout}ms (${upperMethod} ${requestUrl}).`,
|
|
1097
|
+
{
|
|
1098
|
+
kind: 'timeout',
|
|
1099
|
+
status: 408,
|
|
1100
|
+
code: 'REQUEST_TIMED_OUT',
|
|
1101
|
+
method: upperMethod,
|
|
1102
|
+
url: requestUrl,
|
|
1103
|
+
key: requestKey,
|
|
1104
|
+
isTimedOut: true,
|
|
1105
|
+
cause: bodyErr,
|
|
1106
|
+
}
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
throw bodyErr;
|
|
1110
|
+
}
|
|
1111
|
+
checkSupersededOrDisconnected();
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// Handle non-2xx HTTP responses
|
|
1115
|
+
if (!response.ok) {
|
|
1116
|
+
const errPayload = parsedBody && typeof parsedBody === 'object' ? parsedBody : {};
|
|
1117
|
+
const errBlock = errPayload.error && typeof errPayload.error === 'object' ? errPayload.error : errPayload;
|
|
1118
|
+
const errCode = errBlock.code || errPayload.code || null;
|
|
1119
|
+
const { fieldErrors, fieldErrorList, issues, formError } = extractValidationErrors(parsedBody);
|
|
1120
|
+
|
|
1121
|
+
const isValidationStatus =
|
|
1122
|
+
status === 422 ||
|
|
1123
|
+
errCode === 'VALIDATION_ERROR' ||
|
|
1124
|
+
(status === 400 && (issues.length > 0 || Object.keys(fieldErrors).length > 0));
|
|
1125
|
+
|
|
1126
|
+
const errorKind = isValidationStatus ? 'validation' : 'http';
|
|
1127
|
+
const message =
|
|
1128
|
+
formError ||
|
|
1129
|
+
(typeof errBlock.message === 'string' && errBlock.message) ||
|
|
1130
|
+
(typeof errPayload.message === 'string' && errPayload.message) ||
|
|
1131
|
+
`HTTP ${status} (${upperMethod} ${requestUrl})`;
|
|
1132
|
+
|
|
1133
|
+
if (debug) {
|
|
1134
|
+
debug.trackRequestEnd(requestId, {
|
|
1135
|
+
status: 'failed',
|
|
1136
|
+
httpStatus: status,
|
|
1137
|
+
errorKind,
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
throw new ClientRequestError(message, {
|
|
1142
|
+
kind: errorKind,
|
|
1143
|
+
status,
|
|
1144
|
+
code: errCode || defaultCodeForKind(errorKind, status),
|
|
1145
|
+
method: upperMethod,
|
|
1146
|
+
url: requestUrl,
|
|
1147
|
+
key: requestKey,
|
|
1148
|
+
fieldErrors,
|
|
1149
|
+
fieldErrorList,
|
|
1150
|
+
issues,
|
|
1151
|
+
formError,
|
|
1152
|
+
data: parsedBody,
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// 2xx Success: unwrap data and check for fragment invalidations
|
|
1157
|
+
const { data, invalidate } = unwrapApiResultAndInvalidate(parsedBody, response.headers);
|
|
1158
|
+
|
|
1159
|
+
if (debug) {
|
|
1160
|
+
debug.trackRequestEnd(requestId, { status: 'completed', httpStatus: status });
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// Trigger two-phase fragment invalidation if tags are present.
|
|
1164
|
+
// IMPORTANT: Refresh failure MUST NOT reject or fail the completed mutation!
|
|
1165
|
+
if (invalidate.length > 0 && onInvalidate) {
|
|
1166
|
+
const chainId = debug
|
|
1167
|
+
? debug.startCausalChain({
|
|
1168
|
+
method: upperMethod,
|
|
1169
|
+
url: requestUrl,
|
|
1170
|
+
requestId,
|
|
1171
|
+
tags: invalidate,
|
|
1172
|
+
})
|
|
1173
|
+
: null;
|
|
1174
|
+
|
|
1175
|
+
const refreshTask = Promise.resolve()
|
|
1176
|
+
.then(() =>
|
|
1177
|
+
onInvalidate(invalidate, {
|
|
1178
|
+
method: upperMethod,
|
|
1179
|
+
url: requestUrl,
|
|
1180
|
+
requestId,
|
|
1181
|
+
chainId,
|
|
1182
|
+
})
|
|
1183
|
+
)
|
|
1184
|
+
.catch((refreshErr) => {
|
|
1185
|
+
const notifyErr =
|
|
1186
|
+
typeof options.onRefreshError === 'function' ? options.onRefreshError : onRefreshError;
|
|
1187
|
+
if (notifyErr) {
|
|
1188
|
+
try {
|
|
1189
|
+
notifyErr(refreshErr, { tags: invalidate, method: upperMethod, url: requestUrl, chainId });
|
|
1190
|
+
} catch (_e) {
|
|
1191
|
+
// Ignore
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
return { ok: false, error: refreshErr };
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
lastRefreshPromise = refreshTask;
|
|
1198
|
+
|
|
1199
|
+
if (data && typeof data === 'object') {
|
|
1200
|
+
Object.defineProperty(data, '__invalidate', {
|
|
1201
|
+
value: invalidate,
|
|
1202
|
+
enumerable: false,
|
|
1203
|
+
configurable: true,
|
|
1204
|
+
});
|
|
1205
|
+
Object.defineProperty(data, '__refreshPromise', {
|
|
1206
|
+
value: refreshTask,
|
|
1207
|
+
enumerable: false,
|
|
1208
|
+
configurable: true,
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
return data;
|
|
1214
|
+
} finally {
|
|
1215
|
+
if (timeoutTimer) {
|
|
1216
|
+
clearTimeout(timeoutTimer);
|
|
1217
|
+
timeoutTimer = null;
|
|
1218
|
+
}
|
|
1219
|
+
const set = inFlightMap.get(requestKey);
|
|
1220
|
+
if (set) {
|
|
1221
|
+
set.delete(requestId);
|
|
1222
|
+
if (set.size === 0) {
|
|
1223
|
+
inFlightMap.delete(requestKey);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
if (options.signal) {
|
|
1227
|
+
options.signal.removeEventListener('abort', onCallerAbort);
|
|
1228
|
+
}
|
|
1229
|
+
if (lifecycleSignal) {
|
|
1230
|
+
lifecycleSignal.removeEventListener('abort', onCallerAbort);
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
const client = {
|
|
1236
|
+
get(url, inputOrOptions, explicitOptions) {
|
|
1237
|
+
const opts = normalizeGetArgs(inputOrOptions, explicitOptions);
|
|
1238
|
+
return executeRequest('GET', url, undefined, opts);
|
|
1239
|
+
},
|
|
1240
|
+
|
|
1241
|
+
post(url, body, options = {}) {
|
|
1242
|
+
return executeRequest('POST', url, body, options);
|
|
1243
|
+
},
|
|
1244
|
+
|
|
1245
|
+
put(url, body, options = {}) {
|
|
1246
|
+
return executeRequest('PUT', url, body, options);
|
|
1247
|
+
},
|
|
1248
|
+
|
|
1249
|
+
patch(url, body, options = {}) {
|
|
1250
|
+
return executeRequest('PATCH', url, body, options);
|
|
1251
|
+
},
|
|
1252
|
+
|
|
1253
|
+
delete(url, bodyOrOptions, explicitOptions) {
|
|
1254
|
+
if (explicitOptions && typeof explicitOptions === 'object') {
|
|
1255
|
+
return executeRequest('DELETE', url, bodyOrOptions, explicitOptions);
|
|
1256
|
+
}
|
|
1257
|
+
if (
|
|
1258
|
+
bodyOrOptions &&
|
|
1259
|
+
typeof bodyOrOptions === 'object' &&
|
|
1260
|
+
Object.keys(bodyOrOptions).every((k) => RESERVED_OPTION_KEYS.has(k))
|
|
1261
|
+
) {
|
|
1262
|
+
return executeRequest('DELETE', url, undefined, bodyOrOptions);
|
|
1263
|
+
}
|
|
1264
|
+
return executeRequest('DELETE', url, bodyOrOptions, {});
|
|
1265
|
+
},
|
|
1266
|
+
|
|
1267
|
+
request(method, url, body, options = {}) {
|
|
1268
|
+
return executeRequest(method, url, body, options);
|
|
1269
|
+
},
|
|
1270
|
+
|
|
1271
|
+
/**
|
|
1272
|
+
* Escape hatch for arbitrary raw responses (blobs, downloads, custom headers).
|
|
1273
|
+
*/
|
|
1274
|
+
raw(url, options = {}) {
|
|
1275
|
+
const method = options.method || 'GET';
|
|
1276
|
+
return executeRequest(method, url, options.body, { ...options, raw: true });
|
|
1277
|
+
},
|
|
1278
|
+
|
|
1279
|
+
/**
|
|
1280
|
+
* Escape hatch for streaming endpoints (chunked text / NDJSON / SSE).
|
|
1281
|
+
* Yields decoded UTF-8 string chunks as an async generator.
|
|
1282
|
+
*/
|
|
1283
|
+
async *stream(url, options = {}) {
|
|
1284
|
+
const response = await client.raw(url, options);
|
|
1285
|
+
if (!response.body) {
|
|
1286
|
+
const text = await response.text();
|
|
1287
|
+
if (text) yield text;
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
const reader = response.body.getReader();
|
|
1291
|
+
const decoder = new TextDecoder();
|
|
1292
|
+
try {
|
|
1293
|
+
while (true) {
|
|
1294
|
+
const { done, value } = await reader.read();
|
|
1295
|
+
if (done) break;
|
|
1296
|
+
yield decoder.decode(value, { stream: true });
|
|
1297
|
+
}
|
|
1298
|
+
const tail = decoder.decode();
|
|
1299
|
+
if (tail) yield tail;
|
|
1300
|
+
} finally {
|
|
1301
|
+
try {
|
|
1302
|
+
reader.releaseLock();
|
|
1303
|
+
} catch (_e) {
|
|
1304
|
+
// Ignore
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
},
|
|
1308
|
+
|
|
1309
|
+
isInFlight(key) {
|
|
1310
|
+
if (key) {
|
|
1311
|
+
const set = inFlightMap.get(key);
|
|
1312
|
+
return Boolean(set && set.size > 0);
|
|
1313
|
+
}
|
|
1314
|
+
return inFlightMap.size > 0;
|
|
1315
|
+
},
|
|
1316
|
+
|
|
1317
|
+
get lastRefreshPromise() {
|
|
1318
|
+
return lastRefreshPromise;
|
|
1319
|
+
},
|
|
1320
|
+
};
|
|
1321
|
+
|
|
1322
|
+
return client;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
module.exports = {
|
|
1326
|
+
ClientRequestError,
|
|
1327
|
+
createApiClient,
|
|
1328
|
+
discoverCsrfToken,
|
|
1329
|
+
buildRequestUrl,
|
|
1330
|
+
extractValidationErrors,
|
|
1331
|
+
unwrapApiResultAndInvalidate,
|
|
1332
|
+
normalizeRetryConfig,
|
|
1333
|
+
isRetryableError,
|
|
1334
|
+
calculateRetryDelay,
|
|
1335
|
+
abortableSleep,
|
|
1336
|
+
};
|
|
1337
|
+
|