@hot-updater/cloudflare 1.2.0-rc.0 → 1.2.0-rc.10

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.
@@ -106,7 +106,12 @@ const encode = ({ sql, params }) => ({
106
106
  * time, or a batch D1 runs in one transaction.
107
107
  */
108
108
  const d1RestRunner = (config) => {
109
- const cloudflare = new Cloudflare$1({ apiToken: config.cloudflareApiToken });
109
+ const { cloudflareApiToken } = config;
110
+ let client;
111
+ const connect = () => client ??= (async () => new Cloudflare$1({ apiToken: typeof cloudflareApiToken === "function" ? await cloudflareApiToken() : cloudflareApiToken }))().catch((error) => {
112
+ client = void 0;
113
+ throw error;
114
+ });
110
115
  const batch = async (statements) => {
111
116
  const [first] = statements;
112
117
  if (first === void 0) return [];
@@ -117,7 +122,7 @@ const d1RestRunner = (config) => {
117
122
  account_id: config.accountId,
118
123
  batch: statements.map(encode)
119
124
  };
120
- const page = await cloudflare.d1.database.query(config.databaseId, body);
125
+ const page = await (await connect()).d1.database.query(config.databaseId, body);
121
126
  const results = [];
122
127
  for await (const resultPage of page.iterPages()) for (const result of resultPage.result) results.push(toSqlResult(result));
123
128
  if (results.length !== statements.length) throw new D1ExecutionError();
@@ -11313,7 +11318,12 @@ const initProvider = {
11313
11318
  },
11314
11319
  validate: (value) => value === "true" || value === "false"
11315
11320
  }
11316
- }
11321
+ },
11322
+ secrets: [
11323
+ "HOT_UPDATER_CLOUDFLARE_API_TOKEN",
11324
+ "HOT_UPDATER_CLOUDFLARE_R2_ACCESS_KEY_ID",
11325
+ "HOT_UPDATER_CLOUDFLARE_R2_SECRET_ACCESS_KEY"
11326
+ ]
11317
11327
  };
11318
11328
  //#endregion
11319
11329
  //#region iac/cloudflareInitInputs.ts
