@hot-updater/cloudflare 0.31.4 → 0.32.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.
@@ -0,0 +1,197 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import {
5
+ DeleteObjectCommand,
6
+ GetObjectCommand,
7
+ HeadObjectCommand,
8
+ S3Client,
9
+ type S3ClientConfig,
10
+ } from "@aws-sdk/client-s3";
11
+ import { Upload } from "@aws-sdk/lib-storage";
12
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
13
+ import {
14
+ createStorageKeyBuilder,
15
+ getContentType,
16
+ type NodeStorageProfile,
17
+ parseStorageUri,
18
+ type RuntimeStorageProfile,
19
+ } from "@hot-updater/plugin-core";
20
+
21
+ export interface R2S3StorageConfig extends S3ClientConfig {
22
+ accountId: string;
23
+ bucketName: string;
24
+ credentials: NonNullable<S3ClientConfig["credentials"]>;
25
+ /**
26
+ * Base path where bundles will be stored in the bucket
27
+ */
28
+ basePath?: string;
29
+ }
30
+
31
+ const ensureExpectedR2Bucket = (bucket: string, bucketName: string) => {
32
+ if (bucket !== bucketName) {
33
+ throw new Error(
34
+ `Bucket name mismatch: expected "${bucketName}", but found "${bucket}".`,
35
+ );
36
+ }
37
+ };
38
+
39
+ const isS3ObjectNotFoundError = (error: unknown) => {
40
+ if (error instanceof Error) {
41
+ return error.name === "NotFound" || error.name === "NoSuchKey";
42
+ }
43
+
44
+ if (typeof error === "object" && error !== null && "$metadata" in error) {
45
+ return (
46
+ (error as { $metadata?: { httpStatusCode?: number } }).$metadata
47
+ ?.httpStatusCode === 404
48
+ );
49
+ }
50
+
51
+ return false;
52
+ };
53
+
54
+ const createS3Client = (config: R2S3StorageConfig) => {
55
+ const {
56
+ accountId,
57
+ basePath: _basePath,
58
+ bucketName: _bucketName,
59
+ endpoint,
60
+ forcePathStyle,
61
+ region,
62
+ ...s3Config
63
+ } = config;
64
+
65
+ return new S3Client({
66
+ ...s3Config,
67
+ endpoint: endpoint ?? `https://${accountId}.r2.cloudflarestorage.com`,
68
+ forcePathStyle: forcePathStyle ?? true,
69
+ region: region ?? "auto",
70
+ });
71
+ };
72
+
73
+ export const createS3StorageProfile = (
74
+ config: R2S3StorageConfig,
75
+ ): NodeStorageProfile => {
76
+ const { bucketName } = config;
77
+ const client = createS3Client(config);
78
+ const getStorageKey = createStorageKeyBuilder(config.basePath);
79
+
80
+ return {
81
+ async delete(storageUri) {
82
+ const { bucket, key } = parseStorageUri(storageUri, "r2");
83
+ ensureExpectedR2Bucket(bucket, bucketName);
84
+
85
+ await client.send(
86
+ new DeleteObjectCommand({ Bucket: bucketName, Key: key }),
87
+ );
88
+ },
89
+ async upload(key, filePath) {
90
+ const Body = await fs.readFile(filePath);
91
+ const ContentType = getContentType(filePath);
92
+ const filename = path.basename(filePath);
93
+ const Key = getStorageKey(key, filename);
94
+
95
+ const upload = new Upload({
96
+ client,
97
+ params: {
98
+ Body,
99
+ Bucket: bucketName,
100
+ CacheControl: "max-age=31536000",
101
+ ContentType,
102
+ Key,
103
+ },
104
+ });
105
+ await upload.done();
106
+
107
+ return {
108
+ storageUri: `r2://${bucketName}/${Key}`,
109
+ };
110
+ },
111
+ async exists(storageUri: string) {
112
+ const { bucket, key } = parseStorageUri(storageUri, "r2");
113
+ ensureExpectedR2Bucket(bucket, bucketName);
114
+
115
+ try {
116
+ await client.send(
117
+ new HeadObjectCommand({ Bucket: bucketName, Key: key }),
118
+ );
119
+ return true;
120
+ } catch (error) {
121
+ if (isS3ObjectNotFoundError(error)) {
122
+ return false;
123
+ }
124
+
125
+ throw error;
126
+ }
127
+ },
128
+ async downloadFile(storageUri, filePath) {
129
+ const { bucket, key } = parseStorageUri(storageUri, "r2");
130
+ ensureExpectedR2Bucket(bucket, bucketName);
131
+
132
+ const response = await client.send(
133
+ new GetObjectCommand({ Bucket: bucketName, Key: key }),
134
+ );
135
+
136
+ if (!response.Body) {
137
+ throw new Error("R2 object body is empty");
138
+ }
139
+
140
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
141
+ await fs.writeFile(filePath, await response.Body.transformToByteArray());
142
+ },
143
+ };
144
+ };
145
+
146
+ export const createS3RuntimeStorageProfile = (
147
+ config: R2S3StorageConfig,
148
+ ): RuntimeStorageProfile => {
149
+ const { bucketName } = config;
150
+ const client = createS3Client(config);
151
+
152
+ return {
153
+ async readText(storageUri) {
154
+ const { bucket, key } = parseStorageUri(storageUri, "r2");
155
+ ensureExpectedR2Bucket(bucket, bucketName);
156
+
157
+ try {
158
+ const response = await client.send(
159
+ new GetObjectCommand({ Bucket: bucketName, Key: key }),
160
+ );
161
+
162
+ if (!response.Body) {
163
+ return null;
164
+ }
165
+
166
+ return response.Body.transformToString();
167
+ } catch (error) {
168
+ if (isS3ObjectNotFoundError(error)) {
169
+ return null;
170
+ }
171
+
172
+ throw error;
173
+ }
174
+ },
175
+ async getDownloadUrl(storageUri) {
176
+ const { bucket, key } = parseStorageUri(storageUri, "r2");
177
+ ensureExpectedR2Bucket(bucket, bucketName);
178
+
179
+ const command = new GetObjectCommand({ Bucket: bucketName, Key: key });
180
+ const signedUrl = await getSignedUrl(
181
+ client as unknown as Parameters<typeof getSignedUrl>[0],
182
+ command,
183
+ {
184
+ expiresIn: 3600,
185
+ },
186
+ );
187
+
188
+ if (!signedUrl) {
189
+ throw new Error("Failed to presign R2 URL");
190
+ }
191
+
192
+ return {
193
+ fileUrl: signedUrl,
194
+ };
195
+ },
196
+ };
197
+ };
@@ -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
  });