@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.
- package/dist/index.cjs +13 -87
- package/dist/index.d.cts +12 -6
- package/dist/index.d.mts +12 -6
- package/dist/index.mjs +14 -88
- package/dist/init/index.cjs +75 -19
- package/dist/init/index.d.cts +2 -1
- package/dist/init/index.d.mts +2 -1
- package/dist/init/index.mjs +75 -19
- package/dist/worker/index.cjs +39 -0
- package/dist/worker/index.d.cts +3 -2
- package/dist/worker/index.d.mts +3 -2
- package/dist/worker/index.mjs +39 -0
- package/package.json +10 -9
- package/src/d1Rest.ts +21 -5
- package/src/r2S3Storage.ts +18 -141
- package/src/r2Storage.ts +2 -3
- package/src/r2WorkerStorage.ts +56 -3
- package/worker/dist/README.md +1 -1
- package/worker/dist/index.js +43 -1
- package/worker/dist/index.js.map +3 -3
package/dist/init/index.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
11405
|
-
|
|
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:
|
|
11415
|
-
secretAccessKey:
|
|
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:
|
|
11455
|
+
cloudflareApiToken: secret("HOT_UPDATER_CLOUDFLARE_API_TOKEN"),
|
|
11427
11456
|
})`
|
|
11428
|
-
}).
|
|
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/
|
|
11431
|
-
named: [
|
|
11432
|
-
"apiKeys",
|
|
11433
|
-
"insights",
|
|
11434
|
-
"remoteConfig"
|
|
11435
|
-
]
|
|
11464
|
+
pkg: "@hot-updater/cloudflare",
|
|
11465
|
+
named: ["r2Storage"]
|
|
11436
11466
|
}],
|
|
11437
|
-
configString:
|
|
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
|
});
|
package/dist/worker/index.cjs
CHANGED
|
@@ -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
|
}
|
package/dist/worker/index.d.cts
CHANGED
|
@@ -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<
|
|
60
|
-
declare function r2WorkerStorage(config: CloudflareWorkerStorageConfig): StorageAdapterWith<
|
|
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 };
|
package/dist/worker/index.d.mts
CHANGED
|
@@ -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<
|
|
60
|
-
declare function r2WorkerStorage(config: CloudflareWorkerStorageConfig): StorageAdapterWith<
|
|
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 };
|
package/dist/worker/index.mjs
CHANGED
|
@@ -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.
|
|
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.
|
|
59
|
-
"@hot-updater/
|
|
60
|
-
"@hot-updater/server": "1.2.0-rc.
|
|
61
|
-
"@hot-updater/
|
|
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.
|
|
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
|
-
|
|
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
|
|
25
|
-
|
|
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,
|
package/src/r2S3Storage.ts
CHANGED
|
@@ -1,154 +1,31 @@
|
|
|
1
|
+
import type { StorageAdapterWith } from "@hot-updater/plugin-core";
|
|
1
2
|
import {
|
|
2
|
-
|
|
3
|
-
GetObjectCommand,
|
|
4
|
-
HeadObjectCommand,
|
|
5
|
-
S3Client,
|
|
3
|
+
createS3Storage,
|
|
6
4
|
type S3ClientConfig,
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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.",
|
package/src/r2WorkerStorage.ts
CHANGED
|
@@ -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<
|
|
53
|
+
): StorageAdapterWith<R2WorkerOperations | "getDownloadUrl">;
|
|
43
54
|
export function r2WorkerStorage(
|
|
44
55
|
config: CloudflareWorkerStorageConfig,
|
|
45
|
-
): StorageAdapterWith<
|
|
56
|
+
): StorageAdapterWith<R2WorkerOperations>;
|
|
46
57
|
export function r2WorkerStorage(
|
|
47
58
|
config: CloudflareWorkerStorageConfig,
|
|
48
|
-
): StorageAdapterWith<
|
|
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
|
}
|
package/worker/dist/README.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
This folder contains the built output assets for the worker "hot-updater" generated at 2026-10-
|
|
1
|
+
This folder contains the built output assets for the worker "hot-updater" generated at 2026-10-30T00:58:50.180Z.
|