@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,23 @@
1
+ import { createModuleRuntime } from "@stapel/core";
2
+ import { createCdnApi } from "../api/cdnApi.js";
3
+ import { resolveCdnLimits } from "./limits.js";
4
+ /**
5
+ * ```tsx
6
+ * const runtime = createCdnRuntime({ baseUrl: "/cdn/api/v1/" });
7
+ * <CdnProvider runtime={runtime}>{app}</CdnProvider>
8
+ * ```
9
+ *
10
+ * NOT ANONYMOUS. Every endpoint this pair calls needs at least a guest
11
+ * identity (`IsNotAnonymousUser`), and the avatar intake and the dedup
12
+ * pre-check need a real session (`IsAuthenticated`). A storefront mounts this
13
+ * behind its member routes; the public catalogue never touches it.
14
+ */
15
+ export function createCdnRuntime(options) {
16
+ const base = createModuleRuntime((client) => createCdnApi(client), options);
17
+ return {
18
+ ...base,
19
+ limits: resolveCdnLimits(options.limits),
20
+ variants: options.variants,
21
+ };
22
+ }
23
+ //# sourceMappingURL=runtime.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runtime.js","sourceRoot":"","sources":["../../src/model/runtime.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAEnD,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAEhD,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AA6B/C;;;;;;;;;;GAUG;AACH,MAAM,UAAU,gBAAgB,CAAC,OAAgC;IAC/D,MAAM,IAAI,GAAG,mBAAmB,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,YAAY,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,CAAC;IAC5E,OAAO;QACL,GAAG,IAAI;QACP,MAAM,EAAE,gBAAgB,CAAC,OAAO,CAAC,MAAM,CAAC;QACxC,QAAQ,EAAE,OAAO,CAAC,QAAQ;KAC3B,CAAC;AACJ,CAAC"}
@@ -0,0 +1,88 @@
1
+ import type { CdnApi } from "../api/cdnApi.js";
2
+ import type { CdnImage, CdnRef } from "../api/types.js";
3
+ import type { CdnIntakeLimits } from "./limits.js";
4
+ /** Where the bytes go, and therefore what asset type the row gets. */
5
+ export type CdnUploadTarget = {
6
+ /**
7
+ * `POST /upload/image/`. The general intake — note that it stores
8
+ * `type="product"` regardless of `ASSET_TYPES`.
9
+ */
10
+ readonly kind: "image";
11
+ } | {
12
+ /** `POST /upload/avatar/`. The only intake that needs a real session. */
13
+ readonly kind: "avatar";
14
+ } | {
15
+ /** `POST /images/<assetType>/upload/`, validated against `ASSET_TYPES`. */
16
+ readonly kind: "typed";
17
+ readonly assetType: string;
18
+ };
19
+ /**
20
+ * The asset type a target produces, which is what the pre-check must match
21
+ * before it may short-circuit. `"product"` is not a guess: it is the literal
22
+ * the view writes (`ImageUploadView.post`).
23
+ */
24
+ export declare function targetAssetType(target: CdnUploadTarget): string;
25
+ /** Which step of the flow is running. */
26
+ export type UploadPhase = "idle" | "hashing" | "checking" | "uploading" | "processing" | "done" | "failed" | "canceled";
27
+ /** Why the dedup pre-check did not happen (or did not answer). */
28
+ export type DedupSkipReason =
29
+ /** No `crypto.subtle` — this page is not a secure context. */
30
+ "no_crypto"
31
+ /** `file/exists/` needs `IsAuthenticated`; a guest identity may still upload. */
32
+ | "unauthorized"
33
+ /** The check itself failed. Never fatal: the upload proceeds. */
34
+ | "check_failed"
35
+ /** The caller asked for no pre-check. */
36
+ | "disabled";
37
+ /** What a finished upload yields. */
38
+ export interface UploadOutcome {
39
+ /** `<type>/<hash>` — the value a consuming module stores. */
40
+ readonly ref: CdnRef;
41
+ readonly image: CdnImage;
42
+ /** The pre-check hit and NO upload request was made. */
43
+ readonly deduped: boolean;
44
+ /** `undefined` when the pre-check ran; a reason when it did not. */
45
+ readonly dedupSkipped: DedupSkipReason | undefined;
46
+ /**
47
+ * Whether the variant ladder had been generated by the time the flow
48
+ * stopped waiting. `false` is not a failure — variants are produced by a
49
+ * background task and the reference is valid immediately; it means a skin
50
+ * should show the original (or its own placeholder) for now.
51
+ */
52
+ readonly variantsReady: boolean;
53
+ }
54
+ export interface RunUploadOptions {
55
+ readonly target: CdnUploadTarget;
56
+ readonly limits: CdnIntakeLimits;
57
+ readonly signal?: AbortSignal;
58
+ /** Phase transitions, in order. Called synchronously. */
59
+ readonly onPhase?: (phase: UploadPhase) => void;
60
+ /** Skip the pre-check entirely (reported as `dedupSkipped: "disabled"`). */
61
+ readonly dedup?: boolean;
62
+ /** How long to wait for the variant ladder. Default: 8 tries, 750 ms apart. */
63
+ readonly variants?: CdnVariantWaitOptions;
64
+ }
65
+ export interface CdnVariantWaitOptions {
66
+ /** `0` disables waiting; the outcome then reports the row as it arrived. */
67
+ readonly attempts?: number;
68
+ readonly intervalMs?: number;
69
+ /** Injectable timer (tests). Default: `setTimeout`. */
70
+ readonly wait?: (ms: number, signal?: AbortSignal) => Promise<void>;
71
+ }
72
+ /** The abort a caller asked for, told apart from a genuine transport fault. */
73
+ export declare class UploadCanceled extends Error {
74
+ constructor();
75
+ }
76
+ export declare function isUploadCanceled(value: unknown): value is UploadCanceled;
77
+ /**
78
+ * Run the whole flow for one file.
79
+ *
80
+ * Rejects with a {@link StapelApiError} for every failure that is one — the
81
+ * client-side refusal, the server's, a transport fault folded by
82
+ * `toStapelApiError` — and with {@link UploadCanceled} when the signal fired.
83
+ * A caller therefore branches on cancellation without having to recognise
84
+ * `AbortError` by name, which is a DOMException whose shape differs between
85
+ * runtimes.
86
+ */
87
+ export declare function runUpload(api: CdnApi, file: File, options: RunUploadOptions): Promise<UploadOutcome>;
88
+ //# sourceMappingURL=upload.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../../src/model/upload.ts"],"names":[],"mappings":"AA6CA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAGxD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAGnD,sEAAsE;AACtE,MAAM,MAAM,eAAe,GACvB;IACE;;;OAGG;IACH,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;CACxB,GACD;IACE,yEAAyE;IACzE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;CACzB,GACD;IACE,2EAA2E;IAC3E,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B,CAAC;AAEN;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,eAAe,GAAG,MAAM,CAS/D;AAED,yCAAyC;AACzC,MAAM,MAAM,WAAW,GACnB,MAAM,GACN,SAAS,GACT,UAAU,GACV,WAAW,GACX,YAAY,GACZ,MAAM,GACN,QAAQ,GACR,UAAU,CAAC;AAEf,kEAAkE;AAClE,MAAM,MAAM,eAAe;AACzB,8DAA8D;AAC5D,WAAW;AACb,iFAAiF;GAC/E,cAAc;AAChB,iEAAiE;GAC/D,cAAc;AAChB,yCAAyC;GACvC,UAAU,CAAC;AAEf,qCAAqC;AACrC,MAAM,WAAW,aAAa;IAC5B,6DAA6D;IAC7D,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC;IACzB,wDAAwD;IACxD,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,oEAAoE;IACpE,QAAQ,CAAC,YAAY,EAAE,eAAe,GAAG,SAAS,CAAC;IACnD;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC;CACjC;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,MAAM,EAAE,eAAe,CAAC;IACjC,QAAQ,CAAC,MAAM,EAAE,eAAe,CAAC;IACjC,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC;IAC9B,yDAAyD;IACzD,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAC;IAChD,4EAA4E;IAC5E,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC;IACzB,+EAA+E;IAC/E,QAAQ,CAAC,QAAQ,CAAC,EAAE,qBAAqB,CAAC;CAC3C;AAED,MAAM,WAAW,qBAAqB;IACpC,4EAA4E;IAC5E,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,uDAAuD;IACvD,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACrE;AA+BD,+EAA+E;AAC/E,qBAAa,cAAe,SAAQ,KAAK;;CAKxC;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,cAAc,CAExE;AAMD;;;;;;;;;GASG;AACH,wBAAsB,SAAS,CAC7B,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,IAAI,EACV,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,aAAa,CAAC,CA4FxB"}
@@ -0,0 +1,254 @@
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 { canHashLocally, sha256Hex } from "./hash.js";
47
+ import { validateFile } from "./limits.js";
48
+ import { refOf } from "./refs.js";
49
+ /**
50
+ * The asset type a target produces, which is what the pre-check must match
51
+ * before it may short-circuit. `"product"` is not a guess: it is the literal
52
+ * the view writes (`ImageUploadView.post`).
53
+ */
54
+ export function targetAssetType(target) {
55
+ switch (target.kind) {
56
+ case "image":
57
+ return "product";
58
+ case "avatar":
59
+ return "avatar";
60
+ case "typed":
61
+ return target.assetType;
62
+ }
63
+ }
64
+ const DEFAULT_VARIANT_ATTEMPTS = 8;
65
+ const DEFAULT_VARIANT_INTERVAL_MS = 750;
66
+ /**
67
+ * Named, not inlined, for the same reason `stapel/no-adhoc-401` names it in
68
+ * its own source: the rule bans a bare `=== 401` because that shape is how ad
69
+ * hoc refresh/redirect logic gets written outside core's one seam. What
70
+ * happens below is not that — nothing is refreshed, retried or redirected;
71
+ * a 401 from the OPTIONAL pre-check is merely CLASSIFIED, so the outcome can
72
+ * say "a guest may upload but may not pre-check" instead of "the check
73
+ * failed". The real 401 handling stays where it belongs, on the client's
74
+ * `onAuthRefresh` seam, and this flow never sees it.
75
+ */
76
+ const HTTP_UNAUTHORIZED = 401;
77
+ function defaultWait(ms, signal) {
78
+ return new Promise((resolve) => {
79
+ const timer = setTimeout(resolve, ms);
80
+ signal?.addEventListener("abort", () => {
81
+ clearTimeout(timer);
82
+ resolve();
83
+ }, { once: true });
84
+ });
85
+ }
86
+ /** The abort a caller asked for, told apart from a genuine transport fault. */
87
+ export class UploadCanceled extends Error {
88
+ constructor() {
89
+ super("Upload canceled");
90
+ this.name = "UploadCanceled";
91
+ }
92
+ }
93
+ export function isUploadCanceled(value) {
94
+ return value instanceof UploadCanceled;
95
+ }
96
+ function throwIfAborted(signal) {
97
+ if (signal?.aborted === true)
98
+ throw new UploadCanceled();
99
+ }
100
+ /**
101
+ * Run the whole flow for one file.
102
+ *
103
+ * Rejects with a {@link StapelApiError} for every failure that is one — the
104
+ * client-side refusal, the server's, a transport fault folded by
105
+ * `toStapelApiError` — and with {@link UploadCanceled} when the signal fired.
106
+ * A caller therefore branches on cancellation without having to recognise
107
+ * `AbortError` by name, which is a DOMException whose shape differs between
108
+ * runtimes.
109
+ */
110
+ export async function runUpload(api, file, options) {
111
+ const { target, limits, signal } = options;
112
+ const phase = (next) => options.onPhase?.(next);
113
+ throwIfAborted(signal);
114
+ const refusal = validateFile(file, limits);
115
+ if (refusal !== null) {
116
+ phase("failed");
117
+ throw refusal;
118
+ }
119
+ const assetType = targetAssetType(target);
120
+ let fileHash = null;
121
+ let dedupSkipped;
122
+ if (options.dedup === false) {
123
+ dedupSkipped = "disabled";
124
+ }
125
+ else if (!canHashLocally()) {
126
+ dedupSkipped = "no_crypto";
127
+ }
128
+ else {
129
+ phase("hashing");
130
+ fileHash = await sha256Hex(file);
131
+ throwIfAborted(signal);
132
+ phase("checking");
133
+ try {
134
+ const found = await api.fileExists(fileHash, sig(signal));
135
+ throwIfAborted(signal);
136
+ // Three conditions, all required. `exists` alone is not enough: the
137
+ // endpoint answers about ANY object with these bytes, so the same file
138
+ // stored earlier as a video or a document reports a hit that is not an
139
+ // image at all. And an image of a DIFFERENT asset type is not the row
140
+ // this POST would return either — the upload views filter dedup on
141
+ // `type=`, so uploading the bytes of one's own avatar as a listing photo
142
+ // must really upload them.
143
+ if (found.exists && found.type === "image" && found.file !== null) {
144
+ const image = found.file;
145
+ if (image.type === assetType) {
146
+ phase("done");
147
+ return {
148
+ ref: refOf(image),
149
+ image,
150
+ deduped: true,
151
+ dedupSkipped: undefined,
152
+ variantsReady: image.is_processed,
153
+ };
154
+ }
155
+ }
156
+ }
157
+ catch (error) {
158
+ if (isUploadCanceled(error))
159
+ throw error;
160
+ const failure = toStapelApiError(error);
161
+ // 401 is the documented asymmetry: `file/exists/` needs
162
+ // `IsAuthenticated` while the upload endpoints take
163
+ // `IsNotAnonymousUser`, so a guest legitimately reaches this line and
164
+ // must still be able to upload. Every other failure is treated the same
165
+ // way for the same reason — the pre-check is an OPTIMISATION, and an
166
+ // optimisation that can fail the operation it optimises is a defect.
167
+ dedupSkipped =
168
+ failure.status === HTTP_UNAUTHORIZED ? "unauthorized" : "check_failed";
169
+ }
170
+ }
171
+ throwIfAborted(signal);
172
+ phase("uploading");
173
+ let image;
174
+ try {
175
+ const response = await uploadTo(api, target, file, signal);
176
+ image = response.image;
177
+ }
178
+ catch (error) {
179
+ if (signal?.aborted === true) {
180
+ phase("canceled");
181
+ throw new UploadCanceled();
182
+ }
183
+ phase("failed");
184
+ throw toStapelApiError(error);
185
+ }
186
+ const settled = await waitForVariants(api, image, {
187
+ ...(signal !== undefined ? { signal } : {}),
188
+ ...(options.variants !== undefined ? { variants: options.variants } : {}),
189
+ onPhase: phase,
190
+ });
191
+ phase("done");
192
+ return {
193
+ ref: refOf(settled),
194
+ image: settled,
195
+ deduped: false,
196
+ dedupSkipped,
197
+ variantsReady: settled.is_processed,
198
+ };
199
+ }
200
+ function sig(signal) {
201
+ return signal !== undefined ? { signal } : {};
202
+ }
203
+ function uploadTo(api, target, file, signal) {
204
+ switch (target.kind) {
205
+ case "avatar":
206
+ return api.uploadAvatar(file, sig(signal));
207
+ case "typed":
208
+ return api.uploadTypedImage(target.assetType, file, sig(signal));
209
+ case "image":
210
+ return api.uploadImage(file, sig(signal));
211
+ }
212
+ }
213
+ /**
214
+ * Wait for the background task to produce the variant ladder, by re-asking
215
+ * `file/exists/` — which is the only read stapel-cdn offers for a stored row.
216
+ *
217
+ * Bounded, and bounded is the point: variants are generated by a worker that
218
+ * may be down, and a polling loop with no ceiling turns "the thumbnail is not
219
+ * ready yet" into a tab that never stops making requests. When the budget runs
220
+ * out the flow returns the row it has, with `variantsReady: false` — a stated
221
+ * outcome, not a hang and not a failure. The reference is already valid.
222
+ */
223
+ async function waitForVariants(api, image, options) {
224
+ if (image.is_processed)
225
+ return image;
226
+ const attempts = options.variants?.attempts ?? DEFAULT_VARIANT_ATTEMPTS;
227
+ if (attempts <= 0)
228
+ return image;
229
+ const intervalMs = options.variants?.intervalMs ?? DEFAULT_VARIANT_INTERVAL_MS;
230
+ const wait = options.variants?.wait ?? defaultWait;
231
+ options.onPhase("processing");
232
+ let latest = image;
233
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
234
+ await wait(intervalMs, options.signal);
235
+ if (options.signal?.aborted === true)
236
+ return latest;
237
+ try {
238
+ const found = await api.fileExists(latest.file_hash, sig(options.signal));
239
+ if (found.exists && found.type === "image" && found.file !== null) {
240
+ latest = found.file;
241
+ if (latest.is_processed)
242
+ return latest;
243
+ }
244
+ }
245
+ catch {
246
+ // Same posture as the pre-check: the ladder is an enhancement of a row
247
+ // that already exists. A failed poll ends the wait and reports the row
248
+ // as unprocessed; it never turns a stored upload into a failed one.
249
+ return latest;
250
+ }
251
+ }
252
+ return latest;
253
+ }
254
+ //# sourceMappingURL=upload.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"upload.js","sourceRoot":"","sources":["../../src/model/upload.ts"],"names":[],"mappings":"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"}
package/llms.txt ADDED
@@ -0,0 +1,83 @@
1
+ # @stapel/cdn-react 0.1.0
2
+
3
+ Headless React flow pair for stapel-cdn (contract >=0.12 <0.13) — business + state, zero visual opinion.
4
+ Built on @stapel/core: typed client + StapelApiError envelope, auth token refresh,
5
+ verification-403 interception, i18n engine, analytics facade, TanStack Query layer.
6
+
7
+ ## The one right way (do this, the rest is a review/lint smell)
8
+ - No raw fetch/axios. The client is injected via <CdnProvider>/StapelConfigProvider;
9
+ every hook and flow already carries auth, refresh, and the error envelope.
10
+ - Render errors, never try/catch them: a flow's state carries FlowError{code,params};
11
+ render `t(code, params)` and branch on `explainCdnError(code)` remediation.
12
+ - Server state = the use* hooks (query layer); keys come only from cdnQueryKeys.
13
+ - User strings = i18n keys (registerCdnI18n); never string literals.
14
+ - Sign-in UI = a headless flow component; copy it (shadcn-style) to restyle.
15
+
16
+ ## Layers
17
+ api (typed client) · model (hooks, session) · flows (machines) · headless · i18n
18
+
19
+ ## Documented flows (flows.json — canonical id, steps, endpoints)
20
+
21
+ ## Operations (typed; use the named op, never a path string)
22
+ Request/response schema names are in manifest.json + the generated types.
23
+ Paths are relative to `/cdn/api/v1/`.
24
+ - check_file_exists_get: GET /file/exists/
25
+ - check_file_exists_post: POST /file/exists/
26
+ - random_image: GET /images/{image_type}/random/
27
+ - sync_refs: POST /refs/sync/
28
+ - upload_avatar: POST /upload/avatar/
29
+ - upload_file: POST /upload/file/
30
+ - upload_image: POST /upload/image/
31
+ - upload_typed_image: POST /images/{image_type}/upload/
32
+ - upload_video: POST /upload/video/
33
+
34
+ ## Query hooks (server state; keys come only from the key factory)
35
+ - useCdnRef (query) → fileExists
36
+
37
+ ## Errors (render t(code, params); UX from remediation)
38
+ 53 keys (full catalog: manifest.json §errors). By remediation: fix_input 24 · retry 15 · verify 5 · wait_and_retry 5 · contact_support 3 · reauthenticate 1.
39
+ Param-bearing keys (interpolation slots matter):
40
+ - error.400.field.blank [400] → fix_input {field}
41
+ - error.400.field.does_not_exist [400] → fix_input {field}
42
+ - error.400.field.invalid [400] → fix_input {field}
43
+ - error.400.field.invalid_choice [400] → fix_input {field}
44
+ - error.400.field.max_length [400] → fix_input {field,max_length}
45
+ - error.400.field.max_value [400] → fix_input {field,max_value}
46
+ - error.400.field.min_length [400] → fix_input {field,min_length}
47
+ - error.400.field.min_value [400] → fix_input {field,min_value}
48
+ - error.400.field.null [400] → fix_input {field}
49
+ - error.400.field.required [400] → fix_input {field}
50
+ - error.400.field.unique [400] → fix_input {field}
51
+ - error.429.rate_limit [429] → wait_and_retry {retry_after_minutes}
52
+ - error.503.image_decoder_unavailable [503] → contact_support {extension}
53
+
54
+ ## Analytics events (typed; defineEvent → events.json, drift-gated)
55
+ - (no app defineEvent() in this pair — its analytic events are the
56
+ auto-instrumented flow funnels below)
57
+
58
+ ## Flow funnels (auto-instrumented: flow.<id>.<step> {phase})
59
+
60
+ ```tsx
61
+ // Typed event + tracked() click (the one right way; §3.1).
62
+ const planSelected = defineEvent({
63
+ name: "pricing.plan.selected",
64
+ description: "User picked a plan",
65
+ props: { plan: prop.oneOf(["free", "pro", "team"], "Plan code") },
66
+ });
67
+ const { tracked } = useTracked();
68
+ <Button onClick={tracked(planSelected, { plan }, startCheckout)} />
69
+ // A click that STEPS a flow machine is already instrumented — mark it
70
+ // data-analytics="flow" instead; tracked() on top double-counts (§3.2).
71
+ ```
72
+
73
+ ## Demos (defineDemo → manifest.demos; compiled, linted, rendered examples)
74
+ - cdn.gallery → <MediaUploader> [default|reopened-draft|full] demo/MediaUploader.demo.tsx
75
+ - cdn.single → <ImageUpload> [default|already-stored] demo/ImageUpload.demo.tsx
76
+ Each source file is the canonical usage snippet (open the default variant).
77
+
78
+ ## Snippets
79
+ ```tsx
80
+ // Error rendering + remediation branch (one pattern for every pair).
81
+ const r = explainCdnError(err.code); // 'wait_and_retry' | 'verify' | ...
82
+ return <Alert action={r}>{t(err.code, err.params)}</Alert>;
83
+ ```