@stapel/cdn-react 0.1.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 (164) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/MODULE.md +124 -0
  3. package/README.md +103 -0
  4. package/dist/api/cdnApi.d.ts +122 -0
  5. package/dist/api/cdnApi.d.ts.map +1 -0
  6. package/dist/api/cdnApi.js +27 -0
  7. package/dist/api/cdnApi.js.map +1 -0
  8. package/dist/api/generated/schema.d.ts +1050 -0
  9. package/dist/api/generated/schema.d.ts.map +1 -0
  10. package/dist/api/generated/schema.js +2 -0
  11. package/dist/api/generated/schema.js.map +1 -0
  12. package/dist/api/types.d.ts +76 -0
  13. package/dist/api/types.d.ts.map +1 -0
  14. package/dist/api/types.js +2 -0
  15. package/dist/api/types.js.map +1 -0
  16. package/dist/default/ErrorAlert.d.ts +8 -0
  17. package/dist/default/ErrorAlert.d.ts.map +1 -0
  18. package/dist/default/ErrorAlert.js +26 -0
  19. package/dist/default/ErrorAlert.js.map +1 -0
  20. package/dist/default/ImageUploadField.d.ts +12 -0
  21. package/dist/default/ImageUploadField.d.ts.map +1 -0
  22. package/dist/default/ImageUploadField.js +48 -0
  23. package/dist/default/ImageUploadField.js.map +1 -0
  24. package/dist/default/MediaGalleryField.d.ts +14 -0
  25. package/dist/default/MediaGalleryField.d.ts.map +1 -0
  26. package/dist/default/MediaGalleryField.js +65 -0
  27. package/dist/default/MediaGalleryField.js.map +1 -0
  28. package/dist/default/index.d.ts +15 -0
  29. package/dist/default/index.d.ts.map +1 -0
  30. package/dist/default/index.js +13 -0
  31. package/dist/default/index.js.map +1 -0
  32. package/dist/default/phase.d.ts +17 -0
  33. package/dist/default/phase.d.ts.map +1 -0
  34. package/dist/default/phase.js +25 -0
  35. package/dist/default/phase.js.map +1 -0
  36. package/dist/flows/registry.d.ts +28 -0
  37. package/dist/flows/registry.d.ts.map +1 -0
  38. package/dist/flows/registry.js +26 -0
  39. package/dist/flows/registry.js.map +1 -0
  40. package/dist/headless/CdnProvider.d.ts +18 -0
  41. package/dist/headless/CdnProvider.d.ts.map +1 -0
  42. package/dist/headless/CdnProvider.js +14 -0
  43. package/dist/headless/CdnProvider.js.map +1 -0
  44. package/dist/headless/ImageUpload.d.ts +18 -0
  45. package/dist/headless/ImageUpload.d.ts.map +1 -0
  46. package/dist/headless/ImageUpload.js +16 -0
  47. package/dist/headless/ImageUpload.js.map +1 -0
  48. package/dist/headless/MediaUploader.d.ts +31 -0
  49. package/dist/headless/MediaUploader.d.ts.map +1 -0
  50. package/dist/headless/MediaUploader.js +30 -0
  51. package/dist/headless/MediaUploader.js.map +1 -0
  52. package/dist/headless/useUploadImage.d.ts +33 -0
  53. package/dist/headless/useUploadImage.d.ts.map +1 -0
  54. package/dist/headless/useUploadImage.js +118 -0
  55. package/dist/headless/useUploadImage.js.map +1 -0
  56. package/dist/headless/useUploadPreview.d.ts +22 -0
  57. package/dist/headless/useUploadPreview.d.ts.map +1 -0
  58. package/dist/headless/useUploadPreview.js +37 -0
  59. package/dist/headless/useUploadPreview.js.map +1 -0
  60. package/dist/headless/useUploadQueue.d.ts +83 -0
  61. package/dist/headless/useUploadQueue.d.ts.map +1 -0
  62. package/dist/headless/useUploadQueue.js +0 -0
  63. package/dist/headless/useUploadQueue.js.map +1 -0
  64. package/dist/i18n/errorsMap.d.ts +12 -0
  65. package/dist/i18n/errorsMap.d.ts.map +1 -0
  66. package/dist/i18n/errorsMap.js +22 -0
  67. package/dist/i18n/errorsMap.js.map +1 -0
  68. package/dist/i18n/es.d.ts +17 -0
  69. package/dist/i18n/es.d.ts.map +1 -0
  70. package/dist/i18n/es.js +65 -0
  71. package/dist/i18n/es.js.map +1 -0
  72. package/dist/i18n/generated/errors.es.gen.d.ts +16 -0
  73. package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
  74. package/dist/i18n/generated/errors.es.gen.js +58 -0
  75. package/dist/i18n/generated/errors.es.gen.js.map +1 -0
  76. package/dist/i18n/generated/errors.gen.d.ts +353 -0
  77. package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
  78. package/dist/i18n/generated/errors.gen.js +180 -0
  79. package/dist/i18n/generated/errors.gen.js.map +1 -0
  80. package/dist/i18n/generated/errors.ru.gen.d.ts +16 -0
  81. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
  82. package/dist/i18n/generated/errors.ru.gen.js +58 -0
  83. package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
  84. package/dist/i18n/keys.d.ts +61 -0
  85. package/dist/i18n/keys.d.ts.map +1 -0
  86. package/dist/i18n/keys.js +110 -0
  87. package/dist/i18n/keys.js.map +1 -0
  88. package/dist/i18n/ru.d.ts +21 -0
  89. package/dist/i18n/ru.d.ts.map +1 -0
  90. package/dist/i18n/ru.js +70 -0
  91. package/dist/i18n/ru.js.map +1 -0
  92. package/dist/index.d.ts +89 -0
  93. package/dist/index.d.ts.map +1 -0
  94. package/dist/index.js +83 -0
  95. package/dist/index.js.map +1 -0
  96. package/dist/model/context.d.ts +11 -0
  97. package/dist/model/context.d.ts.map +1 -0
  98. package/dist/model/context.js +15 -0
  99. package/dist/model/context.js.map +1 -0
  100. package/dist/model/hash.d.ts +36 -0
  101. package/dist/model/hash.d.ts.map +1 -0
  102. package/dist/model/hash.js +59 -0
  103. package/dist/model/hash.js.map +1 -0
  104. package/dist/model/limits.d.ts +86 -0
  105. package/dist/model/limits.d.ts.map +1 -0
  106. package/dist/model/limits.js +163 -0
  107. package/dist/model/limits.js.map +1 -0
  108. package/dist/model/queries.d.ts +29 -0
  109. package/dist/model/queries.d.ts.map +1 -0
  110. package/dist/model/queries.js +32 -0
  111. package/dist/model/queries.js.map +1 -0
  112. package/dist/model/queryKeys.d.ts +18 -0
  113. package/dist/model/queryKeys.d.ts.map +1 -0
  114. package/dist/model/queryKeys.js +18 -0
  115. package/dist/model/queryKeys.js.map +1 -0
  116. package/dist/model/refs.d.ts +54 -0
  117. package/dist/model/refs.d.ts.map +1 -0
  118. package/dist/model/refs.js +82 -0
  119. package/dist/model/refs.js.map +1 -0
  120. package/dist/model/runtime.d.ts +40 -0
  121. package/dist/model/runtime.d.ts.map +1 -0
  122. package/dist/model/runtime.js +23 -0
  123. package/dist/model/runtime.js.map +1 -0
  124. package/dist/model/upload.d.ts +88 -0
  125. package/dist/model/upload.d.ts.map +1 -0
  126. package/dist/model/upload.js +254 -0
  127. package/dist/model/upload.js.map +1 -0
  128. package/llms.txt +83 -0
  129. package/manifest.json +601 -0
  130. package/package.json +120 -0
  131. package/src/analytics/generated/events.json +7 -0
  132. package/src/api/cdnApi.ts +187 -0
  133. package/src/api/generated/schema.ts +1052 -0
  134. package/src/api/types.ts +86 -0
  135. package/src/default/ErrorAlert.tsx +43 -0
  136. package/src/default/ImageUploadField.tsx +131 -0
  137. package/src/default/MediaGalleryField.tsx +233 -0
  138. package/src/default/index.ts +14 -0
  139. package/src/default/phase.ts +34 -0
  140. package/src/flows/registry.ts +38 -0
  141. package/src/headless/CdnProvider.tsx +19 -0
  142. package/src/headless/ImageUpload.tsx +24 -0
  143. package/src/headless/MediaUploader.tsx +43 -0
  144. package/src/headless/useUploadImage.ts +155 -0
  145. package/src/headless/useUploadPreview.ts +48 -0
  146. package/src/headless/useUploadQueue.ts +411 -0
  147. package/src/i18n/errorsMap.ts +33 -0
  148. package/src/i18n/es.ts +81 -0
  149. package/src/i18n/generated/errors.es.gen.ts +64 -0
  150. package/src/i18n/generated/errors.gen.ts +211 -0
  151. package/src/i18n/generated/errors.json +403 -0
  152. package/src/i18n/generated/errors.ru.gen.ts +64 -0
  153. package/src/i18n/keys.ts +131 -0
  154. package/src/i18n/ru.ts +85 -0
  155. package/src/index.ts +154 -0
  156. package/src/model/context.tsx +25 -0
  157. package/src/model/hash.ts +67 -0
  158. package/src/model/limits.ts +207 -0
  159. package/src/model/queries.ts +64 -0
  160. package/src/model/queryKeys.ts +23 -0
  161. package/src/model/refs.ts +95 -0
  162. package/src/model/runtime.ts +52 -0
  163. package/src/model/upload.ts +365 -0
  164. package/tsconfig.json +26 -0
