@zoralabs/coins-sdk 0.5.0 → 0.5.2

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.
@@ -1,8 +1,11 @@
1
1
  import { Address } from "viem";
2
- import { Uploader, UploadResult } from "../types";
2
+ import { Uploader, UploadOptions, UploadResult } from "../types";
3
3
  import { getApiKey } from "../../api/api-key";
4
4
  import { setCreateUploadJwt } from "../../api/internal";
5
5
 
6
+ const JWT_TTL_MS = 1000 * 60 * 60; // 1 hour
7
+ const JWT_FETCH_TIMEOUT_MS = 15_000; // 15 seconds
8
+
6
9
  /**
7
10
  * Zora IPFS uploader implementation
8
11
  */
@@ -18,7 +21,12 @@ export class ZoraUploader implements Uploader {
18
21
  private jwtApiKey: string | undefined;
19
22
  private jwtApiKeyExpiresAt: number | undefined;
20
23
 
21
- async getJWTApiKey() {
24
+ private invalidateJwt() {
25
+ this.jwtApiKey = undefined;
26
+ this.jwtApiKeyExpiresAt = undefined;
27
+ }
28
+
29
+ private async getJWTApiKey(signal?: AbortSignal) {
22
30
  if (
23
31
  this.jwtApiKey &&
24
32
  this.jwtApiKeyExpiresAt &&
@@ -26,36 +34,70 @@ export class ZoraUploader implements Uploader {
26
34
  ) {
27
35
  return this.jwtApiKey;
28
36
  }
29
- // Expires in 1 hour
30
- this.jwtApiKeyExpiresAt = Date.now() + 1000 * 60 * 60;
31
37
 
32
- const response = await setCreateUploadJwt({
33
- creatorAddress: this.creatorAddress,
34
- });
38
+ const jwtSignal = AbortSignal.timeout(JWT_FETCH_TIMEOUT_MS);
39
+ const combinedSignal = signal
40
+ ? AbortSignal.any([signal, jwtSignal])
41
+ : jwtSignal;
42
+
43
+ const response = await setCreateUploadJwt(
44
+ {
45
+ creatorAddress: this.creatorAddress,
46
+ },
47
+ { signal: combinedSignal },
48
+ );
35
49
  this.jwtApiKey = response.data?.createUploadJwtFromApiKey;
36
50
  if (!this.jwtApiKey) {
37
51
  throw new Error("Failed to create upload JWT");
38
52
  }
39
53
 
54
+ this.jwtApiKeyExpiresAt = Date.now() + JWT_TTL_MS;
55
+
40
56
  return this.jwtApiKey;
41
57
  }
42
58
 
43
- async upload(file: File): Promise<UploadResult> {
44
- const jwtApiKey = await this.getJWTApiKey();
59
+ private buildUploadSignal(options?: UploadOptions): AbortSignal | undefined {
60
+ const { signal, timeout } = options ?? {};
61
+ if (signal && timeout) {
62
+ return AbortSignal.any([signal, AbortSignal.timeout(timeout)]);
63
+ }
64
+ if (timeout) {
65
+ return AbortSignal.timeout(timeout);
66
+ }
67
+ return signal;
68
+ }
69
+
70
+ private async doUpload(
71
+ file: File,
72
+ jwt: string,
73
+ signal?: AbortSignal,
74
+ ): Promise<Response> {
45
75
  const formData = new FormData();
46
76
  formData.append("file", file, file.name);
47
77
 
48
- const response = await fetch(
49
- "https://ipfs-uploader.zora.co/api/v0/add?cid-version=1",
50
- {
51
- method: "POST",
52
- headers: {
53
- Authorization: `Bearer ${jwtApiKey}`,
54
- Accept: "*/*",
55
- },
56
- body: formData,
78
+ return fetch("https://ipfs-uploader.zora.co/api/v0/add?cid-version=1", {
79
+ method: "POST",
80
+ headers: {
81
+ Authorization: `Bearer ${jwt}`,
82
+ Accept: "*/*",
57
83
  },
58
- );
84
+ body: formData,
85
+ signal,
86
+ });
87
+ }
88
+
89
+ async upload(file: File, options?: UploadOptions): Promise<UploadResult> {
90
+ const uploadSignal = this.buildUploadSignal(options);
91
+
92
+ const jwt = await this.getJWTApiKey(uploadSignal);
93
+ let response = await this.doUpload(file, jwt, uploadSignal);
94
+
95
+ // On 401, refresh the JWT exactly once and retry
96
+ if (response.status === 401) {
97
+ this.invalidateJwt();
98
+ const freshJwt = await this.getJWTApiKey(uploadSignal);
99
+ response = await this.doUpload(file, freshJwt, uploadSignal);
100
+ }
59
101
 
60
102
  if (!response.ok) {
61
103
  console.error(await response.text());
@@ -10,6 +10,45 @@ global.fetch = mockFetch;
10
10
  console.log = vi.fn();
11
11
  console.error = vi.fn();
12
12
 
13
+ function mockJwtResponse(jwt = "test-jwt-token") {
14
+ return {
15
+ ok: true,
16
+ headers: new Headers({
17
+ "Content-Type": "application/json",
18
+ "Content-Length": "1000",
19
+ }),
20
+ json: async () => ({
21
+ createUploadJwtFromApiKey: jwt,
22
+ }),
23
+ };
24
+ }
25
+
26
+ function mockUploadResponse(
27
+ cid = "bafybeiguslukdujd22p7ix53rcszgbg4ine464g33zk2st3lnjpx4uvmri",
28
+ ) {
29
+ return {
30
+ ok: true,
31
+ status: 200,
32
+ headers: new Headers({
33
+ "Content-Type": "application/json",
34
+ }),
35
+ json: async () => ({
36
+ cid,
37
+ size: 100,
38
+ mimeType: "image/png",
39
+ }),
40
+ };
41
+ }
42
+
43
+ function mock401Response() {
44
+ return {
45
+ ok: false,
46
+ status: 401,
47
+ statusText: "Unauthorized",
48
+ text: async () => "Unauthorized",
49
+ };
50
+ }
51
+
13
52
  describe("ZoraUploader", () => {
14
53
  beforeEach(() => {
15
54
  vi.clearAllMocks();
@@ -31,36 +70,15 @@ describe("ZoraUploader", () => {
31
70
 
32
71
  describe("upload", () => {
33
72
  it("should successfully upload a file with the zora uploader", async () => {
34
- // Mock JWT token creation response
35
73
  mockFetch
36
- .mockResolvedValueOnce({
37
- ok: true,
38
- headers: new Headers({
39
- "Content-Type": "application/json",
40
- "Content-Length": "1000",
41
- }),
42
- json: async () => ({
43
- createUploadJwtFromApiKey: "test-jwt-token",
44
- }),
45
- })
46
- .mockResolvedValueOnce({
47
- ok: true,
48
- headers: new Headers({
49
- "Content-Type": "application/json",
50
- }),
51
- json: async () => ({
52
- cid: "bafybeiguslukdujd22p7ix53rcszgbg4ine464g33zk2st3lnjpx4uvmri",
53
- size: 100,
54
- mimeType: "image/png",
55
- }),
56
- });
74
+ .mockResolvedValueOnce(mockJwtResponse())
75
+ .mockResolvedValueOnce(mockUploadResponse());
57
76
 
58
77
  setApiKey("test-api-key");
59
78
  const uploader = new ZoraUploader("0x123");
60
79
  const file = new File(["test"], "test.png", { type: "image/png" });
61
80
  const result = await uploader.upload(file);
62
81
 
63
- // Verify fetch was called with correct parameters
64
82
  expect(mockFetch).toHaveBeenCalledTimes(2);
65
83
  expect(mockFetch).toHaveBeenCalledWith(
66
84
  "https://ipfs-uploader.zora.co/api/v0/add?cid-version=1",
@@ -72,7 +90,6 @@ describe("ZoraUploader", () => {
72
90
  }),
73
91
  );
74
92
 
75
- // Verify result
76
93
  expect(result).toEqual({
77
94
  url: "ipfs://bafybeiguslukdujd22p7ix53rcszgbg4ine464g33zk2st3lnjpx4uvmri",
78
95
  size: 100,
@@ -80,23 +97,65 @@ describe("ZoraUploader", () => {
80
97
  });
81
98
  });
82
99
 
83
- it("should throw an error if upload fails", async () => {
84
- // Mock failed response
100
+ it("should throw an error if upload fails with non-401 status", async () => {
101
+ mockFetch.mockResolvedValueOnce(mockJwtResponse()).mockResolvedValueOnce({
102
+ ok: false,
103
+ status: 400,
104
+ statusText: "Bad Request",
105
+ text: async () => "Invalid file",
106
+ });
107
+
108
+ setApiKey("test-api-key");
109
+ const uploader = new ZoraUploader("0x123");
110
+ const file = new File(["test"], "test.png", { type: "image/png" });
111
+
112
+ await expect(uploader.upload(file)).rejects.toThrow(
113
+ "Failed to upload file",
114
+ );
115
+
116
+ // Should NOT retry on non-401 errors
117
+ expect(mockFetch).toHaveBeenCalledTimes(2); // 1 JWT + 1 upload
118
+ });
119
+
120
+ it("should retry once on 401 with a fresh JWT", async () => {
121
+ mockFetch
122
+ // First JWT fetch
123
+ .mockResolvedValueOnce(mockJwtResponse("stale-jwt"))
124
+ // First upload returns 401
125
+ .mockResolvedValueOnce(mock401Response())
126
+ // Second JWT fetch (refresh)
127
+ .mockResolvedValueOnce(mockJwtResponse("fresh-jwt"))
128
+ // Retry upload succeeds
129
+ .mockResolvedValueOnce(mockUploadResponse());
130
+
131
+ setApiKey("test-api-key");
132
+ const uploader = new ZoraUploader("0x123");
133
+ const file = new File(["test"], "test.png", { type: "image/png" });
134
+ const result = await uploader.upload(file);
135
+
136
+ expect(mockFetch).toHaveBeenCalledTimes(4); // 2 JWT + 2 upload
137
+
138
+ // Verify the retry used the fresh JWT
139
+ const lastUploadCall = mockFetch.mock.calls[3];
140
+ expect(lastUploadCall[1].headers.Authorization).toBe("Bearer fresh-jwt");
141
+
142
+ expect(result).toEqual({
143
+ url: "ipfs://bafybeiguslukdujd22p7ix53rcszgbg4ine464g33zk2st3lnjpx4uvmri",
144
+ size: 100,
145
+ mimeType: "image/png",
146
+ });
147
+ });
148
+
149
+ it("should throw if retry after 401 also fails", async () => {
85
150
  mockFetch
86
- .mockResolvedValueOnce({
87
- ok: true,
88
- headers: new Headers({
89
- "Content-Type": "application/json",
90
- "Content-Length": "1000",
91
- }),
92
- json: async () => ({
93
- createUploadJwtFromApiKey: "test-jwt-token",
94
- }),
95
- })
151
+ .mockResolvedValueOnce(mockJwtResponse("stale-jwt"))
152
+ .mockResolvedValueOnce(mock401Response())
153
+ .mockResolvedValueOnce(mockJwtResponse("also-bad-jwt"))
96
154
  .mockResolvedValueOnce({
97
155
  ok: false,
98
- statusText: "Bad Request",
99
- text: async () => "Invalid file",
156
+ status: 403,
157
+ statusText: "Forbidden",
158
+ text: async () => "Forbidden",
100
159
  });
101
160
 
102
161
  setApiKey("test-api-key");
@@ -107,17 +166,78 @@ describe("ZoraUploader", () => {
107
166
  "Failed to upload file",
108
167
  );
109
168
 
110
- // Verify fetch was called with correct parameters
111
- expect(mockFetch).toHaveBeenCalledTimes(2);
112
- expect(mockFetch).toHaveBeenCalledWith(
113
- "https://ipfs-uploader.zora.co/api/v0/add?cid-version=1",
114
- expect.objectContaining({
115
- method: "POST",
116
- headers: expect.objectContaining({
117
- Authorization: "Bearer test-jwt-token",
118
- }),
119
- }),
120
- );
169
+ expect(mockFetch).toHaveBeenCalledTimes(4);
170
+ });
171
+
172
+ it("should reuse cached JWT for subsequent uploads", async () => {
173
+ mockFetch
174
+ .mockResolvedValueOnce(mockJwtResponse())
175
+ .mockResolvedValueOnce(mockUploadResponse())
176
+ .mockResolvedValueOnce(mockUploadResponse());
177
+
178
+ setApiKey("test-api-key");
179
+ const uploader = new ZoraUploader("0x123");
180
+ const file1 = new File(["test1"], "test1.png", { type: "image/png" });
181
+ const file2 = new File(["test2"], "test2.png", { type: "image/png" });
182
+
183
+ await uploader.upload(file1);
184
+ await uploader.upload(file2);
185
+
186
+ // Only 1 JWT fetch + 2 uploads
187
+ expect(mockFetch).toHaveBeenCalledTimes(3);
188
+ });
189
+
190
+ it("should pass signal to upload request", async () => {
191
+ mockFetch
192
+ .mockResolvedValueOnce(mockJwtResponse())
193
+ .mockResolvedValueOnce(mockUploadResponse());
194
+
195
+ setApiKey("test-api-key");
196
+ const uploader = new ZoraUploader("0x123");
197
+ const file = new File(["test"], "test.png", { type: "image/png" });
198
+ const controller = new AbortController();
199
+
200
+ await uploader.upload(file, { signal: controller.signal });
201
+
202
+ const uploadCall = mockFetch.mock.calls[1];
203
+ expect(uploadCall[1].signal).toBe(controller.signal);
204
+ });
205
+
206
+ it("should pass timeout as AbortSignal to upload request", async () => {
207
+ mockFetch
208
+ .mockResolvedValueOnce(mockJwtResponse())
209
+ .mockResolvedValueOnce(mockUploadResponse());
210
+
211
+ setApiKey("test-api-key");
212
+ const uploader = new ZoraUploader("0x123");
213
+ const file = new File(["test"], "test.png", { type: "image/png" });
214
+
215
+ await uploader.upload(file, { timeout: 30_000 });
216
+
217
+ const uploadCall = mockFetch.mock.calls[1];
218
+ expect(uploadCall[1].signal).toBeDefined();
219
+ });
220
+
221
+ it("should pass combined signal when both signal and timeout are provided", async () => {
222
+ mockFetch
223
+ .mockResolvedValueOnce(mockJwtResponse())
224
+ .mockResolvedValueOnce(mockUploadResponse());
225
+
226
+ setApiKey("test-api-key");
227
+ const uploader = new ZoraUploader("0x123");
228
+ const file = new File(["test"], "test.png", { type: "image/png" });
229
+ const controller = new AbortController();
230
+
231
+ await uploader.upload(file, {
232
+ signal: controller.signal,
233
+ timeout: 30_000,
234
+ });
235
+
236
+ const uploadCall = mockFetch.mock.calls[1];
237
+ // When both signal and timeout are provided, AbortSignal.any() is used
238
+ // so the resulting signal is different from the original
239
+ expect(uploadCall[1].signal).toBeDefined();
240
+ expect(uploadCall[1].signal).not.toBe(controller.signal);
121
241
  });
122
242
  });
123
243
 
@@ -13,11 +13,21 @@ export type UploadResult = {
13
13
  mimeType: string | undefined;
14
14
  };
15
15
 
16
+ /**
17
+ * Options for file upload operations
18
+ */
19
+ export type UploadOptions = {
20
+ /** AbortSignal to cancel the upload */
21
+ signal?: AbortSignal;
22
+ /** Timeout in milliseconds for the upload request (no default) */
23
+ timeout?: number;
24
+ };
25
+
16
26
  /**
17
27
  * Interface for file uploaders (IPFS, Arweave, etc.)
18
28
  */
19
29
  export interface Uploader {
20
- upload(file: File): Promise<UploadResult>;
30
+ upload(file: File, options?: UploadOptions): Promise<UploadResult>;
21
31
  }
22
32
 
23
33
  export type CreateMetadataParameters = {