@hot-updater/cloudflare 0.36.6 → 1.0.0-rc.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.
Files changed (49) hide show
  1. package/dist/iac/index.cjs +989 -11
  2. package/dist/iac/index.mjs +992 -15
  3. package/dist/index.cjs +919 -6989
  4. package/dist/index.d.cts +11 -47
  5. package/dist/index.d.mts +11 -47
  6. package/dist/index.mjs +917 -7006
  7. package/dist/worker/index.cjs +898 -336
  8. package/dist/worker/index.d.cts +18 -33
  9. package/dist/worker/index.d.mts +18 -33
  10. package/dist/worker/index.mjs +899 -331
  11. package/package.json +7 -10
  12. package/sql/bundles.sql +183 -35
  13. package/src/d1Database.spec.ts +143 -392
  14. package/src/d1Database.ts +55 -650
  15. package/src/d1Implementation.spec.ts +252 -0
  16. package/src/d1Implementation.ts +742 -0
  17. package/src/d1Query.spec.ts +46 -0
  18. package/src/d1Query.ts +89 -0
  19. package/src/d1Rows.spec.ts +111 -0
  20. package/src/d1Rows.ts +326 -0
  21. package/src/d1Sql.spec.ts +30 -0
  22. package/src/d1Sql.ts +175 -0
  23. package/src/r2S3Storage.ts +93 -137
  24. package/src/r2Storage.spec.ts +120 -218
  25. package/src/r2Storage.ts +13 -61
  26. package/src/r2WorkerStorage.spec.ts +161 -50
  27. package/src/r2WorkerStorage.ts +85 -118
  28. package/src/worker/d1Database.spec.ts +33 -0
  29. package/src/worker/d1Database.ts +52 -0
  30. package/src/worker/index.ts +3 -31
  31. package/worker/dist/README.md +1 -1
  32. package/worker/dist/index.js +5968 -4701
  33. package/worker/dist/index.js.map +4 -4
  34. package/worker/migrations/0001_hot-updater_1.0.0.sql +197 -0
  35. package/worker/migrations/0002_hot-updater_artifact_byte_sizes.sql +11 -0
  36. package/worker/src/databaseMigration.integration.spec.ts +114 -0
  37. package/worker/src/databasePlugins.integration.spec.ts +292 -0
  38. package/worker/src/index.integration.spec.ts +102 -294
  39. package/worker/src/index.ts +14 -66
  40. package/worker/src/storagePlugin.integration.spec.ts +59 -0
  41. package/worker/wrangler.json +5 -2
  42. package/src/cloudflareWorkerDatabase.spec.ts +0 -260
  43. package/src/cloudflareWorkerDatabase.ts +0 -651
  44. package/src/r2WranglerStorage.ts +0 -193
  45. package/worker/migrations/0001_hot-updater_init.sql +0 -16
  46. package/worker/migrations/0002_hot-updater_0.13.0.sql +0 -8
  47. package/worker/migrations/0003_hot-updater_0.18.0.sql +0 -38
  48. package/worker/migrations/0004_hot-updater_0.29.0.sql +0 -11
  49. package/worker/migrations/0005_hot-updater_0.31.0.sql +0 -24
@@ -1,5 +1,4 @@
1
1
  import { Buffer } from "buffer";
2
- import fs from "fs/promises";
3
2
 