@@ -0,0 +1,52 @@
1
+ import { createModuleRuntime } from "@stapel/core";
2
+ import type { CreateModuleRuntimeOptions, ModuleRuntime } from "@stapel/core";
3
+ import { createCdnApi } from "../api/cdnApi.js";
4
+ import type { CdnApi } from "../api/cdnApi.js";
5
+ import { resolveCdnLimits } from "./limits.js";
6
+ import type { CdnLimits, CdnLimitsOverride } from "./limits.js";
7
+ import type { CdnVariantWaitOptions } from "./upload.js";
8
+
9
+ /**
10
+ * The wired CDN runtime — core's `ModuleRuntime` bound to this pair's API,
11
+ * plus the two things an upload pair has and a plain read pair does not: the
12
+ * DEPLOYMENT's intake ceilings, and how long to wait for a variant ladder.
13
+ *
14
+ * Both are runtime configuration rather than module state, so they ride here
15
+ * instead of through a second provider — the same argument chat-react makes
16
+ * for its socket URL.
17
+ */
18
+ export type CdnRuntime = ModuleRuntime<CdnApi> & {
19
+ readonly limits: CdnLimits;
20
+ readonly variants: CdnVariantWaitOptions | undefined;
21
+ };
22
+
23
+ export interface CreateCdnRuntimeOptions extends CreateModuleRuntimeOptions {
24
+ /**
25
+ * Override the client-side mirror of `STAPEL_CDN`'s ceilings. Defaults to
26
+ * the library's own defaults — see `model/limits.ts` for why this is a knob
27
+ * and not a constant.
28
+ */
29
+ readonly limits?: CdnLimitsOverride;
30
+ /** How long the flow waits for the variant ladder after a store. */
31
+ readonly variants?: CdnVariantWaitOptions;
32
+ }
33
+
34
+ /**
35
+ * ```tsx
36
+ * const runtime = createCdnRuntime({ baseUrl: "/cdn/api/v1/" });
37
+ * <CdnProvider runtime={runtime}>{app}</CdnProvider>
38
+ * ```
39
+ *
40
+ * NOT ANONYMOUS. Every endpoint this pair calls needs at least a guest
41
+ * identity (`IsNotAnonymousUser`), and the avatar intake and the dedup
42
+ * pre-check need a real session (`IsAuthenticated`). A storefront mounts this
43
+ * behind its member routes; the public catalogue never touches it.
44
+ */
45
+ export function createCdnRuntime(options: CreateCdnRuntimeOptions): CdnRuntime {
46
+ const base = createModuleRuntime((client) => createCdnApi(client), options);
47
+ return {
48
+ ...base,
49
+ limits: resolveCdnLimits(options.limits),
50
+ variants: options.variants,
51
+ };
52
+ }
@@ -0,0 +1,365 @@
1
+ /**
2
+ * The dedup-first upload flow — the one piece of business this pair exists
3
+ * for, written once, with no React in it.
4
+ *
5
+ * ```
6
+ * validate ─┬─ refuse (client-side mirror, cdn's own error codes)
7
+ * │
8
+ * └─ hash ── check `file/exists/` ─┬─ HIT → done, ZERO bytes sent
9
+ * │
10
+ * └─ MISS → POST multipart
11
+ * └─ poll for variants
12
+ * ```
13
+ *
14
+ * ── Why the phases are named and not a percentage ──────────────────────────
15
+ *
16
+ * There is no honest byte-percentage to report. `fetch` cannot observe how
17
+ * much of a request body has gone out (only a `ReadableStream` body with
18
+ * `duplex: "half"` can, and that is neither universal nor reachable through
19
+ * the injected client), and `crypto.subtle.digest` reports nothing between
20
+ * "started" and "finished". The two ways to have a moving bar anyway are to
21
+ * fork the transport onto `XMLHttpRequest` — which means re-implementing the
22
+ * client's bearer/refresh/verification/error seams, i.e. a second transport
23
+ * with its own bugs — or to animate a number that is not measured. This
24
+ * package does neither: `UploadPhase` says which step is running, and a skin
25
+ * shows an indeterminate indicator during the two steps whose duration is
26
+ * real. Naming the step the person is waiting on is more information than a
27
+ * bar that is lying, and it is the same rule the rest of this fleet applies to
28
+ * counts it did not compute.
29
+ *
30
+ * ── What the pre-check can and cannot promise ──────────────────────────────
31
+ *
32
+ * A HIT is a promise: the bytes are already stored, under this caller's
33
+ * ownership, as the asset type being uploaded — so the POST is skipped and the
34
+ * reference is handed back immediately. That is the property spec §8.2 asks to
35
+ * be tested, and `test/dedup.test.ts` asserts it by counting requests.
36
+ *
37
+ * A MISS is not a promise of the opposite. `file/exists/` filters on
38
+ * `uploaded_by=request.user` unconditionally, while the upload paths honour
39
+ * `STAPEL_CDN["DEDUP_SCOPE"]` (default `"owner"`, optionally `"global"`), so
40
+ * under a global scope the POST can still answer 200 "already exists". Nothing
41
+ * downstream cares — the same body comes back either way — but this is why
42
+ * `deduped` reports what THIS CLIENT observed rather than claiming to know
43
+ * what the server did with the bytes.
44
+ */
45
+ import { toStapelApiError } from "@stapel/core";
46
+ import type { CdnApi } from "../api/cdnApi.js";
47
+ import type { CdnImage, CdnRef } from "../api/types.js";
48
+ import { canHashLocally, sha256Hex } from "./hash.js";
49
+ import { validateFile } from "./limits.js";
50
+ import type { CdnIntakeLimits } from "./limits.js";
51
+ import { refOf } from "./refs.js";
52
+
53
+ /** Where the bytes go, and therefore what asset type the row gets. */
54
+ export type CdnUploadTarget =
55
+ | {
56
+ /**
57
+ * `POST /upload/image/`. The general intake — note that it stores
58
+ * `type="product"` regardless of `ASSET_TYPES`.
59
+ */
60
+ readonly kind: "image";
61
+ }
62
+ | {
63
+ /** `POST /upload/avatar/`. The only intake that needs a real session. */
64
+ readonly kind: "avatar";
65
+ }
66
+ | {
67
+ /** `POST /images/<assetType>/upload/`, validated against `ASSET_TYPES`. */
68
+ readonly kind: "typed";
69
+ readonly assetType: string;
70
+ };
71
+
72
+ /**
73
+ * The asset type a target produces, which is what the pre-check must match
74
+ * before it may short-circuit. `"product"` is not a guess: it is the literal
75
+ * the view writes (`ImageUploadView.post`).
76
+ */
77
+ export function targetAssetType(target: CdnUploadTarget): string {
78
+ switch (target.kind) {
79
+ case "image":
80
+ return "product";
81
+ case "avatar":
82
+ return "avatar";
83
+ case "typed":
84
+ return target.assetType;
85
+ }
86
+ }
87
+
88
+ /** Which step of the flow is running. */
89
+ export type UploadPhase =
90
+ | "idle"
91
+ | "hashing"
92
+ | "checking"
93
+ | "uploading"
94
+ | "processing"
95
+ | "done"
96
+ | "failed"
97
+ | "canceled";
98
+
99
+ /** Why the dedup pre-check did not happen (or did not answer). */
100
+ export type DedupSkipReason =
101
+ /** No `crypto.subtle` — this page is not a secure context. */
102
+ | "no_crypto"
103
+ /** `file/exists/` needs `IsAuthenticated`; a guest identity may still upload. */
104
+ | "unauthorized"
105
+ /** The check itself failed. Never fatal: the upload proceeds. */
106
+ | "check_failed"
107
+ /** The caller asked for no pre-check. */
108
+ | "disabled";
109
+
110
+ /** What a finished upload yields. */
111
+ export interface UploadOutcome {
112
+ /** `<type>/<hash>` — the value a consuming module stores. */
113
+ readonly ref: CdnRef;
114
+ readonly image: CdnImage;
115
+ /** The pre-check hit and NO upload request was made. */
116
+ readonly deduped: boolean;
117
+ /** `undefined` when the pre-check ran; a reason when it did not. */
118
+ readonly dedupSkipped: DedupSkipReason | undefined;
119
+ /**
120
+ * Whether the variant ladder had been generated by the time the flow
121
+ * stopped waiting. `false` is not a failure — variants are produced by a
122
+ * background task and the reference is valid immediately; it means a skin
123
+ * should show the original (or its own placeholder) for now.
124
+ */
125
+ readonly variantsReady: boolean;
126
+ }
127
+
128
+ export interface RunUploadOptions {
129
+ readonly target: CdnUploadTarget;
130
+ readonly limits: CdnIntakeLimits;
131
+ readonly signal?: AbortSignal;
132
+ /** Phase transitions, in order. Called synchronously. */
133
+ readonly onPhase?: (phase: UploadPhase) => void;
134
+ /** Skip the pre-check entirely (reported as `dedupSkipped: "disabled"`). */
135
+ readonly dedup?: boolean;
136
+ /** How long to wait for the variant ladder. Default: 8 tries, 750 ms apart. */
137
+ readonly variants?: CdnVariantWaitOptions;
138
+ }
139
+
140
+ export interface CdnVariantWaitOptions {
141
+ /** `0` disables waiting; the outcome then reports the row as it arrived. */
142
+ readonly attempts?: number;
143
+ readonly intervalMs?: number;
144
+ /** Injectable timer (tests). Default: `setTimeout`. */
145
+ readonly wait?: (ms: number, signal?: AbortSignal) => Promise<void>;
146
+ }
147
+
148
+ const DEFAULT_VARIANT_ATTEMPTS = 8;
149
+ const DEFAULT_VARIANT_INTERVAL_MS = 750;
150
+
151
+ /**
152
+ * Named, not inlined, for the same reason `stapel/no-adhoc-401` names it in
153
+ * its own source: the rule bans a bare `=== 401` because that shape is how ad
154
+ * hoc refresh/redirect logic gets written outside core's one seam. What
155
+ * happens below is not that — nothing is refreshed, retried or redirected;
156
+ * a 401 from the OPTIONAL pre-check is merely CLASSIFIED, so the outcome can
157
+ * say "a guest may upload but may not pre-check" instead of "the check
158
+ * failed". The real 401 handling stays where it belongs, on the client's
159
+ * `onAuthRefresh` seam, and this flow never sees it.
160
+ */
161
+ const HTTP_UNAUTHORIZED = 401;
162
+
163
+ function defaultWait(ms: number, signal?: AbortSignal): Promise<void> {
164
+ return new Promise((resolve) => {
165
+ const timer = setTimeout(resolve, ms);
166
+ signal?.addEventListener(
167
+ "abort",
168
+ () => {
169
+ clearTimeout(timer);
170
+ resolve();
171
+ },
172
+ { once: true }
173
+ );
174
+ });
175
+ }
176
+
177
+ /** The abort a caller asked for, told apart from a genuine transport fault. */
178
+ export class UploadCanceled extends Error {
179
+ constructor() {
180
+ super("Upload canceled");
181
+ this.name = "UploadCanceled";
182
+ }
183
+ }
184
+
185
+ export function isUploadCanceled(value: unknown): value is UploadCanceled {
186
+ return value instanceof UploadCanceled;
187
+ }
188
+
189
+ function throwIfAborted(signal: AbortSignal | undefined): void {
190
+ if (signal?.aborted === true) throw new UploadCanceled();
191
+ }
192
+
193
+ /**
194
+ * Run the whole flow for one file.
195
+ *
196
+ * Rejects with a {@link StapelApiError} for every failure that is one — the
197
+ * client-side refusal, the server's, a transport fault folded by
198
+ * `toStapelApiError` — and with {@link UploadCanceled} when the signal fired.
199
+ * A caller therefore branches on cancellation without having to recognise
200
+ * `AbortError` by name, which is a DOMException whose shape differs between
201
+ * runtimes.
202
+ */
203
+ export async function runUpload(
204
+ api: CdnApi,
205
+ file: File,
206
+ options: RunUploadOptions
207
+ ): Promise<UploadOutcome> {
208
+ const { target, limits, signal } = options;
209
+ const phase = (next: UploadPhase): void => options.onPhase?.(next);
210
+
211
+ throwIfAborted(signal);
212
+
213
+ const refusal = validateFile(file, limits);
214
+ if (refusal !== null) {
215
+ phase("failed");
216
+ throw refusal;
217
+ }
218
+
219
+ const assetType = targetAssetType(target);
220
+ let fileHash: string | null = null;
221
+ let dedupSkipped: DedupSkipReason | undefined;
222
+
223
+ if (options.dedup === false) {
224
+ dedupSkipped = "disabled";
225
+ } else if (!canHashLocally()) {
226
+ dedupSkipped = "no_crypto";
227
+ } else {
228
+ phase("hashing");
229
+ fileHash = await sha256Hex(file);
230
+ throwIfAborted(signal);
231
+
232
+ phase("checking");
233
+ try {
234
+ const found = await api.fileExists(fileHash, sig(signal));
235
+ throwIfAborted(signal);
236
+ // Three conditions, all required. `exists` alone is not enough: the
237
+ // endpoint answers about ANY object with these bytes, so the same file
238
+ // stored earlier as a video or a document reports a hit that is not an
239
+ // image at all. And an image of a DIFFERENT asset type is not the row
240
+ // this POST would return either — the upload views filter dedup on
241
+ // `type=`, so uploading the bytes of one's own avatar as a listing photo
242
+ // must really upload them.
243
+ if (found.exists && found.type === "image" && found.file !== null) {
244
+ const image = found.file as CdnImage;
245
+ if (image.type === assetType) {
246
+ phase("done");
247
+ return {
248
+ ref: refOf(image),
249
+ image,
250
+ deduped: true,
251
+ dedupSkipped: undefined,
252
+ variantsReady: image.is_processed,
253
+ };
254
+ }
255
+ }
256
+ } catch (error) {
257
+ if (isUploadCanceled(error)) throw error;
258
+ const failure = toStapelApiError(error);
259
+ // 401 is the documented asymmetry: `file/exists/` needs
260
+ // `IsAuthenticated` while the upload endpoints take
261
+ // `IsNotAnonymousUser`, so a guest legitimately reaches this line and
262
+ // must still be able to upload. Every other failure is treated the same
263
+ // way for the same reason — the pre-check is an OPTIMISATION, and an
264
+ // optimisation that can fail the operation it optimises is a defect.
265
+ dedupSkipped =
266
+ failure.status === HTTP_UNAUTHORIZED ? "unauthorized" : "check_failed";
267
+ }
268
+ }
269
+
270
+ throwIfAborted(signal);
271
+ phase("uploading");
272
+ let image: CdnImage;
273
+ try {
274
+ const response = await uploadTo(api, target, file, signal);
275
+ image = response.image;
276
+ } catch (error) {
277
+ if (signal?.aborted === true) {
278
+ phase("canceled");
279
+ throw new UploadCanceled();
280
+ }
281
+ phase("failed");
282
+ throw toStapelApiError(error);
283
+ }
284
+
285
+ const settled = await waitForVariants(api, image, {
286
+ ...(signal !== undefined ? { signal } : {}),
287
+ ...(options.variants !== undefined ? { variants: options.variants } : {}),
288
+ onPhase: phase,
289
+ });
290
+
291
+ phase("done");
292
+ return {
293
+ ref: refOf(settled),
294
+ image: settled,
295
+ deduped: false,
296
+ dedupSkipped,
297
+ variantsReady: settled.is_processed,
298
+ };
299
+ }
300
+
301
+ function sig(signal: AbortSignal | undefined): { signal?: AbortSignal } {
302
+ return signal !== undefined ? { signal } : {};
303
+ }
304
+
305
+ function uploadTo(
306
+ api: CdnApi,
307
+ target: CdnUploadTarget,
308
+ file: File,
309
+ signal: AbortSignal | undefined
310
+ ): Promise<{ readonly image: CdnImage }> {
311
+ switch (target.kind) {
312
+ case "avatar":
313
+ return api.uploadAvatar(file, sig(signal));
314
+ case "typed":
315
+ return api.uploadTypedImage(target.assetType, file, sig(signal));
316
+ case "image":
317
+ return api.uploadImage(file, sig(signal));
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Wait for the background task to produce the variant ladder, by re-asking
323
+ * `file/exists/` — which is the only read stapel-cdn offers for a stored row.
324
+ *
325
+ * Bounded, and bounded is the point: variants are generated by a worker that
326
+ * may be down, and a polling loop with no ceiling turns "the thumbnail is not
327
+ * ready yet" into a tab that never stops making requests. When the budget runs
328
+ * out the flow returns the row it has, with `variantsReady: false` — a stated
329
+ * outcome, not a hang and not a failure. The reference is already valid.
330
+ */
331
+ async function waitForVariants(
332
+ api: CdnApi,
333
+ image: CdnImage,
334
+ options: {
335
+ readonly signal?: AbortSignal;
336
+ readonly variants?: CdnVariantWaitOptions;
337
+ readonly onPhase: (phase: UploadPhase) => void;
338
+ }
339
+ ): Promise<CdnImage> {
340
+ if (image.is_processed) return image;
341
+ const attempts = options.variants?.attempts ?? DEFAULT_VARIANT_ATTEMPTS;
342
+ if (attempts <= 0) return image;
343
+ const intervalMs = options.variants?.intervalMs ?? DEFAULT_VARIANT_INTERVAL_MS;
344
+ const wait = options.variants?.wait ?? defaultWait;
345
+
346
+ options.onPhase("processing");
347
+ let latest = image;
348
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
349
+ await wait(intervalMs, options.signal);
350
+ if (options.signal?.aborted === true) return latest;
351
+ try {
352
+ const found = await api.fileExists(latest.file_hash, sig(options.signal));
353
+ if (found.exists && found.type === "image" && found.file !== null) {
354
+ latest = found.file as CdnImage;
355
+ if (latest.is_processed) return latest;
356
+ }
357
+ } catch {
358
+ // Same posture as the pre-check: the ladder is an enhancement of a row
359
+ // that already exists. A failed poll ends the wait and reports the row
360
+ // as unprocessed; it never turns a stored upload into a failed one.
361
+ return latest;
362
+ }
363
+ }
364
+ return latest;
365
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "_comment": "Self-contained on purpose: standalone-buildable per frontend-standard §7. Mirrors the root tsconfig.base.json settings.",
4
+ "compilerOptions": {
5
+ "target": "ES2022",
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "module": "ESNext",
8
+ "moduleResolution": "bundler",
9
+ "jsx": "react-jsx",
10
+ "strict": true,
11
+ "noUncheckedIndexedAccess": true,
12
+ "noImplicitOverride": true,
13
+ "exactOptionalPropertyTypes": true,
14
+ "isolatedModules": true,
15
+ "isolatedDeclarations": true,
16
+ "verbatimModuleSyntax": true,
17
+ "declaration": true,
18
+ "declarationMap": true,
19
+ "sourceMap": true,
20
+ "skipLibCheck": true,
21
+ "forceConsistentCasingInFileNames": true,
22
+ "outDir": "dist",
23
+ "rootDir": "src"
24
+ },
25
+ "include": ["src"]
26
+ }