@dloizides/content-upload 1.0.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 ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0
4
+
5
+ Initial release (KEFI-PEOPLE-1 "Organizer people editor", task T3).
6
+
7
+ - `uploadImage(file, options)`: multipart POST to a consumer-supplied endpoint; CSRF header,
8
+ credentials, client-side validation (empty / `maxBytes` / `allowedTypes`), abort, progress over
9
+ XMLHttpRequest, typed `UploadError` with `UploadErrorKind`.
10
+ - `buildUploadRequest` for pinning the write shape (no Content-Type on FormData).
11
+ - `blobFromUri(uri)`.
12
+ - `useImageUpload(options)` hook: `UploadStatus` idle / uploading / error / done, progress,
13
+ supersede-on-new-upload, abort on reset and unmount.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 dloizides
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # @dloizides/content-upload
2
+
3
+ Image upload for the dloizides.com RN-web portals: one multipart POST to the ContentService
4
+ through a same-origin BFF proxy, plus a React hook with `idle / uploading / error / done` states.
5
+ Merged from kefi-web `uploadContent.ts` and katalogos-web `useUploadContent.ts`.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @dloizides/content-upload
11
+ ```
12
+
13
+ Peer dependency: `react >= 18` (only for the hook).
14
+
15
+ ## Client
16
+
17
+ ```ts
18
+ import { uploadImage, UploadError, UploadErrorKind } from '@dloizides/content-upload';
19
+
20
+ const content = await uploadImage(file, {
21
+ endpoint: '/bff/api/content/api/v1/content/upload', // consumer-supplied
22
+ maxBytes: 5 * 1024 * 1024,
23
+ allowedTypes: ['image/png', 'image/jpeg', 'image/webp'],
24
+ });
25
+ // content = { contentId, status, url }
26
+ ```
27
+
28
+ The request is `multipart/form-data` with fields `File`, `Category` (default `Image`) and
29
+ `IsPublic` (default `true`), `credentials: 'include'` and the `X-BFF-Csrf: 1` anti-forgery header
30
+ (`csrf: null` disables it, `csrf: { name, value }` replaces it). **No Content-Type is ever set**:
31
+ the runtime adds the multipart boundary, and a JSON content type on a FormData body is a 415 (the
32
+ same rule `@dloizides/bff-web-client`'s `writeContentTypeGuard` enforces for axios).
33
+
34
+ Every failure throws `UploadError` with a `kind`: `Validation`, `Network`, `Http` (`status` set),
35
+ `Aborted`, `InvalidResponse`. Map `kind` to translated copy; `message` is developer-facing.
36
+
37
+ Transport: with `onProgress` and no `fetchImpl`, the upload uses `XMLHttpRequest` (the only
38
+ transport that reports upload progress, available in browsers and React Native). Otherwise it
39
+ uses `fetch`. `signal` aborts either.
40
+
41
+ `blobFromUri(uri)` reads a `blob:` / `data:` / RN picker `file:` URI into a `Blob`.
42
+
43
+ ## Hook
44
+
45
+ ```ts
46
+ import { useImageUpload, UploadStatus } from '@dloizides/content-upload';
47
+
48
+ const { status, progress, error, result, upload, reset } = useImageUpload({
49
+ endpoint: '/bff/api/content/api/v1/content/upload',
50
+ onSuccess: (content) => setAvatarUrl(content.url),
51
+ });
52
+
53
+ await upload(file); // resolves to the content, or null on error / abort
54
+ ```
55
+
56
+ - A new `upload()` or `reset()` aborts the in-flight request; its late result is dropped.
57
+ - Unmount aborts the in-flight request.
58
+ - Options are read at call time, so an inline options object is fine.
59
+
60
+ ## Scripts
61
+
62
+ `npm run lint` · `npm run typecheck` · `npm run test:coverage` (100% threshold) · `npm run build`.
63
+ Publish only via `./publish.ps1 -Bump <patch|minor|major>`.
@@ -0,0 +1,141 @@
1
+ /** A header the BFF anti-forgery middleware requires on mutating requests. */
2
+ interface CsrfHeader {
3
+ name: string;
4
+ value: string;
5
+ }
6
+ /** Body returned by the ContentService upload endpoint. */
7
+ interface UploadedContent {
8
+ /** Stable id of the persisted content row. */
9
+ contentId: string;
10
+ /** Server status (e.g. "Active"). */
11
+ status: string;
12
+ /** Public download URL; present when the upload was public. */
13
+ url?: string;
14
+ }
15
+ /** Receives whole-number upload progress, 0-100. */
16
+ type ProgressListener = (percent: number) => void;
17
+ /** Options for `uploadImage`. Only `endpoint` is required. */
18
+ interface UploadImageOptions {
19
+ /** Same-origin URL of the upload endpoint, e.g. `/bff/api/content/api/v1/content/upload`. */
20
+ endpoint: string;
21
+ /** Filename sent with the file part. Defaults to `File.name`, else `"upload"`. */
22
+ fileName?: string;
23
+ /** ContentService `Category` form field. Default `"Image"`. */
24
+ category?: string;
25
+ /** ContentService `IsPublic` form field. Default `true`. */
26
+ isPublic?: boolean;
27
+ /** Reject a 2xx body without `url`. Defaults to the value of `isPublic`. */
28
+ requireUrl?: boolean;
29
+ /** Anti-forgery header. Default `X-BFF-Csrf: 1`; pass `null` to send none. */
30
+ csrf?: CsrfHeader | null;
31
+ /** Fetch credentials mode. Default `"include"` (BFF session cookie). */
32
+ credentials?: RequestCredentials;
33
+ /** Reject files larger than this many bytes before sending. */
34
+ maxBytes?: number;
35
+ /** Reject files whose MIME type is not in this list before sending. */
36
+ allowedTypes?: readonly string[];
37
+ /** Aborts the request; the promise rejects with `UploadErrorKind.Aborted`. */
38
+ signal?: AbortSignal;
39
+ /**
40
+ * Progress listener. When set (and no `fetchImpl` is given) the upload goes over
41
+ * XMLHttpRequest, the only browser/RN transport that reports upload progress.
42
+ */
43
+ onProgress?: ProgressListener;
44
+ /** Custom fetch (tests, SSR). Forces the fetch transport. */
45
+ fetchImpl?: typeof fetch;
46
+ /** Custom XMLHttpRequest factory (tests). */
47
+ xhrFactory?: () => XMLHttpRequest;
48
+ }
49
+
50
+ /** A fully built multipart upload, independent of the transport that sends it. */
51
+ interface UploadRequest {
52
+ url: string;
53
+ /** Never contains Content-Type: the runtime sets the multipart boundary for a FormData body. */
54
+ headers: Record<string, string>;
55
+ form: FormData;
56
+ credentials: RequestCredentials;
57
+ signal: AbortSignal | undefined;
58
+ }
59
+
60
+ /** The Bff.AspNetCore anti-forgery convention: header presence, any value. */
61
+ declare const DEFAULT_CSRF_HEADER: CsrfHeader;
62
+ /** Build the transport-independent request. Exported so consumers can pin the write shape. */
63
+ declare function buildUploadRequest(file: Blob, options: UploadImageOptions): UploadRequest;
64
+ /**
65
+ * Upload one image as multipart/form-data (`File`, `Category`, `IsPublic`) to a
66
+ * consumer-supplied endpoint and return the parsed `{ contentId, status, url }`.
67
+ *
68
+ * Never sets Content-Type: the runtime adds the multipart boundary, and a JSON content type
69
+ * on a FormData body is a 415 (see `@dloizides/bff-web-client` writeContentTypeGuard).
70
+ *
71
+ * @throws {UploadError} for every failure; switch on `error.kind`.
72
+ */
73
+ declare function uploadImage(file: Blob, options: UploadImageOptions): Promise<UploadedContent>;
74
+
75
+ /**
76
+ * Read a local URI (`blob:`, `data:`, or an RN image-picker `file:` URI) into a Blob so it
77
+ * can be passed to `uploadImage`.
78
+ *
79
+ * @throws {UploadError} `Validation` when the URI cannot be read.
80
+ */
81
+ declare function blobFromUri(uri: string, fetchImpl?: typeof fetch): Promise<Blob>;
82
+
83
+ /** Lifecycle of a single `useImageUpload` slot. */
84
+ declare const enum UploadStatus {
85
+ Idle = "idle",
86
+ Uploading = "uploading",
87
+ Error = "error",
88
+ Done = "done"
89
+ }
90
+
91
+ /** Why an upload failed. Consumers map this to translated copy; `message` is developer-facing only. */
92
+ declare const enum UploadErrorKind {
93
+ /** Rejected client-side before any request: empty file, too large, disallowed MIME type. */
94
+ Validation = "validation",
95
+ /** The request never produced an HTTP response (offline, DNS, CORS). */
96
+ Network = "network",
97
+ /** The server answered outside 2xx; `status` carries the code. */
98
+ Http = "http",
99
+ /** The caller aborted (AbortSignal / hook reset). */
100
+ Aborted = "aborted",
101
+ /** 2xx, but the body was not `{ contentId, status, url? }` (or lacked a required url). */
102
+ InvalidResponse = "invalid-response"
103
+ }
104
+
105
+ /** The single error type thrown by `uploadImage` and surfaced by `useImageUpload`. */
106
+ declare class UploadError extends Error {
107
+ readonly kind: UploadErrorKind;
108
+ /** HTTP status for `UploadErrorKind.Http`; undefined otherwise. */
109
+ readonly status: number | undefined;
110
+ constructor(kind: UploadErrorKind, message: string, status?: number);
111
+ }
112
+ /** Normalise anything thrown into an `UploadError` (unknown throws become `Network`). */
113
+ declare function toUploadError(cause: unknown): UploadError;
114
+
115
+ /** Options for `useImageUpload`: everything `uploadImage` takes except the per-call wiring. */
116
+ interface UseImageUploadOptions extends Omit<UploadImageOptions, 'signal' | 'onProgress'> {
117
+ onSuccess?: (content: UploadedContent) => void;
118
+ onError?: (error: UploadError) => void;
119
+ }
120
+ interface ImageUploadState {
121
+ status: UploadStatus;
122
+ /** 0-100. Stays 0 while uploading when the transport cannot report progress (fetch). */
123
+ progress: number;
124
+ error: UploadError | null;
125
+ result: UploadedContent | null;
126
+ }
127
+ interface UseImageUploadResult extends ImageUploadState {
128
+ /** Start an upload (aborting any in-flight one). Resolves to the content, or null on error/abort. */
129
+ upload: (file: Blob, fileName?: string) => Promise<UploadedContent | null>;
130
+ /** Abort any in-flight upload and return to idle. */
131
+ reset: () => void;
132
+ }
133
+ /**
134
+ * One image-upload slot: idle -> uploading -> done | error. Progress is reported when the
135
+ * XMLHttpRequest transport is available (browser, React Native) and no `fetchImpl` is set.
136
+ * A newer `upload()` or `reset()` supersedes the previous request; its late result is dropped.
137
+ * Unmount aborts the in-flight request.
138
+ */
139
+ declare function useImageUpload(options: UseImageUploadOptions): UseImageUploadResult;
140
+
141
+ export { type CsrfHeader, DEFAULT_CSRF_HEADER, type ImageUploadState, type ProgressListener, UploadError, UploadErrorKind, type UploadImageOptions, type UploadRequest, UploadStatus, type UploadedContent, type UseImageUploadOptions, type UseImageUploadResult, blobFromUri, buildUploadRequest, toUploadError, uploadImage, useImageUpload };
@@ -0,0 +1,141 @@
1
+ /** A header the BFF anti-forgery middleware requires on mutating requests. */
2
+ interface CsrfHeader {
3
+ name: string;
4
+ value: string;
5
+ }
6
+ /** Body returned by the ContentService upload endpoint. */
7
+ interface UploadedContent {
8
+ /** Stable id of the persisted content row. */
9
+ contentId: string;
10
+ /** Server status (e.g. "Active"). */
11
+ status: string;
12
+ /** Public download URL; present when the upload was public. */
13
+ url?: string;
14
+ }
15
+ /** Receives whole-number upload progress, 0-100. */
16
+ type ProgressListener = (percent: number) => void;
17
+ /** Options for `uploadImage`. Only `endpoint` is required. */
18
+ interface UploadImageOptions {
19
+ /** Same-origin URL of the upload endpoint, e.g. `/bff/api/content/api/v1/content/upload`. */
20
+ endpoint: string;
21
+ /** Filename sent with the file part. Defaults to `File.name`, else `"upload"`. */
22
+ fileName?: string;
23
+ /** ContentService `Category` form field. Default `"Image"`. */
24
+ category?: string;
25
+ /** ContentService `IsPublic` form field. Default `true`. */
26
+ isPublic?: boolean;
27
+ /** Reject a 2xx body without `url`. Defaults to the value of `isPublic`. */
28
+ requireUrl?: boolean;
29
+ /** Anti-forgery header. Default `X-BFF-Csrf: 1`; pass `null` to send none. */
30
+ csrf?: CsrfHeader | null;
31
+ /** Fetch credentials mode. Default `"include"` (BFF session cookie). */
32
+ credentials?: RequestCredentials;
33
+ /** Reject files larger than this many bytes before sending. */
34
+ maxBytes?: number;
35
+ /** Reject files whose MIME type is not in this list before sending. */
36
+ allowedTypes?: readonly string[];
37
+ /** Aborts the request; the promise rejects with `UploadErrorKind.Aborted`. */
38
+ signal?: AbortSignal;
39
+ /**
40
+ * Progress listener. When set (and no `fetchImpl` is given) the upload goes over
41
+ * XMLHttpRequest, the only browser/RN transport that reports upload progress.
42
+ */
43
+ onProgress?: ProgressListener;
44
+ /** Custom fetch (tests, SSR). Forces the fetch transport. */
45
+ fetchImpl?: typeof fetch;
46
+ /** Custom XMLHttpRequest factory (tests). */
47
+ xhrFactory?: () => XMLHttpRequest;
48
+ }
49
+
50
+ /** A fully built multipart upload, independent of the transport that sends it. */
51
+ interface UploadRequest {
52
+ url: string;
53
+ /** Never contains Content-Type: the runtime sets the multipart boundary for a FormData body. */
54
+ headers: Record<string, string>;
55
+ form: FormData;
56
+ credentials: RequestCredentials;
57
+ signal: AbortSignal | undefined;
58
+ }
59
+
60
+ /** The Bff.AspNetCore anti-forgery convention: header presence, any value. */
61
+ declare const DEFAULT_CSRF_HEADER: CsrfHeader;
62
+ /** Build the transport-independent request. Exported so consumers can pin the write shape. */
63
+ declare function buildUploadRequest(file: Blob, options: UploadImageOptions): UploadRequest;
64
+ /**
65
+ * Upload one image as multipart/form-data (`File`, `Category`, `IsPublic`) to a
66
+ * consumer-supplied endpoint and return the parsed `{ contentId, status, url }`.
67
+ *
68
+ * Never sets Content-Type: the runtime adds the multipart boundary, and a JSON content type
69
+ * on a FormData body is a 415 (see `@dloizides/bff-web-client` writeContentTypeGuard).
70
+ *
71
+ * @throws {UploadError} for every failure; switch on `error.kind`.
72
+ */
73
+ declare function uploadImage(file: Blob, options: UploadImageOptions): Promise<UploadedContent>;
74
+
75
+ /**
76
+ * Read a local URI (`blob:`, `data:`, or an RN image-picker `file:` URI) into a Blob so it
77
+ * can be passed to `uploadImage`.
78
+ *
79
+ * @throws {UploadError} `Validation` when the URI cannot be read.
80
+ */
81
+ declare function blobFromUri(uri: string, fetchImpl?: typeof fetch): Promise<Blob>;
82
+
83
+ /** Lifecycle of a single `useImageUpload` slot. */
84
+ declare const enum UploadStatus {
85
+ Idle = "idle",
86
+ Uploading = "uploading",
87
+ Error = "error",
88
+ Done = "done"
89
+ }
90
+
91
+ /** Why an upload failed. Consumers map this to translated copy; `message` is developer-facing only. */
92
+ declare const enum UploadErrorKind {
93
+ /** Rejected client-side before any request: empty file, too large, disallowed MIME type. */
94
+ Validation = "validation",
95
+ /** The request never produced an HTTP response (offline, DNS, CORS). */
96
+ Network = "network",
97
+ /** The server answered outside 2xx; `status` carries the code. */
98
+ Http = "http",
99
+ /** The caller aborted (AbortSignal / hook reset). */
100
+ Aborted = "aborted",
101
+ /** 2xx, but the body was not `{ contentId, status, url? }` (or lacked a required url). */
102
+ InvalidResponse = "invalid-response"
103
+ }
104
+
105
+ /** The single error type thrown by `uploadImage` and surfaced by `useImageUpload`. */
106
+ declare class UploadError extends Error {
107
+ readonly kind: UploadErrorKind;
108
+ /** HTTP status for `UploadErrorKind.Http`; undefined otherwise. */
109
+ readonly status: number | undefined;
110
+ constructor(kind: UploadErrorKind, message: string, status?: number);
111
+ }
112
+ /** Normalise anything thrown into an `UploadError` (unknown throws become `Network`). */
113
+ declare function toUploadError(cause: unknown): UploadError;
114
+
115
+ /** Options for `useImageUpload`: everything `uploadImage` takes except the per-call wiring. */
116
+ interface UseImageUploadOptions extends Omit<UploadImageOptions, 'signal' | 'onProgress'> {
117
+ onSuccess?: (content: UploadedContent) => void;
118
+ onError?: (error: UploadError) => void;
119
+ }
120
+ interface ImageUploadState {
121
+ status: UploadStatus;
122
+ /** 0-100. Stays 0 while uploading when the transport cannot report progress (fetch). */
123
+ progress: number;
124
+ error: UploadError | null;
125
+ result: UploadedContent | null;
126
+ }
127
+ interface UseImageUploadResult extends ImageUploadState {
128
+ /** Start an upload (aborting any in-flight one). Resolves to the content, or null on error/abort. */
129
+ upload: (file: Blob, fileName?: string) => Promise<UploadedContent | null>;
130
+ /** Abort any in-flight upload and return to idle. */
131
+ reset: () => void;
132
+ }
133
+ /**
134
+ * One image-upload slot: idle -> uploading -> done | error. Progress is reported when the
135
+ * XMLHttpRequest transport is available (browser, React Native) and no `fetchImpl` is set.
136
+ * A newer `upload()` or `reset()` supersedes the previous request; its late result is dropped.
137
+ * Unmount aborts the in-flight request.
138
+ */
139
+ declare function useImageUpload(options: UseImageUploadOptions): UseImageUploadResult;
140
+
141
+ export { type CsrfHeader, DEFAULT_CSRF_HEADER, type ImageUploadState, type ProgressListener, UploadError, UploadErrorKind, type UploadImageOptions, type UploadRequest, UploadStatus, type UploadedContent, type UseImageUploadOptions, type UseImageUploadResult, blobFromUri, buildUploadRequest, toUploadError, uploadImage, useImageUpload };
package/dist/index.js ADDED
@@ -0,0 +1,304 @@
1
+ 'use strict';
2
+
3
+ var react = require('react');
4
+
5
+ // src/UploadErrorKind.ts
6
+ var UploadErrorKind = /* @__PURE__ */ ((UploadErrorKind2) => {
7
+ UploadErrorKind2["Validation"] = "validation";
8
+ UploadErrorKind2["Network"] = "network";
9
+ UploadErrorKind2["Http"] = "http";
10
+ UploadErrorKind2["Aborted"] = "aborted";
11
+ UploadErrorKind2["InvalidResponse"] = "invalid-response";
12
+ return UploadErrorKind2;
13
+ })(UploadErrorKind || {});
14
+
15
+ // src/UploadError.ts
16
+ var UploadError = class extends Error {
17
+ constructor(kind, message, status) {
18
+ super(message);
19
+ this.name = "UploadError";
20
+ this.kind = kind;
21
+ this.status = status;
22
+ }
23
+ };
24
+ function toUploadError(cause) {
25
+ if (cause instanceof UploadError) {
26
+ return cause;
27
+ }
28
+ const message = cause instanceof Error ? cause.message : String(cause);
29
+ return new UploadError("network" /* Network */, message);
30
+ }
31
+
32
+ // src/parseUploadedContent.ts
33
+ function isRecord(value) {
34
+ return typeof value === "object" && value !== null && !Array.isArray(value);
35
+ }
36
+ function invalid(message) {
37
+ return new UploadError("invalid-response" /* InvalidResponse */, message);
38
+ }
39
+ function parseUploadedContent(text, requireUrl) {
40
+ let payload;
41
+ try {
42
+ payload = JSON.parse(text);
43
+ } catch {
44
+ throw invalid("Upload response was not JSON.");
45
+ }
46
+ if (!isRecord(payload) || typeof payload.contentId !== "string" || typeof payload.status !== "string") {
47
+ throw invalid("Upload response shape was invalid.");
48
+ }
49
+ const url = typeof payload.url === "string" && payload.url !== "" ? payload.url : void 0;
50
+ if (requireUrl && url === void 0) {
51
+ throw invalid("Upload response did not include a public URL.");
52
+ }
53
+ return { contentId: payload.contentId, status: payload.status, url };
54
+ }
55
+
56
+ // src/transports.ts
57
+ var HTTP_OK_MIN = 200;
58
+ var HTTP_OK_MAX_EXCLUSIVE = 300;
59
+ var PERCENT_COMPLETE = 100;
60
+ function isOk(status) {
61
+ return status >= HTTP_OK_MIN && status < HTTP_OK_MAX_EXCLUSIVE;
62
+ }
63
+ function httpError(status) {
64
+ return new UploadError("http" /* Http */, `Upload failed (${String(status)}).`, status);
65
+ }
66
+ function abortedError() {
67
+ return new UploadError("aborted" /* Aborted */, "Upload was cancelled.");
68
+ }
69
+ async function sendWithFetch(request, fetchImpl) {
70
+ let response;
71
+ try {
72
+ response = await fetchImpl(request.url, {
73
+ method: "POST",
74
+ credentials: request.credentials,
75
+ headers: request.headers,
76
+ body: request.form,
77
+ signal: request.signal
78
+ });
79
+ } catch (cause) {
80
+ if (request.signal?.aborted === true) {
81
+ throw abortedError();
82
+ }
83
+ const detail = cause instanceof Error ? cause.message : String(cause);
84
+ throw new UploadError("network" /* Network */, detail);
85
+ }
86
+ if (!isOk(response.status)) {
87
+ throw httpError(response.status);
88
+ }
89
+ return response.text();
90
+ }
91
+ function reportProgress(event, onProgress) {
92
+ if (event.lengthComputable && event.total > 0) {
93
+ onProgress(Math.round(event.loaded / event.total * PERCENT_COMPLETE));
94
+ }
95
+ }
96
+ function sendWithXhr(request, createXhr, onProgress) {
97
+ return new Promise((resolve, reject) => {
98
+ const { signal } = request;
99
+ if (signal?.aborted === true) {
100
+ reject(abortedError());
101
+ return;
102
+ }
103
+ const xhr = createXhr();
104
+ xhr.upload.addEventListener("progress", (event) => reportProgress(event, onProgress));
105
+ xhr.addEventListener("load", () => {
106
+ if (isOk(xhr.status)) {
107
+ resolve(xhr.responseText);
108
+ } else {
109
+ reject(httpError(xhr.status));
110
+ }
111
+ });
112
+ xhr.addEventListener("error", () => reject(new UploadError("network" /* Network */, "Upload failed (network error).")));
113
+ xhr.addEventListener("abort", () => reject(abortedError()));
114
+ signal?.addEventListener("abort", () => xhr.abort(), { once: true });
115
+ xhr.open("POST", request.url);
116
+ xhr.withCredentials = request.credentials === "include";
117
+ Object.entries(request.headers).forEach(([name, value]) => xhr.setRequestHeader(name, value));
118
+ xhr.send(request.form);
119
+ });
120
+ }
121
+
122
+ // src/uploadImage.ts
123
+ var DEFAULT_CSRF_HEADER = { name: "X-BFF-Csrf", value: "1" };
124
+ var DEFAULT_CATEGORY = "Image";
125
+ var DEFAULT_FILE_NAME = "upload";
126
+ var FILE_FIELD = "File";
127
+ var CATEGORY_FIELD = "Category";
128
+ var IS_PUBLIC_FIELD = "IsPublic";
129
+ function validationError(message) {
130
+ return new UploadError("validation" /* Validation */, message);
131
+ }
132
+ function validate(file, options) {
133
+ if (file.size === 0) {
134
+ throw validationError("File is empty.");
135
+ }
136
+ if (options.maxBytes !== void 0 && file.size > options.maxBytes) {
137
+ throw validationError(`File is ${String(file.size)} bytes; the limit is ${String(options.maxBytes)}.`);
138
+ }
139
+ if (options.allowedTypes !== void 0 && !options.allowedTypes.includes(file.type)) {
140
+ throw validationError(`File type "${file.type}" is not allowed.`);
141
+ }
142
+ }
143
+ function resolveFileName(file, explicit) {
144
+ if (explicit !== void 0 && explicit !== "") {
145
+ return explicit;
146
+ }
147
+ if ("name" in file && typeof file.name === "string" && file.name !== "") {
148
+ return file.name;
149
+ }
150
+ return DEFAULT_FILE_NAME;
151
+ }
152
+ function buildHeaders(csrf) {
153
+ const headers = { Accept: "application/json" };
154
+ const header = csrf === void 0 ? DEFAULT_CSRF_HEADER : csrf;
155
+ if (header !== null) {
156
+ headers[header.name] = header.value;
157
+ }
158
+ return headers;
159
+ }
160
+ function buildUploadRequest(file, options) {
161
+ const form = new FormData();
162
+ form.append(FILE_FIELD, file, resolveFileName(file, options.fileName));
163
+ form.append(CATEGORY_FIELD, options.category ?? DEFAULT_CATEGORY);
164
+ form.append(IS_PUBLIC_FIELD, String(options.isPublic ?? true));
165
+ return {
166
+ url: options.endpoint,
167
+ headers: buildHeaders(options.csrf),
168
+ form,
169
+ credentials: options.credentials ?? "include",
170
+ signal: options.signal
171
+ };
172
+ }
173
+ function resolveXhrFactory(options) {
174
+ if (options.fetchImpl !== void 0) {
175
+ return void 0;
176
+ }
177
+ if (options.xhrFactory !== void 0) {
178
+ return options.xhrFactory;
179
+ }
180
+ return typeof XMLHttpRequest === "undefined" ? void 0 : () => new XMLHttpRequest();
181
+ }
182
+ async function send(request, options) {
183
+ const { onProgress } = options;
184
+ const createXhr = onProgress === void 0 ? void 0 : resolveXhrFactory(options);
185
+ if (createXhr !== void 0 && onProgress !== void 0) {
186
+ return sendWithXhr(request, createXhr, onProgress);
187
+ }
188
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis);
189
+ return sendWithFetch(request, fetchImpl);
190
+ }
191
+ async function uploadImage(file, options) {
192
+ validate(file, options);
193
+ const request = buildUploadRequest(file, options);
194
+ const text = await send(request, options);
195
+ return parseUploadedContent(text, options.requireUrl ?? options.isPublic ?? true);
196
+ }
197
+
198
+ // src/blobFromUri.ts
199
+ async function blobFromUri(uri, fetchImpl) {
200
+ const doFetch = fetchImpl ?? globalThis.fetch.bind(globalThis);
201
+ let response;
202
+ try {
203
+ response = await doFetch(uri);
204
+ } catch (cause) {
205
+ const detail = cause instanceof Error ? cause.message : String(cause);
206
+ throw new UploadError("validation" /* Validation */, `Could not read file: ${detail}`);
207
+ }
208
+ if (!response.ok) {
209
+ throw new UploadError("validation" /* Validation */, `Could not read file (${String(response.status)}).`);
210
+ }
211
+ return response.blob();
212
+ }
213
+
214
+ // src/UploadStatus.ts
215
+ var UploadStatus = /* @__PURE__ */ ((UploadStatus2) => {
216
+ UploadStatus2["Idle"] = "idle";
217
+ UploadStatus2["Uploading"] = "uploading";
218
+ UploadStatus2["Error"] = "error";
219
+ UploadStatus2["Done"] = "done";
220
+ return UploadStatus2;
221
+ })(UploadStatus || {});
222
+
223
+ // src/useImageUpload.ts
224
+ var IDLE = { status: "idle" /* Idle */, progress: 0, error: null, result: null };
225
+ var UPLOADING = { status: "uploading" /* Uploading */, progress: 0, error: null, result: null };
226
+ function reducer(state, action) {
227
+ switch (action.type) {
228
+ case "start":
229
+ return UPLOADING;
230
+ case "progress":
231
+ return { ...state, progress: action.percent };
232
+ case "done":
233
+ return { status: "done" /* Done */, progress: PERCENT_COMPLETE, error: null, result: action.result };
234
+ case "fail":
235
+ return { ...state, status: "error" /* Error */, error: action.error };
236
+ default:
237
+ return IDLE;
238
+ }
239
+ }
240
+ function useImageUpload(options) {
241
+ const [state, dispatch] = react.useReducer(reducer, IDLE);
242
+ const optionsRef = react.useRef(options);
243
+ optionsRef.current = options;
244
+ const controllerRef = react.useRef(null);
245
+ const requestIdRef = react.useRef(0);
246
+ const abortInFlight = react.useCallback(() => {
247
+ controllerRef.current?.abort();
248
+ controllerRef.current = null;
249
+ requestIdRef.current += 1;
250
+ }, []);
251
+ react.useEffect(() => abortInFlight, [abortInFlight]);
252
+ const upload = react.useCallback(async (file, fileName) => {
253
+ abortInFlight();
254
+ const controller = new AbortController();
255
+ controllerRef.current = controller;
256
+ const requestId = requestIdRef.current;
257
+ const isCurrent = () => requestId === requestIdRef.current;
258
+ const { onSuccess, onError, ...uploadOptions } = optionsRef.current;
259
+ dispatch({ type: "start" });
260
+ try {
261
+ const result = await uploadImage(file, {
262
+ ...uploadOptions,
263
+ fileName: fileName ?? uploadOptions.fileName,
264
+ signal: controller.signal,
265
+ onProgress: (percent) => {
266
+ if (isCurrent()) {
267
+ dispatch({ type: "progress", percent });
268
+ }
269
+ }
270
+ });
271
+ if (!isCurrent()) {
272
+ return null;
273
+ }
274
+ dispatch({ type: "done", result });
275
+ onSuccess?.(result);
276
+ return result;
277
+ } catch (cause) {
278
+ if (!isCurrent()) {
279
+ return null;
280
+ }
281
+ const error = toUploadError(cause);
282
+ dispatch({ type: "fail", error });
283
+ onError?.(error);
284
+ return null;
285
+ }
286
+ }, [abortInFlight]);
287
+ const reset = react.useCallback(() => {
288
+ abortInFlight();
289
+ dispatch({ type: "reset" });
290
+ }, [abortInFlight]);
291
+ return { ...state, upload, reset };
292
+ }
293
+
294
+ exports.DEFAULT_CSRF_HEADER = DEFAULT_CSRF_HEADER;
295
+ exports.UploadError = UploadError;
296
+ exports.UploadErrorKind = UploadErrorKind;
297
+ exports.UploadStatus = UploadStatus;
298
+ exports.blobFromUri = blobFromUri;
299
+ exports.buildUploadRequest = buildUploadRequest;
300
+ exports.toUploadError = toUploadError;
301
+ exports.uploadImage = uploadImage;
302
+ exports.useImageUpload = useImageUpload;
303
+ //# sourceMappingURL=index.js.map
304
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/UploadErrorKind.ts","../src/UploadError.ts","../src/parseUploadedContent.ts","../src/transports.ts","../src/uploadImage.ts","../src/blobFromUri.ts","../src/UploadStatus.ts","../src/useImageUpload.ts"],"names":["UploadErrorKind","UploadStatus","useReducer","useRef","useCallback","useEffect"],"mappings":"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","file":"index.js","sourcesContent":["/** Why an upload failed. Consumers map this to translated copy; `message` is developer-facing only. */\nexport const enum UploadErrorKind {\n /** Rejected client-side before any request: empty file, too large, disallowed MIME type. */\n Validation = 'validation',\n /** The request never produced an HTTP response (offline, DNS, CORS). */\n Network = 'network',\n /** The server answered outside 2xx; `status` carries the code. */\n Http = 'http',\n /** The caller aborted (AbortSignal / hook reset). */\n Aborted = 'aborted',\n /** 2xx, but the body was not `{ contentId, status, url? }` (or lacked a required url). */\n InvalidResponse = 'invalid-response',\n}\n","import { UploadErrorKind } from './UploadErrorKind';\n\n/** The single error type thrown by `uploadImage` and surfaced by `useImageUpload`. */\nexport class UploadError extends Error {\n readonly kind: UploadErrorKind;\n /** HTTP status for `UploadErrorKind.Http`; undefined otherwise. */\n readonly status: number | undefined;\n\n constructor(kind: UploadErrorKind, message: string, status?: number) {\n super(message);\n this.name = 'UploadError';\n this.kind = kind;\n this.status = status;\n }\n}\n\n/** Normalise anything thrown into an `UploadError` (unknown throws become `Network`). */\nexport function toUploadError(cause: unknown): UploadError {\n if (cause instanceof UploadError) {\n return cause;\n }\n const message = cause instanceof Error ? cause.message : String(cause);\n return new UploadError(UploadErrorKind.Network, message);\n}\n","import { UploadError } from './UploadError';\nimport { UploadErrorKind } from './UploadErrorKind';\n\nimport type { UploadedContent } from './types';\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\nfunction invalid(message: string): UploadError {\n return new UploadError(UploadErrorKind.InvalidResponse, message);\n}\n\n/** Parse the upload endpoint's JSON body into `UploadedContent`, or throw `InvalidResponse`. */\nexport function parseUploadedContent(text: string, requireUrl: boolean): UploadedContent {\n let payload: unknown;\n try {\n payload = JSON.parse(text);\n } catch {\n throw invalid('Upload response was not JSON.');\n }\n if (!isRecord(payload) || typeof payload.contentId !== 'string' || typeof payload.status !== 'string') {\n throw invalid('Upload response shape was invalid.');\n }\n const url = typeof payload.url === 'string' && payload.url !== '' ? payload.url : undefined;\n if (requireUrl && url === undefined) {\n throw invalid('Upload response did not include a public URL.');\n }\n return { contentId: payload.contentId, status: payload.status, url };\n}\n","import { UploadError } from './UploadError';\nimport { UploadErrorKind } from './UploadErrorKind';\n\nimport type { ProgressListener } from './types';\n\nconst HTTP_OK_MIN = 200;\nconst HTTP_OK_MAX_EXCLUSIVE = 300;\nexport const PERCENT_COMPLETE = 100;\n\n/** A fully built multipart upload, independent of the transport that sends it. */\nexport interface UploadRequest {\n url: string;\n /** Never contains Content-Type: the runtime sets the multipart boundary for a FormData body. */\n headers: Record<string, string>;\n form: FormData;\n credentials: RequestCredentials;\n signal: AbortSignal | undefined;\n}\n\nfunction isOk(status: number): boolean {\n return status >= HTTP_OK_MIN && status < HTTP_OK_MAX_EXCLUSIVE;\n}\n\nfunction httpError(status: number): UploadError {\n return new UploadError(UploadErrorKind.Http, `Upload failed (${String(status)}).`, status);\n}\n\nfunction abortedError(): UploadError {\n return new UploadError(UploadErrorKind.Aborted, 'Upload was cancelled.');\n}\n\n/** POST with fetch; resolves to the raw response text of a 2xx. */\nexport async function sendWithFetch(request: UploadRequest, fetchImpl: typeof fetch): Promise<string> {\n let response: Response;\n try {\n response = await fetchImpl(request.url, {\n method: 'POST',\n credentials: request.credentials,\n headers: request.headers,\n body: request.form,\n signal: request.signal,\n });\n } catch (cause) {\n if (request.signal?.aborted === true) {\n throw abortedError();\n }\n const detail = cause instanceof Error ? cause.message : String(cause);\n throw new UploadError(UploadErrorKind.Network, detail);\n }\n if (!isOk(response.status)) {\n throw httpError(response.status);\n }\n return response.text();\n}\n\nfunction reportProgress(event: ProgressEvent, onProgress: ProgressListener): void {\n if (event.lengthComputable && event.total > 0) {\n onProgress(Math.round((event.loaded / event.total) * PERCENT_COMPLETE));\n }\n}\n\n/** POST with XMLHttpRequest so upload progress can be observed. */\nexport function sendWithXhr(\n request: UploadRequest,\n createXhr: () => XMLHttpRequest,\n onProgress: ProgressListener,\n): Promise<string> {\n return new Promise((resolve, reject) => {\n const { signal } = request;\n if (signal?.aborted === true) {\n reject(abortedError());\n return;\n }\n const xhr = createXhr();\n xhr.upload.addEventListener('progress', (event) => reportProgress(event, onProgress));\n xhr.addEventListener('load', () => {\n if (isOk(xhr.status)) {\n resolve(xhr.responseText);\n } else {\n reject(httpError(xhr.status));\n }\n });\n xhr.addEventListener('error', () => reject(new UploadError(UploadErrorKind.Network, 'Upload failed (network error).')));\n xhr.addEventListener('abort', () => reject(abortedError()));\n signal?.addEventListener('abort', () => xhr.abort(), { once: true });\n xhr.open('POST', request.url);\n xhr.withCredentials = request.credentials === 'include';\n Object.entries(request.headers).forEach(([name, value]) => xhr.setRequestHeader(name, value));\n xhr.send(request.form);\n });\n}\n","import { parseUploadedContent } from './parseUploadedContent';\nimport { sendWithFetch, sendWithXhr } from './transports';\nimport { UploadError } from './UploadError';\nimport { UploadErrorKind } from './UploadErrorKind';\n\nimport type { UploadRequest } from './transports';\nimport type { CsrfHeader, UploadedContent, UploadImageOptions } from './types';\n\n/** The Bff.AspNetCore anti-forgery convention: header presence, any value. */\nexport const DEFAULT_CSRF_HEADER: CsrfHeader = { name: 'X-BFF-Csrf', value: '1' };\n\nconst DEFAULT_CATEGORY = 'Image';\nconst DEFAULT_FILE_NAME = 'upload';\nconst FILE_FIELD = 'File';\nconst CATEGORY_FIELD = 'Category';\nconst IS_PUBLIC_FIELD = 'IsPublic';\n\nfunction validationError(message: string): UploadError {\n return new UploadError(UploadErrorKind.Validation, message);\n}\n\n/** Client-side checks that must fail before a byte leaves the device. */\nfunction validate(file: Blob, options: UploadImageOptions): void {\n if (file.size === 0) {\n throw validationError('File is empty.');\n }\n if (options.maxBytes !== undefined && file.size > options.maxBytes) {\n throw validationError(`File is ${String(file.size)} bytes; the limit is ${String(options.maxBytes)}.`);\n }\n if (options.allowedTypes !== undefined && !options.allowedTypes.includes(file.type)) {\n throw validationError(`File type \"${file.type}\" is not allowed.`);\n }\n}\n\nfunction resolveFileName(file: Blob, explicit: string | undefined): string {\n if (explicit !== undefined && explicit !== '') {\n return explicit;\n }\n if ('name' in file && typeof file.name === 'string' && file.name !== '') {\n return file.name;\n }\n return DEFAULT_FILE_NAME;\n}\n\nfunction buildHeaders(csrf: CsrfHeader | null | undefined): Record<string, string> {\n const headers: Record<string, string> = { Accept: 'application/json' };\n const header = csrf === undefined ? DEFAULT_CSRF_HEADER : csrf;\n if (header !== null) {\n headers[header.name] = header.value;\n }\n return headers;\n}\n\n/** Build the transport-independent request. Exported so consumers can pin the write shape. */\nexport function buildUploadRequest(file: Blob, options: UploadImageOptions): UploadRequest {\n const form = new FormData();\n form.append(FILE_FIELD, file, resolveFileName(file, options.fileName));\n form.append(CATEGORY_FIELD, options.category ?? DEFAULT_CATEGORY);\n form.append(IS_PUBLIC_FIELD, String(options.isPublic ?? true));\n return {\n url: options.endpoint,\n headers: buildHeaders(options.csrf),\n form,\n credentials: options.credentials ?? 'include',\n signal: options.signal,\n };\n}\n\nfunction resolveXhrFactory(options: UploadImageOptions): (() => XMLHttpRequest) | undefined {\n if (options.fetchImpl !== undefined) {\n return undefined;\n }\n if (options.xhrFactory !== undefined) {\n return options.xhrFactory;\n }\n return typeof XMLHttpRequest === 'undefined' ? undefined : (): XMLHttpRequest => new XMLHttpRequest();\n}\n\nasync function send(request: UploadRequest, options: UploadImageOptions): Promise<string> {\n const { onProgress } = options;\n const createXhr = onProgress === undefined ? undefined : resolveXhrFactory(options);\n if (createXhr !== undefined && onProgress !== undefined) {\n return sendWithXhr(request, createXhr, onProgress);\n }\n const fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis);\n return sendWithFetch(request, fetchImpl);\n}\n\n/**\n * Upload one image as multipart/form-data (`File`, `Category`, `IsPublic`) to a\n * consumer-supplied endpoint and return the parsed `{ contentId, status, url }`.\n *\n * Never sets Content-Type: the runtime adds the multipart boundary, and a JSON content type\n * on a FormData body is a 415 (see `@dloizides/bff-web-client` writeContentTypeGuard).\n *\n * @throws {UploadError} for every failure; switch on `error.kind`.\n */\nexport async function uploadImage(file: Blob, options: UploadImageOptions): Promise<UploadedContent> {\n validate(file, options);\n const request = buildUploadRequest(file, options);\n const text = await send(request, options);\n return parseUploadedContent(text, options.requireUrl ?? options.isPublic ?? true);\n}\n","import { UploadError } from './UploadError';\nimport { UploadErrorKind } from './UploadErrorKind';\n\n/**\n * Read a local URI (`blob:`, `data:`, or an RN image-picker `file:` URI) into a Blob so it\n * can be passed to `uploadImage`.\n *\n * @throws {UploadError} `Validation` when the URI cannot be read.\n */\nexport async function blobFromUri(uri: string, fetchImpl?: typeof fetch): Promise<Blob> {\n const doFetch = fetchImpl ?? globalThis.fetch.bind(globalThis);\n let response: Response;\n try {\n response = await doFetch(uri);\n } catch (cause) {\n const detail = cause instanceof Error ? cause.message : String(cause);\n throw new UploadError(UploadErrorKind.Validation, `Could not read file: ${detail}`);\n }\n if (!response.ok) {\n throw new UploadError(UploadErrorKind.Validation, `Could not read file (${String(response.status)}).`);\n }\n return response.blob();\n}\n","/** Lifecycle of a single `useImageUpload` slot. */\nexport const enum UploadStatus {\n Idle = 'idle',\n Uploading = 'uploading',\n Error = 'error',\n Done = 'done',\n}\n","import { useCallback, useEffect, useReducer, useRef } from 'react';\n\nimport { PERCENT_COMPLETE } from './transports';\nimport { toUploadError } from './UploadError';\nimport { uploadImage } from './uploadImage';\nimport { UploadStatus } from './UploadStatus';\n\nimport type { UploadedContent, UploadImageOptions } from './types';\nimport type { UploadError } from './UploadError';\n\n/** Options for `useImageUpload`: everything `uploadImage` takes except the per-call wiring. */\nexport interface UseImageUploadOptions extends Omit<UploadImageOptions, 'signal' | 'onProgress'> {\n onSuccess?: (content: UploadedContent) => void;\n onError?: (error: UploadError) => void;\n}\n\nexport interface ImageUploadState {\n status: UploadStatus;\n /** 0-100. Stays 0 while uploading when the transport cannot report progress (fetch). */\n progress: number;\n error: UploadError | null;\n result: UploadedContent | null;\n}\n\nexport interface UseImageUploadResult extends ImageUploadState {\n /** Start an upload (aborting any in-flight one). Resolves to the content, or null on error/abort. */\n upload: (file: Blob, fileName?: string) => Promise<UploadedContent | null>;\n /** Abort any in-flight upload and return to idle. */\n reset: () => void;\n}\n\nconst IDLE: ImageUploadState = { status: UploadStatus.Idle, progress: 0, error: null, result: null };\nconst UPLOADING: ImageUploadState = { status: UploadStatus.Uploading, progress: 0, error: null, result: null };\n\ntype Action =\n | { type: 'start' }\n | { type: 'progress'; percent: number }\n | { type: 'done'; result: UploadedContent }\n | { type: 'fail'; error: UploadError }\n | { type: 'reset' };\n\nfunction reducer(state: ImageUploadState, action: Action): ImageUploadState {\n switch (action.type) {\n case 'start':\n return UPLOADING;\n case 'progress':\n return { ...state, progress: action.percent };\n case 'done':\n return { status: UploadStatus.Done, progress: PERCENT_COMPLETE, error: null, result: action.result };\n case 'fail':\n return { ...state, status: UploadStatus.Error, error: action.error };\n default:\n return IDLE;\n }\n}\n\n/**\n * One image-upload slot: idle -> uploading -> done | error. Progress is reported when the\n * XMLHttpRequest transport is available (browser, React Native) and no `fetchImpl` is set.\n * A newer `upload()` or `reset()` supersedes the previous request; its late result is dropped.\n * Unmount aborts the in-flight request.\n */\nexport function useImageUpload(options: UseImageUploadOptions): UseImageUploadResult {\n const [state, dispatch] = useReducer(reducer, IDLE);\n const optionsRef = useRef(options);\n optionsRef.current = options;\n const controllerRef = useRef<AbortController | null>(null);\n const requestIdRef = useRef(0);\n\n const abortInFlight = useCallback((): void => {\n controllerRef.current?.abort();\n controllerRef.current = null;\n requestIdRef.current += 1;\n }, []);\n\n useEffect(() => abortInFlight, [abortInFlight]);\n\n const upload = useCallback(async (file: Blob, fileName?: string): Promise<UploadedContent | null> => {\n abortInFlight();\n const controller = new AbortController();\n controllerRef.current = controller;\n const requestId = requestIdRef.current;\n const isCurrent = (): boolean => requestId === requestIdRef.current;\n const { onSuccess, onError, ...uploadOptions } = optionsRef.current;\n dispatch({ type: 'start' });\n try {\n const result = await uploadImage(file, {\n ...uploadOptions,\n fileName: fileName ?? uploadOptions.fileName,\n signal: controller.signal,\n onProgress: (percent) => {\n if (isCurrent()) {\n dispatch({ type: 'progress', percent });\n }\n },\n });\n if (!isCurrent()) {\n return null;\n }\n dispatch({ type: 'done', result });\n onSuccess?.(result);\n return result;\n } catch (cause) {\n if (!isCurrent()) {\n return null;\n }\n const error = toUploadError(cause);\n dispatch({ type: 'fail', error });\n onError?.(error);\n return null;\n }\n }, [abortInFlight]);\n\n const reset = useCallback((): void => {\n abortInFlight();\n dispatch({ type: 'reset' });\n }, [abortInFlight]);\n\n return { ...state, upload, reset };\n}\n"]}
package/dist/index.mjs ADDED
@@ -0,0 +1,294 @@
1
+ import { useReducer, useRef, useCallback, useEffect } from 'react';
2
+
3
+ // src/UploadErrorKind.ts
4
+ var UploadErrorKind = /* @__PURE__ */ ((UploadErrorKind2) => {
5
+ UploadErrorKind2["Validation"] = "validation";
6
+ UploadErrorKind2["Network"] = "network";
7
+ UploadErrorKind2["Http"] = "http";
8
+ UploadErrorKind2["Aborted"] = "aborted";
9
+ UploadErrorKind2["InvalidResponse"] = "invalid-response";
10
+ return UploadErrorKind2;
11
+ })(UploadErrorKind || {});
12
+
13
+ // src/UploadError.ts
14
+ var UploadError = class extends Error {
15
+ constructor(kind, message, status) {
16
+ super(message);
17
+ this.name = "UploadError";
18
+ this.kind = kind;
19
+ this.status = status;
20
+ }
21
+ };
22
+ function toUploadError(cause) {
23
+ if (cause instanceof UploadError) {
24
+ return cause;
25
+ }
26
+ const message = cause instanceof Error ? cause.message : String(cause);
27
+ return new UploadError("network" /* Network */, message);
28
+ }
29
+
30
+ // src/parseUploadedContent.ts
31
+ function isRecord(value) {
32
+ return typeof value === "object" && value !== null && !Array.isArray(value);
33
+ }
34
+ function invalid(message) {
35
+ return new UploadError("invalid-response" /* InvalidResponse */, message);
36
+ }
37
+ function parseUploadedContent(text, requireUrl) {
38
+ let payload;
39
+ try {
40
+ payload = JSON.parse(text);
41
+ } catch {
42
+ throw invalid("Upload response was not JSON.");
43
+ }
44
+ if (!isRecord(payload) || typeof payload.contentId !== "string" || typeof payload.status !== "string") {
45
+ throw invalid("Upload response shape was invalid.");
46
+ }
47
+ const url = typeof payload.url === "string" && payload.url !== "" ? payload.url : void 0;
48
+ if (requireUrl && url === void 0) {
49
+ throw invalid("Upload response did not include a public URL.");
50
+ }
51
+ return { contentId: payload.contentId, status: payload.status, url };
52
+ }
53
+
54
+ // src/transports.ts
55
+ var HTTP_OK_MIN = 200;
56
+ var HTTP_OK_MAX_EXCLUSIVE = 300;
57
+ var PERCENT_COMPLETE = 100;
58
+ function isOk(status) {
59
+ return status >= HTTP_OK_MIN && status < HTTP_OK_MAX_EXCLUSIVE;
60
+ }
61
+ function httpError(status) {
62
+ return new UploadError("http" /* Http */, `Upload failed (${String(status)}).`, status);
63
+ }
64
+ function abortedError() {
65
+ return new UploadError("aborted" /* Aborted */, "Upload was cancelled.");
66
+ }
67
+ async function sendWithFetch(request, fetchImpl) {
68
+ let response;
69
+ try {
70
+ response = await fetchImpl(request.url, {
71
+ method: "POST",
72
+ credentials: request.credentials,
73
+ headers: request.headers,
74
+ body: request.form,
75
+ signal: request.signal
76
+ });
77
+ } catch (cause) {
78
+ if (request.signal?.aborted === true) {
79
+ throw abortedError();
80
+ }
81
+ const detail = cause instanceof Error ? cause.message : String(cause);
82
+ throw new UploadError("network" /* Network */, detail);
83
+ }
84
+ if (!isOk(response.status)) {
85
+ throw httpError(response.status);
86
+ }
87
+ return response.text();
88
+ }
89
+ function reportProgress(event, onProgress) {
90
+ if (event.lengthComputable && event.total > 0) {
91
+ onProgress(Math.round(event.loaded / event.total * PERCENT_COMPLETE));
92
+ }
93
+ }
94
+ function sendWithXhr(request, createXhr, onProgress) {
95
+ return new Promise((resolve, reject) => {
96
+ const { signal } = request;
97
+ if (signal?.aborted === true) {
98
+ reject(abortedError());
99
+ return;
100
+ }
101
+ const xhr = createXhr();
102
+ xhr.upload.addEventListener("progress", (event) => reportProgress(event, onProgress));
103
+ xhr.addEventListener("load", () => {
104
+ if (isOk(xhr.status)) {
105
+ resolve(xhr.responseText);
106
+ } else {
107
+ reject(httpError(xhr.status));
108
+ }
109
+ });
110
+ xhr.addEventListener("error", () => reject(new UploadError("network" /* Network */, "Upload failed (network error).")));
111
+ xhr.addEventListener("abort", () => reject(abortedError()));
112
+ signal?.addEventListener("abort", () => xhr.abort(), { once: true });
113
+ xhr.open("POST", request.url);
114
+ xhr.withCredentials = request.credentials === "include";
115
+ Object.entries(request.headers).forEach(([name, value]) => xhr.setRequestHeader(name, value));
116
+ xhr.send(request.form);
117
+ });
118
+ }
119
+
120
+ // src/uploadImage.ts
121
+ var DEFAULT_CSRF_HEADER = { name: "X-BFF-Csrf", value: "1" };
122
+ var DEFAULT_CATEGORY = "Image";
123
+ var DEFAULT_FILE_NAME = "upload";
124
+ var FILE_FIELD = "File";
125
+ var CATEGORY_FIELD = "Category";
126
+ var IS_PUBLIC_FIELD = "IsPublic";
127
+ function validationError(message) {
128
+ return new UploadError("validation" /* Validation */, message);
129
+ }
130
+ function validate(file, options) {
131
+ if (file.size === 0) {
132
+ throw validationError("File is empty.");
133
+ }
134
+ if (options.maxBytes !== void 0 && file.size > options.maxBytes) {
135
+ throw validationError(`File is ${String(file.size)} bytes; the limit is ${String(options.maxBytes)}.`);
136
+ }
137
+ if (options.allowedTypes !== void 0 && !options.allowedTypes.includes(file.type)) {
138
+ throw validationError(`File type "${file.type}" is not allowed.`);
139
+ }
140
+ }
141
+ function resolveFileName(file, explicit) {
142
+ if (explicit !== void 0 && explicit !== "") {
143
+ return explicit;
144
+ }
145
+ if ("name" in file && typeof file.name === "string" && file.name !== "") {
146
+ return file.name;
147
+ }
148
+ return DEFAULT_FILE_NAME;
149
+ }
150
+ function buildHeaders(csrf) {
151
+ const headers = { Accept: "application/json" };
152
+ const header = csrf === void 0 ? DEFAULT_CSRF_HEADER : csrf;
153
+ if (header !== null) {
154
+ headers[header.name] = header.value;
155
+ }
156
+ return headers;
157
+ }
158
+ function buildUploadRequest(file, options) {
159
+ const form = new FormData();
160
+ form.append(FILE_FIELD, file, resolveFileName(file, options.fileName));
161
+ form.append(CATEGORY_FIELD, options.category ?? DEFAULT_CATEGORY);
162
+ form.append(IS_PUBLIC_FIELD, String(options.isPublic ?? true));
163
+ return {
164
+ url: options.endpoint,
165
+ headers: buildHeaders(options.csrf),
166
+ form,
167
+ credentials: options.credentials ?? "include",
168
+ signal: options.signal
169
+ };
170
+ }
171
+ function resolveXhrFactory(options) {
172
+ if (options.fetchImpl !== void 0) {
173
+ return void 0;
174
+ }
175
+ if (options.xhrFactory !== void 0) {
176
+ return options.xhrFactory;
177
+ }
178
+ return typeof XMLHttpRequest === "undefined" ? void 0 : () => new XMLHttpRequest();
179
+ }
180
+ async function send(request, options) {
181
+ const { onProgress } = options;
182
+ const createXhr = onProgress === void 0 ? void 0 : resolveXhrFactory(options);
183
+ if (createXhr !== void 0 && onProgress !== void 0) {
184
+ return sendWithXhr(request, createXhr, onProgress);
185
+ }
186
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis);
187
+ return sendWithFetch(request, fetchImpl);
188
+ }
189
+ async function uploadImage(file, options) {
190
+ validate(file, options);
191
+ const request = buildUploadRequest(file, options);
192
+ const text = await send(request, options);
193
+ return parseUploadedContent(text, options.requireUrl ?? options.isPublic ?? true);
194
+ }
195
+
196
+ // src/blobFromUri.ts
197
+ async function blobFromUri(uri, fetchImpl) {
198
+ const doFetch = fetchImpl ?? globalThis.fetch.bind(globalThis);
199
+ let response;
200
+ try {
201
+ response = await doFetch(uri);
202
+ } catch (cause) {
203
+ const detail = cause instanceof Error ? cause.message : String(cause);
204
+ throw new UploadError("validation" /* Validation */, `Could not read file: ${detail}`);
205
+ }
206
+ if (!response.ok) {
207
+ throw new UploadError("validation" /* Validation */, `Could not read file (${String(response.status)}).`);
208
+ }
209
+ return response.blob();
210
+ }
211
+
212
+ // src/UploadStatus.ts
213
+ var UploadStatus = /* @__PURE__ */ ((UploadStatus2) => {
214
+ UploadStatus2["Idle"] = "idle";
215
+ UploadStatus2["Uploading"] = "uploading";
216
+ UploadStatus2["Error"] = "error";
217
+ UploadStatus2["Done"] = "done";
218
+ return UploadStatus2;
219
+ })(UploadStatus || {});
220
+
221
+ // src/useImageUpload.ts
222
+ var IDLE = { status: "idle" /* Idle */, progress: 0, error: null, result: null };
223
+ var UPLOADING = { status: "uploading" /* Uploading */, progress: 0, error: null, result: null };
224
+ function reducer(state, action) {
225
+ switch (action.type) {
226
+ case "start":
227
+ return UPLOADING;
228
+ case "progress":
229
+ return { ...state, progress: action.percent };
230
+ case "done":
231
+ return { status: "done" /* Done */, progress: PERCENT_COMPLETE, error: null, result: action.result };
232
+ case "fail":
233
+ return { ...state, status: "error" /* Error */, error: action.error };
234
+ default:
235
+ return IDLE;
236
+ }
237
+ }
238
+ function useImageUpload(options) {
239
+ const [state, dispatch] = useReducer(reducer, IDLE);
240
+ const optionsRef = useRef(options);
241
+ optionsRef.current = options;
242
+ const controllerRef = useRef(null);
243
+ const requestIdRef = useRef(0);
244
+ const abortInFlight = useCallback(() => {
245
+ controllerRef.current?.abort();
246
+ controllerRef.current = null;
247
+ requestIdRef.current += 1;
248
+ }, []);
249
+ useEffect(() => abortInFlight, [abortInFlight]);
250
+ const upload = useCallback(async (file, fileName) => {
251
+ abortInFlight();
252
+ const controller = new AbortController();
253
+ controllerRef.current = controller;
254
+ const requestId = requestIdRef.current;
255
+ const isCurrent = () => requestId === requestIdRef.current;
256
+ const { onSuccess, onError, ...uploadOptions } = optionsRef.current;
257
+ dispatch({ type: "start" });
258
+ try {
259
+ const result = await uploadImage(file, {
260
+ ...uploadOptions,
261
+ fileName: fileName ?? uploadOptions.fileName,
262
+ signal: controller.signal,
263
+ onProgress: (percent) => {
264
+ if (isCurrent()) {
265
+ dispatch({ type: "progress", percent });
266
+ }
267
+ }
268
+ });
269
+ if (!isCurrent()) {
270
+ return null;
271
+ }
272
+ dispatch({ type: "done", result });
273
+ onSuccess?.(result);
274
+ return result;
275
+ } catch (cause) {
276
+ if (!isCurrent()) {
277
+ return null;
278
+ }
279
+ const error = toUploadError(cause);
280
+ dispatch({ type: "fail", error });
281
+ onError?.(error);
282
+ return null;
283
+ }
284
+ }, [abortInFlight]);
285
+ const reset = useCallback(() => {
286
+ abortInFlight();
287
+ dispatch({ type: "reset" });
288
+ }, [abortInFlight]);
289
+ return { ...state, upload, reset };
290
+ }
291
+
292
+ export { DEFAULT_CSRF_HEADER, UploadError, UploadErrorKind, UploadStatus, blobFromUri, buildUploadRequest, toUploadError, uploadImage, useImageUpload };
293
+ //# sourceMappingURL=index.mjs.map
294
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/UploadErrorKind.ts","../src/UploadError.ts","../src/parseUploadedContent.ts","../src/transports.ts","../src/uploadImage.ts","../src/blobFromUri.ts","../src/UploadStatus.ts","../src/useImageUpload.ts"],"names":["UploadErrorKind","UploadStatus"],"mappings":"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","file":"index.mjs","sourcesContent":["/** Why an upload failed. Consumers map this to translated copy; `message` is developer-facing only. */\nexport const enum UploadErrorKind {\n /** Rejected client-side before any request: empty file, too large, disallowed MIME type. */\n Validation = 'validation',\n /** The request never produced an HTTP response (offline, DNS, CORS). */\n Network = 'network',\n /** The server answered outside 2xx; `status` carries the code. */\n Http = 'http',\n /** The caller aborted (AbortSignal / hook reset). */\n Aborted = 'aborted',\n /** 2xx, but the body was not `{ contentId, status, url? }` (or lacked a required url). */\n InvalidResponse = 'invalid-response',\n}\n","import { UploadErrorKind } from './UploadErrorKind';\n\n/** The single error type thrown by `uploadImage` and surfaced by `useImageUpload`. */\nexport class UploadError extends Error {\n readonly kind: UploadErrorKind;\n /** HTTP status for `UploadErrorKind.Http`; undefined otherwise. */\n readonly status: number | undefined;\n\n constructor(kind: UploadErrorKind, message: string, status?: number) {\n super(message);\n this.name = 'UploadError';\n this.kind = kind;\n this.status = status;\n }\n}\n\n/** Normalise anything thrown into an `UploadError` (unknown throws become `Network`). */\nexport function toUploadError(cause: unknown): UploadError {\n if (cause instanceof UploadError) {\n return cause;\n }\n const message = cause instanceof Error ? cause.message : String(cause);\n return new UploadError(UploadErrorKind.Network, message);\n}\n","import { UploadError } from './UploadError';\nimport { UploadErrorKind } from './UploadErrorKind';\n\nimport type { UploadedContent } from './types';\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\nfunction invalid(message: string): UploadError {\n return new UploadError(UploadErrorKind.InvalidResponse, message);\n}\n\n/** Parse the upload endpoint's JSON body into `UploadedContent`, or throw `InvalidResponse`. */\nexport function parseUploadedContent(text: string, requireUrl: boolean): UploadedContent {\n let payload: unknown;\n try {\n payload = JSON.parse(text);\n } catch {\n throw invalid('Upload response was not JSON.');\n }\n if (!isRecord(payload) || typeof payload.contentId !== 'string' || typeof payload.status !== 'string') {\n throw invalid('Upload response shape was invalid.');\n }\n const url = typeof payload.url === 'string' && payload.url !== '' ? payload.url : undefined;\n if (requireUrl && url === undefined) {\n throw invalid('Upload response did not include a public URL.');\n }\n return { contentId: payload.contentId, status: payload.status, url };\n}\n","import { UploadError } from './UploadError';\nimport { UploadErrorKind } from './UploadErrorKind';\n\nimport type { ProgressListener } from './types';\n\nconst HTTP_OK_MIN = 200;\nconst HTTP_OK_MAX_EXCLUSIVE = 300;\nexport const PERCENT_COMPLETE = 100;\n\n/** A fully built multipart upload, independent of the transport that sends it. */\nexport interface UploadRequest {\n url: string;\n /** Never contains Content-Type: the runtime sets the multipart boundary for a FormData body. */\n headers: Record<string, string>;\n form: FormData;\n credentials: RequestCredentials;\n signal: AbortSignal | undefined;\n}\n\nfunction isOk(status: number): boolean {\n return status >= HTTP_OK_MIN && status < HTTP_OK_MAX_EXCLUSIVE;\n}\n\nfunction httpError(status: number): UploadError {\n return new UploadError(UploadErrorKind.Http, `Upload failed (${String(status)}).`, status);\n}\n\nfunction abortedError(): UploadError {\n return new UploadError(UploadErrorKind.Aborted, 'Upload was cancelled.');\n}\n\n/** POST with fetch; resolves to the raw response text of a 2xx. */\nexport async function sendWithFetch(request: UploadRequest, fetchImpl: typeof fetch): Promise<string> {\n let response: Response;\n try {\n response = await fetchImpl(request.url, {\n method: 'POST',\n credentials: request.credentials,\n headers: request.headers,\n body: request.form,\n signal: request.signal,\n });\n } catch (cause) {\n if (request.signal?.aborted === true) {\n throw abortedError();\n }\n const detail = cause instanceof Error ? cause.message : String(cause);\n throw new UploadError(UploadErrorKind.Network, detail);\n }\n if (!isOk(response.status)) {\n throw httpError(response.status);\n }\n return response.text();\n}\n\nfunction reportProgress(event: ProgressEvent, onProgress: ProgressListener): void {\n if (event.lengthComputable && event.total > 0) {\n onProgress(Math.round((event.loaded / event.total) * PERCENT_COMPLETE));\n }\n}\n\n/** POST with XMLHttpRequest so upload progress can be observed. */\nexport function sendWithXhr(\n request: UploadRequest,\n createXhr: () => XMLHttpRequest,\n onProgress: ProgressListener,\n): Promise<string> {\n return new Promise((resolve, reject) => {\n const { signal } = request;\n if (signal?.aborted === true) {\n reject(abortedError());\n return;\n }\n const xhr = createXhr();\n xhr.upload.addEventListener('progress', (event) => reportProgress(event, onProgress));\n xhr.addEventListener('load', () => {\n if (isOk(xhr.status)) {\n resolve(xhr.responseText);\n } else {\n reject(httpError(xhr.status));\n }\n });\n xhr.addEventListener('error', () => reject(new UploadError(UploadErrorKind.Network, 'Upload failed (network error).')));\n xhr.addEventListener('abort', () => reject(abortedError()));\n signal?.addEventListener('abort', () => xhr.abort(), { once: true });\n xhr.open('POST', request.url);\n xhr.withCredentials = request.credentials === 'include';\n Object.entries(request.headers).forEach(([name, value]) => xhr.setRequestHeader(name, value));\n xhr.send(request.form);\n });\n}\n","import { parseUploadedContent } from './parseUploadedContent';\nimport { sendWithFetch, sendWithXhr } from './transports';\nimport { UploadError } from './UploadError';\nimport { UploadErrorKind } from './UploadErrorKind';\n\nimport type { UploadRequest } from './transports';\nimport type { CsrfHeader, UploadedContent, UploadImageOptions } from './types';\n\n/** The Bff.AspNetCore anti-forgery convention: header presence, any value. */\nexport const DEFAULT_CSRF_HEADER: CsrfHeader = { name: 'X-BFF-Csrf', value: '1' };\n\nconst DEFAULT_CATEGORY = 'Image';\nconst DEFAULT_FILE_NAME = 'upload';\nconst FILE_FIELD = 'File';\nconst CATEGORY_FIELD = 'Category';\nconst IS_PUBLIC_FIELD = 'IsPublic';\n\nfunction validationError(message: string): UploadError {\n return new UploadError(UploadErrorKind.Validation, message);\n}\n\n/** Client-side checks that must fail before a byte leaves the device. */\nfunction validate(file: Blob, options: UploadImageOptions): void {\n if (file.size === 0) {\n throw validationError('File is empty.');\n }\n if (options.maxBytes !== undefined && file.size > options.maxBytes) {\n throw validationError(`File is ${String(file.size)} bytes; the limit is ${String(options.maxBytes)}.`);\n }\n if (options.allowedTypes !== undefined && !options.allowedTypes.includes(file.type)) {\n throw validationError(`File type \"${file.type}\" is not allowed.`);\n }\n}\n\nfunction resolveFileName(file: Blob, explicit: string | undefined): string {\n if (explicit !== undefined && explicit !== '') {\n return explicit;\n }\n if ('name' in file && typeof file.name === 'string' && file.name !== '') {\n return file.name;\n }\n return DEFAULT_FILE_NAME;\n}\n\nfunction buildHeaders(csrf: CsrfHeader | null | undefined): Record<string, string> {\n const headers: Record<string, string> = { Accept: 'application/json' };\n const header = csrf === undefined ? DEFAULT_CSRF_HEADER : csrf;\n if (header !== null) {\n headers[header.name] = header.value;\n }\n return headers;\n}\n\n/** Build the transport-independent request. Exported so consumers can pin the write shape. */\nexport function buildUploadRequest(file: Blob, options: UploadImageOptions): UploadRequest {\n const form = new FormData();\n form.append(FILE_FIELD, file, resolveFileName(file, options.fileName));\n form.append(CATEGORY_FIELD, options.category ?? DEFAULT_CATEGORY);\n form.append(IS_PUBLIC_FIELD, String(options.isPublic ?? true));\n return {\n url: options.endpoint,\n headers: buildHeaders(options.csrf),\n form,\n credentials: options.credentials ?? 'include',\n signal: options.signal,\n };\n}\n\nfunction resolveXhrFactory(options: UploadImageOptions): (() => XMLHttpRequest) | undefined {\n if (options.fetchImpl !== undefined) {\n return undefined;\n }\n if (options.xhrFactory !== undefined) {\n return options.xhrFactory;\n }\n return typeof XMLHttpRequest === 'undefined' ? undefined : (): XMLHttpRequest => new XMLHttpRequest();\n}\n\nasync function send(request: UploadRequest, options: UploadImageOptions): Promise<string> {\n const { onProgress } = options;\n const createXhr = onProgress === undefined ? undefined : resolveXhrFactory(options);\n if (createXhr !== undefined && onProgress !== undefined) {\n return sendWithXhr(request, createXhr, onProgress);\n }\n const fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis);\n return sendWithFetch(request, fetchImpl);\n}\n\n/**\n * Upload one image as multipart/form-data (`File`, `Category`, `IsPublic`) to a\n * consumer-supplied endpoint and return the parsed `{ contentId, status, url }`.\n *\n * Never sets Content-Type: the runtime adds the multipart boundary, and a JSON content type\n * on a FormData body is a 415 (see `@dloizides/bff-web-client` writeContentTypeGuard).\n *\n * @throws {UploadError} for every failure; switch on `error.kind`.\n */\nexport async function uploadImage(file: Blob, options: UploadImageOptions): Promise<UploadedContent> {\n validate(file, options);\n const request = buildUploadRequest(file, options);\n const text = await send(request, options);\n return parseUploadedContent(text, options.requireUrl ?? options.isPublic ?? true);\n}\n","import { UploadError } from './UploadError';\nimport { UploadErrorKind } from './UploadErrorKind';\n\n/**\n * Read a local URI (`blob:`, `data:`, or an RN image-picker `file:` URI) into a Blob so it\n * can be passed to `uploadImage`.\n *\n * @throws {UploadError} `Validation` when the URI cannot be read.\n */\nexport async function blobFromUri(uri: string, fetchImpl?: typeof fetch): Promise<Blob> {\n const doFetch = fetchImpl ?? globalThis.fetch.bind(globalThis);\n let response: Response;\n try {\n response = await doFetch(uri);\n } catch (cause) {\n const detail = cause instanceof Error ? cause.message : String(cause);\n throw new UploadError(UploadErrorKind.Validation, `Could not read file: ${detail}`);\n }\n if (!response.ok) {\n throw new UploadError(UploadErrorKind.Validation, `Could not read file (${String(response.status)}).`);\n }\n return response.blob();\n}\n","/** Lifecycle of a single `useImageUpload` slot. */\nexport const enum UploadStatus {\n Idle = 'idle',\n Uploading = 'uploading',\n Error = 'error',\n Done = 'done',\n}\n","import { useCallback, useEffect, useReducer, useRef } from 'react';\n\nimport { PERCENT_COMPLETE } from './transports';\nimport { toUploadError } from './UploadError';\nimport { uploadImage } from './uploadImage';\nimport { UploadStatus } from './UploadStatus';\n\nimport type { UploadedContent, UploadImageOptions } from './types';\nimport type { UploadError } from './UploadError';\n\n/** Options for `useImageUpload`: everything `uploadImage` takes except the per-call wiring. */\nexport interface UseImageUploadOptions extends Omit<UploadImageOptions, 'signal' | 'onProgress'> {\n onSuccess?: (content: UploadedContent) => void;\n onError?: (error: UploadError) => void;\n}\n\nexport interface ImageUploadState {\n status: UploadStatus;\n /** 0-100. Stays 0 while uploading when the transport cannot report progress (fetch). */\n progress: number;\n error: UploadError | null;\n result: UploadedContent | null;\n}\n\nexport interface UseImageUploadResult extends ImageUploadState {\n /** Start an upload (aborting any in-flight one). Resolves to the content, or null on error/abort. */\n upload: (file: Blob, fileName?: string) => Promise<UploadedContent | null>;\n /** Abort any in-flight upload and return to idle. */\n reset: () => void;\n}\n\nconst IDLE: ImageUploadState = { status: UploadStatus.Idle, progress: 0, error: null, result: null };\nconst UPLOADING: ImageUploadState = { status: UploadStatus.Uploading, progress: 0, error: null, result: null };\n\ntype Action =\n | { type: 'start' }\n | { type: 'progress'; percent: number }\n | { type: 'done'; result: UploadedContent }\n | { type: 'fail'; error: UploadError }\n | { type: 'reset' };\n\nfunction reducer(state: ImageUploadState, action: Action): ImageUploadState {\n switch (action.type) {\n case 'start':\n return UPLOADING;\n case 'progress':\n return { ...state, progress: action.percent };\n case 'done':\n return { status: UploadStatus.Done, progress: PERCENT_COMPLETE, error: null, result: action.result };\n case 'fail':\n return { ...state, status: UploadStatus.Error, error: action.error };\n default:\n return IDLE;\n }\n}\n\n/**\n * One image-upload slot: idle -> uploading -> done | error. Progress is reported when the\n * XMLHttpRequest transport is available (browser, React Native) and no `fetchImpl` is set.\n * A newer `upload()` or `reset()` supersedes the previous request; its late result is dropped.\n * Unmount aborts the in-flight request.\n */\nexport function useImageUpload(options: UseImageUploadOptions): UseImageUploadResult {\n const [state, dispatch] = useReducer(reducer, IDLE);\n const optionsRef = useRef(options);\n optionsRef.current = options;\n const controllerRef = useRef<AbortController | null>(null);\n const requestIdRef = useRef(0);\n\n const abortInFlight = useCallback((): void => {\n controllerRef.current?.abort();\n controllerRef.current = null;\n requestIdRef.current += 1;\n }, []);\n\n useEffect(() => abortInFlight, [abortInFlight]);\n\n const upload = useCallback(async (file: Blob, fileName?: string): Promise<UploadedContent | null> => {\n abortInFlight();\n const controller = new AbortController();\n controllerRef.current = controller;\n const requestId = requestIdRef.current;\n const isCurrent = (): boolean => requestId === requestIdRef.current;\n const { onSuccess, onError, ...uploadOptions } = optionsRef.current;\n dispatch({ type: 'start' });\n try {\n const result = await uploadImage(file, {\n ...uploadOptions,\n fileName: fileName ?? uploadOptions.fileName,\n signal: controller.signal,\n onProgress: (percent) => {\n if (isCurrent()) {\n dispatch({ type: 'progress', percent });\n }\n },\n });\n if (!isCurrent()) {\n return null;\n }\n dispatch({ type: 'done', result });\n onSuccess?.(result);\n return result;\n } catch (cause) {\n if (!isCurrent()) {\n return null;\n }\n const error = toUploadError(cause);\n dispatch({ type: 'fail', error });\n onError?.(error);\n return null;\n }\n }, [abortInFlight]);\n\n const reset = useCallback((): void => {\n abortInFlight();\n dispatch({ type: 'reset' });\n }, [abortInFlight]);\n\n return { ...state, upload, reset };\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,83 @@
1
+ {
2
+ "name": "@dloizides/content-upload",
3
+ "version": "1.0.0",
4
+ "description": "Framework-light image upload client for the dloizides.com ContentService (multipart POST through a same-origin BFF proxy, CSRF header, abort, optional progress) plus a useImageUpload React hook with idle/uploading/error/done states. The endpoint is supplied by the consumer.",
5
+ "keywords": [
6
+ "upload",
7
+ "multipart",
8
+ "content",
9
+ "bff",
10
+ "react-hook",
11
+ "dloizides"
12
+ ],
13
+ "author": "dloizides",
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/openmindednewby/content-upload.git"
18
+ },
19
+ "homepage": "https://github.com/openmindednewby/content-upload#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/openmindednewby/content-upload/issues"
22
+ },
23
+ "main": "./dist/index.js",
24
+ "module": "./dist/index.mjs",
25
+ "types": "./dist/index.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "require": {
29
+ "types": "./dist/index.d.ts",
30
+ "default": "./dist/index.js"
31
+ },
32
+ "import": {
33
+ "types": "./dist/index.d.mts",
34
+ "default": "./dist/index.mjs"
35
+ }
36
+ }
37
+ },
38
+ "files": [
39
+ "dist",
40
+ "README.md",
41
+ "CHANGELOG.md"
42
+ ],
43
+ "sideEffects": false,
44
+ "engines": {
45
+ "node": ">=18.0.0"
46
+ },
47
+ "scripts": {
48
+ "build": "rimraf dist && tsup",
49
+ "test": "jest",
50
+ "test:coverage": "jest --coverage",
51
+ "lint": "eslint src --ext .ts",
52
+ "lint:fix": "eslint src --ext .ts --fix",
53
+ "typecheck": "tsc --noEmit",
54
+ "clean": "rimraf dist",
55
+ "security:audit": "npm audit --audit-level=high",
56
+ "deps:outdated": "npm outdated || exit 0",
57
+ "deps:unused": "npx depcheck --ignores \"@types/jest,@types/node,@types/react,rimraf,react-dom,@testing-library/react,@testing-library/dom\"",
58
+ "deps:licenses": "npx license-checker --onlyAllow \"MIT;Apache-2.0;ISC;BSD-2-Clause;BSD-3-Clause;0BSD;Unlicense;CC0-1.0\"",
59
+ "prepublishOnly": "npm run clean && npm run build && npm run test"
60
+ },
61
+ "peerDependencies": {
62
+ "react": ">=18.0.0"
63
+ },
64
+ "devDependencies": {
65
+ "@testing-library/dom": "^10.4.0",
66
+ "@testing-library/react": "^16.1.0",
67
+ "@types/jest": "^29.5.0",
68
+ "@types/node": "^20.19.32",
69
+ "@types/react": "^18.2.0",
70
+ "@typescript-eslint/eslint-plugin": "^7.0.0",
71
+ "@typescript-eslint/parser": "^7.0.0",
72
+ "eslint": "^8.57.0",
73
+ "eslint-plugin-sonarjs": "^4.0.3",
74
+ "jest": "^29.7.0",
75
+ "jest-environment-jsdom": "^29.7.0",
76
+ "react": "^18.2.0",
77
+ "react-dom": "^18.2.0",
78
+ "rimraf": "^5.0.0",
79
+ "ts-jest": "^29.1.0",
80
+ "tsup": "^8.0.0",
81
+ "typescript": "^5.4.0"
82
+ }
83
+ }