@hot-updater/cloudflare 0.30.12 → 0.31.1
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/iac/index.cjs +0 -1
- package/dist/iac/index.mjs +0 -1
- package/dist/index.cjs +164 -57
- package/dist/index.d.cts +1 -21
- package/dist/index.d.mts +1 -21
- package/dist/index.mjs +174 -67
- package/dist/worker/index.cjs +109 -25
- package/dist/worker/index.d.cts +6 -1
- package/dist/worker/index.d.mts +6 -1
- package/dist/worker/index.mjs +111 -27
- package/package.json +7 -7
- package/sql/bundles.sql +23 -3
- package/src/cloudflareWorkerDatabase.ts +198 -15
- package/src/d1Database.spec.ts +85 -3
- package/src/d1Database.ts +206 -19
- package/src/r2Storage.spec.ts +85 -0
- package/src/r2Storage.ts +33 -37
- package/src/r2WorkerStorage.spec.ts +50 -0
- package/src/r2WorkerStorage.ts +52 -20
- package/worker/dist/README.md +1 -1
- package/worker/dist/index.js +3038 -247
- package/worker/dist/index.js.map +4 -4
- package/worker/migrations/0005_hot-updater_0.31.0.sql +24 -0
- package/worker/src/index.integration.spec.ts +209 -18
package/src/d1Database.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
DEFAULT_ROLLOUT_COHORT_COUNT,
|
|
3
|
-
|
|
3
|
+
getAssetBaseStorageUri,
|
|
4
|
+
getBundlePatches,
|
|
5
|
+
getManifestFileHash,
|
|
6
|
+
getManifestStorageUri,
|
|
7
|
+
stripBundleArtifactMetadata,
|
|
4
8
|
} from "@hot-updater/core";
|
|
5
9
|
import type {
|
|
6
10
|
Bundle,
|
|
@@ -30,6 +34,36 @@ interface BuildQueryResult {
|
|
|
30
34
|
params: any[];
|
|
31
35
|
}
|
|
32
36
|
|
|
37
|
+
interface D1BundleRow {
|
|
38
|
+
id: string;
|
|
39
|
+
channel: string;
|
|
40
|
+
enabled: number | boolean;
|
|
41
|
+
should_force_update: number | boolean;
|
|
42
|
+
file_hash: string;
|
|
43
|
+
git_commit_hash: string | null;
|
|
44
|
+
message: string | null;
|
|
45
|
+
platform: "ios" | "android";
|
|
46
|
+
target_app_version: string | null;
|
|
47
|
+
storage_uri: string;
|
|
48
|
+
fingerprint_hash: string | null;
|
|
49
|
+
metadata: unknown;
|
|
50
|
+
manifest_storage_uri?: string | null;
|
|
51
|
+
manifest_file_hash?: string | null;
|
|
52
|
+
asset_base_storage_uri?: string | null;
|
|
53
|
+
rollout_cohort_count: number | null;
|
|
54
|
+
target_cohorts: string | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface D1BundlePatchRow {
|
|
58
|
+
id: string;
|
|
59
|
+
bundle_id: string;
|
|
60
|
+
base_bundle_id: string;
|
|
61
|
+
base_file_hash: string;
|
|
62
|
+
patch_file_hash: string;
|
|
63
|
+
patch_storage_uri: string;
|
|
64
|
+
order_index: number | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
33
67
|
async function resolvePage<T>(singlePage: any): Promise<T[]> {
|
|
34
68
|
const results: T[] = [];
|
|
35
69
|
for await (const page of singlePage.iterPages()) {
|
|
@@ -152,8 +186,54 @@ function parseTargetCohorts(value: unknown): string[] | null {
|
|
|
152
186
|
return null;
|
|
153
187
|
}
|
|
154
188
|
|
|
155
|
-
|
|
156
|
-
|
|
189
|
+
const parseMetadata = (value: unknown): Bundle["metadata"] => {
|
|
190
|
+
if (!value) return undefined;
|
|
191
|
+
if (typeof value === "string") {
|
|
192
|
+
try {
|
|
193
|
+
return parseMetadata(JSON.parse(value) as unknown);
|
|
194
|
+
} catch {
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return typeof value === "object" && !Array.isArray(value)
|
|
199
|
+
? (value as Bundle["metadata"])
|
|
200
|
+
: undefined;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const buildBundlePatchId = (bundleId: string, baseBundleId: string) =>
|
|
204
|
+
`${bundleId}:${baseBundleId}`;
|
|
205
|
+
|
|
206
|
+
const bundleToPatchRows = (bundle: Bundle): D1BundlePatchRow[] =>
|
|
207
|
+
getBundlePatches(bundle).map((patch, index) => ({
|
|
208
|
+
id: buildBundlePatchId(bundle.id, patch.baseBundleId),
|
|
209
|
+
bundle_id: bundle.id,
|
|
210
|
+
base_bundle_id: patch.baseBundleId,
|
|
211
|
+
base_file_hash: patch.baseFileHash,
|
|
212
|
+
patch_file_hash: patch.patchFileHash,
|
|
213
|
+
patch_storage_uri: patch.patchStorageUri,
|
|
214
|
+
order_index: index,
|
|
215
|
+
}));
|
|
216
|
+
|
|
217
|
+
function transformRowToBundle(
|
|
218
|
+
row: D1BundleRow,
|
|
219
|
+
patchRows: D1BundlePatchRow[] = [],
|
|
220
|
+
): Bundle {
|
|
221
|
+
const rawMetadata = parseMetadata(row.metadata);
|
|
222
|
+
const patches = patchRows
|
|
223
|
+
.slice()
|
|
224
|
+
.sort(
|
|
225
|
+
(left, right) =>
|
|
226
|
+
(left.order_index ?? 0) - (right.order_index ?? 0) ||
|
|
227
|
+
left.base_bundle_id.localeCompare(right.base_bundle_id),
|
|
228
|
+
)
|
|
229
|
+
.map((patch) => ({
|
|
230
|
+
baseBundleId: patch.base_bundle_id,
|
|
231
|
+
baseFileHash: patch.base_file_hash,
|
|
232
|
+
patchFileHash: patch.patch_file_hash,
|
|
233
|
+
patchStorageUri: patch.patch_storage_uri,
|
|
234
|
+
}));
|
|
235
|
+
const primaryPatch = patches[0] ?? null;
|
|
236
|
+
|
|
157
237
|
return {
|
|
158
238
|
id: row.id,
|
|
159
239
|
channel: row.channel,
|
|
@@ -166,7 +246,15 @@ function transformRowToBundle(row: SnakeCaseBundle): Bundle {
|
|
|
166
246
|
targetAppVersion: row.target_app_version,
|
|
167
247
|
storageUri: row.storage_uri,
|
|
168
248
|
fingerprintHash: row.fingerprint_hash,
|
|
169
|
-
metadata:
|
|
249
|
+
metadata: stripBundleArtifactMetadata(rawMetadata),
|
|
250
|
+
manifestStorageUri: row.manifest_storage_uri ?? null,
|
|
251
|
+
manifestFileHash: row.manifest_file_hash ?? null,
|
|
252
|
+
assetBaseStorageUri: row.asset_base_storage_uri ?? null,
|
|
253
|
+
patches,
|
|
254
|
+
patchBaseBundleId: primaryPatch?.baseBundleId ?? null,
|
|
255
|
+
patchBaseFileHash: primaryPatch?.baseFileHash ?? null,
|
|
256
|
+
patchFileHash: primaryPatch?.patchFileHash ?? null,
|
|
257
|
+
patchStorageUri: primaryPatch?.patchStorageUri ?? null,
|
|
170
258
|
rolloutCohortCount:
|
|
171
259
|
(row.rollout_cohort_count as number | null) ??
|
|
172
260
|
DEFAULT_ROLLOUT_COHORT_COUNT,
|
|
@@ -180,6 +268,36 @@ export const d1Database = createDatabasePlugin<D1DatabaseConfig>({
|
|
|
180
268
|
const cf = new Cloudflare({
|
|
181
269
|
apiToken: config.cloudflareApiToken,
|
|
182
270
|
});
|
|
271
|
+
const getPatchMap = async (bundleIds: string[]) => {
|
|
272
|
+
const patchMap = new Map<string, D1BundlePatchRow[]>();
|
|
273
|
+
|
|
274
|
+
if (bundleIds.length === 0) {
|
|
275
|
+
return patchMap;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const placeholders = bundleIds.map(() => "?").join(", ");
|
|
279
|
+
const sql = minify(`
|
|
280
|
+
SELECT *
|
|
281
|
+
FROM bundle_patches
|
|
282
|
+
WHERE bundle_id IN (${placeholders})
|
|
283
|
+
ORDER BY order_index ASC, base_bundle_id ASC
|
|
284
|
+
`);
|
|
285
|
+
|
|
286
|
+
const result = await cf.d1.database.query(config.databaseId, {
|
|
287
|
+
account_id: config.accountId,
|
|
288
|
+
sql,
|
|
289
|
+
params: bundleIds,
|
|
290
|
+
});
|
|
291
|
+
const rows = await resolvePage<D1BundlePatchRow>(result);
|
|
292
|
+
|
|
293
|
+
for (const row of rows) {
|
|
294
|
+
const current = patchMap.get(row.bundle_id) ?? [];
|
|
295
|
+
current.push(row);
|
|
296
|
+
patchMap.set(row.bundle_id, current);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return patchMap;
|
|
300
|
+
};
|
|
183
301
|
|
|
184
302
|
// Helper function to get total count
|
|
185
303
|
async function getTotalCount(conditions: QueryConditions): Promise<number> {
|
|
@@ -227,8 +345,9 @@ export const d1Database = createDatabasePlugin<D1DatabaseConfig>({
|
|
|
227
345
|
params,
|
|
228
346
|
});
|
|
229
347
|
|
|
230
|
-
const rows = await resolvePage<
|
|
231
|
-
|
|
348
|
+
const rows = await resolvePage<D1BundleRow>(result);
|
|
349
|
+
const patchMap = await getPatchMap(rows.map((row) => row.id));
|
|
350
|
+
return rows.map((row) => transformRowToBundle(row, patchMap.get(row.id)));
|
|
232
351
|
}
|
|
233
352
|
|
|
234
353
|
async function queryBundlesForUpdateInfo(
|
|
@@ -246,8 +365,9 @@ export const d1Database = createDatabasePlugin<D1DatabaseConfig>({
|
|
|
246
365
|
params,
|
|
247
366
|
});
|
|
248
367
|
|
|
249
|
-
const rows = await resolvePage<
|
|
250
|
-
|
|
368
|
+
const rows = await resolvePage<D1BundleRow>(result);
|
|
369
|
+
const patchMap = await getPatchMap(rows.map((row) => row.id));
|
|
370
|
+
return rows.map((row) => transformRowToBundle(row, patchMap.get(row.id)));
|
|
251
371
|
}
|
|
252
372
|
|
|
253
373
|
async function getTargetAppVersionsForUpdateInfo({
|
|
@@ -318,19 +438,22 @@ export const d1Database = createDatabasePlugin<D1DatabaseConfig>({
|
|
|
318
438
|
async getBundleById(bundleId) {
|
|
319
439
|
const sql = minify(/* sql */ `
|
|
320
440
|
SELECT * FROM bundles WHERE id = ? LIMIT 1`);
|
|
321
|
-
const singlePage = await
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
441
|
+
const [singlePage, patchMap] = await Promise.all([
|
|
442
|
+
cf.d1.database.query(config.databaseId, {
|
|
443
|
+
account_id: config.accountId,
|
|
444
|
+
sql,
|
|
445
|
+
params: [bundleId],
|
|
446
|
+
}),
|
|
447
|
+
getPatchMap([bundleId]),
|
|
448
|
+
]);
|
|
326
449
|
|
|
327
|
-
const rows = await resolvePage<
|
|
450
|
+
const rows = await resolvePage<D1BundleRow>(singlePage);
|
|
328
451
|
|
|
329
452
|
if (rows.length === 0) {
|
|
330
453
|
return null;
|
|
331
454
|
}
|
|
332
455
|
|
|
333
|
-
return transformRowToBundle(rows[0]);
|
|
456
|
+
return transformRowToBundle(rows[0], patchMap.get(bundleId));
|
|
334
457
|
},
|
|
335
458
|
|
|
336
459
|
async getBundles(options) {
|
|
@@ -388,6 +511,24 @@ export const d1Database = createDatabasePlugin<D1DatabaseConfig>({
|
|
|
388
511
|
DELETE FROM bundles WHERE id = ?
|
|
389
512
|
`);
|
|
390
513
|
|
|
514
|
+
const deletePatchSql = minify(/* sql */ `
|
|
515
|
+
DELETE FROM bundle_patches WHERE bundle_id = ?
|
|
516
|
+
`);
|
|
517
|
+
await cf.d1.database.query(config.databaseId, {
|
|
518
|
+
account_id: config.accountId,
|
|
519
|
+
sql: deletePatchSql,
|
|
520
|
+
params: [op.data.id],
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
const deleteBasePatchSql = minify(/* sql */ `
|
|
524
|
+
DELETE FROM bundle_patches WHERE base_bundle_id = ?
|
|
525
|
+
`);
|
|
526
|
+
await cf.d1.database.query(config.databaseId, {
|
|
527
|
+
account_id: config.accountId,
|
|
528
|
+
sql: deleteBasePatchSql,
|
|
529
|
+
params: [op.data.id],
|
|
530
|
+
});
|
|
531
|
+
|
|
391
532
|
await cf.d1.database.query(config.databaseId, {
|
|
392
533
|
account_id: config.accountId,
|
|
393
534
|
sql: deleteSql,
|
|
@@ -410,10 +551,13 @@ export const d1Database = createDatabasePlugin<D1DatabaseConfig>({
|
|
|
410
551
|
storage_uri,
|
|
411
552
|
fingerprint_hash,
|
|
412
553
|
metadata,
|
|
554
|
+
manifest_storage_uri,
|
|
555
|
+
manifest_file_hash,
|
|
556
|
+
asset_base_storage_uri,
|
|
413
557
|
rollout_cohort_count,
|
|
414
558
|
target_cohorts
|
|
415
559
|
)
|
|
416
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
560
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
417
561
|
`);
|
|
418
562
|
|
|
419
563
|
const params = [
|
|
@@ -428,9 +572,12 @@ export const d1Database = createDatabasePlugin<D1DatabaseConfig>({
|
|
|
428
572
|
bundle.targetAppVersion,
|
|
429
573
|
bundle.storageUri,
|
|
430
574
|
bundle.fingerprintHash,
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
575
|
+
JSON.stringify(
|
|
576
|
+
stripBundleArtifactMetadata(bundle.metadata) ?? {},
|
|
577
|
+
),
|
|
578
|
+
getManifestStorageUri(bundle),
|
|
579
|
+
getManifestFileHash(bundle),
|
|
580
|
+
getAssetBaseStorageUri(bundle),
|
|
434
581
|
bundle.rolloutCohortCount ?? DEFAULT_ROLLOUT_COHORT_COUNT,
|
|
435
582
|
bundle.targetCohorts
|
|
436
583
|
? JSON.stringify(bundle.targetCohorts)
|
|
@@ -442,6 +589,46 @@ export const d1Database = createDatabasePlugin<D1DatabaseConfig>({
|
|
|
442
589
|
sql: upsertSql,
|
|
443
590
|
params: params as string[],
|
|
444
591
|
});
|
|
592
|
+
|
|
593
|
+
await cf.d1.database.query(config.databaseId, {
|
|
594
|
+
account_id: config.accountId,
|
|
595
|
+
sql: minify(`
|
|
596
|
+
DELETE FROM bundle_patches WHERE bundle_id = ?
|
|
597
|
+
`),
|
|
598
|
+
params: [bundle.id],
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
const patchRows = bundleToPatchRows(bundle);
|
|
602
|
+
if (patchRows.length > 0) {
|
|
603
|
+
const patchInsertSql = minify(`
|
|
604
|
+
INSERT OR REPLACE INTO bundle_patches (
|
|
605
|
+
id,
|
|
606
|
+
bundle_id,
|
|
607
|
+
base_bundle_id,
|
|
608
|
+
base_file_hash,
|
|
609
|
+
patch_file_hash,
|
|
610
|
+
patch_storage_uri,
|
|
611
|
+
order_index
|
|
612
|
+
)
|
|
613
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
614
|
+
`);
|
|
615
|
+
|
|
616
|
+
for (const patchRow of patchRows) {
|
|
617
|
+
await cf.d1.database.query(config.databaseId, {
|
|
618
|
+
account_id: config.accountId,
|
|
619
|
+
sql: patchInsertSql,
|
|
620
|
+
params: [
|
|
621
|
+
patchRow.id,
|
|
622
|
+
patchRow.bundle_id,
|
|
623
|
+
patchRow.base_bundle_id,
|
|
624
|
+
patchRow.base_file_hash,
|
|
625
|
+
patchRow.patch_file_hash,
|
|
626
|
+
patchRow.patch_storage_uri,
|
|
627
|
+
String(patchRow.order_index ?? 0),
|
|
628
|
+
],
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
}
|
|
445
632
|
}
|
|
446
633
|
}
|
|
447
634
|
},
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { r2Storage } from "./r2Storage";
|
|
6
|
+
|
|
7
|
+
const { wrangler } = vi.hoisted(() => ({
|
|
8
|
+
wrangler: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock("./utils/createWrangler", () => ({
|
|
12
|
+
createWrangler: vi.fn(() => wrangler),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
describe("r2Storage", () => {
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
wrangler.mockReset();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("downloads R2 objects with wrangler to the given file path", async () => {
|
|
21
|
+
wrangler.mockImplementation(async (...args: string[]) => {
|
|
22
|
+
const fileIndex = args.indexOf("--file");
|
|
23
|
+
const downloadPath = args[fileIndex + 1];
|
|
24
|
+
|
|
25
|
+
await fs.writeFile(
|
|
26
|
+
downloadPath,
|
|
27
|
+
JSON.stringify({
|
|
28
|
+
bundleId: "bundle-1",
|
|
29
|
+
assets: {},
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
exitCode: 0,
|
|
35
|
+
stderr: "",
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const storage = r2Storage({
|
|
40
|
+
accountId: "account-id",
|
|
41
|
+
bucketName: "test-bucket",
|
|
42
|
+
cloudflareApiToken: "api-token",
|
|
43
|
+
})();
|
|
44
|
+
|
|
45
|
+
const downloadPath = "/tmp/hot-updater-test-manifest.json";
|
|
46
|
+
await fs.rm(downloadPath, { force: true });
|
|
47
|
+
|
|
48
|
+
await storage.profiles.node.downloadFile(
|
|
49
|
+
"r2://test-bucket/releases/bundle-1/manifest.json",
|
|
50
|
+
downloadPath,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
expect(JSON.parse(await fs.readFile(downloadPath, "utf8"))).toEqual({
|
|
54
|
+
bundleId: "bundle-1",
|
|
55
|
+
assets: {},
|
|
56
|
+
});
|
|
57
|
+
expect(wrangler).toHaveBeenCalledWith(
|
|
58
|
+
"r2",
|
|
59
|
+
"object",
|
|
60
|
+
"get",
|
|
61
|
+
"test-bucket/releases/bundle-1/manifest.json",
|
|
62
|
+
"--file",
|
|
63
|
+
downloadPath,
|
|
64
|
+
"--remote",
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("rejects downloads from a different bucket", async () => {
|
|
69
|
+
const storage = r2Storage({
|
|
70
|
+
accountId: "account-id",
|
|
71
|
+
bucketName: "test-bucket",
|
|
72
|
+
cloudflareApiToken: "api-token",
|
|
73
|
+
})();
|
|
74
|
+
|
|
75
|
+
await expect(
|
|
76
|
+
storage.profiles.node.downloadFile(
|
|
77
|
+
"r2://other-bucket/releases/bundle-1/manifest.json",
|
|
78
|
+
"/tmp/hot-updater-test-manifest.json",
|
|
79
|
+
),
|
|
80
|
+
).rejects.toThrow(
|
|
81
|
+
'Bucket name mismatch: expected "test-bucket", but found "other-bucket".',
|
|
82
|
+
);
|
|
83
|
+
expect(wrangler).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
});
|
package/src/r2Storage.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
2
3
|
|
|
3
4
|
import {
|
|
4
5
|
createStorageKeyBuilder,
|
|
5
|
-
|
|
6
|
+
createNodeStoragePlugin,
|
|
6
7
|
getContentType,
|
|
7
8
|
parseStorageUri,
|
|
8
9
|
} from "@hot-updater/plugin-core";
|
|
@@ -22,28 +23,8 @@ export interface R2StorageConfig {
|
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* Cloudflare R2 storage plugin for Hot Updater.
|
|
25
|
-
*
|
|
26
|
-
* Note: This plugin does not support `getDownloadUrl()`.
|
|
27
|
-
* If you need download URL generation, use `s3Storage` from `@hot-updater/aws` instead,
|
|
28
|
-
* which is fully compatible with Cloudflare R2.
|
|
29
|
-
*
|
|
30
|
-
* @example
|
|
31
|
-
* ```typescript
|
|
32
|
-
* // Using s3Storage with Cloudflare R2 for download URL support
|
|
33
|
-
* import { s3Storage } from "@hot-updater/aws";
|
|
34
|
-
*
|
|
35
|
-
* s3Storage({
|
|
36
|
-
* region: "auto",
|
|
37
|
-
* endpoint: "https://YOUR_ACCOUNT_ID.r2.cloudflarestorage.com",
|
|
38
|
-
* credentials: {
|
|
39
|
-
* accessKeyId: "YOUR_ACCESS_KEY_ID",
|
|
40
|
-
* secretAccessKey: "YOUR_SECRET_ACCESS_KEY",
|
|
41
|
-
* },
|
|
42
|
-
* bucketName: "YOUR_BUCKET_NAME",
|
|
43
|
-
* })
|
|
44
|
-
* ```
|
|
45
26
|
*/
|
|
46
|
-
export const r2Storage =
|
|
27
|
+
export const r2Storage = createNodeStoragePlugin<R2StorageConfig>({
|
|
47
28
|
name: "r2Storage",
|
|
48
29
|
supportedProtocol: "r2",
|
|
49
30
|
factory: (config) => {
|
|
@@ -110,20 +91,35 @@ export const r2Storage = createStoragePlugin<R2StorageConfig>({
|
|
|
110
91
|
storageUri: `r2://${bucketName}/${Key}`,
|
|
111
92
|
};
|
|
112
93
|
},
|
|
113
|
-
async
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
"
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
"
|
|
125
|
-
"
|
|
126
|
-
|
|
94
|
+
async downloadFile(storageUri, filePath) {
|
|
95
|
+
const { bucket, key } = parseStorageUri(storageUri, "r2");
|
|
96
|
+
if (bucket !== bucketName) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Bucket name mismatch: expected "${bucketName}", but found "${bucket}".`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
104
|
+
const { stderr, exitCode } = await wrangler(
|
|
105
|
+
"r2",
|
|
106
|
+
"object",
|
|
107
|
+
"get",
|
|
108
|
+
[bucketName, key].join("/"),
|
|
109
|
+
"--file",
|
|
110
|
+
filePath,
|
|
111
|
+
"--remote",
|
|
112
|
+
);
|
|
113
|
+
if (exitCode !== 0 && stderr) {
|
|
114
|
+
throw new Error(stderr);
|
|
115
|
+
}
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (error instanceof ExecaError) {
|
|
118
|
+
throw new Error(error.stderr || error.stdout);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
127
123
|
},
|
|
128
124
|
};
|
|
129
125
|
},
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { RequestEnvContext } from "@hot-updater/plugin-core";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
type CloudflareWorkerStorageEnv,
|
|
6
|
+
r2WorkerStorage,
|
|
7
|
+
} from "./r2WorkerStorage";
|
|
8
|
+
|
|
9
|
+
type TestContext = RequestEnvContext<CloudflareWorkerStorageEnv>;
|
|
10
|
+
|
|
11
|
+
describe("r2WorkerStorage", () => {
|
|
12
|
+
it("reads manifest text directly from the R2 binding", async () => {
|
|
13
|
+
const get = vi.fn(async (key: string) => ({
|
|
14
|
+
text: async () => `text:${key}`,
|
|
15
|
+
}));
|
|
16
|
+
const storage = r2WorkerStorage({
|
|
17
|
+
jwtSecret: "secret",
|
|
18
|
+
publicBaseUrl: "https://assets.example.com",
|
|
19
|
+
})();
|
|
20
|
+
|
|
21
|
+
await expect(
|
|
22
|
+
storage.profiles.runtime.readText("r2://bundles/app/manifest.json", {
|
|
23
|
+
env: {
|
|
24
|
+
BUCKET: { get },
|
|
25
|
+
JWT_SECRET: "secret",
|
|
26
|
+
},
|
|
27
|
+
request: new Request("https://updates.example.com"),
|
|
28
|
+
} satisfies TestContext),
|
|
29
|
+
).resolves.toBe("text:app/manifest.json");
|
|
30
|
+
expect(get).toHaveBeenCalledWith("app/manifest.json");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("fails fast when the R2 binding is missing", async () => {
|
|
34
|
+
const storage = r2WorkerStorage({
|
|
35
|
+
jwtSecret: "secret",
|
|
36
|
+
publicBaseUrl: "https://assets.example.com",
|
|
37
|
+
})();
|
|
38
|
+
|
|
39
|
+
await expect(
|
|
40
|
+
storage.profiles.runtime.readText("r2://bundles/app/manifest.json", {
|
|
41
|
+
env: {
|
|
42
|
+
JWT_SECRET: "secret",
|
|
43
|
+
},
|
|
44
|
+
request: new Request("https://updates.example.com"),
|
|
45
|
+
} as TestContext),
|
|
46
|
+
).rejects.toThrow(
|
|
47
|
+
"r2WorkerStorage requires env.BUCKET in the hot updater context.",
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
});
|
package/src/r2WorkerStorage.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { signToken } from "@hot-updater/js";
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
import {
|
|
3
|
+
createRuntimeStoragePlugin,
|
|
4
|
+
type HotUpdaterContext,
|
|
5
|
+
type RequestEnvContext,
|
|
6
6
|
} from "@hot-updater/plugin-core";
|
|
7
7
|
|
|
8
8
|
export interface CloudflareWorkerStorageEnv {
|
|
9
9
|
JWT_SECRET: string;
|
|
10
|
+
BUCKET: {
|
|
11
|
+
get: (key: string) => Promise<{ text: () => Promise<string> } | null>;
|
|
12
|
+
};
|
|
10
13
|
}
|
|
11
14
|
|
|
12
15
|
type ContextResolver<TContext, TValue> = (
|
|
@@ -43,25 +46,54 @@ const resolveJwtSecretFromContext = (
|
|
|
43
46
|
return jwtSecret;
|
|
44
47
|
};
|
|
45
48
|
|
|
49
|
+
const resolveR2BucketFromContext = (
|
|
50
|
+
context?: RequestEnvContext<CloudflareWorkerStorageEnv>,
|
|
51
|
+
) => {
|
|
52
|
+
const bucket = context?.env?.BUCKET;
|
|
53
|
+
|
|
54
|
+
if (!bucket) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
"r2WorkerStorage requires env.BUCKET in the hot updater context.",
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return bucket;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const createPublicObjectPath = (storageUrl: URL) =>
|
|
64
|
+
`${storageUrl.host}${storageUrl.pathname}`;
|
|
65
|
+
|
|
66
|
+
const createR2ObjectKey = (storageUrl: URL) =>
|
|
67
|
+
storageUrl.pathname.replace(/^\/+/, "");
|
|
68
|
+
|
|
46
69
|
export const r2WorkerStorage = <
|
|
47
70
|
TContext extends RequestEnvContext<CloudflareWorkerStorageEnv> =
|
|
48
71
|
RequestEnvContext<CloudflareWorkerStorageEnv>,
|
|
49
72
|
>(
|
|
50
73
|
config: CloudflareWorkerStorageConfig<TContext>,
|
|
51
74
|
) => {
|
|
52
|
-
return
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
75
|
+
return createRuntimeStoragePlugin<
|
|
76
|
+
CloudflareWorkerStorageConfig<TContext>,
|
|
77
|
+
TContext
|
|
78
|
+
>({
|
|
79
|
+
name: "r2WorkerStorage",
|
|
80
|
+
supportedProtocol: "r2",
|
|
81
|
+
factory: (config) => ({
|
|
82
|
+
async readText(storageUri, context) {
|
|
83
|
+
const storageUrl = new URL(storageUri);
|
|
84
|
+
|
|
85
|
+
if (storageUrl.protocol !== "r2:") {
|
|
86
|
+
throw new Error("Invalid R2 storage URI protocol");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const bucket = resolveR2BucketFromContext(context);
|
|
90
|
+
const key = createR2ObjectKey(storageUrl);
|
|
91
|
+
const object = await bucket.get(key);
|
|
92
|
+
if (!object) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return object.text();
|
|
65
97
|
},
|
|
66
98
|
async getDownloadUrl(storageUri, context) {
|
|
67
99
|
const storageUrl = new URL(storageUri);
|
|
@@ -70,7 +102,7 @@ export const r2WorkerStorage = <
|
|
|
70
102
|
throw new Error("Invalid R2 storage URI protocol");
|
|
71
103
|
}
|
|
72
104
|
|
|
73
|
-
const key =
|
|
105
|
+
const key = createPublicObjectPath(storageUrl);
|
|
74
106
|
const [jwtSecret, publicBaseUrl] = await Promise.all([
|
|
75
107
|
resolveContextValue(
|
|
76
108
|
config.jwtSecret ?? resolveJwtSecretFromContext,
|
|
@@ -89,6 +121,6 @@ export const r2WorkerStorage = <
|
|
|
89
121
|
fileUrl: url.toString(),
|
|
90
122
|
};
|
|
91
123
|
},
|
|
92
|
-
}
|
|
93
|
-
};
|
|
124
|
+
}),
|
|
125
|
+
})(config);
|
|
94
126
|
};
|
package/worker/dist/README.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
This folder contains the built output assets for the worker "hot-updater" generated at 2026-05-
|
|
1
|
+
This folder contains the built output assets for the worker "hot-updater" generated at 2026-05-16T10:12:39.005Z.
|