@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.
- package/CHANGELOG.md +1 -0
- package/MODULE.md +124 -0
- package/README.md +103 -0
- package/dist/api/cdnApi.d.ts +122 -0
- package/dist/api/cdnApi.d.ts.map +1 -0
- package/dist/api/cdnApi.js +27 -0
- package/dist/api/cdnApi.js.map +1 -0
- package/dist/api/generated/schema.d.ts +1050 -0
- package/dist/api/generated/schema.d.ts.map +1 -0
- package/dist/api/generated/schema.js +2 -0
- package/dist/api/generated/schema.js.map +1 -0
- package/dist/api/types.d.ts +76 -0
- package/dist/api/types.d.ts.map +1 -0
- package/dist/api/types.js +2 -0
- package/dist/api/types.js.map +1 -0
- package/dist/default/ErrorAlert.d.ts +8 -0
- package/dist/default/ErrorAlert.d.ts.map +1 -0
- package/dist/default/ErrorAlert.js +26 -0
- package/dist/default/ErrorAlert.js.map +1 -0
- package/dist/default/ImageUploadField.d.ts +12 -0
- package/dist/default/ImageUploadField.d.ts.map +1 -0
- package/dist/default/ImageUploadField.js +48 -0
- package/dist/default/ImageUploadField.js.map +1 -0
- package/dist/default/MediaGalleryField.d.ts +14 -0
- package/dist/default/MediaGalleryField.d.ts.map +1 -0
- package/dist/default/MediaGalleryField.js +65 -0
- package/dist/default/MediaGalleryField.js.map +1 -0
- package/dist/default/index.d.ts +15 -0
- package/dist/default/index.d.ts.map +1 -0
- package/dist/default/index.js +13 -0
- package/dist/default/index.js.map +1 -0
- package/dist/default/phase.d.ts +17 -0
- package/dist/default/phase.d.ts.map +1 -0
- package/dist/default/phase.js +25 -0
- package/dist/default/phase.js.map +1 -0
- package/dist/flows/registry.d.ts +28 -0
- package/dist/flows/registry.d.ts.map +1 -0
- package/dist/flows/registry.js +26 -0
- package/dist/flows/registry.js.map +1 -0
- package/dist/headless/CdnProvider.d.ts +18 -0
- package/dist/headless/CdnProvider.d.ts.map +1 -0
- package/dist/headless/CdnProvider.js +14 -0
- package/dist/headless/CdnProvider.js.map +1 -0
- package/dist/headless/ImageUpload.d.ts +18 -0
- package/dist/headless/ImageUpload.d.ts.map +1 -0
- package/dist/headless/ImageUpload.js +16 -0
- package/dist/headless/ImageUpload.js.map +1 -0
- package/dist/headless/MediaUploader.d.ts +31 -0
- package/dist/headless/MediaUploader.d.ts.map +1 -0
- package/dist/headless/MediaUploader.js +30 -0
- package/dist/headless/MediaUploader.js.map +1 -0
- package/dist/headless/useUploadImage.d.ts +33 -0
- package/dist/headless/useUploadImage.d.ts.map +1 -0
- package/dist/headless/useUploadImage.js +118 -0
- package/dist/headless/useUploadImage.js.map +1 -0
- package/dist/headless/useUploadPreview.d.ts +22 -0
- package/dist/headless/useUploadPreview.d.ts.map +1 -0
- package/dist/headless/useUploadPreview.js +37 -0
- package/dist/headless/useUploadPreview.js.map +1 -0
- package/dist/headless/useUploadQueue.d.ts +83 -0
- package/dist/headless/useUploadQueue.d.ts.map +1 -0
- package/dist/headless/useUploadQueue.js +0 -0
- package/dist/headless/useUploadQueue.js.map +1 -0
- package/dist/i18n/errorsMap.d.ts +12 -0
- package/dist/i18n/errorsMap.d.ts.map +1 -0
- package/dist/i18n/errorsMap.js +22 -0
- package/dist/i18n/errorsMap.js.map +1 -0
- package/dist/i18n/es.d.ts +17 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +65 -0
- package/dist/i18n/es.js.map +1 -0
- package/dist/i18n/generated/errors.es.gen.d.ts +16 -0
- package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.es.gen.js +58 -0
- package/dist/i18n/generated/errors.es.gen.js.map +1 -0
- package/dist/i18n/generated/errors.gen.d.ts +353 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.gen.js +180 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts +16 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.js +58 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
- package/dist/i18n/keys.d.ts +61 -0
- package/dist/i18n/keys.d.ts.map +1 -0
- package/dist/i18n/keys.js +110 -0
- package/dist/i18n/keys.js.map +1 -0
- package/dist/i18n/ru.d.ts +21 -0
- package/dist/i18n/ru.d.ts.map +1 -0
- package/dist/i18n/ru.js +70 -0
- package/dist/i18n/ru.js.map +1 -0
- package/dist/index.d.ts +89 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +83 -0
- package/dist/index.js.map +1 -0
- package/dist/model/context.d.ts +11 -0
- package/dist/model/context.d.ts.map +1 -0
- package/dist/model/context.js +15 -0
- package/dist/model/context.js.map +1 -0
- package/dist/model/hash.d.ts +36 -0
- package/dist/model/hash.d.ts.map +1 -0
- package/dist/model/hash.js +59 -0
- package/dist/model/hash.js.map +1 -0
- package/dist/model/limits.d.ts +86 -0
- package/dist/model/limits.d.ts.map +1 -0
- package/dist/model/limits.js +163 -0
- package/dist/model/limits.js.map +1 -0
- package/dist/model/queries.d.ts +29 -0
- package/dist/model/queries.d.ts.map +1 -0
- package/dist/model/queries.js +32 -0
- package/dist/model/queries.js.map +1 -0
- package/dist/model/queryKeys.d.ts +18 -0
- package/dist/model/queryKeys.d.ts.map +1 -0
- package/dist/model/queryKeys.js +18 -0
- package/dist/model/queryKeys.js.map +1 -0
- package/dist/model/refs.d.ts +54 -0
- package/dist/model/refs.d.ts.map +1 -0
- package/dist/model/refs.js +82 -0
- package/dist/model/refs.js.map +1 -0
- package/dist/model/runtime.d.ts +40 -0
- package/dist/model/runtime.d.ts.map +1 -0
- package/dist/model/runtime.js +23 -0
- package/dist/model/runtime.js.map +1 -0
- package/dist/model/upload.d.ts +88 -0
- package/dist/model/upload.d.ts.map +1 -0
- package/dist/model/upload.js +254 -0
- package/dist/model/upload.js.map +1 -0
- package/llms.txt +83 -0
- package/manifest.json +601 -0
- package/package.json +120 -0
- package/src/analytics/generated/events.json +7 -0
- package/src/api/cdnApi.ts +187 -0
- package/src/api/generated/schema.ts +1052 -0
- package/src/api/types.ts +86 -0
- package/src/default/ErrorAlert.tsx +43 -0
- package/src/default/ImageUploadField.tsx +131 -0
- package/src/default/MediaGalleryField.tsx +233 -0
- package/src/default/index.ts +14 -0
- package/src/default/phase.ts +34 -0
- package/src/flows/registry.ts +38 -0
- package/src/headless/CdnProvider.tsx +19 -0
- package/src/headless/ImageUpload.tsx +24 -0
- package/src/headless/MediaUploader.tsx +43 -0
- package/src/headless/useUploadImage.ts +155 -0
- package/src/headless/useUploadPreview.ts +48 -0
- package/src/headless/useUploadQueue.ts +411 -0
- package/src/i18n/errorsMap.ts +33 -0
- package/src/i18n/es.ts +81 -0
- package/src/i18n/generated/errors.es.gen.ts +64 -0
- package/src/i18n/generated/errors.gen.ts +211 -0
- package/src/i18n/generated/errors.json +403 -0
- package/src/i18n/generated/errors.ru.gen.ts +64 -0
- package/src/i18n/keys.ts +131 -0
- package/src/i18n/ru.ts +85 -0
- package/src/index.ts +154 -0
- package/src/model/context.tsx +25 -0
- package/src/model/hash.ts +67 -0
- package/src/model/limits.ts +207 -0
- package/src/model/queries.ts +64 -0
- package/src/model/queryKeys.ts +23 -0
- package/src/model/refs.ts +95 -0
- package/src/model/runtime.ts +52 -0
- package/src/model/upload.ts +365 -0
- package/tsconfig.json +26 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# @stapel/cdn-react
|
package/MODULE.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# @stapel/cdn-react — module guide
|
|
2
|
+
|
|
3
|
+
Headless React pair for **stapel-cdn**. This is the human companion to the
|
|
4
|
+
generated `llms.txt` (agent context) and `manifest.json` (machine catalog).
|
|
5
|
+
|
|
6
|
+
## The one thing to understand first: dedup is a step, not a side effect
|
|
7
|
+
|
|
8
|
+
Three modules in this fleet upload media, under three DIFFERENT contracts: cdn
|
|
9
|
+
takes a multipart POST, docs opens a session and finalises it, recordings opens
|
|
10
|
+
a session with a size ceiling. They were never one algorithm wearing three hats,
|
|
11
|
+
and this package does not pretend otherwise — it implements **cdn's** contract.
|
|
12
|
+
The two genuinely contract-free bones the other two shared were extracted into
|
|
13
|
+
`@stapel/core` instead (`putToForeignOrigin`, `useObjectUrlPreview`), and only
|
|
14
|
+
the second of them appears here: there is no foreign origin in this contract to
|
|
15
|
+
PUT to.
|
|
16
|
+
|
|
17
|
+
What cdn's contract has, and the others do not, is a content-addressed store
|
|
18
|
+
with a public "do you already have these bytes?" question. So the flow is:
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
validate ─┬─ refuse (client-side mirror, cdn's own error codes)
|
|
22
|
+
│
|
|
23
|
+
└─ hash ── GET file/exists/?file_hash=… ─┬─ HIT → done, no POST
|
|
24
|
+
│
|
|
25
|
+
└─ MISS → POST multipart
|
|
26
|
+
└─ poll for variants
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`test/dedup.test.ts` asserts the HIT leg by **counting requests**, which is the
|
|
30
|
+
only assertion that cannot be satisfied by a flow that merely looks right.
|
|
31
|
+
|
|
32
|
+
### What a hit promises, and what a miss does not
|
|
33
|
+
|
|
34
|
+
A hit means: these bytes are stored, owned by this caller, as the asset type
|
|
35
|
+
this upload would produce. All three conditions are checked, because
|
|
36
|
+
`file/exists/` answers about any object with those bytes — the same file stored
|
|
37
|
+
earlier as a video, or as an avatar rather than a listing photo, is not the row
|
|
38
|
+
the POST would return, and short-circuiting on it would hand a composer a
|
|
39
|
+
reference to the wrong thing.
|
|
40
|
+
|
|
41
|
+
A miss promises nothing about the server. `file/exists/` filters on
|
|
42
|
+
`uploaded_by=request.user` **unconditionally**, while the upload views honour
|
|
43
|
+
`STAPEL_CDN["DEDUP_SCOPE"]` (default `"owner"`, optionally `"global"`). Under a
|
|
44
|
+
global scope the POST can still answer 200 "already exists". `deduped` therefore
|
|
45
|
+
reports what this client observed, never a claim about what the server did.
|
|
46
|
+
|
|
47
|
+
## Layers
|
|
48
|
+
|
|
49
|
+
| Layer | What lives there |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `api/generated/schema.ts` | `pnpm gen:api` from stapel-cdn's `docs/schema.json`. Never hand-edited. |
|
|
52
|
+
| `api/types.ts` | The public projection, with two documented widenings (see below). |
|
|
53
|
+
| `api/cdnApi.ts` | One method per endpoint a browser may call. The one legal home of a path string. |
|
|
54
|
+
| `model/limits.ts` | The client-side mirror of `STAPEL_CDN`'s ceilings — configurable, because they are settings. |
|
|
55
|
+
| `model/hash.ts` | SHA-256 over the file, and the honest answer when the platform cannot compute one. |
|
|
56
|
+
| `model/upload.ts` | The flow. No React. |
|
|
57
|
+
| `model/refs.ts` | `<type>/<hash>` and the single conversion to `@stapel/image`'s ladder. |
|
|
58
|
+
| `model/queries.ts` | `useCdnRef` — the pair's one cached read. |
|
|
59
|
+
| `headless/` | `useUploadQueue` / `useUploadImage` and their render-prop components. |
|
|
60
|
+
| `default/` | The antd skin, behind its own subpath. |
|
|
61
|
+
| `i18n/` | Keys + the generated error bundles + authored ru/es. |
|
|
62
|
+
| `flows/registry.ts` | The zero-flow shim: stapel-cdn annotates no `@flow_step`. |
|
|
63
|
+
|
|
64
|
+
## Two widenings of the generated types, and why
|
|
65
|
+
|
|
66
|
+
1. **`Image.type` is a string here, not the generated `"avatar"` enum.** The
|
|
67
|
+
schema builds `TypeEnum` from `STAPEL_CDN["ASSET_TYPES"]`, whose library
|
|
68
|
+
default is `("avatar",)`. `POST /upload/image/` never reads that setting: it
|
|
69
|
+
writes `type="product"` unconditionally. So on a default deployment the
|
|
70
|
+
general intake returns a value the generated union does not contain — and
|
|
71
|
+
`POST /images/product/upload/` (which DOES validate against `ASSET_TYPES`)
|
|
72
|
+
answers 400 for the very type the other endpoint just stored. Both facts are
|
|
73
|
+
upstream's; the pair reflects them rather than pretending the enum is closed.
|
|
74
|
+
2. **`FileExistsResponse.type`** is `string | null` on the wire. `CdnFileKind`
|
|
75
|
+
names the three values the view can produce without narrowing the parsed
|
|
76
|
+
value, so a fourth kind added upstream is not a runtime cast that lies.
|
|
77
|
+
|
|
78
|
+
## The progress question, answered once
|
|
79
|
+
|
|
80
|
+
There is no honest byte-percentage available. `fetch` cannot observe
|
|
81
|
+
request-body progress through the injected client, and `SubtleCrypto.digest`
|
|
82
|
+
takes the whole buffer and reports nothing until it is done. The two ways to
|
|
83
|
+
show a moving bar anyway are to fork the transport onto `XMLHttpRequest` — a
|
|
84
|
+
second transport with its own bearer/refresh/verification/error handling, i.e.
|
|
85
|
+
the duplication this package exists to end — or to animate a number nobody
|
|
86
|
+
measured. `UploadPhase` names the step instead, and the skins render an
|
|
87
|
+
indeterminate indicator for the two steps that take real time.
|
|
88
|
+
|
|
89
|
+
## The gates a composer reads
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
bag.canAdd // available | blocked{ cdn.upload.blocked.full, {max} }
|
|
93
|
+
bag.settled // available | blocked{ …blocked.pending } | blocked{ …blocked.failed }
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Both are `ActionAvailability` rather than booleans because a Save button that is
|
|
97
|
+
off has to be able to say WHICH of the two reasons it is. `bag.refs` contains
|
|
98
|
+
only settled references, so it is never a promise about bytes that are not
|
|
99
|
+
there.
|
|
100
|
+
|
|
101
|
+
## Upstream notes (recorded, not worked around)
|
|
102
|
+
|
|
103
|
+
- **No public read-by-reference.** `file/exists/` is owner-scoped, so nothing in
|
|
104
|
+
this contract resolves a stranger's `<type>/<hash>` to URLs. A storefront
|
|
105
|
+
renders a listing's photos from the listings API's own payload.
|
|
106
|
+
- **`refs/sync/` is `IsServiceRequest`** and unreachable from a browser: the
|
|
107
|
+
consuming module's server syncs references. Not on this pair's API surface.
|
|
108
|
+
- **`GET /images/{type}/random/` is `IsStaffUser`** — an admin convenience, not
|
|
109
|
+
a storefront operation. Also not on the surface.
|
|
110
|
+
- **413 is undeclared for the image endpoints.** `upload/video/` lists it;
|
|
111
|
+
`upload/image/`, `upload/avatar/` and `images/{type}/upload/` describe the
|
|
112
|
+
ceiling in prose and return 413 without declaring it in `responses`. The pair
|
|
113
|
+
handles it identically for all four.
|
|
114
|
+
- **The variant ladder has no readiness callback.** Polling `file/exists/` is the
|
|
115
|
+
only way to learn that the background task finished, so the wait is bounded
|
|
116
|
+
and its exhaustion is a stated outcome (`variantsReady: false`), not a hang.
|
|
117
|
+
|
|
118
|
+
## Testing
|
|
119
|
+
|
|
120
|
+
`test/` mocks the WIRE, never the module: every request goes through the real
|
|
121
|
+
`StapelClient`, the bodies are the ones stapel-cdn's serializers render, and the
|
|
122
|
+
SHA-256 is really computed — which is what makes the request-count assertions
|
|
123
|
+
meaningful. jsdom's gaps (`Blob.prototype.arrayBuffer`, `URL.createObjectURL`)
|
|
124
|
+
are filled minimally in `test/vitest.setup.ts` and nowhere else.
|
package/README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# @stapel/cdn-react
|
|
2
|
+
|
|
3
|
+
Headless React pair for **stapel-cdn** (frontend-standard §2). Business + state
|
|
4
|
+
only in the main entry, zero visual opinion — any design layers on top. Built on
|
|
5
|
+
`@stapel/core` (typed client + `StapelApiError` envelope, token refresh,
|
|
6
|
+
verification-403 interception, i18n engine, TanStack Query).
|
|
7
|
+
|
|
8
|
+
It uploads media the way stapel-cdn wants it uploaded, and its first move is not
|
|
9
|
+
to upload: it hashes the file and asks whether the CDN already has these bytes.
|
|
10
|
+
When it does, nothing is sent.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
pnpm add @stapel/cdn-react @stapel/core @tanstack/react-query react
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Wire the app once
|
|
19
|
+
|
|
20
|
+
```tsx
|
|
21
|
+
import { CdnProvider, createCdnRuntime } from "@stapel/cdn-react";
|
|
22
|
+
|
|
23
|
+
const cdn = createCdnRuntime({ baseUrl: "/cdn/api/v1/" });
|
|
24
|
+
|
|
25
|
+
<StapelConfigProvider config={{ clients: { cdn: cdn.client } }}>
|
|
26
|
+
<CdnProvider runtime={cdn}>{app}</CdnProvider>
|
|
27
|
+
</StapelConfigProvider>;
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## A gallery (what a listing composer uses)
|
|
31
|
+
|
|
32
|
+
```tsx
|
|
33
|
+
import { MediaUploader } from "@stapel/cdn-react";
|
|
34
|
+
|
|
35
|
+
<MediaUploader max={10} onRefsChange={(refs) => form.set("images_draft", refs)}>
|
|
36
|
+
{({ items, refs, canAdd, settled, add, remove, reorder }) => …}
|
|
37
|
+
</MediaUploader>;
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`refs` is the list of `<type>/<hash>` strings to store, **in display order**.
|
|
41
|
+
`canAdd` and `settled` are `ActionAvailability`, so the Add button and the Save
|
|
42
|
+
button can each say why they are off — "this gallery holds at most 10 photos",
|
|
43
|
+
"wait for the uploads to finish", "retry the photos that failed".
|
|
44
|
+
|
|
45
|
+
The antd skin over the same bag is one import away:
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
import { MediaGalleryField, ImageUploadField } from "@stapel/cdn-react/default";
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## One slot (avatar, cover)
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
const { upload, previewUrl, phase, error } = useUploadImage({
|
|
55
|
+
target: { kind: "avatar" },
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## What the flow does, in order
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
validate ─┬─ refuse (client-side mirror of the deployment's own ceilings)
|
|
63
|
+
└─ hash ── file/exists/ ─┬─ HIT → done, ZERO bytes sent
|
|
64
|
+
└─ MISS → multipart POST → wait for variants
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Three things worth knowing before you use it
|
|
68
|
+
|
|
69
|
+
**There is no progress percentage, on purpose.** `fetch` cannot observe how much
|
|
70
|
+
of a request body has gone out, and `crypto.subtle.digest` reports nothing
|
|
71
|
+
mid-digest. So the bag names the **phase** — hashing, checking, uploading,
|
|
72
|
+
preparing previews — and a skin shows an indeterminate indicator. Getting a real
|
|
73
|
+
number would mean re-implementing the upload on `XMLHttpRequest`, with its own
|
|
74
|
+
copy of the client's auth, refresh and error handling. A bar that is measured
|
|
75
|
+
beats a bar that moves.
|
|
76
|
+
|
|
77
|
+
**The pre-check can be skipped and that is never fatal.** `file/exists/` needs
|
|
78
|
+
`IsAuthenticated`, while the upload endpoints take `IsNotAnonymousUser` — so a
|
|
79
|
+
guest can upload but cannot pre-check. A page served over plain `http://` has no
|
|
80
|
+
`crypto.subtle` at all. Both fall through to the POST (the server deduplicates
|
|
81
|
+
on its own side regardless) and report `dedupSkipped` so a skin can explain it.
|
|
82
|
+
|
|
83
|
+
**Reading somebody else's reference is not possible here, and that is upstream's
|
|
84
|
+
gap, not this pair's.** `file/exists/` filters on `uploaded_by=request.user`
|
|
85
|
+
unconditionally, so `useCdnRef` resolves the caller's OWN references — which is
|
|
86
|
+
what a reopened draft needs and what a buyer looking at a seller's gallery does
|
|
87
|
+
not. stapel-cdn exposes no public read-by-reference endpoint; a storefront
|
|
88
|
+
renders a listing's photos from what the listings API hands it. Recorded here
|
|
89
|
+
rather than worked around with a URL convention this pair would have invented.
|
|
90
|
+
|
|
91
|
+
## Layers
|
|
92
|
+
|
|
93
|
+
`api/` (generated schema + the typed operations) → `model/` (the flow, the
|
|
94
|
+
limits mirror, the reference, the one cached read) → `headless/` (the bags and
|
|
95
|
+
their render-prop components) → `i18n/`. `default/` is the antd skin behind its
|
|
96
|
+
own subpath. `MODULE.md` has the full map.
|
|
97
|
+
|
|
98
|
+
## Locales
|
|
99
|
+
|
|
100
|
+
English is inline. `./i18n/ru` and `./i18n/es` are opt-in subpaths. stapel-cdn
|
|
101
|
+
ships no `translations/` directory at all, so the 11 error keys it owns are
|
|
102
|
+
authored in this package until upstream localizes them; the 42 cross-cutting
|
|
103
|
+
keys come from stapel-core's catalogue through `gen:errors`.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { StapelClient } from "@stapel/core";
|
|
2
|
+
import type { CdnFileExistsResponse, CdnFileUploadResponse, CdnImageUploadResponse, CdnVideoUploadResponse } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* The pair's typed operation surface — one method per stapel-cdn endpoint a
|
|
5
|
+
* browser may call, bound to the injected {@link StapelClient} (the per-module
|
|
6
|
+
* override seam of frontend-standard §7.2). Paths are relative to the
|
|
7
|
+
* runtime's `baseUrl` (`/cdn/api/v1/`).
|
|
8
|
+
*
|
|
9
|
+
* ── The two endpoints that are NOT here, and why ───────────────────────────
|
|
10
|
+
*
|
|
11
|
+
* `POST /refs/sync/` is `IsServiceRequest`: it is how one BACKEND tells the
|
|
12
|
+
* CDN that an entity now references a set of media, and a browser cannot
|
|
13
|
+
* authenticate as a service. A pair that exposed it would invite a screen to
|
|
14
|
+
* call something that can only answer 403 (`stapel_cdn/views.py`, RefSyncView).
|
|
15
|
+
* Reference bookkeeping is the consuming module's server-side job — the
|
|
16
|
+
* storefront's part is to hand `images_draft` to stapel-listings and let it
|
|
17
|
+
* sync.
|
|
18
|
+
*
|
|
19
|
+
* `GET /images/{image_type}/random/` is `IsStaffUser` and exists to let an
|
|
20
|
+
* admin UI grab a test image. Same argument.
|
|
21
|
+
*
|
|
22
|
+
* Both stay in the generated schema (and therefore in `manifest.json`, which
|
|
23
|
+
* lists the whole contract), so nothing is hidden; they are simply not this
|
|
24
|
+
* pair's surface.
|
|
25
|
+
*
|
|
26
|
+
* ── Upload is a POST of `multipart/form-data`, not a presigned PUT ─────────
|
|
27
|
+
*
|
|
28
|
+
* This is the whole reason `@stapel/core`'s `putToForeignOrigin` does not
|
|
29
|
+
* appear anywhere in this package. That primitive exists for the docs and
|
|
30
|
+
* recordings contracts, which open a session and hand back a URL at an object
|
|
31
|
+
* store. stapel-cdn takes the bytes itself, through the same authenticated
|
|
32
|
+
* origin as every other call, so the injected client IS the right instrument
|
|
33
|
+
* here and there is no foreign origin to PUT to. Three upload implementations,
|
|
34
|
+
* three contracts (spec §1.6) — the bones core extracted are the ones the
|
|
35
|
+
* OTHER two share.
|
|
36
|
+
*/
|
|
37
|
+
export interface CdnApi {
|
|
38
|
+
readonly client: StapelClient;
|
|
39
|
+
/**
|
|
40
|
+
* Has the CDN already got these bytes?
|
|
41
|
+
*
|
|
42
|
+
* The dedup pre-check, and the pair's only read. Answers `200` either way:
|
|
43
|
+
* `{exists: false, type: null, file: null}` is a successful answer, not an
|
|
44
|
+
* error — "we asked and there is none" (`FileExistsView`).
|
|
45
|
+
*
|
|
46
|
+
* OWNER-SCOPED, ALWAYS. The view filters on `uploaded_by=request.user`
|
|
47
|
+
* unconditionally, unlike the upload paths, which honour
|
|
48
|
+
* `STAPEL_CDN["DEDUP_SCOPE"]`. Two consequences the flow in `model/upload.ts`
|
|
49
|
+
* is built around: (a) a miss here does NOT mean the upload will store new
|
|
50
|
+
* bytes — under `DEDUP_SCOPE: "global"` the POST may still answer "already
|
|
51
|
+
* exists"; (b) a buyer cannot resolve a seller's reference through this
|
|
52
|
+
* endpoint, so it is a re-open-your-own-draft read, never a public one.
|
|
53
|
+
*
|
|
54
|
+
* `IsAuthenticated | IsServiceRequest` — note that this is STRICTER than the
|
|
55
|
+
* upload endpoints, which take `IsNotAnonymousUser` (a guest identity is
|
|
56
|
+
* enough). A guest can therefore upload but not pre-check, which is why a
|
|
57
|
+
* 401 here is a skipped optimisation rather than a failed upload.
|
|
58
|
+
*/
|
|
59
|
+
fileExists(fileHash: string, options?: {
|
|
60
|
+
readonly signal?: AbortSignal;
|
|
61
|
+
}): Promise<CdnFileExistsResponse>;
|
|
62
|
+
/**
|
|
63
|
+
* `POST /upload/image/` — the general image intake (`IsNotAnonymousUser`).
|
|
64
|
+
*
|
|
65
|
+
* Answers `201` for stored bytes and `200` for a server-side dedup hit, with
|
|
66
|
+
* the SAME body either way. The pair does not report which: `StapelClient`
|
|
67
|
+
* resolves a body, not a status, and the only in-body difference is the
|
|
68
|
+
* English `message` string, which is not a contract. Dedup is therefore
|
|
69
|
+
* reported from the pre-check (which is a contract) and from nowhere else —
|
|
70
|
+
* see `model/upload.ts`.
|
|
71
|
+
*
|
|
72
|
+
* The stored row's `type` is `"product"`, hardcoded in the view.
|
|
73
|
+
*/
|
|
74
|
+
uploadImage(file: File, options?: {
|
|
75
|
+
readonly signal?: AbortSignal;
|
|
76
|
+
}): Promise<CdnImageUploadResponse>;
|
|
77
|
+
/**
|
|
78
|
+
* `POST /upload/avatar/` — the same intake with `type="avatar"`, and the one
|
|
79
|
+
* upload endpoint that requires a real `IsAuthenticated` principal. This is
|
|
80
|
+
* what `profiles-react`'s `useSetAvatar` calls (today through its own
|
|
81
|
+
* documented stopgap, `api/cdnAvatarApi.ts`, which this pair exists to
|
|
82
|
+
* replace).
|
|
83
|
+
*/
|
|
84
|
+
uploadAvatar(file: File, options?: {
|
|
85
|
+
readonly signal?: AbortSignal;
|
|
86
|
+
}): Promise<CdnImageUploadResponse>;
|
|
87
|
+
/**
|
|
88
|
+
* `POST /images/{image_type}/upload/` — an image stored under a caller-named
|
|
89
|
+
* type, validated against `STAPEL_CDN["ASSET_TYPES"]`.
|
|
90
|
+
*
|
|
91
|
+
* Refuses `error.400.invalid_image_type` for a type this deployment does not
|
|
92
|
+
* declare. Note the asymmetry with {@link uploadImage}, which writes
|
|
93
|
+
* `"product"` without consulting that setting at all: on a default
|
|
94
|
+
* deployment `POST /images/product/upload/` is a 400 while
|
|
95
|
+
* `POST /upload/image/` happily stores a `product` row. A host that wants
|
|
96
|
+
* `product` addressable by name adds it to `ASSET_TYPES`.
|
|
97
|
+
*/
|
|
98
|
+
uploadTypedImage(imageType: string, file: File, options?: {
|
|
99
|
+
readonly signal?: AbortSignal;
|
|
100
|
+
}): Promise<CdnImageUploadResponse>;
|
|
101
|
+
/**
|
|
102
|
+
* `POST /upload/video/`. Typed and callable; this pair ships no hook and no
|
|
103
|
+
* widget over it (the storefront MVP is images), so a host that needs video
|
|
104
|
+
* today calls it directly rather than waiting for a version of the pair that
|
|
105
|
+
* has the queue for it. Variants are not generated yet upstream — the
|
|
106
|
+
* response's `is_processed` stays false and the ladder is empty.
|
|
107
|
+
*/
|
|
108
|
+
uploadVideo(file: File, options?: {
|
|
109
|
+
readonly signal?: AbortSignal;
|
|
110
|
+
}): Promise<CdnVideoUploadResponse>;
|
|
111
|
+
/**
|
|
112
|
+
* `POST /upload/file/` — documents and archives. Same status as
|
|
113
|
+
* {@link uploadVideo}: typed, callable, no hook. Its allowlist is a MIME
|
|
114
|
+
* allowlist as well as an extension one, and deliberately excludes
|
|
115
|
+
* `application/octet-stream`.
|
|
116
|
+
*/
|
|
117
|
+
uploadFile(file: File, options?: {
|
|
118
|
+
readonly signal?: AbortSignal;
|
|
119
|
+
}): Promise<CdnFileUploadResponse>;
|
|
120
|
+
}
|
|
121
|
+
export declare function createCdnApi(client: StapelClient): CdnApi;
|
|
122
|
+
//# sourceMappingURL=cdnApi.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cdnApi.d.ts","sourceRoot":"","sources":["../../src/api/cdnApi.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AACjD,OAAO,KAAK,EACV,qBAAqB,EACrB,qBAAqB,EACrB,sBAAsB,EACtB,sBAAsB,EACvB,MAAM,YAAY,CAAC;AAEpB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,MAAM,WAAW,MAAM;IACrB,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAC;IAE9B;;;;;;;;;;;;;;;;;;;OAmBG;IACH,UAAU,CACR,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAA;KAAE,GAC1C,OAAO,CAAC,qBAAqB,CAAC,CAAC;IAElC;;;;;;;;;;;OAWG;IACH,WAAW,CACT,IAAI,EAAE,IAAI,EACV,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAA;KAAE,GAC1C,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAEnC;;;;;;OAMG;IACH,YAAY,CACV,IAAI,EAAE,IAAI,EACV,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAA;KAAE,GAC1C,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAEnC;;;;;;;;;;OAUG;IACH,gBAAgB,CACd,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,IAAI,EACV,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAA;KAAE,GAC1C,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAEnC;;;;;;OAMG;IACH,WAAW,CACT,IAAI,EAAE,IAAI,EACV,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAA;KAAE,GAC1C,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAEnC;;;;;OAKG;IACH,UAAU,CACR,IAAI,EAAE,IAAI,EACV,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAA;KAAE,GAC1C,OAAO,CAAC,qBAAqB,CAAC,CAAC;CACnC;AAmBD,wBAAgB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,MAAM,CA6BzD"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one multipart body every upload endpoint takes: a single `file` part.
|
|
3
|
+
* `FormData` is a `BodyInit`, so `StapelClient` sends it verbatim and lets the
|
|
4
|
+
* browser write the `Content-Type` boundary — setting that header by hand is
|
|
5
|
+
* the classic way to make a multipart POST unparseable server-side.
|
|
6
|
+
*/
|
|
7
|
+
function filePart(file) {
|
|
8
|
+
const form = new FormData();
|
|
9
|
+
form.append("file", file);
|
|
10
|
+
return form;
|
|
11
|
+
}
|
|
12
|
+
const signalOf = (options) => options?.signal !== undefined ? { signal: options.signal } : {};
|
|
13
|
+
export function createCdnApi(client) {
|
|
14
|
+
return {
|
|
15
|
+
client,
|
|
16
|
+
fileExists: (fileHash, options) => client.get("/file/exists/", {
|
|
17
|
+
query: { file_hash: fileHash },
|
|
18
|
+
...signalOf(options),
|
|
19
|
+
}),
|
|
20
|
+
uploadImage: (file, options) => client.post("/upload/image/", filePart(file), signalOf(options)),
|
|
21
|
+
uploadAvatar: (file, options) => client.post("/upload/avatar/", filePart(file), signalOf(options)),
|
|
22
|
+
uploadTypedImage: (imageType, file, options) => client.post(`/images/${encodeURIComponent(imageType)}/upload/`, filePart(file), signalOf(options)),
|
|
23
|
+
uploadVideo: (file, options) => client.post("/upload/video/", filePart(file), signalOf(options)),
|
|
24
|
+
uploadFile: (file, options) => client.post("/upload/file/", filePart(file), signalOf(options)),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=cdnApi.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cdnApi.js","sourceRoot":"","sources":["../../src/api/cdnApi.ts"],"names":[],"mappings":"AA4IA;;;;;GAKG;AACH,SAAS,QAAQ,CAAC,IAAU;IAC1B,MAAM,IAAI,GAAG,IAAI,QAAQ,EAAE,CAAC;IAC5B,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;IAC1B,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,QAAQ,GAAG,CAAC,OAEjB,EAA4B,EAAE,CAC7B,OAAO,EAAE,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;AAElE,MAAM,UAAU,YAAY,CAAC,MAAoB;IAC/C,OAAO;QACL,MAAM;QAEN,UAAU,EAAE,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,CAChC,MAAM,CAAC,GAAG,CAAC,eAAe,EAAE;YAC1B,KAAK,EAAE,EAAE,SAAS,EAAE,QAAQ,EAAE;YAC9B,GAAG,QAAQ,CAAC,OAAO,CAAC;SACrB,CAAC;QAEJ,WAAW,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,CAC7B,MAAM,CAAC,IAAI,CAAC,gBAAgB,EAAE,QAAQ,CAAC,IAAI,CAAC,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAC;QAElE,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,CAC9B,MAAM,CAAC,IAAI,CAAC,iBAAiB,EAAE,QAAQ,CAAC,IAAI,CAAC,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAC;QAEnE,gBAAgB,EAAE,CAAC,SAAS,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,CAC7C,MAAM,CAAC,IAAI,CACT,WAAW,kBAAkB,CAAC,SAAS,CAAC,UAAU,EAClD,QAAQ,CAAC,IAAI,CAAC,EACd,QAAQ,CAAC,OAAO,CAAC,CAClB;QAEH,WAAW,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,CAC7B,MAAM,CAAC,IAAI,CAAC,gBAAgB,EAAE,QAAQ,CAAC,IAAI,CAAC,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAC;QAElE,UAAU,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,CAC5B,MAAM,CAAC,IAAI,CAAC,eAAe,EAAE,QAAQ,CAAC,IAAI,CAAC,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAC;KAClE,CAAC;AACJ,CAAC"}
|