@@ -11401,8 +11411,27 @@ const inputCloudflareInitSecrets = async ({ accountId, bucketName, apiToken, acc
11401
11411
  };
11402
11412
  //#endregion
11403
11413
  //#region iac/configTemplate.ts
11404
- const getConfigScaffold = (build) => {
11405
- return createHotUpdaterConfigScaffoldFromBuilder(new ConfigBuilder().setBuildType(build).setStorage({
11414
+ /**
11415
+ * The config init writes. With the `resources` init set up, it names them,
11416
+ * and reads the API token and R2 keys with `secret()`, from the
11417
+ * environment and `.env.hotupdater`, then the config's `secrets` adapter.
11418
+ * Without them, as the agent scaffold writes it before the agent creates the
11419
+ * resources, it reads their IDs from the environment, where the agent
11420
+ * records them, and the credentials with `secret()` alike.
11421
+ */
11422
+ const getConfigScaffold = (build, resources) => {
11423
+ const builder = new ConfigBuilder().setBuildType(build).setPlugins({
11424
+ imports: [{
11425
+ pkg: "hot-updater/plugins",
11426
+ named: [
11427
+ "apiKeys",
11428
+ "insights",
11429
+ "remoteConfig"
11430
+ ]
11431
+ }],
11432
+ configString: "[apiKeys(), insights(), remoteConfig()]"
11433
+ });
11434
+ if (resources === void 0) return createHotUpdaterConfigScaffoldFromBuilder(builder.setStorage({
11406
11435
  imports: [{
11407
11436
  pkg: "@hot-updater/cloudflare",
11408
11437
  named: ["r2Storage"]
@@ -11411,8 +11440,8 @@ const getConfigScaffold = (build) => {
11411
11440
  bucketName: process.env.HOT_UPDATER_CLOUDFLARE_R2_BUCKET_NAME!,
11412
11441
  accountId: process.env.HOT_UPDATER_CLOUDFLARE_ACCOUNT_ID!,
11413
11442
  credentials: {
11414
- accessKeyId: process.env.HOT_UPDATER_CLOUDFLARE_R2_ACCESS_KEY_ID!,
11415
- secretAccessKey: process.env.HOT_UPDATER_CLOUDFLARE_R2_SECRET_ACCESS_KEY!,
11443
+ accessKeyId: secret("HOT_UPDATER_CLOUDFLARE_R2_ACCESS_KEY_ID"),
11444
+ secretAccessKey: secret("HOT_UPDATER_CLOUDFLARE_R2_SECRET_ACCESS_KEY"),
11416
11445
  },
11417
11446
  })`
11418
11447
  }).setDatabase({
@@ -11423,18 +11452,41 @@ const getConfigScaffold = (build) => {
11423
11452
  configString: `d1Database({
11424
11453
  databaseId: process.env.HOT_UPDATER_CLOUDFLARE_D1_DATABASE_ID!,
11425
11454
  accountId: process.env.HOT_UPDATER_CLOUDFLARE_ACCOUNT_ID!,
11426
- cloudflareApiToken: process.env.HOT_UPDATER_CLOUDFLARE_API_TOKEN!,
11455
+ cloudflareApiToken: secret("HOT_UPDATER_CLOUDFLARE_API_TOKEN"),
11427
11456
  })`
11428
- }).setPlugins({
11457
+ }).addImport({
11458
+ pkg: "hot-updater",
11459
+ named: ["secret"]
11460
+ }));
11461
+ const accountId = JSON.stringify(resources.accountId);
11462
+ const storageConfig = {
11429
11463
  imports: [{
11430
- pkg: "hot-updater/plugins",
11431
- named: [
11432
- "apiKeys",
11433
- "insights",
11434
- "remoteConfig"
11435
- ]
11464
+ pkg: "@hot-updater/cloudflare",
11465
+ named: ["r2Storage"]
11436
11466
  }],
11437
- configString: "[apiKeys(), insights(), remoteConfig()]"
11467
+ configString: `r2Storage({
11468
+ bucketName: ${JSON.stringify(resources.bucketName)},
11469
+ accountId: ${accountId},
11470
+ credentials: {
11471
+ accessKeyId: secret("HOT_UPDATER_CLOUDFLARE_R2_ACCESS_KEY_ID"),
11472
+ secretAccessKey: secret("HOT_UPDATER_CLOUDFLARE_R2_SECRET_ACCESS_KEY"),
11473
+ },
11474
+ })`
11475
+ };
11476
+ const databaseConfig = {
11477
+ imports: [{
11478
+ pkg: "@hot-updater/cloudflare",
11479
+ named: ["d1Database"]
11480
+ }],
11481
+ configString: `d1Database({
11482
+ databaseId: ${JSON.stringify(resources.d1DatabaseId)},
11483
+ accountId: ${accountId},
11484
+ cloudflareApiToken: secret("HOT_UPDATER_CLOUDFLARE_API_TOKEN"),
11485
+ })`
11486
+ };
11487
+ return createHotUpdaterConfigScaffoldFromBuilder(builder.setStorage(storageConfig).setDatabase(databaseConfig).addImport({
11488
+ pkg: "hot-updater",
11489
+ named: ["secret"]
11438
11490
  }));
11439
11491
  };
11440
11492
  //#endregion
@@ -11507,10 +11559,10 @@ const deployWorker = async (apiToken, accountId, { credentialSource, d1DatabaseI
11507
11559
  await removeTmpDir();
11508
11560
  }
11509
11561
  };
11510
- const runInit = async ({ build, envFile }) => {
11562
+ const runInit = async ({ build, envFile, secretsEnv }) => {
11511
11563
  const cwd = getCwd();
11512
11564
  const nonInteractive = envFile !== void 0;
11513
- const initEnvSources = await readHotUpdaterInitEnv(cwd, envFile);
11565
+ const initEnvSources = await readHotUpdaterInitEnv(cwd, envFile, secretsEnv);
11514
11566
  const { managedEnv } = initEnvSources;
11515
11567
  const initInputEnv = getHotUpdaterInitInputEnv(initEnvSources, nonInteractive);
11516
11568
  const existingInputs = resolveCloudflareInitInputs(initInputEnv);
@@ -11860,7 +11912,11 @@ const runInit = async ({ build, envFile }) => {
11860
11912
  await database.dispose?.();
11861
11913
  }
11862
11914
  p.log.success("Generated '.env.hotupdater' file with Cloudflare settings.");
11863
- await writeHotUpdaterFiles(getConfigScaffold(build), {
11915
+ await writeHotUpdaterFiles(getConfigScaffold(build, {
11916
+ accountId,
11917
+ bucketName: selectedBucketName,
11918
+ d1DatabaseId: selectedD1DatabaseId
11919
+ }), {
11864
11920
  cwd,
11865
11921
  settings: "Cloudflare"
11866
11922
  });
@@ -121,8 +121,11 @@ const presignR2GetUrl = async ({ accountId, bucketName, credentials, expiresInSe
121
121
  //#region src/r2WorkerStorage.ts
122
122
  /** Devices download right after the update check that returns the URL. */
123
123
  const PRESIGNED_URL_EXPIRES_IN_SECONDS = 3600;
124
+ /** Keys the binding deletes in one call. */
125
+ const DELETE_BATCH_SIZE = 1e3;
124
126
  function r2WorkerStorage(config) {
125
127
  const getStorageKey = (0, _hot_updater_plugin_core.createStorageKeyBuilder)(config.basePath);
128
+ const basePrefix = getStorageKey() ? `${getStorageKey()}/` : "";
126
129
  const parseAndValidate = (storageUri) => {
127
130
  const parsed = (0, _hot_updater_plugin_core.parseStorageUri)(storageUri, "r2");
128
131
  if (parsed.bucket !== config.bucketName) throw new Error(`Bucket name mismatch: expected "${config.bucketName}", but found "${parsed.bucket}".`);
@@ -178,6 +181,42 @@ function r2WorkerStorage(config) {
178
181
  const { key } = parseAndValidate(storageUri);
179
182
  await config.bucket.delete(key);
180
183
  return { deleted: true };
184
+ },
185
+ async listObjects(prefix) {
186
+ const folder = getStorageKey(prefix ?? "");
187
+ const objects = [];
188
+ let cursor;
189
+ do {
190
+ const listed = await config.bucket.list({
191
+ prefix: folder ? `${folder}/` : "",
192
+ ...cursor === void 0 ? {} : { cursor }
193
+ });
194
+ for (const object of listed.objects) {
195
+ if (!object.key.startsWith(basePrefix)) continue;
196
+ let storageUri;
197
+ try {
198
+ storageUri = (0, _hot_updater_plugin_core.createStorageUri)({
199
+ protocol: "r2",
200
+ bucket: config.bucketName,
201
+ key: object.key
202
+ });
203
+ } catch {
204
+ continue;
205
+ }
206
+ objects.push({
207
+ key: object.key.slice(basePrefix.length),
208
+ lastModifiedAt: object.uploaded,
209
+ size: object.size,
210
+ storageUri
211
+ });
212
+ }
213
+ cursor = listed.truncated ? listed.cursor : void 0;
214
+ } while (cursor !== void 0);
215
+ return objects;
216
+ },
217
+ async deleteObjects(relativeKeys) {
218
+ const keys = relativeKeys.map((key) => getStorageKey(key));
219
+ for (let offset = 0; offset < keys.length; offset += DELETE_BATCH_SIZE) await config.bucket.delete(keys.slice(offset, offset + DELETE_BATCH_SIZE));
181
220
  }
182
221
  });
183
222
  }
@@ -48,6 +48,7 @@ type CloudflareWorkerStorageConfig = {
48
48
  readonly accountId?: undefined;
49
49
  readonly credentials?: undefined;
50
50
  });
51
+ type R2WorkerOperations = "put" | "get" | "exists" | "delete" | "listObjects" | "deleteObjects";
51
52
  /**
52
53
  * Storage on the Worker's R2 binding. With R2's S3-compatible credentials,
53
54
  * its download URLs are presigned and devices download from the bucket;
@@ -56,7 +57,7 @@ type CloudflareWorkerStorageConfig = {
56
57
  */
57
58
  declare function r2WorkerStorage(config: CloudflareWorkerStorageConfig & {
58
59
  readonly credentials: R2Credentials;
59
- }): StorageAdapterWith<"put" | "get" | "getDownloadUrl" | "exists" | "delete">;
60
- declare function r2WorkerStorage(config: CloudflareWorkerStorageConfig): StorageAdapterWith<"put" | "get" | "exists" | "delete">;
60
+ }): StorageAdapterWith<R2WorkerOperations | "getDownloadUrl">;
61
+ declare function r2WorkerStorage(config: CloudflareWorkerStorageConfig): StorageAdapterWith<R2WorkerOperations>;
61
62
  //#endregion
62
63
  export { type CloudflareWorkerStorageConfig, type D1Like, d1Database, r2WorkerStorage as r2Storage };
@@ -48,6 +48,7 @@ type CloudflareWorkerStorageConfig = {
48
48
  readonly accountId?: undefined;
49
49
  readonly credentials?: undefined;
50
50
  });
51
+ type R2WorkerOperations = "put" | "get" | "exists" | "delete" | "listObjects" | "deleteObjects";
51
52
  /**
52
53
  * Storage on the Worker's R2 binding. With R2's S3-compatible credentials,
53
54
  * its download URLs are presigned and devices download from the bucket;
@@ -56,7 +57,7 @@ type CloudflareWorkerStorageConfig = {
56
57
  */
57
58
  declare function r2WorkerStorage(config: CloudflareWorkerStorageConfig & {
58
59
  readonly credentials: R2Credentials;
59
- }): StorageAdapterWith<"put" | "get" | "getDownloadUrl" | "exists" | "delete">;
60
- declare function r2WorkerStorage(config: CloudflareWorkerStorageConfig): StorageAdapterWith<"put" | "get" | "exists" | "delete">;
60
+ }): StorageAdapterWith<R2WorkerOperations | "getDownloadUrl">;
61
+ declare function r2WorkerStorage(config: CloudflareWorkerStorageConfig): StorageAdapterWith<R2WorkerOperations>;
61
62
  //#endregion
62
63
  export { type CloudflareWorkerStorageConfig, type D1Like, d1Database, r2WorkerStorage as r2Storage };
@@ -120,8 +120,11 @@ const presignR2GetUrl = async ({ accountId, bucketName, credentials, expiresInSe
120
120
  //#region src/r2WorkerStorage.ts
121
121
  /** Devices download right after the update check that returns the URL. */
122
122
  const PRESIGNED_URL_EXPIRES_IN_SECONDS = 3600;
123
+ /** Keys the binding deletes in one call. */
124
+ const DELETE_BATCH_SIZE = 1e3;
123
125
  function r2WorkerStorage(config) {
124
126
  const getStorageKey = createStorageKeyBuilder(config.basePath);
127
+ const basePrefix = getStorageKey() ? `${getStorageKey()}/` : "";
125
128
  const parseAndValidate = (storageUri) => {
126
129
  const parsed = parseStorageUri(storageUri, "r2");
127
130
  if (parsed.bucket !== config.bucketName) throw new Error(`Bucket name mismatch: expected "${config.bucketName}", but found "${parsed.bucket}".`);
@@ -177,6 +180,42 @@ function r2WorkerStorage(config) {
177
180
  const { key } = parseAndValidate(storageUri);
178
181
  await config.bucket.delete(key);
179
182
  return { deleted: true };
183
+ },
184
+ async listObjects(prefix) {
185
+ const folder = getStorageKey(prefix ?? "");
186
+ const objects = [];
187
+ let cursor;
188
+ do {
189
+ const listed = await config.bucket.list({
190
+ prefix: folder ? `${folder}/` : "",
191
+ ...cursor === void 0 ? {} : { cursor }
192
+ });
193
+ for (const object of listed.objects) {
194
+ if (!object.key.startsWith(basePrefix)) continue;
195
+ let storageUri;
196
+ try {
197
+ storageUri = createStorageUri({
198
+ protocol: "r2",
199
+ bucket: config.bucketName,
200
+ key: object.key
201
+ });
202
+ } catch {
203
+ continue;
204
+ }
205
+ objects.push({
206
+ key: object.key.slice(basePrefix.length),
207
+ lastModifiedAt: object.uploaded,
208
+ size: object.size,
209
+ storageUri
210
+ });
211
+ }
212
+ cursor = listed.truncated ? listed.cursor : void 0;
213
+ } while (cursor !== void 0);
214
+ return objects;
215
+ },
216
+ async deleteObjects(relativeKeys) {
217
+ const keys = relativeKeys.map((key) => getStorageKey(key));
218
+ for (let offset = 0; offset < keys.length; offset += DELETE_BATCH_SIZE) await config.bucket.delete(keys.slice(offset, offset + DELETE_BATCH_SIZE));
180
219
  }
181
220
  });
182
221
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hot-updater/cloudflare",
3
3
  "type": "module",
4
- "version": "1.2.0-rc.0",
4
+ "version": "1.2.0-rc.10",
5
5
  "description": "React Native OTA solution for self-hosted",
6
6
  "main": "dist/index.cjs",
7
7
  "module": "dist/index.mjs",
@@ -49,18 +49,19 @@
49
49
  "package.json"
50
50
  ],
51
51
  "dependencies": {
52
- "@aws-sdk/client-s3": "3.1066.0",
53
- "@aws-sdk/lib-storage": "3.1008.0",
54
- "@aws-sdk/s3-request-presigner": "3.1066.0",
55
52
  "cloudflare": "4.2.0",
56
53
  "hono": "4.12.34",
57
54
  "uuidv7": "^1.0.2",
58
- "@hot-updater/protocol": "1.2.0-rc.0",
59
- "@hot-updater/plugin-core": "1.2.0-rc.0",
60
- "@hot-updater/server": "1.2.0-rc.0",
61
- "@hot-updater/cli-tools": "1.2.0-rc.0"
55
+ "@hot-updater/protocol": "1.2.0-rc.10",
56
+ "@hot-updater/cli-tools": "1.2.0-rc.10",
57
+ "@hot-updater/server": "1.2.0-rc.10",
58
+ "@hot-updater/storage-s3": "1.2.0-rc.10",
59
+ "@hot-updater/plugin-core": "1.2.0-rc.10"
62
60
  },
63
61
  "devDependencies": {
62
+ "@aws-sdk/client-s3": "3.1066.0",
63
+ "@aws-sdk/lib-storage": "3.1066.0",
64
+ "@aws-sdk/s3-request-presigner": "3.1066.0",
64
65
  "@cloudflare/vitest-pool-workers": "0.13.0",
65
66
  "@cloudflare/workers-types": "^4.20260312.1",
66
67
  "@types/node": "^20",
@@ -74,7 +75,7 @@
74
75
  "vitest": "4.1.4",
75
76
  "wrangler": "^4.69.0",
76
77
  "xdg-app-paths": "^8.3.0",
77
- "@hot-updater/test-utils": "1.2.0-rc.0"
78
+ "@hot-updater/test-utils": "1.2.0-rc.10"
78
79
  },
79
80
  "scripts": {
80
81
  "build": "tsdown && pnpm build:worker",
package/src/d1Rest.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { SqlResult, SqlStatement } from "@hot-updater/plugin-core";
1
+ import type { Lazy, SqlResult, SqlStatement } from "@hot-updater/plugin-core";
2
2
  import Cloudflare from "cloudflare";
3
3
 
4
4
  import { D1ExecutionError, type D1ResultLike, toSqlResult } from "./d1Executor";
@@ -6,7 +6,12 @@ import { D1ExecutionError, type D1ResultLike, toSqlResult } from "./d1Executor";
6
6
  export interface D1RestConfig {
7
7
  readonly databaseId: string;
8
8
  readonly accountId: string;
9
- readonly cloudflareApiToken: string;
9
+ /**
10
+ * An API token with D1 edit permission, or a function that returns it,
11
+ * such as `secret("<name>")` in `hot-updater.config.ts`: called on the
12
+ * first query, so a config loads without the token.
13
+ */
14
+ readonly cloudflareApiToken: Lazy<string>;
10
15
  }
11
16
 
12
17
  /** The REST API binds text: each value goes as JSON and is read back with `json_extract`. */
@@ -21,9 +26,19 @@ const encode = ({ sql, params }: SqlStatement) => ({
21
26
  * time, or a batch D1 runs in one transaction.
22
27
  */
23
28
  export const d1RestRunner = (config: D1RestConfig) => {
24
- const cloudflare = new Cloudflare({
25
- apiToken: config.cloudflareApiToken,
26
- });
29
+ const { cloudflareApiToken } = config;
30
+ let client: Promise<Cloudflare> | undefined;
31
+ const connect = () =>
32
+ (client ??= (async () =>
33
+ new Cloudflare({
34
+ apiToken:
35
+ typeof cloudflareApiToken === "function"
36
+ ? await cloudflareApiToken()
37
+ : cloudflareApiToken,
38
+ }))().catch((error: unknown) => {
39
+ client = undefined;
40
+ throw error;
41
+ }));
27
42
  const batch = async (
28
43
  statements: readonly SqlStatement[],
29
44
  ): Promise<SqlResult[]> => {
@@ -33,6 +48,7 @@ export const d1RestRunner = (config: D1RestConfig) => {
33
48
  statements.length === 1
34
49
  ? { account_id: config.accountId, ...encode(first) }
35
50
  : { account_id: config.accountId, batch: statements.map(encode) };
51
+ const cloudflare = await connect();
36
52
  // cloudflare@4 predates the D1 REST API's parameterized batch body type.
37
53
  const page = await cloudflare.d1.database.query(
38
54
  config.databaseId,
@@ -1,154 +1,31 @@
1
+ import type { StorageAdapterWith } from "@hot-updater/plugin-core";
1
2
  import {
2
- DeleteObjectCommand,
3
- GetObjectCommand,
4
- HeadObjectCommand,
5
- S3Client,
3
+ createS3Storage,
6
4
  type S3ClientConfig,
7
- } from "@aws-sdk/client-s3";
8
- import { Upload } from "@aws-sdk/lib-storage";
9
- import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
10
- import {
11
- createStorageKeyBuilder,
12
- createStorageAdapter,
13
- createStorageUri,
14
- parseStorageUri,
15
- type StorageAdapterWith,
16
- } from "@hot-updater/plugin-core";
5
+ type S3CredentialsOption,
6
+ type S3StorageOperations,
7
+ } from "@hot-updater/storage-s3";
17
8
 
18
- export interface R2S3StorageConfig extends S3ClientConfig {
9
+ export interface R2S3StorageConfig extends Omit<S3ClientConfig, "credentials"> {
19
10
  accountId: string;
20
11
  bucketName: string;
21
- credentials: NonNullable<S3ClientConfig["credentials"]>;
12
+ /** The SDK's credentials, or an R2 access key whose fields are lazy. */
13
+ credentials: NonNullable<S3CredentialsOption>;
22
14
  /** Base path where bundles will be stored in the bucket. */
23
15
  basePath?: string;
24
16
  }
25
17
 
26
- /** Devices download right after the update check that returns the URL. */
27
- const PRESIGNED_URL_EXPIRES_IN_SECONDS = 3600;
28
-
29
- const isObjectNotFoundError = (error: unknown) => {
30
- if (
31
- error instanceof Error &&
32
- (error.name === "NotFound" || error.name === "NoSuchKey")
33
- ) {
34
- return true;
35
- }
36
- return (
37
- typeof error === "object" &&
38
- error !== null &&
39
- "$metadata" in error &&
40
- (error as { $metadata?: { httpStatusCode?: number } }).$metadata
41
- ?.httpStatusCode === 404
42
- );
43
- };
44
-
45
18
  export const createR2S3Storage = (
46
19
  config: R2S3StorageConfig,
47
- ): StorageAdapterWith<
48
- "put" | "get" | "getDownloadUrl" | "exists" | "delete"
49
- > => {
50
- const {
51
- accountId,
52
- basePath,
53
- bucketName,
54
- endpoint,
55
- forcePathStyle,
56
- region,
57
- ...s3Config
58
- } = config;
59
- const client = new S3Client({
60
- ...s3Config,
61
- endpoint: endpoint ?? `https://${accountId}.r2.cloudflarestorage.com`,
62
- forcePathStyle: forcePathStyle ?? true,
63
- region: region ?? "auto",
64
- });
65
- const getStorageKey = createStorageKeyBuilder(basePath);
66
-
67
- const parseAndValidate = (storageUri: string) => {
68
- const parsed = parseStorageUri(storageUri, "r2");
69
- if (parsed.bucket !== bucketName) {
70
- throw new Error(
71
- `Bucket name mismatch: expected "${bucketName}", but found "${parsed.bucket}".`,
72
- );
73
- }
74
- return parsed;
75
- };
76
-
77
- return createStorageAdapter({
78
- name: "r2Storage",
79
- protocol: "r2",
80
- async put({ key, body, contentLength, contentType }) {
81
- const storageKey = getStorageKey(key);
82
- await new Upload({
83
- client,
84
- params: {
85
- Body: body,
86
- Bucket: bucketName,
87
- CacheControl: "max-age=31536000",
88
- ContentLength: contentLength,
89
- ContentType: contentType,
90
- Key: storageKey,
91
- },
92
- }).done();
93
- return {
94
- storageUri: createStorageUri({
95
- protocol: "r2",
96
- bucket: bucketName,
97
- key: storageKey,
98
- }),
99
- };
100
- },
101
- async get({ storageUri }) {
102
- const { key } = parseAndValidate(storageUri);
103
- try {
104
- const response = await client.send(
105
- new GetObjectCommand({ Bucket: bucketName, Key: key }),
106
- );
107
- if (!response.Body) return { response: null };
108
- const headers = new Headers();
109
- if (response.ContentType)
110
- headers.set("content-type", response.ContentType);
111
- if (response.ContentLength !== undefined) {
112
- headers.set("content-length", String(response.ContentLength));
113
- }
114
- return {
115
- response: new Response(response.Body.transformToWebStream(), {
116
- headers,
117
- }),
118
- };
119
- } catch (error) {
120
- if (isObjectNotFoundError(error)) return { response: null };
121
- throw error;
122
- }
123
- },
124
- async getDownloadUrl({ storageUri }) {
125
- const { key } = parseAndValidate(storageUri);
126
- return {
127
- url: await getSignedUrl(
128
- client,
129
- new GetObjectCommand({ Bucket: bucketName, Key: key }),
130
- { expiresIn: PRESIGNED_URL_EXPIRES_IN_SECONDS },
131
- ),
132
- };
20
+ ): StorageAdapterWith<S3StorageOperations> => {
21
+ const { accountId, endpoint, forcePathStyle, region, ...s3Config } = config;
22
+ return createS3Storage(
23
+ { name: "r2Storage", protocol: "r2" },
24
+ {
25
+ ...s3Config,
26
+ endpoint: endpoint ?? `https://${accountId}.r2.cloudflarestorage.com`,
27
+ forcePathStyle: forcePathStyle ?? true,
28
+ region: region ?? "auto",
133
29
  },
134
- async exists({ storageUri }) {
135
- const { key } = parseAndValidate(storageUri);
136
- try {
137
- await client.send(
138
- new HeadObjectCommand({ Bucket: bucketName, Key: key }),
139
- );
140
- return { exists: true };
141
- } catch (error) {
142
- if (isObjectNotFoundError(error)) return { exists: false };
143
- throw error;
144
- }
145
- },
146
- async delete({ storageUri }) {
147
- const { key } = parseAndValidate(storageUri);
148
- await client.send(
149
- new DeleteObjectCommand({ Bucket: bucketName, Key: key }),
150
- );
151
- return { deleted: true };
152
- },
153
- });
30
+ );
154
31
  };
package/src/r2Storage.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { StorageAdapterWith } from "@hot-updater/plugin-core";
2
+ import type { S3StorageOperations } from "@hot-updater/storage-s3";
2
3
 
3
4
  import { createR2S3Storage, type R2S3StorageConfig } from "./r2S3Storage";
4
5
 
@@ -7,9 +8,7 @@ export type { R2S3StorageConfig };
7
8
 
8
9
  export const r2Storage = (
9
10
  config: R2S3StorageConfig,
10
- ): StorageAdapterWith<
11
- "put" | "get" | "getDownloadUrl" | "exists" | "delete"
12
- > => {
11
+ ): StorageAdapterWith<S3StorageOperations> => {
13
12
  if (!config.credentials) {
14
13
  throw new Error(
15
14
  "r2Storage requires S3-compatible credentials: pass credentials with an R2 access key ID and secret access key.",
@@ -4,6 +4,7 @@ import {
4
4
  createStorageUri,
5
5
  parseStorageUri,
6
6
  type StorageAdapterWith,
7
+ type StorageObject,
7
8
  } from "@hot-updater/plugin-core";
8
9
 
9
10
  import { presignR2GetUrl, type R2Credentials } from "./presignR2Url";
@@ -28,6 +29,16 @@ export type CloudflareWorkerStorageConfig = {
28
29
 
29
30
  /** Devices download right after the update check that returns the URL. */
30
31
  const PRESIGNED_URL_EXPIRES_IN_SECONDS = 3600;
32
+ /** Keys the binding deletes in one call. */
33
+ const DELETE_BATCH_SIZE = 1000;
34
+
35
+ type R2WorkerOperations =
36
+ | "put"
37
+ | "get"
38
+ | "exists"
39
+ | "delete"
40
+ | "listObjects"
41
+ | "deleteObjects";
31
42
 
32
43
  /**
33
44
  * Storage on the Worker's R2 binding. With R2's S3-compatible credentials,
@@ -39,14 +50,15 @@ export function r2WorkerStorage(
39
50
  config: CloudflareWorkerStorageConfig & {
40
51
  readonly credentials: R2Credentials;
41
52
  },
42
- ): StorageAdapterWith<"put" | "get" | "getDownloadUrl" | "exists" | "delete">;
53
+ ): StorageAdapterWith<R2WorkerOperations | "getDownloadUrl">;
43
54
  export function r2WorkerStorage(
44
55
  config: CloudflareWorkerStorageConfig,
45
- ): StorageAdapterWith<"put" | "get" | "exists" | "delete">;
56
+ ): StorageAdapterWith<R2WorkerOperations>;
46
57
  export function r2WorkerStorage(
47
58
  config: CloudflareWorkerStorageConfig,
48
- ): StorageAdapterWith<"put" | "get" | "exists" | "delete"> {
59
+ ): StorageAdapterWith<R2WorkerOperations> {
49
60
  const getStorageKey = createStorageKeyBuilder(config.basePath);
61
+ const basePrefix = getStorageKey() ? `${getStorageKey()}/` : "";
50
62
 
51
63
  const parseAndValidate = (storageUri: string) => {
52
64
  const parsed = parseStorageUri(storageUri, "r2");
@@ -128,5 +140,46 @@ export function r2WorkerStorage(
128
140
  await config.bucket.delete(key);
129
141
  return { deleted: true };
130
142
  },
143
+ async listObjects(prefix) {
144
+ const folder = getStorageKey(prefix ?? "");
145
+ const objects: StorageObject[] = [];
146
+ let cursor: string | undefined;
147
+ do {
148
+ const listed = await config.bucket.list({
149
+ prefix: folder ? `${folder}/` : "",
150
+ ...(cursor === undefined ? {} : { cursor }),
151
+ });
152
+ for (const object of listed.objects) {
153
+ if (!object.key.startsWith(basePrefix)) continue;
154
+ // A key no `put` writes, such as a folder marker, has no URI.
155
+ let storageUri: string;
156
+ try {
157
+ storageUri = createStorageUri({
158
+ protocol: "r2",
159
+ bucket: config.bucketName,
160
+ key: object.key,
161
+ });
162
+ } catch {
163
+ continue;
164
+ }
165
+ objects.push({
166
+ key: object.key.slice(basePrefix.length),
167
+ lastModifiedAt: object.uploaded,
168
+ size: object.size,
169
+ storageUri,
170
+ });
171
+ }
172
+ cursor = listed.truncated ? listed.cursor : undefined;
173
+ } while (cursor !== undefined);
174
+ return objects;
175
+ },
176
+ async deleteObjects(relativeKeys) {
177
+ const keys = relativeKeys.map((key) => getStorageKey(key));
178
+ for (let offset = 0; offset < keys.length; offset += DELETE_BATCH_SIZE) {
179
+ await config.bucket.delete(
180
+ keys.slice(offset, offset + DELETE_BATCH_SIZE),
181
+ );
182
+ }
183
+ },
131
184
  });
132
185
  }
@@ -1 +1 @@
1
- This folder contains the built output assets for the worker "hot-updater" generated at 2026-10-27T15:32:58.641Z.
1
+ This folder contains the built output assets for the worker "hot-updater" generated at 2026-10-30T00:58:50.180Z.