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.
Files changed (184) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +3 -3
  3. package/bin/commands/add-esbuild.js +7 -3
  4. package/bin/commands/add-tailwind.js +9 -5
  5. package/bin/commands/build.js +20 -4
  6. package/bin/commands/dev.js +57 -7
  7. package/bin/commands/doctor.js +42 -1
  8. package/bin/commands/favicon-generate.js +10 -3
  9. package/bin/commands/new.js +8 -3
  10. package/bin/commands/start.js +3 -1
  11. package/bin/utils/add-command.js +16 -0
  12. package/core/app.js +45 -3
  13. package/core/auth/authenticate.js +3 -3
  14. package/core/auth/hash.js +167 -43
  15. package/core/auth/index.js +13 -1
  16. package/core/auth/jwt.js +21 -2
  17. package/core/auth/manager.js +63 -14
  18. package/core/auth/middleware.js +4 -2
  19. package/core/auth/oauth.js +5 -1
  20. package/core/auth/tokens.js +7 -2
  21. package/core/auth/totp.js +42 -6
  22. package/core/auth/user-agent.js +2 -0
  23. package/core/client/adapter/datastar.js +1401 -0
  24. package/core/client/api.js +1337 -0
  25. package/core/client/controller.js +1706 -0
  26. package/core/client/debug.js +367 -0
  27. package/core/client/devtool.js +1134 -0
  28. package/core/client/forms.js +1164 -0
  29. package/core/client/fragments.js +894 -0
  30. package/core/client/index.d.ts +59 -0
  31. package/core/client/index.js +391 -0
  32. package/core/client/lite.js +175 -0
  33. package/core/client/server-fragments.js +868 -0
  34. package/core/client/storage.js +339 -0
  35. package/core/client/utils.js +281 -0
  36. package/core/compression/middleware.js +277 -26
  37. package/core/context/app-context.js +25 -3
  38. package/core/context/request.js +32 -4
  39. package/core/errors/middleware.js +14 -0
  40. package/core/events/dispatcher.js +33 -0
  41. package/core/forms/flash.js +136 -102
  42. package/core/health/middleware.js +36 -6
  43. package/core/http/pipeline.js +45 -6
  44. package/core/index.js +25 -7
  45. package/core/kernel/view.js +41 -10
  46. package/core/metrics/middleware.js +30 -6
  47. package/core/metrics/registry.js +13 -5
  48. package/core/orm/cache/fingerprint.js +32 -11
  49. package/core/orm/cache/layer.js +19 -2
  50. package/core/orm/hidden.js +145 -0
  51. package/core/orm/model.js +48 -2
  52. package/core/orm/query-builder.js +343 -6
  53. package/core/orm/query-filters.js +14 -2
  54. package/core/orm/repository.js +64 -13
  55. package/core/orm/schema-helpers.js +9 -6
  56. package/core/orm/transaction.js +50 -6
  57. package/core/orm/types.js +4 -0
  58. package/core/queue/adapters/database.js +184 -2
  59. package/core/queue/adapters/memory.js +153 -5
  60. package/core/queue/adapters/redis.js +59 -4
  61. package/core/queue/job.js +14 -1
  62. package/core/queue/manager.js +115 -0
  63. package/core/resilience/circuit-breaker.js +27 -8
  64. package/core/services/builtins/exchange.js +174 -7
  65. package/core/services/builtins/file-manager.js +36 -0
  66. package/core/services/builtins/mail.js +27 -0
  67. package/core/services/builtins/queue.js +144 -0
  68. package/core/services/builtins/security.js +118 -0
  69. package/core/services/builtins/webhook.js +110 -0
  70. package/core/services/executor.js +38 -16
  71. package/core/services/index.js +4 -1
  72. package/core/sse/stream.js +28 -5
  73. package/core/utils/timeout.js +8 -1
  74. package/core/validation/compiler.js +10 -7
  75. package/index.d.ts +1320 -74
  76. package/index.js +128 -8
  77. package/package.json +75 -27
  78. package/plugins/admin-panel/client/parts/08-record-list.js +717 -70
  79. package/plugins/admin-panel/index.js +19 -8
  80. package/plugins/csrf/index.js +3 -3
  81. package/plugins/data-exchange/export.js +214 -0
  82. package/plugins/data-exchange/import.js +48 -5
  83. package/plugins/data-exchange/index.js +70 -28
  84. package/plugins/data-exchange/preview.js +266 -0
  85. package/plugins/data-exchange/template.js +242 -0
  86. package/plugins/email/admin-component.js +783 -141
  87. package/plugins/email/api-handlers.js +51 -1
  88. package/plugins/email/index.js +5 -0
  89. package/plugins/email/service.js +48 -0
  90. package/plugins/file-manager/admin-component.js +1546 -70
  91. package/plugins/file-manager/api-handlers.js +166 -1
  92. package/plugins/file-manager/core.js +598 -8
  93. package/plugins/file-manager/index.js +8 -0
  94. package/plugins/index.js +7 -0
  95. package/plugins/mcp/auth.js +3 -3
  96. package/plugins/oauth/index.js +36 -3
  97. package/plugins/queue/admin-component.js +1092 -0
  98. package/plugins/queue/api-handlers.js +248 -0
  99. package/plugins/queue/index.js +69 -0
  100. package/plugins/rest-resources/index.js +7 -2
  101. package/plugins/security/admin-component.js +689 -0
  102. package/plugins/security/api-handlers.js +169 -0
  103. package/plugins/security/audit.js +153 -0
  104. package/plugins/security/bruteforce.js +244 -0
  105. package/plugins/security/firewall.js +459 -0
  106. package/plugins/security/headers.js +214 -0
  107. package/plugins/security/honeypot.js +234 -0
  108. package/plugins/security/incident-recorder.js +137 -0
  109. package/plugins/security/index.js +211 -0
  110. package/plugins/seo-checker/analyzer.js +20 -7
  111. package/plugins/upload/index.js +36 -0
  112. package/plugins/webhooks/admin-component.js +702 -175
  113. package/plugins/webhooks/api-handlers.js +75 -1
  114. package/plugins/webhooks/dispatcher.js +290 -11
  115. package/plugins/webhooks/index.js +37 -2
  116. package/plugins/webhooks/sender.js +56 -0
  117. package/plugins/xlsx/engine.js +19 -4
  118. package/src/api/api-loader.js +19 -1
  119. package/src/bundle/index.js +443 -17
  120. package/src/client-runtime/bootstrap-swup.js +9 -0
  121. package/src/client-runtime/mount.js +15 -20
  122. package/src/client-runtime/resolve.js +3 -2
  123. package/src/dev/api-typegen.js +1300 -0
  124. package/src/dev/banner.js +10 -1
  125. package/src/dev/colors.js +1 -1
  126. package/src/dev/index.js +7 -1
  127. package/src/dev/typegen.js +173 -17
  128. package/src/dev-reload/index.js +1 -1
  129. package/src/discovery/file-route-parser.js +17 -3
  130. package/src/discovery/scan-routes.js +4 -1
  131. package/src/file-router.js +253 -69
  132. package/src/helpers.js +46 -26
  133. package/src/njk-frontmatter.js +30 -4
  134. package/src/routing/cascading.js +26 -8
  135. package/src/routing/mount-discovered-routes.js +76 -0
  136. package/src/server.js +231 -70
  137. package/views/partials/webspresso-client-runtime.njk +1 -9
  138. package/.agents/ADMIN.md +0 -426
  139. package/.agents/AGENTS.md +0 -96
  140. package/.agents/ARCHITECTURE.md +0 -163
  141. package/.agents/AUTH.md +0 -581
  142. package/.agents/CACHE.md +0 -148
  143. package/.agents/CLI.md +0 -649
  144. package/.agents/COMPRESSION.md +0 -59
  145. package/.agents/CONTEXT.md +0 -152
  146. package/.agents/CONVENTIONS.md +0 -50
  147. package/.agents/EARLY_HINTS.md +0 -120
  148. package/.agents/EMAIL.md +0 -223
  149. package/.agents/ERRORS.md +0 -260
  150. package/.agents/EVENTS.md +0 -179
  151. package/.agents/FLAGS.md +0 -118
  152. package/.agents/HEALTH.md +0 -118
  153. package/.agents/LIMITS.md +0 -109
  154. package/.agents/LOGGING.md +0 -93
  155. package/.agents/MCP.md +0 -103
  156. package/.agents/METRICS.md +0 -127
  157. package/.agents/MIGRATION_JOBS.md +0 -95
  158. package/.agents/ORM.md +0 -700
  159. package/.agents/PLUGINS.md +0 -729
  160. package/.agents/QUEUE.md +0 -290
  161. package/.agents/RATE_LIMIT.md +0 -147
  162. package/.agents/REALTIME.md +0 -213
  163. package/.agents/RESILIENCE.md +0 -235
  164. package/.agents/ROUTING.md +0 -605
  165. package/.agents/SCALE_OUT.md +0 -109
  166. package/.agents/SECRETS.md +0 -117
  167. package/.agents/SERVICES.md +0 -341
  168. package/.agents/SHUTDOWN.md +0 -74
  169. package/.agents/STREAMING.md +0 -108
  170. package/.agents/WORKFLOW.md +0 -27
  171. package/.agents/internal/DOCUMENTATION_REVIEW.md +0 -485
  172. package/.agents/internal/TECHNICAL_REVIEW.md +0 -428
  173. package/.agents/skills/webspresso-admin-and-plugins/SKILL.md +0 -122
  174. package/.agents/skills/webspresso-framework/SKILL.md +0 -89
  175. package/.agents/skills/webspresso-orm-and-database/SKILL.md +0 -127
  176. package/.agents/skills/webspresso-routing-and-api/SKILL.md +0 -130
  177. package/.agents/skills/webspresso-services/SKILL.md +0 -134
  178. package/.cursorrules +0 -14
  179. package/AGENTS.md +0 -203
  180. package/CLAUDE.md +0 -54
  181. package/core/kernel/run-demo.js +0 -58
  182. package/plugins/admin-panel/styles.js +0 -9
  183. package/plugins/data-exchange/export-xlsx.js +0 -119
  184. 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
+