@saasicat/ui-vue 0.24.2 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +32 -8
  2. package/dist/{catalog-DJcfm5N2.d.cts → catalog-Dch1Ryw0.d.cts} +353 -69
  3. package/dist/{catalog-DJcfm5N2.d.ts → catalog-Dch1Ryw0.d.ts} +353 -69
  4. package/dist/{chunk-F7NRM6KI.js → chunk-BPF2BMCQ.js} +748 -24
  5. package/dist/{chunk-D74FFCCP.js → chunk-NEXTZZRQ.js} +95 -177
  6. package/dist/{chunk-TQQMKWN6.js → chunk-O3J3ITF2.js} +119 -7
  7. package/dist/client/index.cjs +758 -83
  8. package/dist/client/index.d.cts +414 -9
  9. package/dist/client/index.d.ts +414 -9
  10. package/dist/client/index.js +84 -12
  11. package/dist/index.cjs +1325 -294
  12. package/dist/index.d.cts +205 -15
  13. package/dist/index.d.ts +205 -15
  14. package/dist/index.js +566 -240
  15. package/dist/quasar/index.cjs +574 -62
  16. package/dist/quasar/index.d.cts +61 -6
  17. package/dist/quasar/index.d.ts +61 -6
  18. package/dist/quasar/index.js +83 -19
  19. package/dist/{use-sa-theme-OUBlaqgl.d.ts → resource-registry-D7Xjs-5f.d.cts} +157 -7
  20. package/dist/{use-sa-theme-B5t7oXph.d.cts → resource-registry-TaR7rtq0.d.ts} +157 -7
  21. package/package.json +4 -3
  22. package/src/client/admin-error.ts +552 -0
  23. package/src/client/admin-resource-client.ts +9 -12
  24. package/src/client/batch-column-fetcher.ts +2 -2
  25. package/src/client/boot-loader.ts +12 -1
  26. package/src/client/http/axios-http-client.ts +383 -0
  27. package/src/client/http/fetch-http-client.ts +89 -0
  28. package/src/client/http/index.ts +6 -0
  29. package/src/client/http-json.ts +82 -16
  30. package/src/client/i18n/messages/discovery.ts +0 -6
  31. package/src/client/i18n/messages/errors.ts +44 -0
  32. package/src/client/i18n/messages/marketing.ts +0 -6
  33. package/src/client/i18n/messages/promos.ts +0 -2
  34. package/src/client/i18n/messages/shell.ts +12 -0
  35. package/src/client/i18n/messages.ts +3 -0
  36. package/src/client/index.ts +6 -0
  37. package/src/client/manifest-loader.ts +65 -15
  38. package/src/client/resources/audit.resource.ts +25 -0
  39. package/src/client/resources/define-resource.ts +166 -0
  40. package/src/client/resources/index.ts +42 -0
  41. package/src/client/resources/list-resource.ts +176 -0
  42. package/src/client/resources/plans.resource.ts +169 -0
  43. package/src/client/resources/resource-request.ts +117 -0
  44. package/src/client/resources/tenants.resource.ts +27 -0
  45. package/src/client/types.ts +10 -3
  46. package/src/components/BundleVersionPublishDialog.vue +1 -1
  47. package/src/components/MarketingPromotionsTab.vue +263 -241
  48. package/src/components/ThemeSwitcher.vue +85 -0
  49. package/src/components/admin-page/AdminAccordion.vue +125 -0
  50. package/src/components/bundle-editor/BundleCreatePanel.vue +1 -1
  51. package/src/components/dialogs/PromoCodeDialogFields.vue +20 -20
  52. package/src/components/plan/PlanCycleToggle.vue +1 -1
  53. package/src/components/plan-detail/PlanVersionsPanel.vue +5 -2
  54. package/src/index.ts +5 -0
  55. package/src/pages-standard/AdminLayout.vue +57 -0
  56. package/src/pages-standard/AdminManifestErrorPage.vue +4 -3
  57. package/src/pages-standard/BundlesPage.vue +5 -35
  58. package/src/pages-standard/DiscoveryPage.vue +8 -1
  59. package/src/pages-standard/MarketingCatalogPage.vue +14 -3
  60. package/src/pages-standard/SuperAdminLoginPage.vue +23 -2
  61. package/src/pages-standard/SuperAdminSetupWizard.vue +37 -3
  62. package/src/pages-standard/bundles-page/BundleAccordionList.vue +30 -32
  63. package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +80 -88
  64. package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +73 -79
  65. package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +44 -6
  66. package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +8 -2
  67. package/src/pages-tenant/PackageSnapshotPanel.vue +31 -2
  68. package/src/quasar/confirm.ts +83 -0
  69. package/src/quasar/create-super-admin-app.ts +84 -10
  70. package/src/quasar/dark-bridge.ts +47 -10
  71. package/src/quasar/index.ts +1 -0
  72. package/src/ui/theme/components/accordion.css +118 -0
  73. package/src/ui/theme/index.css +1 -0
  74. package/src/vue/create-admin-routes.ts +9 -1
  75. package/src/vue/platform-loaders.ts +12 -2
  76. package/src/vue/resource-registry.ts +211 -0
  77. package/src/vue/super-admin-context.ts +20 -5
  78. package/src/vue/ui-confirm.ts +76 -0
  79. package/src/vue/use-api-list.ts +50 -45
  80. package/src/vue/use-async-action.ts +170 -0
  81. package/src/vue/use-async-data.ts +83 -0
  82. package/src/vue/use-bundles.ts +34 -7
  83. package/src/vue/use-catalog-entries.ts +37 -12
  84. package/src/vue/use-discovery.ts +27 -6
  85. package/src/vue/use-marketing-projections.ts +29 -7
  86. package/src/vue/use-plans.ts +36 -8
  87. package/src/vue/use-promotions.ts +27 -7
  88. package/src/vue/use-resource-list.ts +272 -0
  89. package/src/vue/use-sa-theme.ts +34 -5
  90. package/src/vue/use-tenant-subscription-bundles.ts +22 -3