4
3
  import {
5
4
  DeleteObjectCommand,
@@ -7,25 +6,32 @@ import {
7
6
  HeadObjectCommand,
8
7
  S3Client,
9
8
  } from "@aws-sdk/client-s3";
10
- import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
11
- import { ExecaError } from "execa";
12
9
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
13
10
 
14
11
  import { r2Storage } from "./r2Storage";
15
12
 
16
- const { wrangler } = vi.hoisted(() => ({
17
- wrangler: vi.fn(),
18
- }));
19
-
20
13
  let fakeStore: Record<string, Buffer> = {};
21
14
  let deletedKeys: string[] = [];
15
+ let uploadedParams:
16
+ | {
17
+ Body: ReadableStream<Uint8Array>;
18
+ Bucket: string;
19
+ CacheControl?: string;
20
+ ContentLength?: number;
21
+ ContentType?: string;
22
+ Key: string;
23
+ }
24
+ | undefined;
25
+
26
+ const createBody = (value: string) => new Response(value).body!;
22
27
 
23
28
  vi.mock("@aws-sdk/lib-storage", () => ({
24
29
  Upload: class {
25
30
  params: {
26
- Body: Buffer;
31
+ Body: ReadableStream<Uint8Array>;
27
32
  Bucket: string;
28
33
  CacheControl?: string;
34
+ ContentLength?: number;
29
35
  ContentType?: string;
30
36
  Key: string;
31
37
  };
@@ -34,18 +40,22 @@ vi.mock("@aws-sdk/lib-storage", () => ({
34
40
  params,
35
41
  }: {
36
42
  params: {
37
- Body: Buffer;
43
+ Body: ReadableStream<Uint8Array>;
38
44
  Bucket: string;
39
45
  CacheControl?: string;
46
+ ContentLength?: number;
40
47
  ContentType?: string;
41
48
  Key: string;
42
49
  };
43
50
  }) {
44
51
  this.params = params;
52
+ uploadedParams = params;
45
53
  }
46
54
 
47
55
  async done() {
48
- fakeStore[this.params.Key] = this.params.Body;
56
+ fakeStore[this.params.Key] = Buffer.from(
57
+ await new Response(this.params.Body).arrayBuffer(),
58
+ );
49
59
  return {
50
60
  Bucket: this.params.Bucket,
51
61
  Key: this.params.Key,
@@ -54,27 +64,11 @@ vi.mock("@aws-sdk/lib-storage", () => ({
54
64
  },
55
65
  }));
56
66
 
57
- vi.mock("@aws-sdk/s3-request-presigner", () => ({
58
- getSignedUrl: vi.fn(async () => "https://signed-r2.example.com/object"),
59
- }));
60
-
61
- vi.mock("./utils/createWrangler", () => ({
62
- createWrangler: vi.fn(() => wrangler),
63
- }));
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
-
73
67
  describe("r2Storage", () => {
74
68
  beforeEach(() => {
75
69
  fakeStore = {};
76
70
  deletedKeys = [];
77
- wrangler.mockReset();
71
+ uploadedParams = undefined;
78
72
  });
79
73
 
80
74
  afterEach(() => {
@@ -109,6 +103,7 @@ describe("r2Storage", () => {
109
103
  Body: {
110
104
  transformToByteArray: async () => new Uint8Array(object),
111
105
  transformToString: async () => object.toString("utf8"),
106
+ transformToWebStream: () => new Response(object).body!,
112
107
  },
113
108
  };
114
109
  }
@@ -133,21 +128,55 @@ describe("r2Storage", () => {
133
128
  accessKeyId: "access-key-id",
134
129
  secretAccessKey: "secret-access-key",
135
130
  },
136
- })();
137
-
138
- const filePath = "/tmp/hot-updater-r2-upload.txt";
139
- await fs.writeFile(filePath, "hello r2");
131
+ });
140
132
 
141
133
  await expect(
142
- storage.profiles.node.upload("releases/bundle-1", filePath),
134
+ storage.put?.({
135
+ key: "releases/bundle-1/hot-updater-r2-upload.txt",
136
+ body: createBody("hello r2"),
137
+ contentLength: 8,
138
+ contentType: "text/plain",
139
+ }),
143
140
  ).resolves.toEqual({
144
141
  storageUri:
145
142
  "r2://test-bucket/releases/bundle-1/hot-updater-r2-upload.txt",
146
143
  });
147
- expect(fakeStore["releases/bundle-1/hot-updater-r2-upload.txt"]).toEqual(
148
- Buffer.from("hello r2"),
149
- );
150
- expect(wrangler).not.toHaveBeenCalled();
144
+ expect(
145
+ fakeStore["releases/bundle-1/hot-updater-r2-upload.txt"].toString(),
146
+ ).toBe("hello r2");
147
+ expect(uploadedParams).toMatchObject({
148
+ ContentLength: 8,
149
+ ContentType: "text/plain",
150
+ });
151
+ });
152
+
153
+ it("round-trips spaces, Unicode, fragments, and percent signs in S3 keys", async () => {
154
+ mockS3Client();
155
+ const storage = r2Storage({
156
+ accountId: "account-id",
157
+ bucketName: "test-bucket",
158
+ credentials: {
159
+ accessKeyId: "access-key-id",
160
+ secretAccessKey: "secret-access-key",
161
+ },
162
+ });
163
+ const key = "릴리스 folder/logo@2x #100%/bundle.zip";
164
+
165
+ const uploaded = await storage.put?.({
166
+ key,
167
+ body: createBody("bundle"),
168
+ contentLength: 6,
169
+ contentType: "application/zip",
170
+ });
171
+ expect(uploaded?.storageUri).toContain("logo%402x");
172
+ expect(uploaded?.storageUri).not.toContain("#100%");
173
+ await expect(
174
+ storage.get?.({ storageUri: uploaded!.storageUri }),
175
+ ).resolves.toEqual({ response: expect.any(Response) });
176
+ await storage.delete?.({ storageUri: uploaded!.storageUri });
177
+ await storage.delete?.({ storageUri: uploaded!.storageUri });
178
+
179
+ expect(deletedKeys).toEqual([key, key]);
151
180
  });
152
181
 
153
182
  it("downloads R2 objects through the S3 API when credentials are provided", async () => {
@@ -166,21 +195,16 @@ describe("r2Storage", () => {
166
195
  accessKeyId: "access-key-id",
167
196
  secretAccessKey: "secret-access-key",
168
197
  },
169
- })();
170
-
171
- const downloadPath = "/tmp/hot-updater-test-manifest.json";
172
- await fs.rm(downloadPath, { force: true });
198
+ });
173
199
 
174
- await storage.profiles.node.downloadFile(
175
- "r2://test-bucket/releases/bundle-1/manifest.json",
176
- downloadPath,
177
- );
200
+ const { response } = (await storage.get?.({
201
+ storageUri: "r2://test-bucket/releases/bundle-1/manifest.json",
202
+ })) ?? { response: null };
178
203
 
179
- expect(JSON.parse(await fs.readFile(downloadPath, "utf8"))).toEqual({
204
+ expect(JSON.parse((await response?.text()) ?? "")).toEqual({
180
205
  assets: {},
181
206
  bundleId: "bundle-1",
182
207
  });
183
- expect(wrangler).not.toHaveBeenCalled();
184
208
  });
185
209
 
186
210
  it("checks R2 object existence through the S3 API", async () => {
@@ -194,14 +218,14 @@ describe("r2Storage", () => {
194
218
  accessKeyId: "access-key-id",
195
219
  secretAccessKey: "secret-access-key",
196
220
  },
197
- })();
221
+ });
198
222
 
199
223
  await expect(
200
- storage.profiles.node.exists("r2://test-bucket/releases/logo.png"),
201
- ).resolves.toBe(true);
224
+ storage.exists?.({ storageUri: "r2://test-bucket/releases/logo.png" }),
225
+ ).resolves.toEqual({ exists: true });
202
226
  await expect(
203
- storage.profiles.node.exists("r2://test-bucket/releases/missing.png"),
204
- ).resolves.toBe(false);
227
+ storage.exists?.({ storageUri: "r2://test-bucket/releases/missing.png" }),
228
+ ).resolves.toEqual({ exists: false });
205
229
  });
