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