@hot-updater/cloudflare 0.31.4 → 0.33.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.
@@ -1,6 +1,15 @@
1
+ import { Buffer } from "buffer";
1
2
  import fs from "fs/promises";
2
3
 
3
- import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import {
5
+ DeleteObjectCommand,
6
+ GetObjectCommand,
7
+ HeadObjectCommand,
8
+ S3Client,
9
+ } from "@aws-sdk/client-s3";
10
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
11
+ import { ExecaError } from "execa";
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
13
 
5
14
  import { r2Storage } from "./r2Storage";
6
15
 
@@ -8,16 +17,269 @@ const { wrangler } = vi.hoisted(() => ({
8
17
  wrangler: vi.fn(),
9
18
  }));
10
19
 
20
+ let fakeStore: Record<string, Buffer> = {};
21
+ let deletedKeys: string[] = [];
22
+
23
+ vi.mock("@aws-sdk/lib-storage", () => ({
24
+ Upload: class {
25
+ params: {
26
+ Body: Buffer;
27
+ Bucket: string;
28
+ CacheControl?: string;
29
+ ContentType?: string;
30
+ Key: string;
31
+ };
32
+
33
+ constructor({
34
+ params,
35
+ }: {
36
+ params: {
37
+ Body: Buffer;
38
+ Bucket: string;
39
+ CacheControl?: string;
40
+ ContentType?: string;
41
+ Key: string;
42
+ };
43
+ }) {
44
+ this.params = params;
45
+ }
46
+
47
+ async done() {
48
+ fakeStore[this.params.Key] = this.params.Body;
49
+ return {
50
+ Bucket: this.params.Bucket,
51
+ Key: this.params.Key,
52
+ };
53
+ }
54
+ },
55
+ }));
56
+
57
+ vi.mock("@aws-sdk/s3-request-presigner", () => ({
58
+ getSignedUrl: vi.fn(async () => "https://signed-r2.example.com/object"),
59
+ }));
60
+
11
61
  vi.mock("./utils/createWrangler", () => ({
12
62
  createWrangler: vi.fn(() => wrangler),
13
63
  }));
14
64
 
