@hot-updater/cloudflare 0.28.0 → 0.29.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.
- package/dist/iac/index.cjs +719 -905
- package/dist/iac/{index.js → index.mjs} +677 -851
- package/dist/index.cjs +558 -674
- package/dist/index.d.cts +3 -3
- package/dist/{index.d.ts → index.d.mts} +3 -3
- package/dist/{index.js → index.mjs} +550 -654
- package/dist/worker/index.cjs +225 -0
- package/dist/worker/index.d.cts +39 -0
- package/dist/worker/index.d.mts +39 -0
- package/dist/worker/index.mjs +217 -0
- package/package.json +25 -16
- package/sql/bundles.sql +4 -1
- package/src/cloudflareWorkerDatabase.ts +360 -0
- package/src/d1Database.spec.ts +173 -0
- package/src/d1Database.ts +366 -0
- package/src/index.ts +2 -0
- package/src/r2Storage.ts +129 -0
- package/src/r2WorkerStorage.ts +94 -0
- package/src/utils/createWrangler.ts +27 -0
- package/src/worker/index.ts +33 -0
- package/worker/dist/README.md +1 -1
- package/worker/dist/index.js +8753 -5053
- package/worker/dist/index.js.map +4 -4
- package/worker/migrations/0004_hot-updater_0.29.0.sql +11 -0
- package/worker/src/getUpdateInfo.ts +200 -0
- package/worker/src/index.integration.spec.ts +171 -0
- package/worker/src/index.ts +87 -0
- package/worker/src/testWorker.ts +5 -0
- /package/dist/iac/{index.d.ts → index.d.mts} +0 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_ROLLOUT_COHORT_COUNT,
|
|
3
|
+
type SnakeCaseBundle,
|
|
4
|
+
} from "@hot-updater/core";
|
|
5
|
+
import type {
|
|
6
|
+
Bundle,
|
|
7
|
+
DatabaseBundleQueryOrder,
|
|
8
|
+
DatabaseBundleQueryWhere,
|
|
9
|
+
PaginationOptions,
|
|
10
|
+
} from "@hot-updater/plugin-core";
|
|
11
|
+
import {
|
|
12
|
+
calculatePagination,
|
|
13
|
+
createDatabasePlugin,
|
|
14
|
+
} from "@hot-updater/plugin-core";
|
|
15
|
+
import Cloudflare from "cloudflare";
|
|
16
|
+
import minify from "pg-minify";
|
|
17
|
+
|
|
18
|
+
export interface D1DatabaseConfig {
|
|
19
|
+
databaseId: string;
|
|
20
|
+
accountId: string;
|
|
21
|
+
cloudflareApiToken: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Helper interfaces for clarity
|
|
25
|
+
type QueryConditions = DatabaseBundleQueryWhere;
|
|
26
|
+
|
|
27
|
+
interface BuildQueryResult {
|
|
28
|
+
sql: string;
|
|
29
|
+
params: any[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function resolvePage<T>(singlePage: any): Promise<T[]> {
|
|
33
|
+
const results: T[] = [];
|
|
34
|
+
for await (const page of singlePage.iterPages()) {
|
|
35
|
+
const data = page.result.flatMap((r: any) => r.results);
|
|
36
|
+
results.push(...(data as T[]));
|
|
37
|
+
}
|
|
38
|
+
return results;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Helper function to build WHERE clause
|
|
42
|
+
function buildWhereClause(conditions: QueryConditions): BuildQueryResult {
|
|
43
|
+
const clauses: string[] = [];
|
|
44
|
+
const params: any[] = [];
|
|
45
|
+
|
|
46
|
+
if (conditions.channel) {
|
|
47
|
+
clauses.push("channel = ?");
|
|
48
|
+
params.push(conditions.channel);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (conditions.platform) {
|
|
52
|
+
clauses.push("platform = ?");
|
|
53
|
+
params.push(conditions.platform);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (conditions.enabled !== undefined) {
|
|
57
|
+
clauses.push("enabled = ?");
|
|
58
|
+
params.push(conditions.enabled ? 1 : 0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (conditions.id?.in) {
|
|
62
|
+
if (conditions.id.in.length === 0) {
|
|
63
|
+
clauses.push("1 = 0");
|
|
64
|
+
} else {
|
|
65
|
+
clauses.push(`id IN (${conditions.id.in.map(() => "?").join(", ")})`);
|
|
66
|
+
params.push(...conditions.id.in);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (conditions.id?.eq) {
|
|
71
|
+
clauses.push("id = ?");
|
|
72
|
+
params.push(conditions.id.eq);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (conditions.id?.gt) {
|
|
76
|
+
clauses.push("id > ?");
|
|
77
|
+
params.push(conditions.id.gt);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (conditions.id?.gte) {
|
|
81
|
+
clauses.push("id >= ?");
|
|
82
|
+
params.push(conditions.id.gte);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (conditions.id?.lt) {
|
|
86
|
+
clauses.push("id < ?");
|
|
87
|
+
params.push(conditions.id.lt);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (conditions.id?.lte) {
|
|
91
|
+
clauses.push("id <= ?");
|
|
92
|
+
params.push(conditions.id.lte);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (conditions.targetAppVersionNotNull) {
|
|
96
|
+
clauses.push("target_app_version IS NOT NULL");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (conditions.targetAppVersion !== undefined) {
|
|
100
|
+
if (conditions.targetAppVersion === null) {
|
|
101
|
+
clauses.push("target_app_version IS NULL");
|
|
102
|
+
} else {
|
|
103
|
+
clauses.push("target_app_version = ?");
|
|
104
|
+
params.push(conditions.targetAppVersion);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (conditions.targetAppVersionIn) {
|
|
109
|
+
if (conditions.targetAppVersionIn.length === 0) {
|
|
110
|
+
clauses.push("1 = 0");
|
|
111
|
+
} else {
|
|
112
|
+
clauses.push(
|
|
113
|
+
`target_app_version IN (${conditions.targetAppVersionIn
|
|
114
|
+
.map(() => "?")
|
|
115
|
+
.join(", ")})`,
|
|
116
|
+
);
|
|
117
|
+
params.push(...conditions.targetAppVersionIn);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (conditions.fingerprintHash !== undefined) {
|
|
122
|
+
if (conditions.fingerprintHash === null) {
|
|
123
|
+
clauses.push("fingerprint_hash IS NULL");
|
|
124
|
+
} else {
|
|
125
|
+
clauses.push("fingerprint_hash = ?");
|
|
126
|
+
params.push(conditions.fingerprintHash);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const whereClause =
|
|
131
|
+
clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
|
|
132
|
+
|
|
133
|
+
return { sql: whereClause, params };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function parseTargetCohorts(value: unknown): string[] | null {
|
|
137
|
+
if (!value) return null;
|
|
138
|
+
if (Array.isArray(value)) {
|
|
139
|
+
return value.filter((v): v is string => typeof v === "string");
|
|
140
|
+
}
|
|
141
|
+
if (typeof value === "string") {
|
|
142
|
+
try {
|
|
143
|
+
const parsed = JSON.parse(value) as unknown;
|
|
144
|
+
if (Array.isArray(parsed)) {
|
|
145
|
+
return parsed.filter((v): v is string => typeof v === "string");
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Helper function to transform snake_case row to Bundle
|
|
155
|
+
function transformRowToBundle(row: SnakeCaseBundle): Bundle {
|
|
156
|
+
return {
|
|
157
|
+
id: row.id,
|
|
158
|
+
channel: row.channel,
|
|
159
|
+
enabled: Boolean(row.enabled),
|
|
160
|
+
shouldForceUpdate: Boolean(row.should_force_update),
|
|
161
|
+
fileHash: row.file_hash,
|
|
162
|
+
gitCommitHash: row.git_commit_hash,
|
|
163
|
+
message: row.message,
|
|
164
|
+
platform: row.platform,
|
|
165
|
+
targetAppVersion: row.target_app_version,
|
|
166
|
+
storageUri: row.storage_uri,
|
|
167
|
+
fingerprintHash: row.fingerprint_hash,
|
|
168
|
+
metadata: row?.metadata ? JSON.parse(row?.metadata as string) : {},
|
|
169
|
+
rolloutCohortCount:
|
|
170
|
+
(row.rollout_cohort_count as number | null) ??
|
|
171
|
+
DEFAULT_ROLLOUT_COHORT_COUNT,
|
|
172
|
+
targetCohorts: parseTargetCohorts(row.target_cohorts as unknown),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export const d1Database = createDatabasePlugin<D1DatabaseConfig>({
|
|
177
|
+
name: "d1Database",
|
|
178
|
+
factory: (config) => {
|
|
179
|
+
let bundles: Bundle[] = [];
|
|
180
|
+
const cf = new Cloudflare({
|
|
181
|
+
apiToken: config.cloudflareApiToken,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Helper function to get total count
|
|
185
|
+
async function getTotalCount(conditions: QueryConditions): Promise<number> {
|
|
186
|
+
const { sql: whereClause, params } = buildWhereClause(conditions);
|
|
187
|
+
const countSql = minify(
|
|
188
|
+
`SELECT COUNT(*) as total FROM bundles${whereClause}`,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const countResult = await cf.d1.database.query(config.databaseId, {
|
|
192
|
+
account_id: config.accountId,
|
|
193
|
+
sql: countSql,
|
|
194
|
+
params,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const rows = await resolvePage<{ total: number }>(countResult);
|
|
198
|
+
return rows[0]?.total || 0;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Helper function to get paginated bundles
|
|
202
|
+
async function getPaginatedBundles(
|
|
203
|
+
conditions: QueryConditions,
|
|
204
|
+
limit: number,
|
|
205
|
+
offset: number,
|
|
206
|
+
orderBy?: DatabaseBundleQueryOrder,
|
|
207
|
+
): Promise<Bundle[]> {
|
|
208
|
+
const { sql: whereClause, params } = buildWhereClause(conditions);
|
|
209
|
+
const orderBySql =
|
|
210
|
+
orderBy?.direction === "asc" ? "ORDER BY id ASC" : "ORDER BY id DESC";
|
|
211
|
+
|
|
212
|
+
// Build the complete query
|
|
213
|
+
const sql = minify(`
|
|
214
|
+
SELECT * FROM bundles
|
|
215
|
+
${whereClause}
|
|
216
|
+
${orderBySql}
|
|
217
|
+
LIMIT ?
|
|
218
|
+
OFFSET ?
|
|
219
|
+
`);
|
|
220
|
+
|
|
221
|
+
// Add pagination params
|
|
222
|
+
params.push(limit, offset);
|
|
223
|
+
|
|
224
|
+
const result = await cf.d1.database.query(config.databaseId, {
|
|
225
|
+
account_id: config.accountId,
|
|
226
|
+
sql,
|
|
227
|
+
params,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const rows = await resolvePage<SnakeCaseBundle>(result);
|
|
231
|
+
return rows.map(transformRowToBundle);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
async getBundleById(bundleId) {
|
|
236
|
+
const found = bundles.find((b) => b.id === bundleId);
|
|
237
|
+
if (found) {
|
|
238
|
+
return found;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const sql = minify(/* sql */ `
|
|
242
|
+
SELECT * FROM bundles WHERE id = ? LIMIT 1`);
|
|
243
|
+
const singlePage = await cf.d1.database.query(config.databaseId, {
|
|
244
|
+
account_id: config.accountId,
|
|
245
|
+
sql,
|
|
246
|
+
params: [bundleId],
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const rows = await resolvePage<SnakeCaseBundle>(singlePage);
|
|
250
|
+
|
|
251
|
+
if (rows.length === 0) {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return transformRowToBundle(rows[0]);
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
async getBundles(options) {
|
|
259
|
+
const { where = {}, limit, offset, orderBy } = options;
|
|
260
|
+
|
|
261
|
+
// 1. Get total count for pagination
|
|
262
|
+
const totalCount = await getTotalCount(where);
|
|
263
|
+
|
|
264
|
+
// 2. Get paginated bundles
|
|
265
|
+
bundles = await getPaginatedBundles(where, limit, offset, orderBy);
|
|
266
|
+
|
|
267
|
+
// 3. Calculate pagination metadata
|
|
268
|
+
const paginationOptions: PaginationOptions = { limit, offset };
|
|
269
|
+
const pagination = calculatePagination(totalCount, paginationOptions);
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
data: bundles,
|
|
273
|
+
pagination,
|
|
274
|
+
};
|
|
275
|
+
},
|
|
276
|
+
|
|
277
|
+
async getChannels() {
|
|
278
|
+
const sql = minify(/* sql */ `
|
|
279
|
+
SELECT channel FROM bundles GROUP BY channel
|
|
280
|
+
`);
|
|
281
|
+
const singlePage = await cf.d1.database.query(config.databaseId, {
|
|
282
|
+
account_id: config.accountId,
|
|
283
|
+
sql,
|
|
284
|
+
params: [],
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const rows = await resolvePage<{ channel: string }>(singlePage);
|
|
288
|
+
return rows.map((row) => row.channel);
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
async commitBundle({ changedSets }) {
|
|
292
|
+
if (changedSets.length === 0) {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Process each operation sequentially
|
|
297
|
+
for (const op of changedSets) {
|
|
298
|
+
if (op.operation === "delete") {
|
|
299
|
+
// Handle delete operation
|
|
300
|
+
const deleteSql = minify(/* sql */ `
|
|
301
|
+
DELETE FROM bundles WHERE id = ?
|
|
302
|
+
`);
|
|
303
|
+
|
|
304
|
+
await cf.d1.database.query(config.databaseId, {
|
|
305
|
+
account_id: config.accountId,
|
|
306
|
+
sql: deleteSql,
|
|
307
|
+
params: [op.data.id],
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// Update local bundles array
|
|
311
|
+
bundles = bundles.filter((b) => b.id !== op.data.id);
|
|
312
|
+
} else if (op.operation === "insert" || op.operation === "update") {
|
|
313
|
+
// Handle insert and update operations
|
|
314
|
+
const bundle = op.data;
|
|
315
|
+
const upsertSql = minify(/* sql */ `
|
|
316
|
+
INSERT OR REPLACE INTO bundles (
|
|
317
|
+
id,
|
|
318
|
+
channel,
|
|
319
|
+
enabled,
|
|
320
|
+
should_force_update,
|
|
321
|
+
file_hash,
|
|
322
|
+
git_commit_hash,
|
|
323
|
+
message,
|
|
324
|
+
platform,
|
|
325
|
+
target_app_version,
|
|
326
|
+
storage_uri,
|
|
327
|
+
fingerprint_hash,
|
|
328
|
+
metadata,
|
|
329
|
+
rollout_cohort_count,
|
|
330
|
+
target_cohorts
|
|
331
|
+
)
|
|
332
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
333
|
+
`);
|
|
334
|
+
|
|
335
|
+
const params = [
|
|
336
|
+
bundle.id,
|
|
337
|
+
bundle.channel,
|
|
338
|
+
bundle.enabled ? 1 : 0,
|
|
339
|
+
bundle.shouldForceUpdate ? 1 : 0,
|
|
340
|
+
bundle.fileHash,
|
|
341
|
+
bundle.gitCommitHash || null,
|
|
342
|
+
bundle.message || null,
|
|
343
|
+
bundle.platform,
|
|
344
|
+
bundle.targetAppVersion,
|
|
345
|
+
bundle.storageUri,
|
|
346
|
+
bundle.fingerprintHash,
|
|
347
|
+
bundle.metadata
|
|
348
|
+
? JSON.stringify(bundle.metadata)
|
|
349
|
+
: JSON.stringify({}),
|
|
350
|
+
bundle.rolloutCohortCount ?? DEFAULT_ROLLOUT_COHORT_COUNT,
|
|
351
|
+
bundle.targetCohorts
|
|
352
|
+
? JSON.stringify(bundle.targetCohorts)
|
|
353
|
+
: null,
|
|
354
|
+
];
|
|
355
|
+
|
|
356
|
+
await cf.d1.database.query(config.databaseId, {
|
|
357
|
+
account_id: config.accountId,
|
|
358
|
+
sql: upsertSql,
|
|
359
|
+
params: params as string[],
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
};
|
|
365
|
+
},
|
|
366
|
+
});
|
package/src/index.ts
ADDED
package/src/r2Storage.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createStorageKeyBuilder,
|
|
3
|
+
createStoragePlugin,
|
|
4
|
+
getContentType,
|
|
5
|
+
parseStorageUri,
|
|
6
|
+
} from "@hot-updater/plugin-core";
|
|
7
|
+
import { ExecaError } from "execa";
|
|
8
|
+
|
|
9
|
+
import path from "path";
|
|
10
|
+
import { createWrangler } from "./utils/createWrangler";
|
|
11
|
+
|
|
12
|
+
export interface R2StorageConfig {
|
|
13
|
+
cloudflareApiToken: string;
|
|
14
|
+
accountId: string;
|
|
15
|
+
bucketName: string;
|
|
16
|
+
/**
|
|
17
|
+
* Base path where bundles will be stored in the bucket
|
|
18
|
+
*/
|
|
19
|
+
basePath?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Cloudflare R2 storage plugin for Hot Updater.
|
|
24
|
+
*
|
|
25
|
+
* Note: This plugin does not support `getDownloadUrl()`.
|
|
26
|
+
* If you need download URL generation, use `s3Storage` from `@hot-updater/aws` instead,
|
|
27
|
+
* which is fully compatible with Cloudflare R2.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```typescript
|
|
31
|
+
* // Using s3Storage with Cloudflare R2 for download URL support
|
|
32
|
+
* import { s3Storage } from "@hot-updater/aws";
|
|
33
|
+
*
|
|
34
|
+
* s3Storage({
|
|
35
|
+
* region: "auto",
|
|
36
|
+
* endpoint: "https://YOUR_ACCOUNT_ID.r2.cloudflarestorage.com",
|
|
37
|
+
* credentials: {
|
|
38
|
+
* accessKeyId: "YOUR_ACCESS_KEY_ID",
|
|
39
|
+
* secretAccessKey: "YOUR_SECRET_ACCESS_KEY",
|
|
40
|
+
* },
|
|
41
|
+
* bucketName: "YOUR_BUCKET_NAME",
|
|
42
|
+
* })
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export const r2Storage = createStoragePlugin<R2StorageConfig>({
|
|
46
|
+
name: "r2Storage",
|
|
47
|
+
supportedProtocol: "r2",
|
|
48
|
+
factory: (config) => {
|
|
49
|
+
const { bucketName, cloudflareApiToken, accountId } = config;
|
|
50
|
+
const wrangler = createWrangler({
|
|
51
|
+
accountId,
|
|
52
|
+
cloudflareApiToken: cloudflareApiToken,
|
|
53
|
+
cwd: process.cwd(),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const getStorageKey = createStorageKeyBuilder(config.basePath);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
async delete(storageUri) {
|
|
60
|
+
const { bucket, key } = parseStorageUri(storageUri, "r2");
|
|
61
|
+
if (bucket !== bucketName) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Bucket name mismatch: expected "${bucketName}", but found "${bucket}".`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
await wrangler(
|
|
69
|
+
"r2",
|
|
70
|
+
"object",
|
|
71
|
+
"delete",
|
|
72
|
+
[bucketName, key].join("/"),
|
|
73
|
+
"--remote",
|
|
74
|
+
);
|
|
75
|
+
} catch {
|
|
76
|
+
throw new Error("Can not delete bundle");
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
async upload(key, filePath) {
|
|
80
|
+
const contentType = getContentType(filePath);
|
|
81
|
+
|
|
82
|
+
const filename = path.basename(filePath);
|
|
83
|
+
|
|
84
|
+
const Key = getStorageKey(key, filename);
|
|
85
|
+
try {
|
|
86
|
+
const { stderr, exitCode } = await wrangler(
|
|
87
|
+
"r2",
|
|
88
|
+
"object",
|
|
89
|
+
"put",
|
|
90
|
+
[bucketName, Key].join("/"),
|
|
91
|
+
"--file",
|
|
92
|
+
filePath,
|
|
93
|
+
"--content-type",
|
|
94
|
+
contentType,
|
|
95
|
+
"--remote",
|
|
96
|
+
);
|
|
97
|
+
if (exitCode !== 0 && stderr) {
|
|
98
|
+
throw new Error(stderr);
|
|
99
|
+
}
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error instanceof ExecaError) {
|
|
102
|
+
throw new Error(error.stderr || error.stdout);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
storageUri: `r2://${bucketName}/${Key}`,
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
async getDownloadUrl() {
|
|
113
|
+
throw new Error(
|
|
114
|
+
"`r2Storage` does not support `getDownloadUrl()`. Use `s3Storage` from `@hot-updater/aws` instead (compatible with Cloudflare R2).\n\n" +
|
|
115
|
+
"Example:\n" +
|
|
116
|
+
"s3Storage({\n" +
|
|
117
|
+
" region: 'auto',\n" +
|
|
118
|
+
" endpoint: 'https://YOUR_ACCOUNT_ID.r2.cloudflarestorage.com',\n" +
|
|
119
|
+
" credentials: {\n" +
|
|
120
|
+
" accessKeyId: 'YOUR_ACCESS_KEY_ID',\n" +
|
|
121
|
+
" secretAccessKey: 'YOUR_SECRET_ACCESS_KEY',\n" +
|
|
122
|
+
" },\n" +
|
|
123
|
+
" bucketName: 'YOUR_BUCKET_NAME',\n" +
|
|
124
|
+
"})",
|
|
125
|
+
);
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
},
|
|
129
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { signToken } from "@hot-updater/js";
|
|
2
|
+
import type {
|
|
3
|
+
HotUpdaterContext,
|
|
4
|
+
RequestEnvContext,
|
|
5
|
+
StoragePlugin,
|
|
6
|
+
} from "@hot-updater/plugin-core";
|
|
7
|
+
|
|
8
|
+
export interface CloudflareWorkerStorageEnv {
|
|
9
|
+
JWT_SECRET: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type ContextResolver<TContext, TValue> = (
|
|
13
|
+
context?: HotUpdaterContext<TContext>,
|
|
14
|
+
) => TValue | Promise<TValue>;
|
|
15
|
+
|
|
16
|
+
export interface CloudflareWorkerStorageConfig<
|
|
17
|
+
TContext extends RequestEnvContext<CloudflareWorkerStorageEnv>,
|
|
18
|
+
> {
|
|
19
|
+
jwtSecret?: string | ContextResolver<TContext, string>;
|
|
20
|
+
publicBaseUrl: string | ContextResolver<TContext, string>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const resolveContextValue = async <TContext, TValue>(
|
|
24
|
+
value: TValue | ContextResolver<TContext, TValue>,
|
|
25
|
+
context?: HotUpdaterContext<TContext>,
|
|
26
|
+
) => {
|
|
27
|
+
return typeof value === "function"
|
|
28
|
+
? await (value as ContextResolver<TContext, TValue>)(context)
|
|
29
|
+
: value;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const resolveJwtSecretFromContext = (
|
|
33
|
+
context?: RequestEnvContext<CloudflareWorkerStorageEnv>,
|
|
34
|
+
) => {
|
|
35
|
+
const jwtSecret = context?.env?.JWT_SECRET;
|
|
36
|
+
|
|
37
|
+
if (!jwtSecret) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
"r2WorkerStorage requires env.JWT_SECRET in the hot updater context.",
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return jwtSecret;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const r2WorkerStorage = <
|
|
47
|
+
TContext extends
|
|
48
|
+
RequestEnvContext<CloudflareWorkerStorageEnv> = RequestEnvContext<CloudflareWorkerStorageEnv>,
|
|
49
|
+
>(
|
|
50
|
+
config: CloudflareWorkerStorageConfig<TContext>,
|
|
51
|
+
) => {
|
|
52
|
+
return (): StoragePlugin<TContext> => {
|
|
53
|
+
return {
|
|
54
|
+
name: "r2WorkerStorage",
|
|
55
|
+
supportedProtocol: "r2",
|
|
56
|
+
async upload() {
|
|
57
|
+
throw new Error(
|
|
58
|
+
"r2WorkerStorage does not support upload() in the worker runtime.",
|
|
59
|
+
);
|
|
60
|
+
},
|
|
61
|
+
async delete() {
|
|
62
|
+
throw new Error(
|
|
63
|
+
"r2WorkerStorage does not support delete() in the worker runtime.",
|
|
64
|
+
);
|
|
65
|
+
},
|
|
66
|
+
async getDownloadUrl(storageUri, context) {
|
|
67
|
+
const storageUrl = new URL(storageUri);
|
|
68
|
+
|
|
69
|
+
if (storageUrl.protocol !== "r2:") {
|
|
70
|
+
throw new Error("Invalid R2 storage URI protocol");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const key = `${storageUrl.host}${storageUrl.pathname}`;
|
|
74
|
+
const [jwtSecret, publicBaseUrl] = await Promise.all([
|
|
75
|
+
resolveContextValue(
|
|
76
|
+
config.jwtSecret ?? resolveJwtSecretFromContext,
|
|
77
|
+
context,
|
|
78
|
+
),
|
|
79
|
+
resolveContextValue(config.publicBaseUrl, context),
|
|
80
|
+
]);
|
|
81
|
+
const token = await signToken(key, jwtSecret);
|
|
82
|
+
const url = new URL(publicBaseUrl);
|
|
83
|
+
|
|
84
|
+
url.pathname = key;
|
|
85
|
+
url.search = "";
|
|
86
|
+
url.searchParams.set("token", token);
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
fileUrl: url.toString(),
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { execa } from "execa";
|
|
2
|
+
|
|
3
|
+
export const createWrangler = ({
|
|
4
|
+
stdio,
|
|
5
|
+
accountId,
|
|
6
|
+
|
|
7
|
+
cloudflareApiToken,
|
|
8
|
+
cwd,
|
|
9
|
+
}: {
|
|
10
|
+
stdio?: "inherit" | "pipe" | "ignore" | "overlapped";
|
|
11
|
+
accountId: string;
|
|
12
|
+
cloudflareApiToken: string;
|
|
13
|
+
cwd: string;
|
|
14
|
+
}) => {
|
|
15
|
+
const $ = execa({
|
|
16
|
+
stdio,
|
|
17
|
+
extendsEnv: true,
|
|
18
|
+
shell: stdio === "inherit",
|
|
19
|
+
cwd,
|
|
20
|
+
env: {
|
|
21
|
+
CLOUDFLARE_ACCOUNT_ID: accountId,
|
|
22
|
+
CLOUDFLARE_API_TOKEN: cloudflareApiToken,
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
return (...command: string[]) => $("npx", ["wrangler", ...command]);
|
|
27
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export { verifyJwtSignedUrl } from "@hot-updater/js";
|
|
2
|
+
|
|
3
|
+
import type { RequestEnvContext as BaseRequestEnvContext } from "@hot-updater/plugin-core";
|
|
4
|
+
import {
|
|
5
|
+
type CloudflareWorkerDatabaseEnv,
|
|
6
|
+
d1WorkerDatabase,
|
|
7
|
+
} from "../cloudflareWorkerDatabase";
|
|
8
|
+
import {
|
|
9
|
+
type CloudflareWorkerStorageConfig,
|
|
10
|
+
type CloudflareWorkerStorageEnv,
|
|
11
|
+
r2WorkerStorage,
|
|
12
|
+
} from "../r2WorkerStorage";
|
|
13
|
+
|
|
14
|
+
export type { CloudflareWorkerDatabaseEnv, CloudflareWorkerStorageEnv };
|
|
15
|
+
|
|
16
|
+
export interface CloudflareWorkerRuntimeEnv
|
|
17
|
+
extends CloudflareWorkerDatabaseEnv,
|
|
18
|
+
CloudflareWorkerStorageEnv {}
|
|
19
|
+
|
|
20
|
+
export type RequestEnvContext<TEnv = CloudflareWorkerRuntimeEnv> =
|
|
21
|
+
BaseRequestEnvContext<TEnv>;
|
|
22
|
+
|
|
23
|
+
export const d1Database = <
|
|
24
|
+
TContext extends
|
|
25
|
+
RequestEnvContext<CloudflareWorkerRuntimeEnv> = RequestEnvContext<CloudflareWorkerRuntimeEnv>,
|
|
26
|
+
>() => d1WorkerDatabase<TContext>();
|
|
27
|
+
|
|
28
|
+
export const r2Storage = <
|
|
29
|
+
TContext extends
|
|
30
|
+
RequestEnvContext<CloudflareWorkerRuntimeEnv> = RequestEnvContext<CloudflareWorkerRuntimeEnv>,
|
|
31
|
+
>(
|
|
32
|
+
config: CloudflareWorkerStorageConfig<TContext>,
|
|
33
|
+
) => r2WorkerStorage<TContext>(config);
|
package/worker/dist/README.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
This folder contains the built output assets for the worker "hot-updater" generated at 2026-
|
|
1
|
+
This folder contains the built output assets for the worker "hot-updater" generated at 2026-04-02T18:21:23.609Z.
|