206
230
 
207
231
  it("deletes R2 objects through the S3 API", async () => {
@@ -215,13 +239,18 @@ describe("r2Storage", () => {
215
239
  accessKeyId: "access-key-id",
216
240
  secretAccessKey: "secret-access-key",
217
241
  },
218
- })();
242
+ });
219
243
 
220
- await storage.profiles.node.delete("r2://test-bucket/releases/logo.png");
244
+ await expect(
245
+ storage.delete?.({
246
+ storageUri: "r2://test-bucket/releases/logo.png",
247
+ }),
248
+ ).resolves.toEqual({
249
+ deleted: true,
250
+ });
221
251
 
222
252
  expect(deletedKeys).toEqual(["releases/logo.png"]);
223
253
  expect(fakeStore["releases/logo.png"]).toBeUndefined();
224
- expect(wrangler).not.toHaveBeenCalled();
225
254
  });
226
255
 
227
256
  it("reads R2 text through the runtime S3 API when credentials are provided", async () => {
@@ -240,22 +269,29 @@ describe("r2Storage", () => {
240
269
  accessKeyId: "access-key-id",
241
270
  secretAccessKey: "secret-access-key",
242
271
  },
243
- })();
272
+ });
244
273
 
274
+ const { response } = (await storage.get?.({
275
+ storageUri: "r2://test-bucket/releases/bundle-1/manifest.json",
276
+ })) ?? { response: null };
277
+ expect(await response?.text()).toBe('{"assets":{},"bundleId":"bundle-1"}');
245
278
  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();