65
+ const createExecaError = (message: string) =>
66
+ Object.assign(Object.create(ExecaError.prototype), {
67
+ message,
68
+ shortMessage: message,
69
+ stderr: message,
70
+ stdout: "",
71
+ }) as ExecaError;
72
+
15
73
  describe("r2Storage", () => {
16
74
  beforeEach(() => {
75
+ fakeStore = {};
76
+ deletedKeys = [];
17
77
  wrangler.mockReset();
18
78
  });
19
79
 
20
- it("downloads R2 objects with wrangler to the given file path", async () => {
80
+ afterEach(() => {
81
+ vi.restoreAllMocks();
82
+ });
83
+
84
+ const mockS3Client = () => {
85
+ return vi
86
+ .spyOn(S3Client.prototype, "send")
87
+ .mockImplementation(async (command: any) => {
88
+ if (command instanceof HeadObjectCommand) {
89
+ const key = command.input.Key!;
90
+ if (fakeStore[key]) {
91
+ return {};
92
+ }
93
+
94
+ const error = new Error("Not found");
95
+ error.name = "NotFound";
96
+ throw error;
97
+ }
98
+
99
+ if (command instanceof GetObjectCommand) {
100
+ const key = command.input.Key!;
101
+ const object = fakeStore[key];
102
+ if (!object) {
103
+ const error = new Error("No such key");
104
+ error.name = "NoSuchKey";
105
+ throw error;
106
+ }
107
+
108
+ return {
109
+ Body: {
110
+ transformToByteArray: async () => new Uint8Array(object),
111
+ transformToString: async () => object.toString("utf8"),
112
+ },
113
+ };
114
+ }
115
+
116
+ if (command instanceof DeleteObjectCommand) {
117
+ deletedKeys.push(command.input.Key!);
118
+ delete fakeStore[command.input.Key!];
119
+ return {};
120
+ }
121
+
122
+ throw new Error("Unsupported command");
123
+ });
124
+ };
125
+
126
+ it("uploads R2 objects through the S3 API when credentials are provided", async () => {
127
+ mockS3Client();
128
+
129
+ const storage = r2Storage({
130
+ accountId: "account-id",
131
+ bucketName: "test-bucket",
132
+ credentials: {
133
+ accessKeyId: "access-key-id",
134
+ secretAccessKey: "secret-access-key",
135
+ },
136
+ })();
137
+
138
+ const filePath = "/tmp/hot-updater-r2-upload.txt";
139
+ await fs.writeFile(filePath, "hello r2");
140
+
141
+ await expect(
142
+ storage.profiles.node.upload("releases/bundle-1", filePath),
143
+ ).resolves.toEqual({
144
+ storageUri:
145
+ "r2://test-bucket/releases/bundle-1/hot-updater-r2-upload.txt",
146
+ });
147
+ expect(fakeStore["releases/bundle-1/hot-updater-r2-upload.txt"]).toEqual(
148
+ Buffer.from("hello r2"),
149
+ );
150
+ expect(wrangler).not.toHaveBeenCalled();
151
+ });
152
+
153
+ it("downloads R2 objects through the S3 API when credentials are provided", async () => {
154
+ mockS3Client();
155
+ fakeStore["releases/bundle-1/manifest.json"] = Buffer.from(
156
+ JSON.stringify({
157
+ assets: {},
158
+ bundleId: "bundle-1",
159
+ }),
160
+ );
161
+
162
+ const storage = r2Storage({
163
+ accountId: "account-id",
164
+ bucketName: "test-bucket",
165
+ credentials: {
166
+ accessKeyId: "access-key-id",
167
+ secretAccessKey: "secret-access-key",
168
+ },
169
+ })();
170
+
171
+ const downloadPath = "/tmp/hot-updater-test-manifest.json";
172
+ await fs.rm(downloadPath, { force: true });
173
+
174
+ await storage.profiles.node.downloadFile(
175
+ "r2://test-bucket/releases/bundle-1/manifest.json",
176
+ downloadPath,
177
+ );
178
+
179
+ expect(JSON.parse(await fs.readFile(downloadPath, "utf8"))).toEqual({
180
+ assets: {},
181
+ bundleId: "bundle-1",
182
+ });
183
+ expect(wrangler).not.toHaveBeenCalled();
184
+ });
185
+
186
+ it("checks R2 object existence through the S3 API", async () => {
187
+ mockS3Client();
188
+ fakeStore["releases/logo.png"] = Buffer.from("logo");
189
+
190
+ const storage = r2Storage({
191
+ accountId: "account-id",
192
+ bucketName: "test-bucket",
193
+ credentials: {
194
+ accessKeyId: "access-key-id",
195
+ secretAccessKey: "secret-access-key",
196
+ },
197
+ })();
198
+
199
+ await expect(
200
+ storage.profiles.node.exists("r2://test-bucket/releases/logo.png"),
201
+ ).resolves.toBe(true);
202
+ await expect(
203
+ storage.profiles.node.exists("r2://test-bucket/releases/missing.png"),
204
+ ).resolves.toBe(false);
205
+ });
206
+
207
+ it("deletes R2 objects through the S3 API", async () => {
208
+ mockS3Client();
209
+ fakeStore["releases/logo.png"] = Buffer.from("logo");
210
+
211
+ const storage = r2Storage({
212
+ accountId: "account-id",
213
+ bucketName: "test-bucket",
214
+ credentials: {
215
+ accessKeyId: "access-key-id",
216
+ secretAccessKey: "secret-access-key",
217
+ },
218
+ })();
219
+
220
+ await storage.profiles.node.delete("r2://test-bucket/releases/logo.png");
221
+
222
+ expect(deletedKeys).toEqual(["releases/logo.png"]);
223
+ expect(fakeStore["releases/logo.png"]).toBeUndefined();
224
+ expect(wrangler).not.toHaveBeenCalled();
225
+ });
226
+
227
+ it("reads R2 text through the runtime S3 API when credentials are provided", async () => {
228
+ mockS3Client();
229
+ fakeStore["releases/bundle-1/manifest.json"] = Buffer.from(
230
+ JSON.stringify({
231
+ assets: {},
232
+ bundleId: "bundle-1",
233
+ }),
234
+ );
235
+
236
+ const storage = r2Storage({
237
+ accountId: "account-id",
238
+ bucketName: "test-bucket",
239
+ credentials: {
240
+ accessKeyId: "access-key-id",
241
+ secretAccessKey: "secret-access-key",
242
+ },
243
+ })();
244
+
245
+ await expect(
246
+ storage.profiles.runtime.readText(
247
+ "r2://test-bucket/releases/bundle-1/manifest.json",
248
+ ),
249
+ ).resolves.toBe('{"assets":{},"bundleId":"bundle-1"}');
250
+ await expect(
251
+ storage.profiles.runtime.readText(
252
+ "r2://test-bucket/releases/missing.json",
253
+ ),
254
+ ).resolves.toBeNull();
255
+ });
256
+
257
+ it("creates signed R2 download URLs through the runtime S3 API", async () => {
258
+ mockS3Client();
259
+ const storage = r2Storage({
260
+ accountId: "account-id",
261
+ bucketName: "test-bucket",
262
+ credentials: {
263
+ accessKeyId: "access-key-id",
264
+ secretAccessKey: "secret-access-key",
265
+ },
266
+ })();
267
+
268
+ await expect(
269
+ storage.profiles.runtime.getDownloadUrl(
270
+ "r2://test-bucket/releases/bundle-1/index.bundle",
271
+ ),
272
+ ).resolves.toEqual({
273
+ fileUrl: "https://signed-r2.example.com/object",
274
+ });
275
+ expect(getSignedUrl).toHaveBeenCalledWith(
276
+ expect.any(S3Client),
277
+ expect.any(GetObjectCommand),
278
+ { expiresIn: 3600 },
279
+ );
280
+ });
281
+
282
+ it("falls back to wrangler without S3 credentials", async () => {
21
283
  wrangler.mockImplementation(async (...args: string[]) => {
22
284
  const fileIndex = args.indexOf("--file");
23
285
  const downloadPath = args[fileIndex + 1];
@@ -65,6 +327,20 @@ describe("r2Storage", () => {
65
327
  );
66
328
  });
67
329
 
330
+ it("keeps the deprecated wrangler path node-only at runtime", async () => {
331
+ const storage = r2Storage({
332
+ accountId: "account-id",
333
+ bucketName: "test-bucket",
334
+ cloudflareApiToken: "api-token",
335
+ })();
336
+
337
+ await expect(
338
+ storage.profiles.runtime.readText(
339
+ "r2://test-bucket/releases/bundle-1/manifest.json",
340
+ ),
341
+ ).rejects.toThrow("r2Storage runtime profile requires R2 S3 credentials.");
342
+ });
343
+
68
344
  it("rejects downloads from a different bucket", async () => {
69
345
  const storage = r2Storage({
70
346
  accountId: "account-id",
@@ -82,4 +358,42 @@ describe("r2Storage", () => {
82
358
  );
83
359
  expect(wrangler).not.toHaveBeenCalled();
84
360
  });
361
+
362
+ it("returns false when the R2 object does not exist", async () => {
363
+ wrangler.mockRejectedValueOnce(createExecaError("object not found"));
364
+
365
+ const storage = r2Storage({
366
+ accountId: "account-id",
367
+ bucketName: "test-bucket",
368
+ cloudflareApiToken: "api-token",
369
+ })();
370
+
371
+ await expect(
372
+ storage.profiles.node.exists("r2://test-bucket/releases/logo.png"),
373
+ ).resolves.toBe(false);
374
+ expect(wrangler).toHaveBeenCalledWith(
375
+ "r2",
376
+ "object",
377
+ "get",
378
+ "test-bucket/releases/logo.png",
379
+ "--file",
380
+ expect.any(String),
381
+ "--remote",
382
+ );
383
+ });
384
+
385
+ it("rethrows non-missing R2 existence errors", async () => {
386
+ const error = createExecaError("Authentication failed");
387
+ wrangler.mockRejectedValueOnce(error);
388
+
389
+ const storage = r2Storage({
390
+ accountId: "account-id",
391
+ bucketName: "test-bucket",
392
+ cloudflareApiToken: "api-token",
393
+ })();
394
+
395
+ await expect(
396
+ storage.profiles.node.exists("r2://test-bucket/releases/logo.png"),
397
+ ).rejects.toBe(error);
398
+ });
85
399
  });
package/src/r2Storage.ts CHANGED
@@ -1,126 +1,66 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
3
-
4
1
  import {
5
- createStorageKeyBuilder,
6
- createNodeStoragePlugin,
7
- getContentType,
8
- parseStorageUri,
2
+ createUniversalStoragePlugin,
3
+ type StoragePluginHooks,
4
+ type UniversalStoragePlugin,
9
5
  } from "@hot-updater/plugin-core";
10
- import { ExecaError } from "execa";
11
6
 
12
- import { createWrangler } from "./utils/createWrangler";
7
+ import {
8
+ createS3RuntimeStorageProfile,
9
+ createS3StorageProfile,
10
+ type R2S3StorageConfig,
11
+ } from "./r2S3Storage";
12
+ import {
13
+ createWranglerRuntimeStorageProfile,
14
+ createWranglerStorageProfile,
15
+ type R2WranglerStorageConfig,
16
+ } from "./r2WranglerStorage";
13
17
 
14
- export interface R2StorageConfig {
15
- cloudflareApiToken: string;
16
- accountId: string;
17
- bucketName: string;
18
- /**
19
- * Base path where bundles will be stored in the bucket
20
- */
21
- basePath?: string;
22
- }
18
+ export type R2StorageConfig = R2S3StorageConfig | R2WranglerStorageConfig;
19
+
20
+ export type { R2S3StorageConfig, R2WranglerStorageConfig };
21
+
22
+ const hasS3Credentials = (
23
+ config: R2StorageConfig,
24
+ ): config is R2S3StorageConfig => {
25
+ return Boolean(config.credentials);
26
+ };
23
27
 
24
28
  /**
25
29
  * Cloudflare R2 storage plugin for Hot Updater.
26
30
  */
27
- export const r2Storage = createNodeStoragePlugin<R2StorageConfig>({
31
+ interface R2Storage {
32
+ (
33
+ config: R2S3StorageConfig,
34
+ hooks?: StoragePluginHooks,
35
+ ): () => UniversalStoragePlugin;
36
+ /**
37
+ * @deprecated `cloudflareApiToken` uses the Wrangler CLI for R2 operations,
38
+ * which is slower than direct S3-compatible API access. Create R2
39
+ * S3-compatible credentials in the Cloudflare dashboard and pass them with
40
+ * `r2Storage({ credentials })` instead.
41
+ */
42
+ (
43
+ config: R2WranglerStorageConfig,
44
+ hooks?: StoragePluginHooks,
45
+ ): () => UniversalStoragePlugin;
46
+ }
47
+
48
+ const createR2StoragePlugin = createUniversalStoragePlugin<R2StorageConfig>({
28
49
  name: "r2Storage",
29
50
  supportedProtocol: "r2",
30
51
  factory: (config) => {
31
- const { bucketName, cloudflareApiToken, accountId } = config;
32
- const wrangler = createWrangler({
33
- accountId,
34
- cloudflareApiToken: cloudflareApiToken,
35
- cwd: process.cwd(),
36
- });
37
-
38
- const getStorageKey = createStorageKeyBuilder(config.basePath);
52
+ if (hasS3Credentials(config)) {
53
+ return {
54
+ node: createS3StorageProfile(config),
55
+ runtime: createS3RuntimeStorageProfile(config),
56
+ };
57
+ }
39
58
 
40
59
  return {
41
- async delete(storageUri) {
42
- const { bucket, key } = parseStorageUri(storageUri, "r2");
43
- if (bucket !== bucketName) {
44
- throw new Error(
45
- `Bucket name mismatch: expected "${bucketName}", but found "${bucket}".`,
46
- );
47
- }
48
-
49
- try {
50
- await wrangler(
51
- "r2",
52
- "object",
53
- "delete",
54
- [bucketName, key].join("/"),
55
- "--remote",
56
- );
57
- } catch {
58
- throw new Error("Can not delete bundle");
59
- }
60
- },
61
- async upload(key, filePath) {
62
- const contentType = getContentType(filePath);
63
-
64
- const filename = path.basename(filePath);
65
-
66
- const Key = getStorageKey(key, filename);
67
- try {
68
- const { stderr, exitCode } = await wrangler(
69
- "r2",
70
- "object",
71
- "put",
72
- [bucketName, Key].join("/"),
73
- "--file",
74
- filePath,
75
- "--content-type",
76
- contentType,
77
- "--remote",
78
- );
79
- if (exitCode !== 0 && stderr) {
80
- throw new Error(stderr);
81
- }
82
- } catch (error) {
83
- if (error instanceof ExecaError) {
84
- throw new Error(error.stderr || error.stdout);
85
- }
86
-
87
- throw error;
88
- }
89
-
90
- return {
91
- storageUri: `r2://${bucketName}/${Key}`,
92
- };
93
- },
94
- async downloadFile(storageUri, filePath) {
95
- const { bucket, key } = parseStorageUri(storageUri, "r2");
96
- if (bucket !== bucketName) {
97
- throw new Error(
98
- `Bucket name mismatch: expected "${bucketName}", but found "${bucket}".`,
99
- );
100
- }
101
-
102
- try {
103
- await fs.mkdir(path.dirname(filePath), { recursive: true });
104
- const { stderr, exitCode } = await wrangler(
105
- "r2",
106
- "object",
107
- "get",
108
- [bucketName, key].join("/"),
109
- "--file",
110
- filePath,
111
- "--remote",
112
- );
113
- if (exitCode !== 0 && stderr) {
114
- throw new Error(stderr);
115
- }
116
- } catch (error) {
117
- if (error instanceof ExecaError) {
118
- throw new Error(error.stderr || error.stdout);
119
- }
120
-
121
- throw error;
122
- }
123
- },
60
+ node: createWranglerStorageProfile(config),
61
+ runtime: createWranglerRuntimeStorageProfile(),
124
62
  };
125
63
  },
126
64
  });
65
+
66
+ export const r2Storage: R2Storage = createR2StoragePlugin;