@medialit/uploader 0.0.0-stage → 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/README.md CHANGED
@@ -1,3 +1,54 @@
1
- # Temporary Holding Version
1
+ # @medialit/uploader
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Resumable browser uploads to [MediaLit](https://medialit.cloud), with no framework dependency. For React, use [`@medialit/react`](../react), which wraps this package.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @medialit/uploader
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ Your backend returns an upload signature (see `createSignatureHandler` in the [`medialit`](../medialit) SDK). The browser then uploads directly to MediaLit:
14
+
15
+ ```ts
16
+ import { uploadFile, isUploadAbortedError } from "@medialit/uploader";
17
+
18
+ const controller = new AbortController();
19
+
20
+ try {
21
+ const media = await uploadFile(file, {
22
+ signatureEndpoint: "/api/medialit/signature",
23
+ access: "public",
24
+ signal: controller.signal,
25
+ onProgress: ({ percentage }) => console.log(`${percentage}%`),
26
+ });
27
+ console.log(media.mediaId, media.file);
28
+ } catch (err) {
29
+ if (!isUploadAbortedError(err)) console.error(err.message);
30
+ }
31
+ ```
32
+
33
+ Call `controller.abort()` to cancel. An interrupted upload of the same file resumes where it stopped unless you pass `resume: false`.
34
+
35
+ Uploads are temporary until your backend seals them with `medialit.seal(mediaId)`.
36
+
37
+ ## Options
38
+
39
+ | Option | Type | Description |
40
+ | ------------------- | ----------------------------------------------------- | ------------------------------------------------------------ |
41
+ | `signatureEndpoint` | `string` | Your route that returns `{ signature, endpoint }` on `POST`. |
42
+ | `getSignature` | `() => Promise<{ signature, endpoint }>` | Use instead of `signatureEndpoint` to fetch it yourself. |
43
+ | `access` | `"public" \| "private"` | Defaults to `private`. |
44
+ | `caption` | `string` | |
45
+ | `fileName` | `string` | Required for a `Blob` that is not a `File`. |
46
+ | `chunkSize` | `number` | Bytes per request. Defaults to one request. |
47
+ | `retryDelays` | `number[]` | Milliseconds before each retry. |
48
+ | `resume` | `boolean` | Defaults to `true`. |
49
+ | `signal` | `AbortSignal` | Cancels the upload. |
50
+ | `onProgress` | `({ bytesUploaded, bytesTotal, percentage }) => void` | |
51
+
52
+ ## Errors
53
+
54
+ Failures reject with `MediaLitUploadError`. Its `message` is MediaLit's reason (for example a file over your plan's limit) and `status` is the HTTP status when there was one. Cancelling rejects with `UploadAbortedError`.
package/dist/index.cjs ADDED
@@ -0,0 +1,184 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ MediaLitUploadError: () => MediaLitUploadError,
24
+ UploadAbortedError: () => UploadAbortedError,
25
+ isUploadAbortedError: () => isUploadAbortedError,
26
+ uploadFile: () => uploadFile
27
+ });
28
+ module.exports = __toCommonJS(index_exports);
29
+
30
+ // src/upload.ts
31
+ var import_tus_js_client = require("tus-js-client");
32
+
33
+ // src/errors.ts
34
+ var MediaLitUploadError = class extends Error {
35
+ constructor(message, options = {}) {
36
+ super(message);
37
+ this.name = "MediaLitUploadError";
38
+ this.status = options.status;
39
+ this.cause = options.cause;
40
+ }
41
+ };
42
+ var UploadAbortedError = class extends Error {
43
+ constructor() {
44
+ super("Upload cancelled");
45
+ this.name = "UploadAbortedError";
46
+ }
47
+ };
48
+ function isUploadAbortedError(err) {
49
+ return err instanceof Error && err.name === "UploadAbortedError";
50
+ }
51
+
52
+ // src/signature.ts
53
+ async function resolveSignature(options, signal) {
54
+ if (options.getSignature) {
55
+ return validate(await options.getSignature());
56
+ }
57
+ if (!options.signatureEndpoint) {
58
+ throw new MediaLitUploadError(
59
+ "Pass either signatureEndpoint or getSignature"
60
+ );
61
+ }
62
+ const response = await fetch(options.signatureEndpoint, {
63
+ method: "POST",
64
+ credentials: "same-origin",
65
+ signal
66
+ });
67
+ const body = await response.json().catch(() => ({}));
68
+ if (!response.ok) {
69
+ throw new MediaLitUploadError(
70
+ body?.error || body?.message || `Signature request failed with status ${response.status}`,
71
+ { status: response.status }
72
+ );
73
+ }
74
+ return validate(body);
75
+ }
76
+ function validate(value) {
77
+ if (!value?.signature || !value?.endpoint) {
78
+ throw new MediaLitUploadError(
79
+ "The signature response must include signature and endpoint"
80
+ );
81
+ }
82
+ return { signature: value.signature, endpoint: value.endpoint };
83
+ }
84
+
85
+ // src/upload.ts
86
+ var DEFAULT_RETRY_DELAYS = [0, 3e3, 5e3, 1e4];
87
+ async function uploadFile(file, options) {
88
+ const { signal } = options;
89
+ if (signal?.aborted) throw new UploadAbortedError();
90
+ const { signature, endpoint } = await resolveSignature(
91
+ options,
92
+ signal
93
+ ).catch((err) => {
94
+ throw signal?.aborted ? new UploadAbortedError() : err;
95
+ });
96
+ if (signal?.aborted) throw new UploadAbortedError();
97
+ const blob = typeof Blob !== "undefined" && file instanceof Blob ? file : null;
98
+ const fileName = options.fileName || blob?.name;
99
+ if (!fileName) {
100
+ throw new MediaLitUploadError(
101
+ "Pass fileName when uploading a Blob or a stream"
102
+ );
103
+ }
104
+ return new Promise((resolve, reject) => {
105
+ let settled = false;
106
+ const settle = (finish) => {
107
+ if (settled) return;
108
+ settled = true;
109
+ signal?.removeEventListener("abort", onAbort);
110
+ finish();
111
+ };
112
+ const tusOptions = {
113
+ endpoint: `${endpoint.replace(/\/+$/, "")}/media/create/resumable`,
114
+ headers: { "x-medialit-signature": signature },
115
+ metadata: {
116
+ fileName,
117
+ mimeType: options.mimeType || blob?.type || "application/octet-stream",
118
+ access: options.access || "private",
119
+ caption: options.caption || ""
120
+ },
121
+ retryDelays: options.retryDelays || DEFAULT_RETRY_DELAYS,
122
+ removeFingerprintOnSuccess: true,
123
+ onProgress: (bytesUploaded, bytesTotal) => {
124
+ options.onProgress?.({
125
+ bytesUploaded,
126
+ bytesTotal,
127
+ percentage: bytesTotal ? bytesUploaded / bytesTotal * 100 : 0
128
+ });
129
+ },
130
+ onSuccess: ({ lastResponse }) => {
131
+ const media = parseMedia(lastResponse.getHeader("media"));
132
+ settle(
133
+ () => media ? resolve(media) : reject(
134
+ new MediaLitUploadError(
135
+ "The upload finished but MediaLit did not return the media"
136
+ )
137
+ )
138
+ );
139
+ },
140
+ onError: (err) => settle(() => reject(toUploadError(err)))
141
+ };
142
+ if (options.chunkSize) tusOptions.chunkSize = options.chunkSize;
143
+ const upload = new import_tus_js_client.Upload(file, tusOptions);
144
+ const onAbort = () => {
145
+ upload.abort().catch(() => void 0);
146
+ settle(() => reject(new UploadAbortedError()));
147
+ };
148
+ signal?.addEventListener("abort", onAbort, { once: true });
149
+ const start = async () => {
150
+ if (options.resume !== false) {
151
+ const previous = await upload.findPreviousUploads().catch(() => []);
152
+ if (previous.length) {
153
+ upload.resumeFromPreviousUpload(previous[0]);
154
+ }
155
+ }
156
+ if (!settled) upload.start();
157
+ };
158
+ start();
159
+ });
160
+ }
161
+ function parseMedia(header) {
162
+ if (!header) return null;
163
+ try {
164
+ const media = JSON.parse(header);
165
+ return media?.mediaId ? media : null;
166
+ } catch {
167
+ return null;
168
+ }
169
+ }
170
+ function toUploadError(err) {
171
+ const response = err.originalResponse;
172
+ const body = response?.getBody()?.trim();
173
+ return new MediaLitUploadError(body || err.message, {
174
+ status: response?.getStatus(),
175
+ cause: err
176
+ });
177
+ }
178
+ // Annotate the CommonJS export names for ESM import in node:
179
+ 0 && (module.exports = {
180
+ MediaLitUploadError,
181
+ UploadAbortedError,
182
+ isUploadAbortedError,
183
+ uploadFile
184
+ });
@@ -0,0 +1,83 @@
1
+ type Access = "public" | "private";
2
+ /**
3
+ * A `File` or `Blob` in the browser. In Node.js, a stream from
4
+ * `fs.createReadStream`, which can be resumed after a dropped connection.
5
+ */
6
+ type UploadSource = Blob | {
7
+ readonly path: unknown;
8
+ pipe(destination: never): unknown;
9
+ };
10
+ /** The media object MediaLit returns once an upload finishes. */
11
+ interface UploadedMedia {
12
+ mediaId: string;
13
+ originalFileName: string;
14
+ mimeType: string;
15
+ size: number;
16
+ access: Access;
17
+ /** Signed URL until the media is sealed and public. */
18
+ file: string;
19
+ /** Empty when MediaLit did not generate a thumbnail. */
20
+ thumbnail: string;
21
+ caption?: string;
22
+ group?: string;
23
+ }
24
+ /** What your backend returns after asking MediaLit for an upload signature. */
25
+ interface UploadSignature {
26
+ signature: string;
27
+ /** MediaLit API URL that the browser can reach. */
28
+ endpoint: string;
29
+ }
30
+ interface UploadProgress {
31
+ bytesUploaded: number;
32
+ bytesTotal: number;
33
+ /** 0 to 100. */
34
+ percentage: number;
35
+ }
36
+ interface SignatureOptions {
37
+ /**
38
+ * Your backend route that returns `{ signature, endpoint }`. It is called
39
+ * with `POST` and the browser's cookies.
40
+ */
41
+ signatureEndpoint?: string;
42
+ /** Use instead of `signatureEndpoint` when you need to fetch it yourself. */
43
+ getSignature?: () => Promise<UploadSignature>;
44
+ }
45
+ interface UploadFileOptions extends SignatureOptions {
46
+ /** Defaults to `private`. */
47
+ access?: Access;
48
+ caption?: string;
49
+ /** Required for a `Blob` that is not a `File`, and for a Node.js stream. */
50
+ fileName?: string;
51
+ /** Defaults to the `Blob`'s type. */
52
+ mimeType?: string;
53
+ /** Bytes per request. Defaults to sending the file in one request. */
54
+ chunkSize?: number;
55
+ /** Milliseconds to wait before each retry. */
56
+ retryDelays?: number[];
57
+ /** Resume an interrupted upload of the same file. Defaults to `true`. */
58
+ resume?: boolean;
59
+ signal?: AbortSignal;
60
+ onProgress?: (progress: UploadProgress) => void;
61
+ }
62
+
63
+ /**
64
+ * Uploads a file to MediaLit with the tus resumable protocol. The upload stays
65
+ * temporary until your backend seals it with the MediaLit SDK.
66
+ */
67
+ declare function uploadFile(file: UploadSource, options: UploadFileOptions): Promise<UploadedMedia>;
68
+
69
+ declare class MediaLitUploadError extends Error {
70
+ /** HTTP status from MediaLit or your signature endpoint, when there was one. */
71
+ readonly status?: number;
72
+ readonly cause?: unknown;
73
+ constructor(message: string, options?: {
74
+ status?: number;
75
+ cause?: unknown;
76
+ });
77
+ }
78
+ declare class UploadAbortedError extends Error {
79
+ constructor();
80
+ }
81
+ declare function isUploadAbortedError(err: unknown): err is UploadAbortedError;
82
+
83
+ export { type Access, MediaLitUploadError, type SignatureOptions, UploadAbortedError, type UploadFileOptions, type UploadProgress, type UploadSignature, type UploadSource, type UploadedMedia, isUploadAbortedError, uploadFile };
@@ -0,0 +1,83 @@
1
+ type Access = "public" | "private";
2
+ /**
3
+ * A `File` or `Blob` in the browser. In Node.js, a stream from
4
+ * `fs.createReadStream`, which can be resumed after a dropped connection.
5
+ */
6
+ type UploadSource = Blob | {
7
+ readonly path: unknown;
8
+ pipe(destination: never): unknown;
9
+ };
10
+ /** The media object MediaLit returns once an upload finishes. */
11
+ interface UploadedMedia {
12
+ mediaId: string;
13
+ originalFileName: string;
14
+ mimeType: string;
15
+ size: number;
16
+ access: Access;
17
+ /** Signed URL until the media is sealed and public. */
18
+ file: string;
19
+ /** Empty when MediaLit did not generate a thumbnail. */
20
+ thumbnail: string;
21
+ caption?: string;
22
+ group?: string;
23
+ }
24
+ /** What your backend returns after asking MediaLit for an upload signature. */
25
+ interface UploadSignature {
26
+ signature: string;
27
+ /** MediaLit API URL that the browser can reach. */
28
+ endpoint: string;
29
+ }
30
+ interface UploadProgress {
31
+ bytesUploaded: number;
32
+ bytesTotal: number;
33
+ /** 0 to 100. */
34
+ percentage: number;
35
+ }
36
+ interface SignatureOptions {
37
+ /**
38
+ * Your backend route that returns `{ signature, endpoint }`. It is called
39
+ * with `POST` and the browser's cookies.
40
+ */
41
+ signatureEndpoint?: string;
42
+ /** Use instead of `signatureEndpoint` when you need to fetch it yourself. */
43
+ getSignature?: () => Promise<UploadSignature>;
44
+ }
45
+ interface UploadFileOptions extends SignatureOptions {
46
+ /** Defaults to `private`. */
47
+ access?: Access;
48
+ caption?: string;
49
+ /** Required for a `Blob` that is not a `File`, and for a Node.js stream. */
50
+ fileName?: string;
51
+ /** Defaults to the `Blob`'s type. */
52
+ mimeType?: string;
53
+ /** Bytes per request. Defaults to sending the file in one request. */
54
+ chunkSize?: number;
55
+ /** Milliseconds to wait before each retry. */
56
+ retryDelays?: number[];
57
+ /** Resume an interrupted upload of the same file. Defaults to `true`. */
58
+ resume?: boolean;
59
+ signal?: AbortSignal;
60
+ onProgress?: (progress: UploadProgress) => void;
61
+ }
62
+
63
+ /**
64
+ * Uploads a file to MediaLit with the tus resumable protocol. The upload stays
65
+ * temporary until your backend seals it with the MediaLit SDK.
66
+ */
67
+ declare function uploadFile(file: UploadSource, options: UploadFileOptions): Promise<UploadedMedia>;
68
+
69
+ declare class MediaLitUploadError extends Error {
70
+ /** HTTP status from MediaLit or your signature endpoint, when there was one. */
71
+ readonly status?: number;
72
+ readonly cause?: unknown;
73
+ constructor(message: string, options?: {
74
+ status?: number;
75
+ cause?: unknown;
76
+ });
77
+ }
78
+ declare class UploadAbortedError extends Error {
79
+ constructor();
80
+ }
81
+ declare function isUploadAbortedError(err: unknown): err is UploadAbortedError;
82
+
83
+ export { type Access, MediaLitUploadError, type SignatureOptions, UploadAbortedError, type UploadFileOptions, type UploadProgress, type UploadSignature, type UploadSource, type UploadedMedia, isUploadAbortedError, uploadFile };
package/dist/index.mjs ADDED
@@ -0,0 +1,154 @@
1
+ // src/upload.ts
2
+ import { Upload } from "tus-js-client";
3
+
4
+ // src/errors.ts
5
+ var MediaLitUploadError = class extends Error {
6
+ constructor(message, options = {}) {
7
+ super(message);
8
+ this.name = "MediaLitUploadError";
9
+ this.status = options.status;
10
+ this.cause = options.cause;
11
+ }
12
+ };
13
+ var UploadAbortedError = class extends Error {
14
+ constructor() {
15
+ super("Upload cancelled");
16
+ this.name = "UploadAbortedError";
17
+ }
18
+ };
19
+ function isUploadAbortedError(err) {
20
+ return err instanceof Error && err.name === "UploadAbortedError";
21
+ }
22
+
23
+ // src/signature.ts
24
+ async function resolveSignature(options, signal) {
25
+ if (options.getSignature) {
26
+ return validate(await options.getSignature());
27
+ }
28
+ if (!options.signatureEndpoint) {
29
+ throw new MediaLitUploadError(
30
+ "Pass either signatureEndpoint or getSignature"
31
+ );
32
+ }
33
+ const response = await fetch(options.signatureEndpoint, {
34
+ method: "POST",
35
+ credentials: "same-origin",
36
+ signal
37
+ });
38
+ const body = await response.json().catch(() => ({}));
39
+ if (!response.ok) {
40
+ throw new MediaLitUploadError(
41
+ body?.error || body?.message || `Signature request failed with status ${response.status}`,
42
+ { status: response.status }
43
+ );
44
+ }
45
+ return validate(body);
46
+ }
47
+ function validate(value) {
48
+ if (!value?.signature || !value?.endpoint) {
49
+ throw new MediaLitUploadError(
50
+ "The signature response must include signature and endpoint"
51
+ );
52
+ }
53
+ return { signature: value.signature, endpoint: value.endpoint };
54
+ }
55
+
56
+ // src/upload.ts
57
+ var DEFAULT_RETRY_DELAYS = [0, 3e3, 5e3, 1e4];
58
+ async function uploadFile(file, options) {
59
+ const { signal } = options;
60
+ if (signal?.aborted) throw new UploadAbortedError();
61
+ const { signature, endpoint } = await resolveSignature(
62
+ options,
63
+ signal
64
+ ).catch((err) => {
65
+ throw signal?.aborted ? new UploadAbortedError() : err;
66
+ });
67
+ if (signal?.aborted) throw new UploadAbortedError();
68
+ const blob = typeof Blob !== "undefined" && file instanceof Blob ? file : null;
69
+ const fileName = options.fileName || blob?.name;
70
+ if (!fileName) {
71
+ throw new MediaLitUploadError(
72
+ "Pass fileName when uploading a Blob or a stream"
73
+ );
74
+ }
75
+ return new Promise((resolve, reject) => {
76
+ let settled = false;
77
+ const settle = (finish) => {
78
+ if (settled) return;
79
+ settled = true;
80
+ signal?.removeEventListener("abort", onAbort);
81
+ finish();
82
+ };
83
+ const tusOptions = {
84
+ endpoint: `${endpoint.replace(/\/+$/, "")}/media/create/resumable`,
85
+ headers: { "x-medialit-signature": signature },
86
+ metadata: {
87
+ fileName,
88
+ mimeType: options.mimeType || blob?.type || "application/octet-stream",
89
+ access: options.access || "private",
90
+ caption: options.caption || ""
91
+ },
92
+ retryDelays: options.retryDelays || DEFAULT_RETRY_DELAYS,
93
+ removeFingerprintOnSuccess: true,
94
+ onProgress: (bytesUploaded, bytesTotal) => {
95
+ options.onProgress?.({
96
+ bytesUploaded,
97
+ bytesTotal,
98
+ percentage: bytesTotal ? bytesUploaded / bytesTotal * 100 : 0
99
+ });
100
+ },
101
+ onSuccess: ({ lastResponse }) => {
102
+ const media = parseMedia(lastResponse.getHeader("media"));
103
+ settle(
104
+ () => media ? resolve(media) : reject(
105
+ new MediaLitUploadError(
106
+ "The upload finished but MediaLit did not return the media"
107
+ )
108
+ )
109
+ );
110
+ },
111
+ onError: (err) => settle(() => reject(toUploadError(err)))
112
+ };
113
+ if (options.chunkSize) tusOptions.chunkSize = options.chunkSize;
114
+ const upload = new Upload(file, tusOptions);
115
+ const onAbort = () => {
116
+ upload.abort().catch(() => void 0);
117
+ settle(() => reject(new UploadAbortedError()));
118
+ };
119
+ signal?.addEventListener("abort", onAbort, { once: true });
120
+ const start = async () => {
121
+ if (options.resume !== false) {
122
+ const previous = await upload.findPreviousUploads().catch(() => []);
123
+ if (previous.length) {
124
+ upload.resumeFromPreviousUpload(previous[0]);
125
+ }
126
+ }
127
+ if (!settled) upload.start();
128
+ };
129
+ start();
130
+ });
131
+ }
132
+ function parseMedia(header) {
133
+ if (!header) return null;
134
+ try {
135
+ const media = JSON.parse(header);
136
+ return media?.mediaId ? media : null;
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+ function toUploadError(err) {
142
+ const response = err.originalResponse;
143
+ const body = response?.getBody()?.trim();
144
+ return new MediaLitUploadError(body || err.message, {
145
+ status: response?.getStatus(),
146
+ cause: err
147
+ });
148
+ }
149
+ export {
150
+ MediaLitUploadError,
151
+ UploadAbortedError,
152
+ isUploadAbortedError,
153
+ uploadFile
154
+ };
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "@medialit/uploader",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Resumable browser uploads to MediaLit",
5
+ "type": "module",
6
+ "main": "dist/index.cjs",
7
+ "module": "dist/index.mjs",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.mjs",
13
+ "require": "./dist/index.cjs"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "sideEffects": false,
20
+ "scripts": {
21
+ "build": "tsup",
22
+ "test": "node --import tsx --test '__tests__/**/*.test.ts'",
23
+ "prepublishOnly": "bun run build"
24
+ },
25
+ "keywords": [
26
+ "medialit",
27
+ "upload",
28
+ "tus",
29
+ "resumable"
30
+ ],
31
+ "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/codelitdev/medialit"
35
+ },
36
+ "dependencies": {
37
+ "tus-js-client": "^4.3.1"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^20.0.0",
41
+ "tsup": "^8.0.0",
42
+ "tsx": "^4.19.3",
43
+ "typescript": "^5.0.0"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "packageManager": "bun@1.4.1"
49
+ }