279
+ storage.get?.({
280
+ storageUri: "r2://test-bucket/releases/missing.json",
281
+ }),
282
+ ).resolves.toEqual({ response: null });
255
283
  });
256
284
 
257
- it("creates signed R2 download URLs through the runtime S3 API", async () => {
258
- mockS3Client();
285
+ it("rejects configs without S3 credentials", () => {
286
+ expect(() =>
287
+ r2Storage({
288
+ accountId: "account-id",
289
+ bucketName: "test-bucket",
290
+ } as never),
291
+ ).toThrow("r2Storage requires S3-compatible credentials");
292
+ });
293
+
294
+ it("does not add getDownloadUrl to a deploy-only configuration", () => {
259
295
  const storage = r2Storage({
260
296
  accountId: "account-id",
261
297
  bucketName: "test-bucket",
@@ -263,23 +299,12 @@ describe("r2Storage", () => {
263
299
  accessKeyId: "access-key-id",
264
300
  secretAccessKey: "secret-access-key",
265
301
  },
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
302
  });
275
- expect(getSignedUrl).toHaveBeenCalledWith(
276
- expect.any(S3Client),
277
- expect.any(GetObjectCommand),
278
- { expiresIn: 3600 },
279
- );
303
+
304
+ expect(Reflect.has(storage, "getDownloadUrl")).toBe(false);
280
305
  });
281
306
 
282
- it("decodes percent-encoded keys before signing through the S3 API", async () => {
307
+ it("adds a signed download URL operation when configured for a server", async () => {
283
308
  mockS3Client();
284
309
  const storage = r2Storage({
285
310
  accountId: "account-id",
@@ -288,157 +313,34 @@ describe("r2Storage", () => {
288
313
  accessKeyId: "access-key-id",
289
314
  secretAccessKey: "secret-access-key",
290
315
  },
291
- })();
292
-
293
- await storage.profiles.runtime.getDownloadUrl(
294
- "r2://test-bucket/releases/logo%402x.png",
295
- );
296
-
297
- const command = vi.mocked(getSignedUrl).mock.calls.at(-1)?.[1];
298
- expect(command).toBeInstanceOf(GetObjectCommand);
299
- expect((command as GetObjectCommand).input.Key).toBe(
300
- "releases/logo@2x.png",
301
- );
302
- });
303
-
304
- it("falls back to wrangler without S3 credentials", async () => {
305
- wrangler.mockImplementation(async (...args: string[]) => {
306
- const fileIndex = args.indexOf("--file");
307
- const downloadPath = args[fileIndex + 1];
308
-
309
- await fs.writeFile(
310
- downloadPath,
311
- JSON.stringify({
312
- bundleId: "bundle-1",
313
- assets: {},
314
- }),
315
- );
316
-
317
- return {
318
- exitCode: 0,
319
- stderr: "",
320
- };
316
+ downloadUrlSigningKey: "test-signing-key",
321
317
  });
322
318
 
323
- const storage = r2Storage({
324
- accountId: "account-id",
325
- bucketName: "test-bucket",
326
- cloudflareApiToken: "api-token",
327
- })();
328
-
329
- const downloadPath = "/tmp/hot-updater-test-manifest.json";
330
- await fs.rm(downloadPath, { force: true });
331
-
332
- await storage.profiles.node.downloadFile(
333
- "r2://test-bucket/releases/bundle-1/manifest.json",
334
- downloadPath,
335
- );
336
-
337
- expect(JSON.parse(await fs.readFile(downloadPath, "utf8"))).toEqual({
338
- bundleId: "bundle-1",
339
- assets: {},
340
- });
341
- expect(wrangler).toHaveBeenCalledWith(
342
- "r2",
343
- "object",
344
- "get",
345
- "test-bucket/releases/bundle-1/manifest.json",
346
- "--file",
347
- downloadPath,
348
- "--remote",
349
- );
350
- });
351
-
352
- it("keeps the deprecated wrangler path node-only at runtime", async () => {
353
- const storage = r2Storage({
354
- accountId: "account-id",
355
- bucketName: "test-bucket",
356
- cloudflareApiToken: "api-token",
357
- })();
358
-
359
319
  await expect(
360
- storage.profiles.runtime.readText(
361
- "r2://test-bucket/releases/bundle-1/manifest.json",
362
- ),
363
- ).rejects.toThrow("r2Storage runtime profile requires R2 S3 credentials.");
320
+ storage.getDownloadUrl?.({
321
+ storageUri: "r2://test-bucket/releases/bundle.zip",
322
+ }),
323
+ ).resolves.toEqual({
324
+ url: expect.stringMatching(/^\/storage\//),
325
+ });
364
326
  });
365
327
 
366
328
  it("rejects downloads from a different bucket", async () => {
367
329
  const storage = r2Storage({
368
330
  accountId: "account-id",
369
331
  bucketName: "test-bucket",
370
- cloudflareApiToken: "api-token",
371
- })();
332
+ credentials: {
333
+ accessKeyId: "access-key-id",
334
+ secretAccessKey: "secret-access-key",
335
+ },
336
+ });
372
337
 
373
338
  await expect(
374
- storage.profiles.node.downloadFile(
375
- "r2://other-bucket/releases/bundle-1/manifest.json",
376
- "/tmp/hot-updater-test-manifest.json",
377
- ),
339
+ storage.get?.({
340
+ storageUri: "r2://other-bucket/releases/bundle-1/manifest.json",
341
+ }),
378
342
  ).rejects.toThrow(
379
343
  'Bucket name mismatch: expected "test-bucket", but found "other-bucket".',
380
344
  );
381
- expect(wrangler).not.toHaveBeenCalled();
382
- });
383
-
384
- it("returns false when the R2 object does not exist", async () => {
385
- wrangler.mockRejectedValueOnce(createExecaError("object not found"));
386
-
387
- const storage = r2Storage({
388
- accountId: "account-id",
389
- bucketName: "test-bucket",
390
- cloudflareApiToken: "api-token",
391
- })();
392
-
393
- await expect(
394
- storage.profiles.node.exists("r2://test-bucket/releases/logo.png"),
395
- ).resolves.toBe(false);
396
- expect(wrangler).toHaveBeenCalledWith(
397
- "r2",
398
- "object",
399
- "get",
400
- "test-bucket/releases/logo.png",
401
- "--file",
402
- expect.any(String),
403
- "--remote",
404
- );
405
- });
406
-
407
- it("decodes percent-encoded keys before using Wrangler", async () => {
408
- wrangler.mockResolvedValueOnce({ exitCode: 0, stderr: "" });
409
- const storage = r2Storage({
410
- accountId: "account-id",
411
- bucketName: "test-bucket",
412
- cloudflareApiToken: "api-token",
413
- })();
414
-
415
- await storage.profiles.node.exists(
416
- "r2://test-bucket/releases/logo%402x.png",
417
- );
418
-
419
- expect(wrangler).toHaveBeenCalledWith(
420
- "r2",
421
- "object",
422
- "get",
423
- "test-bucket/releases/logo@2x.png",
424
- "--file",
425
- expect.any(String),
426
- "--remote",
427
- );
428
- });
429
-
430
- it("rethrows non-missing R2 existence errors", async () => {
431
- const error = createExecaError("Authentication failed");
432
- wrangler.mockRejectedValueOnce(error);
433
-
434
- const storage = r2Storage({
435
- accountId: "account-id",
436
- bucketName: "test-bucket",
437
- cloudflareApiToken: "api-token",
438
- })();
439
-
440
- await expect(
441
- storage.profiles.node.exists("r2://test-bucket/releases/logo.png"),
442
- ).rejects.toBe(error);
443
345
  });
444
346
  });
package/src/r2Storage.ts CHANGED
@@ -1,66 +1,18 @@
1
- import {
2
- createUniversalStoragePlugin,
3
- type StoragePluginHooks,
4
- type UniversalStoragePlugin,
5
- } from "@hot-updater/plugin-core";
1
+ import type { StoragePluginWith } from "@hot-updater/plugin-core";
6
2
 
7
- import {
8
- createS3RuntimeStorageProfile,
9
- createS3StorageProfile,
10
- type R2S3StorageConfig,
11
- } from "./r2S3Storage";
12
- import {
13
- createWranglerRuntimeStorageProfile,
14
- createWranglerStorageProfile,
15
- type R2WranglerStorageConfig,
16
- } from "./r2WranglerStorage";
3
+ import { createR2S3Storage, type R2S3StorageConfig } from "./r2S3Storage";
17
4
 
18
- export type R2StorageConfig = R2S3StorageConfig | R2WranglerStorageConfig;
5
+ export type R2StorageConfig = R2S3StorageConfig;
6
+ export type { R2S3StorageConfig };
19
7
 
20
- export type { R2S3StorageConfig, R2WranglerStorageConfig };
8
+ export const r2Storage = (
9
+ config: R2S3StorageConfig,
10
+ ): StoragePluginWith<"put" | "get" | "exists" | "delete"> => {
11
+ if (!config.credentials) {
12
+ throw new Error(
13
+ "r2Storage requires S3-compatible credentials. The Wrangler fallback was removed.",
14
+ );
15
+ }
21
16
 
22
- const hasS3Credentials = (
23
- config: R2StorageConfig,
24
- ): config is R2S3StorageConfig => {
25
- return Boolean(config.credentials);
17
+ return createR2S3Storage(config);
26
18
  };
27
-
28
- /**
29
- * Cloudflare R2 storage plugin for Hot Updater.
30
- */
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>({
49
- name: "r2Storage",
50
- supportedProtocol: "r2",
51
- factory: (config) => {
52
- if (hasS3Credentials(config)) {
53
- return {
54
- node: createS3StorageProfile(config),
55
- runtime: createS3RuntimeStorageProfile(config),
56
- };
57
- }
58
-
59
- return {
60
- node: createWranglerStorageProfile(config),
61
- runtime: createWranglerRuntimeStorageProfile(),
62
- };
63
- },
64
- });
65
-
66
- export const r2Storage: R2Storage = createR2StoragePlugin;