@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
package/src/index.ts ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * `@stapel/cdn-react` — the headless React pair for stapel-cdn
3
+ * (frontend-standard §2). Business + state only, zero visual opinion; the antd
4
+ * skin lives behind the `./default` subpath, so a host that renders its own
5
+ * upload control carries none of it.
6
+ *
7
+ * ── The one-liner ──────────────────────────────────────────────────────────
8
+ *
9
+ * ```tsx
10
+ * const runtime = createCdnRuntime({ baseUrl: "/cdn/api/v1/" });
11
+ * <CdnProvider runtime={runtime}>
12
+ * <MediaUploader max={10} onRefsChange={setImagesDraft}>
13
+ * {(bag) => <YourGrid bag={bag} />}
14
+ * </MediaUploader>
15
+ * </CdnProvider>
16
+ * ```
17
+ *
18
+ * ── The three properties this pair exists to guarantee ─────────────────────
19
+ *
20
+ * 1. **Dedup happens before the bytes move.** The flow hashes the file, asks
21
+ * `file/exists/`, and on a hit returns the reference having sent NOTHING.
22
+ * That is the property the storefront spec asks to be proven, and
23
+ * `test/dedup.test.ts` proves it by counting requests. The pre-check is an
24
+ * optimisation, so it is never allowed to fail the upload: a guest (401 —
25
+ * the endpoint is stricter than the upload endpoints), a page with no
26
+ * `crypto.subtle`, or a check that simply errors all fall through to the
27
+ * POST and say so in `dedupSkipped`.
28
+ * 2. **The unit handed out is the reference, not a URL.** `<type>/<hash>` is
29
+ * what `Profile.avatar` and `Listing.images_draft` store, and it is opaque:
30
+ * the pair does not build URLs out of it and neither should a caller. The
31
+ * row that comes back carries the URLs, and `toStapelImage` converts its
32
+ * ladder for `@stapel/image` in the single place that knows the two
33
+ * contracts spell `tier` differently.
34
+ * 3. **A switched-off control states its reason.** `canAdd` and `settled` are
35
+ * `ActionAvailability`, so "the gallery is full", "wait for the uploads"
36
+ * and "retry the ones that failed" are three different sentences rather
37
+ * than one grey button.
38
+ *
39
+ * ── What this pair does NOT do ─────────────────────────────────────────────
40
+ *
41
+ * No progress percentage: `fetch` cannot observe request-body progress and
42
+ * `crypto.subtle` reports nothing mid-digest, so the bag names the PHASE and a
43
+ * skin shows an indeterminate indicator. Forking onto `XMLHttpRequest` to get
44
+ * a number would mean a second transport with its own auth, refresh and error
45
+ * handling — the exact duplication this package was extracted to end.
46
+ *
47
+ * No `refs/sync/`: that endpoint is `IsServiceRequest` and unreachable from a
48
+ * browser by construction. Reference bookkeeping belongs to the module that
49
+ * owns the entity.
50
+ *
51
+ * No public read-by-reference: `file/exists/` is owner-scoped, so `useCdnRef`
52
+ * resolves the CALLER's own references (a reopened draft) and cannot render a
53
+ * stranger's gallery. Recorded as an upstream gap in the README rather than
54
+ * worked around with a URL convention this pair would have invented.
55
+ *
56
+ * Layers: api → model → headless → i18n. Generated surfaces (the typed schema,
57
+ * the error map, the manifest, llms.txt) are produced by the monorepo `gen:*`
58
+ * drivers from stapel-cdn's own `docs/` artifacts and stand under drift gates.
59
+ */
60
+
61
+ // ── api ──────────────────────────────────────────────────────────────────────
62
+ export { createCdnApi } from "./api/cdnApi.js";
63
+ export type { CdnApi } from "./api/cdnApi.js";
64
+ export type {
65
+ CdnFileExistsResponse,
66
+ CdnFileKind,
67
+ CdnFileModel,
68
+ CdnFileUploadResponse,
69
+ CdnImage,
70
+ CdnImageUploadResponse,
71
+ CdnRef,
72
+ CdnVariantMeta,
73
+ CdnVideo,
74
+ CdnVideoUploadResponse,
75
+ ParsedCdnRef,
76
+ Schemas,
77
+ } from "./api/types.js";
78
+
79
+ // ── model: the flow, the mirror, the reference ───────────────────────────────
80
+ export { createCdnRuntime } from "./model/runtime.js";
81
+ export type { CdnRuntime, CreateCdnRuntimeOptions } from "./model/runtime.js";
82
+ export {
83
+ CdnRuntimeContext,
84
+ useCdnAnalytics,
85
+ useCdnApi,
86
+ useCdnRuntime,
87
+ } from "./model/context.js";
88
+ export { cdnQueryKeys } from "./model/queryKeys.js";
89
+ export { useCdnRef } from "./model/queries.js";
90
+ export type { CdnRefBag } from "./model/queries.js";
91
+ export {
92
+ isUploadCanceled,
93
+ runUpload,
94
+ targetAssetType,
95
+ UploadCanceled,
96
+ } from "./model/upload.js";
97
+ export type {
98
+ CdnUploadTarget,
99
+ CdnVariantWaitOptions,
100
+ DedupSkipReason,
101
+ RunUploadOptions,
102
+ UploadOutcome,
103
+ UploadPhase,
104
+ } from "./model/upload.js";
105
+ export {
106
+ acceptAttribute,
107
+ CDN_DEFAULT_LIMITS,
108
+ ERROR_FILE_TOO_LARGE,
109
+ ERROR_INVALID_FORMAT,
110
+ ERROR_NO_FILE,
111
+ fileExtension,
112
+ resolveCdnLimits,
113
+ validateFile,
114
+ } from "./model/limits.js";
115
+ export type {
116
+ CdnIntakeLimits,
117
+ CdnLimits,
118
+ CdnLimitsOverride,
119
+ } from "./model/limits.js";
120
+ export { formatCdnRef, parseCdnRef, refOf, toStapelImage } from "./model/refs.js";
121
+ export { canHashLocally, sha256Hex } from "./model/hash.js";
122
+
123
+ // ── headless ─────────────────────────────────────────────────────────────────
124
+ export { CdnProvider } from "./headless/CdnProvider.js";
125
+ export { ImageUpload } from "./headless/ImageUpload.js";
126
+ export { MediaUploader } from "./headless/MediaUploader.js";
127
+ export { useUploadImage } from "./headless/useUploadImage.js";
128
+ export type { UploadImageBag } from "./headless/useUploadImage.js";
129
+ export { useUploadQueue } from "./headless/useUploadQueue.js";
130
+ export type {
131
+ UploadItem,
132
+ UploadQueueBag,
133
+ UseUploadQueueOptions,
134
+ } from "./headless/useUploadQueue.js";
135
+ export { smallestVariantUrl, useUploadPreview } from "./headless/useUploadPreview.js";
136
+ export type { UploadPreview } from "./headless/useUploadPreview.js";
137
+
138
+ // ── flows (zero-flow shim — stapel-cdn annotates none) ───────────────────────
139
+ export { CDN_FLOWS, flowEndpoints } from "./flows/registry.js";
140
+ export type { CdnFlowId, CdnFlowSpec, FlowEndpoint } from "./flows/registry.js";
141
+
142
+ // ── i18n ─────────────────────────────────────────────────────────────────────
143
+ export { CDN_I18N_KEYS, cdnI18nBundleEn, registerCdnI18n } from "./i18n/keys.js";
144
+ export {
145
+ CDN_ERROR_CODES,
146
+ CDN_ERRORS,
147
+ cdnErrorBundleEn,
148
+ explainCdnError,
149
+ } from "./i18n/errorsMap.js";
150
+ export type {
151
+ CdnErrorCode,
152
+ CdnErrorSpec,
153
+ Remediation,
154
+ } from "./i18n/errorsMap.js";
@@ -0,0 +1,25 @@
1
+ import type { Context } from "react";
2
+ import { createModuleContext } from "@stapel/core";
3
+ import type { Analytics, ModuleContextKit } from "@stapel/core";
4
+ import type { CdnApi } from "../api/cdnApi.js";
5
+ import type { CdnRuntime } from "./runtime.js";
6
+
7
+ /**
8
+ * The wired {@link CdnRuntime} shared through React context by
9
+ * `<CdnProvider>`. Hooks in `model/` and `headless/` read the singletons from
10
+ * here. One reviewed copy of this plumbing lives in `@stapel/core`
11
+ * (`createModuleContext`); this module binds it under the pair's public names.
12
+ */
13
+ const kit: ModuleContextKit<CdnRuntime> = createModuleContext<CdnRuntime>("Cdn");
14
+
15
+ export const CdnRuntimeContext: Context<CdnRuntime | null> = kit.RuntimeContext;
16
+
17
+ export const useCdnRuntime: () => CdnRuntime = kit.useRuntime;
18
+
19
+ export const useCdnApi: () => CdnApi = kit.useApi;
20
+
21
+ export const useCdnAnalytics: () => Analytics | null = kit.useAnalytics;
22
+
23
+ /** @internal Re-exported as `<CdnProvider>` from `headless/`. */
24
+ export const ModuleProvider: ModuleContextKit<CdnRuntime>["Provider"] =
25
+ kit.Provider;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * SHA-256 of a file's bytes — the key the dedup pre-check is asked with.
3
+ *
4
+ * The backend computes the same digest over the same bytes
5
+ * (`Image.calculate_file_hash`, `hashlib.sha256(...).hexdigest()`), so the two
6
+ * agree by construction: 64 lowercase hex characters of the ORIGINAL upload,
7
+ * before any variant exists.
8
+ *
9
+ * ── Why this can be unavailable, and why that is not an error ──────────────
10
+ *
11
+ * `crypto.subtle` exists only in a SECURE CONTEXT. On `http://` (a LAN test
12
+ * box, an old staging host) it is simply not there, and neither is the
13
+ * pre-check. That is a lost optimisation, not a lost upload: the server
14
+ * deduplicates on its own side regardless, so the POST is still correct — it
15
+ * just costs the bytes. {@link canHashLocally} lets the flow say which of the
16
+ * two paths it took instead of failing, and `UploadItem.dedupSkipped` carries
17
+ * the reason all the way to a skin that wants to explain it.
18
+ *
19
+ * ── Why there is no progress here ──────────────────────────────────────────
20
+ *
21
+ * `SubtleCrypto.digest` takes the whole buffer and returns one promise; it
22
+ * reports nothing in between and cannot be chunked without hand-rolling
23
+ * SHA-256, which is not a thing to hand-roll. Hashing is therefore a PHASE
24
+ * with a duration, not a percentage — see `model/upload.ts` on why the whole
25
+ * bag is phase-shaped.
26
+ */
27
+
28
+ /** Whether this context can compute the digest at all (see the header). */
29
+ export function canHashLocally(): boolean {
30
+ return (
31
+ typeof globalThis.crypto !== "undefined" &&
32
+ typeof globalThis.crypto.subtle?.digest === "function"
33
+ );
34
+ }
35
+
36
+ function toHex(buffer: ArrayBuffer): string {
37
+ const bytes = new Uint8Array(buffer);
38
+ let out = "";
39
+ for (const byte of bytes) out += byte.toString(16).padStart(2, "0");
40
+ return out;
41
+ }
42
+
43
+ /**
44
+ * `digest` is handed a `Uint8Array` VIEW rather than the raw `ArrayBuffer`.
45
+ * Both are valid `BufferSource`, but a buffer that crossed a realm boundary
46
+ * fails a strict `instanceof ArrayBuffer` check in some implementations (jsdom
47
+ * is one, which is where this surfaced), while a typed-array view is accepted
48
+ * everywhere. The view costs nothing — no copy — and removes a portability
49
+ * hazard that would otherwise only show up in somebody else's environment.
50
+ */
51
+ const bufferSource = (buffer: ArrayBuffer): Uint8Array<ArrayBuffer> =>
52
+ new Uint8Array(buffer);
53
+
54
+ /**
55
+ * The 64-character lowercase hex SHA-256 of `blob`'s bytes.
56
+ *
57
+ * Throws whatever the platform throws when there is no `crypto.subtle` — call
58
+ * {@link canHashLocally} first; the upload flow does.
59
+ */
60
+ export async function sha256Hex(blob: Blob): Promise<string> {
61
+ const buffer = await blob.arrayBuffer();
62
+ const digest = await globalThis.crypto.subtle.digest(
63
+ "SHA-256",
64
+ bufferSource(buffer)
65
+ );
66
+ return toHex(digest);
67
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * The client-side mirror of stapel-cdn's own intake gates.
3
+ *
4
+ * ── Why mirror at all ──────────────────────────────────────────────────────
5
+ *
6
+ * Because the alternative is uploading 40 MB over a phone connection to be
7
+ * told "File is too large" by the server that just received all of it. The
8
+ * gates below are the cheap half of what `_validate_image_upload` does before
9
+ * it hashes a byte (`stapel_cdn/views.py`): the size ceiling and the extension
10
+ * allowlist. The expensive half — an actual libvips decode, which is what
11
+ * catches a `.jpg` full of HTML — has no browser equivalent and is not
12
+ * simulated here. A file that passes this mirror can still be refused, and
13
+ * that refusal renders through the same error contour as any other.
14
+ *
15
+ * ── The rule the mirror must never break ───────────────────────────────────
16
+ *
17
+ * A MIRROR THAT REFUSES WHAT THE SERVER WOULD ACCEPT IS WORSE THAN NO MIRROR.
18
+ * It blocks a valid upload with no way for the person to appeal, and the
19
+ * server never even hears about it. So every ceiling here is CONFIGURABLE on
20
+ * the runtime and defaults to the library's own default — because all of them
21
+ * are `STAPEL_CDN` settings a deployment moves (`stapel_cdn/conf.py`), and a
22
+ * hardcoded 20 MB would be a client-side lie on a host that raised it. The
23
+ * same argument in the opposite direction: a host that LOWERED the ceiling
24
+ * passes its number here and gets the refusal early, which is the point.
25
+ *
26
+ * The refusals below are raised with stapel-cdn's OWN error codes, so a skin
27
+ * renders "File is too large" from the generated bundle whether the sentence
28
+ * came from here or from the server. There is no second vocabulary for
29
+ * client-side refusals.
30
+ */
31
+ import { StapelApiError } from "@stapel/core";
32
+
33
+ /** `error.413.file_too_large` — the byte ceiling, from `MAX_*_SIZE`. */
34
+ export const ERROR_FILE_TOO_LARGE = "error.413.file_too_large";
35
+ /** `error.400.invalid_format` — the extension allowlist. */
36
+ export const ERROR_INVALID_FORMAT = "error.400.invalid_format";
37
+ /** `error.400.no_file` — an empty pick. */
38
+ export const ERROR_NO_FILE = "error.400.no_file";
39
+
40
+ /**
41
+ * The ceilings for one intake kind. Defaults reproduce `stapel_cdn/conf.py`'s
42
+ * `DEFAULTS` at the pinned contract (v0.12.0).
43
+ */
44
+ export interface CdnIntakeLimits {
45
+ /** Byte ceiling. `STAPEL_CDN["MAX_IMAGE_SIZE" | …]`. */
46
+ readonly maxBytes: number;
47
+ /** Lowercase extensions INCLUDING the dot, as the backend stores them. */
48
+ readonly extensions: readonly string[];
49
+ /**
50
+ * MIME types the backend narrows on, when it narrows on MIME at all. Only
51
+ * the generic-file intake does (`ALLOWED_FILE_MIME_TYPES`); images and
52
+ * videos are gated on the extension plus a decode/sniff, so this is
53
+ * `undefined` for them rather than a guess. An empty array would mean
54
+ * "nothing is allowed", which is a different and wrong statement.
55
+ */
56
+ readonly mimeTypes?: readonly string[];
57
+ }
58
+
59
+ export interface CdnLimits {
60
+ readonly image: CdnIntakeLimits;
61
+ readonly video: CdnIntakeLimits;
62
+ readonly file: CdnIntakeLimits;
63
+ }
64
+
65
+ const MB = 1024 * 1024;
66
+
67
+ /** stapel-cdn's library defaults — the mirror's starting point. */
68
+ export const CDN_DEFAULT_LIMITS: CdnLimits = {
69
+ image: {
70
+ maxBytes: 20 * MB,
71
+ extensions: [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".heic", ".heif"],
72
+ },
73
+ video: {
74
+ maxBytes: 100 * MB,
75
+ extensions: [".mp4", ".webm", ".mov", ".avi", ".mkv"],
76
+ },
77
+ file: {
78
+ maxBytes: 50 * MB,
79
+ extensions: [
80
+ ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
81
+ ".txt", ".csv", ".zip", ".rar", ".7z", ".gz",
82
+ ],
83
+ mimeTypes: [
84
+ "application/pdf",
85
+ "application/msword",
86
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
87
+ "application/vnd.ms-excel",
88
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
89
+ "application/vnd.ms-powerpoint",
90
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
91
+ "text/plain",
92
+ "text/csv",
93
+ "application/zip",
94
+ "application/x-rar-compressed",
95
+ "application/x-7z-compressed",
96
+ "application/gzip",
97
+ ],
98
+ },
99
+ };
100
+
101
+ /** Host overrides — every field optional, merged over the defaults. */
102
+ export interface CdnLimitsOverride {
103
+ readonly image?: Partial<CdnIntakeLimits>;
104
+ readonly video?: Partial<CdnIntakeLimits>;
105
+ readonly file?: Partial<CdnIntakeLimits>;
106
+ }
107
+
108
+ function mergeIntake(
109
+ base: CdnIntakeLimits,
110
+ override: Partial<CdnIntakeLimits> | undefined
111
+ ): CdnIntakeLimits {
112
+ if (override === undefined) return base;
113
+ return {
114
+ maxBytes: override.maxBytes ?? base.maxBytes,
115
+ extensions: override.extensions ?? base.extensions,
116
+ ...(override.mimeTypes !== undefined
117
+ ? { mimeTypes: override.mimeTypes }
118
+ : base.mimeTypes !== undefined
119
+ ? { mimeTypes: base.mimeTypes }
120
+ : {}),
121
+ };
122
+ }
123
+
124
+ export function resolveCdnLimits(override?: CdnLimitsOverride): CdnLimits {
125
+ return {
126
+ image: mergeIntake(CDN_DEFAULT_LIMITS.image, override?.image),
127
+ video: mergeIntake(CDN_DEFAULT_LIMITS.video, override?.video),
128
+ file: mergeIntake(CDN_DEFAULT_LIMITS.file, override?.file),
129
+ };
130
+ }
131
+
132
+ /** The lowercase extension of a filename, dot included, or `""`. */
133
+ export function fileExtension(name: string): string {
134
+ const dot = name.lastIndexOf(".");
135
+ if (dot <= 0 || dot === name.length - 1) return "";
136
+ return name.slice(dot).toLowerCase();
137
+ }
138
+
139
+ /**
140
+ * The `accept` attribute for a file input, built from the same allowlist the
141
+ * refusal is built from — so the picker and the gate cannot disagree.
142
+ */
143
+ export function acceptAttribute(limits: CdnIntakeLimits): string {
144
+ const parts = [...limits.extensions];
145
+ if (limits.mimeTypes !== undefined) parts.push(...limits.mimeTypes);
146
+ return parts.join(",");
147
+ }
148
+
149
+ /**
150
+ * Check one file against one intake's ceilings.
151
+ *
152
+ * Returns the refusal as a {@link StapelApiError} in stapel-cdn's own
153
+ * vocabulary, or `null` when the file may be sent. Never throws: the caller
154
+ * (a queue admitting ten files at once) needs a per-file verdict, not a
155
+ * control-flow interruption on the first bad one.
156
+ */
157
+ export function validateFile(
158
+ file: File,
159
+ limits: CdnIntakeLimits
160
+ ): StapelApiError | null {
161
+ if (file.size === 0) {
162
+ return new StapelApiError({
163
+ code: ERROR_NO_FILE,
164
+ message: "No file provided",
165
+ status: 400,
166
+ });
167
+ }
168
+ if (file.size > limits.maxBytes) {
169
+ return new StapelApiError({
170
+ code: ERROR_FILE_TOO_LARGE,
171
+ message: "File is too large",
172
+ // The server answers 413 for this; the mirror says the same number so a
173
+ // host branching on `status` cannot tell the two apart — which is the
174
+ // intent. `params` carries what the sentence would need to be specific,
175
+ // even though the backend's own copy has no slots today.
176
+ status: 413,
177
+ params: { max_bytes: limits.maxBytes, size: file.size },
178
+ });
179
+ }
180
+ const extension = fileExtension(file.name);
181
+ if (!limits.extensions.includes(extension)) {
182
+ return new StapelApiError({
183
+ code: ERROR_INVALID_FORMAT,
184
+ message: "Unsupported file format",
185
+ status: 400,
186
+ params: { extension, allowed: limits.extensions.join(", ") },
187
+ });
188
+ }
189
+ // MIME is checked only where the backend checks it, and only when the
190
+ // browser actually supplied one. A blank `file.type` is common (an
191
+ // extension the OS does not know) and is NOT evidence of a bad file — the
192
+ // backend reads the declared Content-Type the same way, as a narrowing
193
+ // device rather than a verdict.
194
+ if (
195
+ limits.mimeTypes !== undefined &&
196
+ file.type.length > 0 &&
197
+ !limits.mimeTypes.includes(file.type)
198
+ ) {
199
+ return new StapelApiError({
200
+ code: ERROR_INVALID_FORMAT,
201
+ message: "Unsupported file format",
202
+ status: 400,
203
+ params: { mime: file.type },
204
+ });
205
+ }
206
+ return null;
207
+ }
@@ -0,0 +1,64 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import { loadStateFromQuery, useActiveSessionReady } from "@stapel/core";
3
+ import type { LoadState } from "@stapel/core";
4
+ import type { CdnFileExistsResponse, CdnImage, CdnRef } from "../api/types.js";
5
+ import { useCdnApi } from "./context.js";
6
+ import { cdnQueryKeys } from "./queryKeys.js";
7
+ import { parseCdnRef } from "./refs.js";
8
+
9
+ /**
10
+ * Resolve a stored `<type>/<hash>` reference back to the CDN row — the read
11
+ * that lets a composer REOPEN a draft and show what is already attached to it.
12
+ *
13
+ * ── The scope this read has, stated rather than discovered ─────────────────
14
+ *
15
+ * `file/exists/` filters on `uploaded_by=request.user`, always. So this
16
+ * resolves the CALLER'S OWN references and nothing else: it is the right
17
+ * instrument for "my draft's photos" and the wrong one for "this seller's
18
+ * photos". A buyer's storefront renders a listing's images from what the
19
+ * listings API gives it, not from here — stapel-cdn exposes no public
20
+ * read-by-reference endpoint at all (recorded as an upstream gap in the
21
+ * package README).
22
+ *
23
+ * A reference that resolves to nothing answers `ready(null)`, not `failed`:
24
+ * `{exists: false}` is a 200 and a true answer. "This reference is not mine /
25
+ * no longer stored" and "we could not ask" are different sentences and a skin
26
+ * must be able to tell them apart — which is why the value behind `ready` is
27
+ * nullable and the failure lives in the discriminant.
28
+ */
29
+ export interface CdnRefBag {
30
+ readonly state: LoadState<CdnImage | null>;
31
+ readonly isFetching: boolean;
32
+ refetch(): void;
33
+ }
34
+
35
+ export function useCdnRef(ref: CdnRef | null | undefined): CdnRefBag {
36
+ const api = useCdnApi();
37
+ // Gated on the session for the reason core's own doc comment gives: the
38
+ // endpoint is `IsAuthenticated`, so firing it during the login bootstrap
39
+ // buys one guaranteed 401 per mounted thumbnail.
40
+ const sessionReady = useActiveSessionReady();
41
+ const parsed = ref === null || ref === undefined ? null : parseCdnRef(ref);
42
+
43
+ const query = useQuery({
44
+ queryKey: cdnQueryKeys.exists(parsed?.fileHash ?? ""),
45
+ queryFn: ({ signal }): Promise<CdnFileExistsResponse> =>
46
+ api.fileExists(parsed?.fileHash ?? "", { signal }),
47
+ enabled: sessionReady && parsed !== null,
48
+ // A content-addressed row does not change. Its VARIANTS do, once, when the
49
+ // background task finishes — the upload flow waits for that itself, so a
50
+ // refetch here would only re-ask a settled question.
51
+ staleTime: Number.POSITIVE_INFINITY,
52
+ retry: false,
53
+ select: (data): CdnImage | null =>
54
+ data.exists && data.type === "image" && data.file !== null
55
+ ? (data.file as CdnImage)
56
+ : null,
57
+ });
58
+
59
+ return {
60
+ state: loadStateFromQuery(query),
61
+ isFetching: query.isFetching,
62
+ refetch: () => void query.refetch(),
63
+ };
64
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Namespaced TanStack Query keys (frontend-standard §2). Everything under the
3
+ * `"cdn"` root so a host can invalidate the whole module or match a single
4
+ * read.
5
+ *
6
+ * There is exactly one cached read in this pair, and it is keyed on the CONTENT
7
+ * HASH rather than on the reference string. Two references that differ only in
8
+ * asset type (`avatar/<h>` and `product/<h>`) resolve through the same
9
+ * `file/exists/` call and must not fetch twice; and because the hash IS the
10
+ * identity of the bytes, a key built from it can never go stale in the way a
11
+ * mutable row's key can. Uploads are mutations and cache nothing.
12
+ */
13
+
14
+ const ROOT = "cdn" as const;
15
+
16
+ export const cdnQueryKeys: {
17
+ readonly all: readonly ["cdn"];
18
+ /** The owner-scoped `file/exists/` read for one content hash. */
19
+ exists(fileHash: string): readonly ["cdn", "exists", string];
20
+ } = {
21
+ all: [ROOT],
22
+ exists: (fileHash) => [ROOT, "exists", fileHash],
23
+ };
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The `<type>/<hash>` reference — the unit this pair hands out — and the one
3
+ * conversion between stapel-cdn's variant ladder and `@stapel/image`'s.
4
+ *
5
+ * `Profile.avatar` stores a reference. `Listing.images_draft` stores a list of
6
+ * them ("Opaque list of CDN image references", `stapel_listings/models.py`).
7
+ * Neither stores a URL, and this pair does not invent one: the reference is
8
+ * content-addressed and opaque, and the URLs live on the row the CDN returns.
9
+ */
10
+ import type { StapelImage, VariantMeta } from "@stapel/image";
11
+ import type { CdnImage, CdnRef, ParsedCdnRef } from "../api/types.js";
12
+
13
+ const HASH = /^[0-9a-f]{64}$/;
14
+
15
+ /** Build the reference for an asset type and a content hash. */
16
+ export function formatCdnRef(assetType: string, fileHash: string): CdnRef {
17
+ return `${assetType}/${fileHash}`;
18
+ }
19
+
20
+ /**
21
+ * Split a reference into its halves, or `null` when the string is not one.
22
+ *
23
+ * Strict about the hash (64 lowercase hex — what the backend writes) and
24
+ * deliberately loose about the type, which is whatever this deployment put in
25
+ * `STAPEL_CDN["ASSET_TYPES"]` plus the hardcoded `product` of the general
26
+ * image endpoint. Rejecting an unfamiliar type here would refuse a host's own
27
+ * configuration.
28
+ */
29
+ export function parseCdnRef(ref: CdnRef): ParsedCdnRef | null {
30
+ const slash = ref.indexOf("/");
31
+ if (slash <= 0) return null;
32
+ const assetType = ref.slice(0, slash);
33
+ const fileHash = ref.slice(slash + 1);
34
+ if (!HASH.test(fileHash)) return null;
35
+ return { assetType, fileHash };
36
+ }
37
+
38
+ /**
39
+ * Convert an uploaded image row into the source-agnostic descriptor
40
+ * `@stapel/image`'s `<Image>` consumes.
41
+ *
42
+ * THE TWO CONTRACTS DISAGREE ON ONE FIELD AND THIS IS THE ONLY PLACE THAT
43
+ * KNOWS. stapel-cdn's `variants_meta[].tier` is an integer; `@stapel/image`
44
+ * reads the `stapel_core.media.dto` form, where `tier` is a decimal STRING
45
+ * with an `"original"` sentinel. Converting at the boundary costs one map and
46
+ * keeps both sides honest; teaching either one the other's shape would put a
47
+ * conditional in every renderer.
48
+ *
49
+ * `branch` is optional upstream and absent for thumbnail-class tiers; the
50
+ * renderer's contract wants an explicit `null` there, which is the same
51
+ * statement said out loud.
52
+ *
53
+ * The type import above is TYPE-ONLY and `@stapel/image` is an OPTIONAL peer:
54
+ * a host that renders its own images carries no dependency on it and this
55
+ * function still typechecks, because nothing of it survives to runtime.
56
+ */
57
+ export function toStapelImage(image: CdnImage): StapelImage {
58
+ const variants: VariantMeta[] = image.variants_meta.map((variant) => ({
59
+ tier: String(variant.tier),
60
+ branch: variant.branch ?? null,
61
+ url: variant.url,
62
+ width: variant.width,
63
+ height: variant.height,
64
+ }));
65
+ const width = image.original_width;
66
+ const height = image.original_height;
67
+ return {
68
+ source: "cdn",
69
+ url: image.original_url,
70
+ // The CDN row carries the extension, not the MIME type, and the variants
71
+ // are all WebP regardless of what came in. Saying `null` is the true
72
+ // statement; guessing a MIME from the extension would be the wrong one.
73
+ mime: null,
74
+ width,
75
+ height,
76
+ aspect: height > 0 ? width / height : null,
77
+ square: width === height,
78
+ // stapel-cdn generates no inline blur placeholder. `<Image>` degrades to
79
+ // the aspect box, which is the layout-shift protection that actually
80
+ // matters here.
81
+ preview_b64: null,
82
+ variants,
83
+ };
84
+ }
85
+
86
+ /**
87
+ * The reference for an uploaded image row.
88
+ *
89
+ * Reads `prefix` — the serializer's own `<type>/<hash>` — rather than
90
+ * rebuilding it from `type` and `file_hash`, so a host that reconfigures the
91
+ * layout upstream does not get a reference this pair invented.
92
+ */
93
+ export function refOf(image: { readonly prefix: string }): CdnRef {
94
+ return image.prefix;
95
+ }