@@ -0,0 +1,383 @@
1
+ // The axios implementation of `HttpClient` — without depending on axios.
2
+ //
3
+ // Six near-identical copies of this function exist across the known consumer
4
+ // apps and the documentation: the notesapp admin and web apps, the scaffolder
5
+ // template, the handbook, vereinsfux and autohauspro. They differ in the
6
+ // prefix they strip and in nothing else that matters.
7
+ //
8
+ // `AxiosLike` is structural on purpose. A `dependencies` entry would make every
9
+ // consumer install axios to use a package that speaks `fetch` by default, and a
10
+ // `peerDependencies` entry would do the same with a warning instead of an
11
+ // install. The one method this needs is `request`, and any axios instance has
12
+ // it.
13
+ //
14
+ // The tests do install axios, as a devDependency, because the one thing a
15
+ // stand-in cannot reproduce is the transform axios applies to a body — and that
16
+ // transform is what `bodyIsRaw` below has to read correctly.
17
+
18
+ import { isAxiosNoResponseError, markTransportFailure } from '../admin-error.js';
19
+ import { trimTrailingSlashes } from '../http-json.js';
20
+ import type { HttpClient, HttpResponse } from '../types.js';
21
+
22
+ /**
23
+ * The part of the merged request config axios echoes back on every response —
24
+ * on the one it resolves with and on the one it attaches to a rejection. These
25
+ * three fields say whether `data` is a decoded value or the body as it arrived,
26
+ * for as long as axios's own response transform is the one that produced it;
27
+ * see `bodyIsRaw`.
28
+ *
29
+ * They are `unknown` rather than their axios types so that a real
30
+ * `AxiosResponse` stays structurally assignable without this package taking a
31
+ * dependency on axios to name them.
32
+ */
33
+ export interface AxiosLikeResponseConfig {
34
+ transformResponse?: unknown;
35
+ responseType?: unknown;
36
+ transitional?: unknown;
37
+ }
38
+
39
+ export interface AxiosLikeResponse {
40
+ status: number;
41
+ data: unknown;
42
+ headers: unknown;
43
+ config?: AxiosLikeResponseConfig;
44
+ }
45
+
46
+ export interface AxiosLikeConfig {
47
+ url: string;
48
+ method?: string;
49
+ headers?: Record<string, string>;
50
+ data?: unknown;
51
+ }
52
+
53
+ /** The part of an axios instance this adapter uses. */
54
+ export interface AxiosLike {
55
+ request(config: AxiosLikeConfig): Promise<AxiosLikeResponse>;
56
+ }
57
+
58
+ /**
59
+ * What an instance leaves in `response.data`, for the one case the response
60
+ * cannot describe: an instance that replaced `transformResponse` with a
61
+ * non-empty pipeline of its own.
62
+ *
63
+ * - `'auto'` — read it off the config axios echoes on the response. Correct for
64
+ * an instance that still runs axios's own transform, and for every way of
65
+ * switching that transform's parsing off: `responseType: 'text'`,
66
+ * `transitional: { forcedJSONParsing: false }`, and a `transformResponse`
67
+ * that runs nothing (`[]` or `null`).
68
+ * - `'raw'` — `data` is the body as it arrived, and `json()` parses it.
69
+ * - `'decoded'` — `data` is a value the pipeline already produced, and `json()`
70
+ * hands it over untouched.
71
+ */
72
+ export type AxiosResponseBody = 'auto' | 'raw' | 'decoded';
73
+
74
+ export interface AxiosHttpClientOptions {
75
+ /**
76
+ * Prefix(es) to remove from the start of a URL before handing it to the
77
+ * instance, for the usual case where the instance already carries them as
78
+ * its `baseURL`. Tried in order, first match wins — so list the longest
79
+ * first (`['/api/v1', '/api']`), or `/api/v1/admin/x` loses only `/api`.
80
+ */
81
+ stripPrefix?: string | readonly string[];
82
+
83
+ /**
84
+ * How this instance hands the body over. Defaults to `'auto'`, which needs
85
+ * no configuration and is right for every instance that leaves axios's own
86
+ * `transformResponse` in place.
87
+ *
88
+ * Set it only if you replaced `transformResponse` with a non-empty pipeline
89
+ * of your own: `'raw'` if that pipeline hands the body over as it arrived
90
+ * (`[(data) => data]`), `'decoded'` if it parses. The response cannot be
91
+ * read for the answer — both echo one opaque function — so `'auto'` would
92
+ * take your pipeline for axios's; see `bodyIsRaw`.
93
+ *
94
+ * The declaration is read before anything else, an empty `data` included:
95
+ * under `'decoded'` an empty `data` is the empty string the pipeline
96
+ * produced, and `json()` hands it over; under `'raw'` it is an empty body,
97
+ * and `json()` throws the way `Response.json()` does.
98
+ */
99
+ responseBody?: AxiosResponseBody;
100
+ }
101
+
102
+ /**
103
+ * Removes a prefix only at a path boundary. `startsWith` alone is not enough:
104
+ * `/api/v10/x` starts with `/api/v1` and would be cut to `0/x`, which is the
105
+ * shape of the `url.slice(7)` the copies of this function all used.
106
+ *
107
+ * The prefix is trimmed of trailing slashes first. Writing the option the way
108
+ * an axios `baseURL` is usually written — `'/api/v1/'` — otherwise left the
109
+ * remainder as `admin/boot`, which does not start with `/`, so the boundary
110
+ * check rejected it and the URL passed through unstripped. axios then prepended
111
+ * its base and sent `/api/v1/api/v1/admin/boot`: every request broken, for a
112
+ * configuration that looks correct.
113
+ */
114
+ function stripped(url: string, prefixes: readonly string[]): string {
115
+ for (const prefix of prefixes) {
116
+ const trimmed = trimTrailingSlashes(prefix);
117
+ if (!trimmed || !url.startsWith(trimmed)) continue;
118
+ const rest = url.slice(trimmed.length);
119
+ if (rest === '') return '/';
120
+ if (rest.startsWith('/')) return rest;
121
+ // A query or a fragment ends the path just as a slash does, so
122
+ // `/api/v1?tenant=acme` is the prefix too — without this it fell
123
+ // through unstripped and was sent doubled.
124
+ if (rest.startsWith('?') || rest.startsWith('#')) return `/${rest}`;
125
+ }
126
+ return url;
127
+ }
128
+
129
+ /**
130
+ * Reads a header without knowing how the instance spells it.
131
+ *
132
+ * Both casings occur on the reading side too: the manifest loader asks for
133
+ * `ETag` and then for `etag`, because it could not rely on the shims agreeing.
134
+ * Answering both is what makes that second attempt unnecessary.
135
+ */
136
+ function headerReader(headers: unknown): (name: string) => string | null {
137
+ const record =
138
+ typeof headers === 'object' && headers !== null ? (headers as Record<string, unknown>) : {};
139
+ const lowered = new Map<string, unknown>();
140
+ for (const [key, value] of Object.entries(record)) lowered.set(key.toLowerCase(), value);
141
+ return (name) => {
142
+ const value = lowered.get(name.toLowerCase());
143
+ return value == null ? null : String(value);
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Whether `response.data` is the body as it arrived, rather than a value axios
149
+ * already decoded out of it.
150
+ *
151
+ * The type of `data` cannot answer that. Under axios's default transform a
152
+ * body of `"ready"` decodes to the string `ready`, and a body that failed to
153
+ * parse is handed back as the string it was — both arrive as strings, both
154
+ * under an `application/json` content type, so neither the value nor the media
155
+ * type separates them. Decoding a second time then throws on `"ready"` and,
156
+ * worse, silently turns the string `"null"` into `null`.
157
+ *
158
+ * `'auto'` reads the answer off the merged config axios echoes on every
159
+ * response, including the one carried by a rejection. Two readings, in order: a
160
+ * `transformResponse` axios's own iteration would run nothing for is proof that
161
+ * nothing touched the body; otherwise the condition axios's own default
162
+ * transform parses under, negated, off `responseType` and `transitional`. An
163
+ * empty `data` is answered before either of them, and the comment on that line
164
+ * says what it costs.
165
+ *
166
+ * **The second reading is an assumption, not a measurement**: it holds while
167
+ * axios's default transform is the one that ran. `config.transformResponse` is
168
+ * not a description of the decoding — it is the function array axios applied,
169
+ * and functions are opaque. An instance built with `[(data) => data]` and one
170
+ * built with `[(data) => JSON.parse(data)]` echo the same config as a stock
171
+ * instance does, down to the array length and the arity of its one element, and
172
+ * each can be handed a body that makes it produce the very same `data` as the
173
+ * others — `"ready"` through the first, `"\"ready\""` through the other two,
174
+ * all three arriving as `"ready"` — while needing the opposite answer.
175
+ * Nothing else on the response separates them either: the
176
+ * only field that differs is `content-length`, which gzip or a chunked reply
177
+ * makes meaningless. Guessing "non-empty means decoded" is silently wrong for
178
+ * the first, guessing "non-empty means raw" for the second.
179
+ *
180
+ * So an instance that replaced `transformResponse` says which it is, through
181
+ * the `responseBody` option, and `'auto'` covers everyone who did not. The
182
+ * declaration is read first, because a reading that overrode it would leave the
183
+ * tie it exists to break unbroken.
184
+ *
185
+ * A response carrying no config at all is read as decoded: real axios always
186
+ * attaches one, so only a stand-in can omit it, and reading a stand-in as
187
+ * already-decoded fails loudly — objects arriving as strings — instead of
188
+ * silently changing what a scalar means.
189
+ */
190
+ function bodyIsRaw(response: AxiosLikeResponse, declared: AxiosResponseBody): boolean {
191
+ // The declaration answers for every body, the empty one included. Under
192
+ // `'decoded'` `data` is the value the pipeline produced and `''` is the
193
+ // empty string; under `'raw'` `data` is the body and an empty one is what
194
+ // `Response.json()` throws on.
195
+ if (declared !== 'auto') return declared === 'raw';
196
+
197
+ // Under `'auto'` an empty `data` is read as no body, and `json()` therefore
198
+ // throws as `Response.json()` does. For an instance that hands the body
199
+ // over that reading is exact — nothing shortens a body to nothing. For one
200
+ // that decodes it is a collision the response cannot resolve: axios's
201
+ // transform turns a zero-byte body and the two bytes `""` into the same
202
+ // `''`, and `""` is valid JSON meaning the empty string. That is the same
203
+ // undecidability the docstring above owns, so it has the same way out —
204
+ // `responseBody: 'decoded'`, which is why it is read one line further up.
205
+ if (response.data === '') return true;
206
+
207
+ // `config.transformResponse` is not a description of the decoding, but it
208
+ // is the collection axios handed to its own `forEach`, and two of the
209
+ // shapes that accepts run nothing at all: the empty array, and `null`
210
+ // (`forEach` returns immediately for a nullish collection). Either is
211
+ // therefore proof that the body was not touched.
212
+ //
213
+ // `=== null` and not `== null`: an *absent* `transformResponse` reads as
214
+ // `undefined` too, and absence is not a statement about decoding — it is
215
+ // what a structural stand-in produces, which the readings below already
216
+ // answer for. Only a config that spells the property out has said anything.
217
+ const config = response.config;
218
+ if (config?.transformResponse === null) return true;
219
+ if (Array.isArray(config?.transformResponse) && config.transformResponse.length === 0) {
220
+ return true;
221
+ }
222
+
223
+ // What follows is the negation of the one condition axios's default
224
+ // transform parses under (`lib/defaults/index.js`):
225
+ //
226
+ // (forcedJSONParsing && !responseType) || responseType === 'json'
227
+ //
228
+ // Written from that condition rather than from the cases it produces:
229
+ // several settings turn the parsing off, and keeping a list of them is how
230
+ // one of them gets forgotten.
231
+ const responseType = config?.responseType;
232
+ if (responseType === 'json') return false;
233
+ if (typeof responseType === 'string' && responseType !== '') return true;
234
+ const transitional = config?.transitional;
235
+ return (
236
+ typeof transitional === 'object' &&
237
+ transitional !== null &&
238
+ (transitional as { forcedJSONParsing?: unknown }).forcedJSONParsing === false
239
+ );
240
+ }
241
+
242
+ /**
243
+ * Reads a body axios was told not to turn into text.
244
+ *
245
+ * `responseType: 'arraybuffer'` and `'blob'` switch the decoding off the way
246
+ * `'text'` does, so the body arrives exactly as it was sent — only as bytes
247
+ * rather than as a string. Turning them back into text is a measurement, not a
248
+ * guess: JSON is defined in UTF-8, and `bodyIsRaw` already answers `true` for
249
+ * both, so the readings below stay the ones the rest of this file reasons about.
250
+ *
251
+ * A stream is refused. It can be consumed once, not synchronously, and not
252
+ * twice by `json()` and `text()` in turn; handing it back would satisfy the
253
+ * return type while breaking what the type promises, which is the failure mode
254
+ * this file spent three rounds removing. The message names the option that
255
+ * makes the request work.
256
+ *
257
+ * Anything else is passed through: a consumer's own transform may return an
258
+ * object, and that object is the body.
259
+ */
260
+ async function readBody(data: unknown): Promise<unknown> {
261
+ if (typeof data === 'string' || data === null || data === undefined) return data;
262
+ if (data instanceof ArrayBuffer) return new TextDecoder().decode(data);
263
+ if (ArrayBuffer.isView(data)) return new TextDecoder().decode(data as Uint8Array);
264
+ const maybe = data as { text?: unknown; pipe?: unknown; getReader?: unknown };
265
+ if (typeof maybe.pipe === 'function' || typeof maybe.getReader === 'function') {
266
+ throw new TypeError(
267
+ "createAxiosHttpClient cannot read a streamed body: responseType 'stream' can be " +
268
+ "consumed only once, and never synchronously. Use 'arraybuffer' or the default " +
269
+ 'for the instance the platform receives.',
270
+ );
271
+ }
272
+ if (typeof maybe.text === 'function') return (maybe.text as () => Promise<string>).call(data);
273
+ return data;
274
+ }
275
+
276
+ /** Presents an axios response as the `HttpResponse` the contract declares. */
277
+ function adapt(response: AxiosLikeResponse, declared: AxiosResponseBody): HttpResponse {
278
+ return {
279
+ status: response.status,
280
+ headers: { get: headerReader(response.headers) },
281
+ json: async () => {
282
+ const data = await readBody(response.data);
283
+ if (typeof data !== 'string') return data;
284
+ return bodyIsRaw({ ...response, data }, declared) ? JSON.parse(data) : data;
285
+ },
286
+ text: async () => {
287
+ const data = await readBody(response.data);
288
+ return typeof data === 'string' ? data : JSON.stringify(data);
289
+ },
290
+ };
291
+ }
292
+
293
+ /**
294
+ * Adapts an axios instance to `HttpClient`.
295
+ *
296
+ * Two decisions are worth knowing about, because five of the six shims this
297
+ * replaces made them differently:
298
+ *
299
+ * **No status throws — but the instance still decides.** A 402, a 404 and a
300
+ * 500 all arrive as responses, because the platform reads statuses itself (a
301
+ * 304 is a cache hit, a 402 carries a limit payload) and can only do that for
302
+ * statuses it is handed. That is achieved by adapting the rejection rather
303
+ * than by overriding `validateStatus`: an override would make axios resolve
304
+ * everything, and a config that never rejects makes the **rejection half of
305
+ * the instance's own response interceptors unreachable** — the conventional
306
+ * place a consumer puts token refresh and retry. Their session would expire
307
+ * silently instead of refreshing.
308
+ *
309
+ * **`json()` decodes only what axios left undecoded.** Which of the two it is
310
+ * cannot be guessed from the value. The response says it for every instance
311
+ * that still runs axios's own `transformResponse`; an instance that replaced it
312
+ * says it with the `responseBody` option, because at that point the response no
313
+ * longer can. See `bodyIsRaw`.
314
+ *
315
+ * Nothing here overrides the instance's own configuration — not
316
+ * `validateStatus`, and not `transformResponse` either. Forcing the transform
317
+ * off would make the answer knowable, at the price of handing the consumer's
318
+ * own response interceptors a string where they had an object.
319
+ */
320
+ export function createAxiosHttpClient(
321
+ instance: AxiosLike,
322
+ options: AxiosHttpClientOptions = {},
323
+ ): HttpClient {
324
+ const prefixes =
325
+ typeof options.stripPrefix === 'string'
326
+ ? [options.stripPrefix]
327
+ : (options.stripPrefix ?? []);
328
+ const responseBody = options.responseBody ?? 'auto';
329
+
330
+ return async (url, init) => {
331
+ try {
332
+ return adapt(
333
+ await instance.request({
334
+ url: stripped(url, prefixes),
335
+ method: (init?.method ?? 'GET').toUpperCase(),
336
+ headers: init?.headers,
337
+ data: init?.body,
338
+ }),
339
+ responseBody,
340
+ );
341
+ } catch (err: unknown) {
342
+ // A status the instance rejected still carries its response, and
343
+ // by then every interceptor has had its turn — including a refresh
344
+ // that may have retried and succeeded. Adapt what came back
345
+ // instead of rethrowing, so the seam keeps its one error shape.
346
+ const response = (err as { response?: AxiosLikeResponse })?.response;
347
+ if (response && typeof response.status === 'number') {
348
+ return adapt(response, responseBody);
349
+ }
350
+ // No response came back through this seam — and that is the whole
351
+ // of what this seam knows. It knows a request was attempted; it
352
+ // does not know whether one arrived, because an interceptor that
353
+ // answered a status with an error of its own has already replaced
354
+ // everything the rejection would have said. So the brand goes on
355
+ // exactly what axios itself reports as "made, nothing came back",
356
+ // which `isAxiosNoResponseError` reads off axios's documented
357
+ // `request`-without-`response` form.
358
+ //
359
+ // Measured with axios 1.18.1 against a real `node:http` server.
360
+ // Marked: connection refused and reset, DNS failure, timeout,
361
+ // abort — each of them also when a rejection interceptor rethrows
362
+ // it. Not marked: an interceptor answering a 401 with
363
+ // `new Error('session expired')`, whether or not it copies
364
+ // `error.config` or `error.request` across; the setup failures
365
+ // axios files under neither half (unsupported protocol, a signal
366
+ // aborted before the call, a throwing `paramsSerializer`); and any
367
+ // structural `AxiosLike` that is not axios at all.
368
+ //
369
+ // That last group is the decision rather than the measurement, and
370
+ // it is the case an earlier round asked to mark: an offline request
371
+ // through a client that says nothing about itself. It stays
372
+ // unmarked because nothing in it can be told from an interceptor's
373
+ // replacement error — `new Error('cannot reach api')` after a dead
374
+ // socket and `new Error('session expired')` after a 401 are the
375
+ // same object down to the last own property, so marking the first
376
+ // costs the second its message, which is the only explanation the
377
+ // operator would have had. A client that knows it could not send
378
+ // says so itself: `markTransportFailure` is exported for that, and
379
+ // `createFetchHttpClient` is the reference use.
380
+ throw isAxiosNoResponseError(err) ? markTransportFailure(err) : err;
381
+ }
382
+ };
383
+ }
@@ -0,0 +1,89 @@
1
+ // The `fetch` implementation of `HttpClient`, and the only place in the
2
+ // package that is allowed to call `fetch` (ESLint enforces that; see the
3
+ // `no-restricted-globals` block in the root config).
4
+ //
5
+ // It exists because every app was writing it. Two of the shims in the known
6
+ // consumer apps are this file with different constants in it: prepend a base
7
+ // URL, merge in the auth header, ask for JSON, hand back the response in the
8
+ // four fields `HttpResponse` declares.
9
+
10
+ import { markTransportFailure } from '../admin-error.js';
11
+ import { trimTrailingSlashes } from '../http-json.js';
12
+ import type { HttpClient, HttpResponse } from '../types.js';
13
+
14
+ export interface FetchHttpClientOptions {
15
+ /**
16
+ * Prefix for relative URLs, e.g. `'https://api.example.com'`. Absolute
17
+ * URLs are passed through untouched.
18
+ *
19
+ * Note that an app configures its API prefix twice if it also passes a
20
+ * fully-qualified `apiBase` to the shell — the two would concatenate. Pick
21
+ * one; a doubled prefix 404s on the first request rather than degrading
22
+ * quietly, which is the reason this does not try to detect it.
23
+ */
24
+ baseUrl?: string;
25
+ /**
26
+ * Headers to add to every request, read per request so a token that
27
+ * changes between calls is picked up without rebuilding the client. May
28
+ * return a promise for a token that has to be refreshed first.
29
+ *
30
+ * Headers passed by the caller win, so a request that sets its own
31
+ * `Content-Type` keeps it.
32
+ */
33
+ headers?: () => Record<string, string> | Promise<Record<string, string>>;
34
+ }
35
+
36
+ const ABSOLUTE_URL = /^[a-z][a-z0-9+.-]*:|^\/\//i;
37
+
38
+ function resolveUrl(url: string, baseUrl: string | undefined): string {
39
+ if (!baseUrl || ABSOLUTE_URL.test(url)) return url;
40
+ return `${trimTrailingSlashes(baseUrl)}${url.startsWith('/') ? '' : '/'}${url}`;
41
+ }
42
+
43
+ /**
44
+ * Builds an `HttpClient` over `fetch`.
45
+ *
46
+ * With no options it is a bare passthrough plus an `Accept: application/json`
47
+ * header — the platform speaks JSON everywhere, and saying so is what stops a
48
+ * content-negotiating gateway from answering with HTML.
49
+ */
50
+ export function createFetchHttpClient(options: FetchHttpClientOptions = {}): HttpClient {
51
+ return async (url, init) => {
52
+ const headers = new Headers(await options.headers?.());
53
+ // Only when the hook did not ask for something else. An app requesting
54
+ // a versioned or vendor representation —
55
+ // `Accept: application/vnd.example.v2+json` — has no other way to say
56
+ // so, because the per-call headers belong to platform code.
57
+ if (!headers.has('Accept')) headers.set('Accept', 'application/json');
58
+ for (const [name, value] of Object.entries(init?.headers ?? {})) {
59
+ headers.set(name, value);
60
+ }
61
+ // Only when the caller did not say — a `Content-Type` it chose itself
62
+ // was set above and is left alone.
63
+ if (init?.body !== undefined && !headers.has('Content-Type')) {
64
+ headers.set('Content-Type', 'application/json');
65
+ }
66
+
67
+ let response: Response;
68
+ try {
69
+ response = await fetch(resolveUrl(url, options.baseUrl), {
70
+ method: init?.method ?? 'GET',
71
+ headers,
72
+ body: init?.body,
73
+ });
74
+ } catch (err) {
75
+ // `fetch` reports every failure before a response — no connection,
76
+ // DNS, CORS, an abort, a URL it cannot parse — as a `TypeError`,
77
+ // which is also what a null dereference in page code raises. The
78
+ // two are indistinguishable downstream, so the fact is declared
79
+ // here: this is the seam that made the request and the only place
80
+ // that knows the throw came out of one. `markTransportFailure` is
81
+ // exported so a consumer's own client can say the same.
82
+ throw markTransportFailure(err);
83
+ }
84
+
85
+ // `Response` already satisfies `HttpResponse` structurally. Naming the
86
+ // type here is what keeps that true if either side changes.
87
+ return response satisfies HttpResponse;
88
+ };
89
+ }
@@ -0,0 +1,6 @@
1
+ // The two implementations of the `HttpClient` contract the package ships.
2
+ // The contract itself stays in `../types.ts`, which is what everything else
3
+ // imports.
4
+
5
+ export * from './fetch-http-client.js';
6
+ export * from './axios-http-client.js';
@@ -3,32 +3,98 @@
3
3
  // instead of raw `fetch` with divergent error handling per component. A
4
4
  // consumer `HttpClient` (auth header, baseURL, retry) then applies everywhere.
5
5
 
6
+ import { AdminError, markTransportFailure, readErrorCode, readErrorDetail } from './admin-error.js';
6
7
  import type { HttpClient } from './types.js';
7
8
 
8
9
  /**
9
- * Error for non-2xx responses. `code` is the machine-readable error code
10
- * from the JSON body (`{ code }`), if present — callers map it to
11
- * a message (e.g. via `SETUP_ERROR_CODES`).
10
+ * Whether a resolved `HttpResponse` describes an answer from the server.
11
+ *
12
+ * `HttpClient` is a bare function type, and an axios or XHR wrapper reports a
13
+ * network error, a CORS rejection or an abort by RESOLVING with `status: 0`
14
+ * rather than by rejecting — a shape the contract permits and its own
15
+ * documentation invites. Every caller that reads a body afterwards is assuming
16
+ * an answer arrived; this is the one place that decides what "arrived" means,
17
+ * so the two sentences it separates ("check your connection" against "check
18
+ * whether the change was applied") cannot drift apart per composable.
19
+ *
20
+ * `> 0` is the same boundary `AdminError` documents for its `status`.
12
21
  */
13
- export class HttpJsonError extends Error {
14
- constructor(
15
- readonly status: number,
16
- readonly code?: string,
17
- ) {
18
- super(code ?? `HTTP ${status}`);
19
- this.name = 'HttpJsonError';
20
- }
22
+ function serverAnswered(status: number): boolean {
23
+ return status > 0;
21
24
  }
22
25
 
23
- async function extractCode(res: { json(): Promise<unknown> }): Promise<string | undefined> {
26
+ /**
27
+ * Fails, at the seam that can still tell, when the client resolved without an
28
+ * HTTP status: the request never reached the server.
29
+ *
30
+ * Every caller below then knows that an absent body means the server answered
31
+ * without one, which is the precondition the empty-response sentinel needs and
32
+ * did not have — `if (!data)` was true for both facts, and the mutation
33
+ * sentinel told the operator of a request that never left the machine to go
34
+ * check whether their change had been applied.
35
+ *
36
+ * `raise` builds the error class of the calling seam from the diagnostic, so
37
+ * the caught error keeps saying which API it came from.
38
+ */
39
+ export function requireServerAnswer(
40
+ status: number,
41
+ method: string,
42
+ url: string,
43
+ raise: (diagnostic: string) => Error,
44
+ ): void {
45
+ if (serverAnswered(status)) return;
46
+ throw markTransportFailure(
47
+ raise(`${method} ${url} produced no HTTP status — the request did not reach the server`),
48
+ );
49
+ }
50
+
51
+ /**
52
+ * @deprecated Renamed to {@link AdminError}, which is the same class: an
53
+ * `instanceof HttpJsonError` check keeps working and now also matches errors
54
+ * raised elsewhere in the package. Construction changed — `AdminError` takes
55
+ * one options object instead of `(status, code)`.
56
+ */
57
+ export const HttpJsonError = AdminError;
58
+ export type HttpJsonError = AdminError;
59
+
60
+ /**
61
+ * Reads the error body once, for both of the things it can carry: the
62
+ * machine-readable `code` a caller maps to its own wording, and the `message`
63
+ * that is the only text available when the code is unknown to it.
64
+ */
65
+ async function readErrorBody(res: { json(): Promise<unknown> }): Promise<unknown> {
24
66
  try {
25
- const body = (await res.json()) as { code?: unknown } | null;
26
- return typeof body?.code === 'string' ? body.code : undefined;
67
+ return await res.json();
27
68
  } catch {
69
+ // A non-JSON error body (an HTML error page, an empty response) is not
70
+ // itself a failure — the status is what the caller acts on.
28
71
  return undefined;
29
72
  }
30
73
  }
31
74
 
75
+ async function failed(
76
+ res: { status: number; json(): Promise<unknown> },
77
+ method: string,
78
+ url: string,
79
+ ): Promise<AdminError> {
80
+ const body = await readErrorBody(res);
81
+ return new AdminError({
82
+ status: res.status,
83
+ // Read here rather than left to the status: `status: 0` reaches
84
+ // `adminErrorMessage` as "nothing is known about this failure", and
85
+ // this seam knows better — it held the response.
86
+ transportFailure: !serverAnswered(res.status),
87
+ code: readErrorCode(body),
88
+ body,
89
+ url,
90
+ method,
91
+ // Shared with `toAdminError` on purpose: a NestJS `ValidationPipe`
92
+ // rejection carries `message` as an array, and reading it here in a
93
+ // second, narrower way is how those constraints went missing.
94
+ detail: readErrorDetail(body),
95
+ });
96
+ }
97
+
32
98
  /**
33
99
  * Removes trailing slashes so an API prefix can be concatenated with paths
34
100
  * that start with `/`. Deliberately index-based instead of
@@ -44,7 +110,7 @@ export function trimTrailingSlashes(url: string): string {
44
110
  export async function getJson<T>(http: HttpClient, url: string): Promise<T> {
45
111
  const res = await http(url);
46
112
  if (res.status < 200 || res.status >= 300) {
47
- throw new HttpJsonError(res.status, await extractCode(res));
113
+ throw await failed(res, 'GET', url);
48
114
  }
49
115
  return (await res.json()) as T;
50
116
  }
@@ -56,7 +122,7 @@ export async function postJson<T>(http: HttpClient, url: string, body: unknown):
56
122
  body: JSON.stringify(body),
57
123
  });
58
124
  if (res.status < 200 || res.status >= 300) {
59
- throw new HttpJsonError(res.status, await extractCode(res));
125
+ throw await failed(res, 'POST', url);
60
126
  }
61
127
  return (await res.json()) as T;
62
128
  }
@@ -23,9 +23,6 @@ export const discoveryMessages = defineMessages(
23
23
  replacedBy: 'ersetzt durch {key}',
24
24
  replaces: 'ersetzt: {keys}',
25
25
  reapproveEmphasis: 'erneut freigeben',
26
- errorDiscoveryHttp: 'Discovery-Endpoint antwortete mit HTTP {status}',
27
- errorRescanHttp: 'Discovery-Rescan antwortete mit HTTP {status}',
28
- errorCatalogHttp: 'Catalog-Entries-API antwortete mit HTTP {status}',
29
26
  capList: {
30
27
  orphanFeature:
31
28
  'Feature im Katalog ohne implementierende Capability — blockiert im blocking-Strict-Mode das Plan-Publish.',
@@ -137,9 +134,6 @@ export const discoveryMessages = defineMessages(
137
134
  replacedBy: 'replaced by {key}',
138
135
  replaces: 'replaces: {keys}',
139
136
  reapproveEmphasis: 'approve again',
140
- errorDiscoveryHttp: 'Discovery endpoint responded with HTTP {status}',
141
- errorRescanHttp: 'Discovery rescan responded with HTTP {status}',
142
- errorCatalogHttp: 'Catalog entries API responded with HTTP {status}',
143
137
  capList: {
144
138
  orphanFeature:
145
139
  'Feature in the catalog without an implementing capability — blocks plan publishing in blocking strict mode.',