@hot-updater/cloudflare 0.36.6 → 1.0.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/iac/index.cjs +989 -11
- package/dist/iac/index.mjs +992 -15
- package/dist/index.cjs +919 -6989
- package/dist/index.d.cts +11 -47
- package/dist/index.d.mts +11 -47
- package/dist/index.mjs +917 -7006
- package/dist/worker/index.cjs +898 -336
- package/dist/worker/index.d.cts +18 -33
- package/dist/worker/index.d.mts +18 -33
- package/dist/worker/index.mjs +899 -331
- package/package.json +7 -10
- package/sql/bundles.sql +183 -35
- package/src/d1Database.spec.ts +143 -392
- package/src/d1Database.ts +55 -650
- package/src/d1Implementation.spec.ts +252 -0
- package/src/d1Implementation.ts +742 -0
- package/src/d1Query.spec.ts +46 -0
- package/src/d1Query.ts +89 -0
- package/src/d1Rows.spec.ts +111 -0
- package/src/d1Rows.ts +326 -0
- package/src/d1Sql.spec.ts +30 -0
- package/src/d1Sql.ts +175 -0
- package/src/r2S3Storage.ts +93 -137
- package/src/r2Storage.spec.ts +120 -218
- package/src/r2Storage.ts +13 -61
- package/src/r2WorkerStorage.spec.ts +161 -50
- package/src/r2WorkerStorage.ts +85 -118
- package/src/worker/d1Database.spec.ts +33 -0
- package/src/worker/d1Database.ts +52 -0
- package/src/worker/index.ts +3 -31
- package/worker/dist/README.md +1 -1
- package/worker/dist/index.js +5968 -4701
- package/worker/dist/index.js.map +4 -4
- package/worker/migrations/0001_hot-updater_1.0.0.sql +197 -0
- package/worker/migrations/0002_hot-updater_artifact_byte_sizes.sql +11 -0
- package/worker/src/databaseMigration.integration.spec.ts +114 -0
- package/worker/src/databasePlugins.integration.spec.ts +292 -0
- package/worker/src/index.integration.spec.ts +102 -294
- package/worker/src/index.ts +14 -66
- package/worker/src/storagePlugin.integration.spec.ts +59 -0
- package/worker/wrangler.json +5 -2
- package/src/cloudflareWorkerDatabase.spec.ts +0 -260
- package/src/cloudflareWorkerDatabase.ts +0 -651
- package/src/r2WranglerStorage.ts +0 -193
- package/worker/migrations/0001_hot-updater_init.sql +0 -16
- package/worker/migrations/0002_hot-updater_0.13.0.sql +0 -8
- package/worker/migrations/0003_hot-updater_0.18.0.sql +0 -38
- package/worker/migrations/0004_hot-updater_0.29.0.sql +0 -11
- package/worker/migrations/0005_hot-updater_0.31.0.sql +0 -24
|
@@ -1,25 +1,17 @@
|
|
|
1
1
|
import {
|
|
2
|
-
getBundlePatches,
|
|
3
2
|
type Bundle,
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
createReleaseCatalogScopeKey,
|
|
4
|
+
encodeChannelKey,
|
|
6
5
|
} from "@hot-updater/core";
|
|
7
|
-
import { signToken } from "@hot-updater/js";
|
|
8
6
|
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
} from "@hot-updater/
|
|
7
|
+
commitReleaseCatalogMutations,
|
|
8
|
+
createUUIDv7,
|
|
9
|
+
} from "@hot-updater/plugin-core";
|
|
10
|
+
import { createHotUpdater, registerApiKey } from "@hot-updater/server";
|
|
12
11
|
import { env } from "cloudflare:test";
|
|
13
|
-
import {
|
|
14
|
-
beforeAll,
|
|
15
|
-
beforeEach,
|
|
16
|
-
describe,
|
|
17
|
-
expect,
|
|
18
|
-
inject,
|
|
19
|
-
it,
|
|
20
|
-
vi,
|
|
21
|
-
} from "vitest";
|
|
12
|
+
import { beforeAll, beforeEach, describe, expect, inject, it } from "vitest";
|
|
22
13
|
|
|
14
|
+
import { d1Database } from "../../src/worker";
|
|
23
15
|
import worker, { HOT_UPDATER_BASE_PATH } from "./index";
|
|
24
16
|
|
|
25
17
|
declare module "vitest" {
|
|
@@ -32,333 +24,149 @@ declare module "cloudflare:test" {
|
|
|
32
24
|
interface ProvidedEnv {
|
|
33
25
|
DB: D1Database;
|
|
34
26
|
BUCKET: R2Bucket;
|
|
35
|
-
|
|
27
|
+
BUCKET_NAME: string;
|
|
28
|
+
STORAGE_DOWNLOAD_URL_SIGNING_KEY: string;
|
|
36
29
|
}
|
|
37
30
|
}
|
|
38
31
|
|
|
39
32
|
const PUBLIC_BASE_URL = "https://updates.example.com";
|
|
40
|
-
|
|
41
|
-
const sqlString = (value: string) => `'${value.replaceAll("'", "''")}'`;
|
|
42
|
-
|
|
43
|
-
const createInsertBundleQuery = (bundle: Bundle) => {
|
|
44
|
-
const rolloutCohortCount = bundle.rolloutCohortCount ?? 1000;
|
|
45
|
-
const targetCohorts = bundle.targetCohorts
|
|
46
|
-
? sqlString(JSON.stringify(bundle.targetCohorts))
|
|
47
|
-
: "null";
|
|
48
|
-
const metadata = sqlString(JSON.stringify(bundle.metadata ?? {}));
|
|
49
|
-
|
|
50
|
-
return `
|
|
51
|
-
INSERT INTO bundles (
|
|
52
|
-
id, file_hash, platform, target_app_version,
|
|
53
|
-
should_force_update, enabled, git_commit_hash, message, channel,
|
|
54
|
-
storage_uri, fingerprint_hash, metadata, manifest_storage_uri,
|
|
55
|
-
manifest_file_hash, asset_base_storage_uri, rollout_cohort_count,
|
|
56
|
-
target_cohorts
|
|
57
|
-
) VALUES (
|
|
58
|
-
${sqlString(bundle.id)},
|
|
59
|
-
${sqlString(bundle.fileHash)},
|
|
60
|
-
${sqlString(bundle.platform)},
|
|
61
|
-
${bundle.targetAppVersion ? sqlString(bundle.targetAppVersion) : "null"},
|
|
62
|
-
${bundle.shouldForceUpdate},
|
|
63
|
-
${bundle.enabled},
|
|
64
|
-
${bundle.gitCommitHash ? sqlString(bundle.gitCommitHash) : "null"},
|
|
65
|
-
${bundle.message ? sqlString(bundle.message) : "null"},
|
|
66
|
-
${sqlString(bundle.channel)},
|
|
67
|
-
${bundle.storageUri ? sqlString(bundle.storageUri) : "null"},
|
|
68
|
-
${bundle.fingerprintHash ? sqlString(bundle.fingerprintHash) : "null"},
|
|
69
|
-
${metadata},
|
|
70
|
-
${bundle.manifestStorageUri ? sqlString(bundle.manifestStorageUri) : "null"},
|
|
71
|
-
${bundle.manifestFileHash ? sqlString(bundle.manifestFileHash) : "null"},
|
|
72
|
-
${bundle.assetBaseStorageUri ? sqlString(bundle.assetBaseStorageUri) : "null"},
|
|
73
|
-
${rolloutCohortCount},
|
|
74
|
-
${targetCohorts}
|
|
75
|
-
) ON CONFLICT(id) DO UPDATE SET
|
|
76
|
-
file_hash = excluded.file_hash,
|
|
77
|
-
platform = excluded.platform,
|
|
78
|
-
target_app_version = excluded.target_app_version,
|
|
79
|
-
should_force_update = excluded.should_force_update,
|
|
80
|
-
enabled = excluded.enabled,
|
|
81
|
-
git_commit_hash = excluded.git_commit_hash,
|
|
82
|
-
message = excluded.message,
|
|
83
|
-
channel = excluded.channel,
|
|
84
|
-
storage_uri = excluded.storage_uri,
|
|
85
|
-
fingerprint_hash = excluded.fingerprint_hash,
|
|
86
|
-
metadata = excluded.metadata,
|
|
87
|
-
manifest_storage_uri = excluded.manifest_storage_uri,
|
|
88
|
-
manifest_file_hash = excluded.manifest_file_hash,
|
|
89
|
-
asset_base_storage_uri = excluded.asset_base_storage_uri,
|
|
90
|
-
rollout_cohort_count = excluded.rollout_cohort_count,
|
|
91
|
-
target_cohorts = excluded.target_cohorts;
|
|
92
|
-
`;
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
const createInsertBundlePatchQueries = (bundle: Bundle) =>
|
|
96
|
-
getBundlePatches(bundle).map(
|
|
97
|
-
(patch, index) => `
|
|
98
|
-
INSERT INTO bundle_patches (
|
|
99
|
-
id,
|
|
100
|
-
bundle_id,
|
|
101
|
-
base_bundle_id,
|
|
102
|
-
base_file_hash,
|
|
103
|
-
patch_file_hash,
|
|
104
|
-
patch_storage_uri,
|
|
105
|
-
order_index
|
|
106
|
-
) VALUES (
|
|
107
|
-
${sqlString(`${bundle.id}:${patch.baseBundleId}`)},
|
|
108
|
-
${sqlString(bundle.id)},
|
|
109
|
-
${sqlString(patch.baseBundleId)},
|
|
110
|
-
${sqlString(patch.baseFileHash)},
|
|
111
|
-
${sqlString(patch.patchFileHash)},
|
|
112
|
-
${sqlString(patch.patchStorageUri)},
|
|
113
|
-
${index}
|
|
114
|
-
) ON CONFLICT(id) DO UPDATE SET
|
|
115
|
-
bundle_id = excluded.bundle_id,
|
|
116
|
-
base_bundle_id = excluded.base_bundle_id,
|
|
117
|
-
base_file_hash = excluded.base_file_hash,
|
|
118
|
-
patch_file_hash = excluded.patch_file_hash,
|
|
119
|
-
patch_storage_uri = excluded.patch_storage_uri,
|
|
120
|
-
order_index = excluded.order_index;
|
|
121
|
-
`,
|
|
122
|
-
);
|
|
33
|
+
const API_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE";
|
|
123
34
|
|
|
124
35
|
const toRuntimeBundle = (bundle: Bundle): Bundle => {
|
|
125
36
|
return {
|
|
126
37
|
...bundle,
|
|
127
|
-
storageUri: `r2
|
|
38
|
+
storageUri: `r2://${env.BUCKET_NAME}/${bundle.id}/bundle.zip`,
|
|
128
39
|
};
|
|
129
40
|
};
|
|
130
41
|
|
|
131
42
|
const seedBundles = async (bundles: Bundle[]) => {
|
|
43
|
+
const database = d1Database(env.DB);
|
|
44
|
+
const seedHotUpdater = createHotUpdater({
|
|
45
|
+
database,
|
|
46
|
+
clientAccess: { type: "public" },
|
|
47
|
+
});
|
|
132
48
|
for (const bundle of bundles.map(toRuntimeBundle)) {
|
|
133
|
-
await
|
|
134
|
-
|
|
135
|
-
await
|
|
49
|
+
const existing = await seedHotUpdater.getBundleById(bundle.id);
|
|
50
|
+
if (existing === null) {
|
|
51
|
+
await seedHotUpdater.insertBundle(bundle);
|
|
52
|
+
} else {
|
|
53
|
+
await seedHotUpdater.updateBundleById(bundle.id, bundle);
|
|
136
54
|
}
|
|
55
|
+
const channelName = "production";
|
|
56
|
+
const channelKey = encodeChannelKey(channelName);
|
|
57
|
+
const channel = (
|
|
58
|
+
await database.models.channels.insert({
|
|
59
|
+
row: { id: `channel:${channelKey}`, name: channelName },
|
|
60
|
+
onConflict: "returnExisting",
|
|
61
|
+
})
|
|
62
|
+
).row;
|
|
63
|
+
const scopeKey = createReleaseCatalogScopeKey({
|
|
64
|
+
channelKey,
|
|
65
|
+
platform: bundle.platform,
|
|
66
|
+
strategy: "APP_VERSION",
|
|
67
|
+
});
|
|
68
|
+
const now = Date.now();
|
|
69
|
+
const releaseId = createUUIDv7();
|
|
70
|
+
await commitReleaseCatalogMutations({
|
|
71
|
+
database,
|
|
72
|
+
mutations: [
|
|
73
|
+
{
|
|
74
|
+
mutation: {
|
|
75
|
+
operation: "insert",
|
|
76
|
+
row: {
|
|
77
|
+
bundle_id: bundle.id,
|
|
78
|
+
channel_id: channel.id,
|
|
79
|
+
created_at_ms: now,
|
|
80
|
+
enabled: true,
|
|
81
|
+
fingerprint_hash: null,
|
|
82
|
+
id: releaseId,
|
|
83
|
+
kind: "BUNDLE",
|
|
84
|
+
message: "hello",
|
|
85
|
+
operation: "DEPLOY",
|
|
86
|
+
platform: bundle.platform,
|
|
87
|
+
revision: 1,
|
|
88
|
+
rollout_cohort_count: 1_000,
|
|
89
|
+
scope_key: scopeKey,
|
|
90
|
+
should_force_update: false,
|
|
91
|
+
source_release_id: null,
|
|
92
|
+
strategy: "APP_VERSION",
|
|
93
|
+
target_app_version: "1.0",
|
|
94
|
+
target_cohorts: [],
|
|
95
|
+
updated_at_ms: now,
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
scope: {
|
|
99
|
+
channelId: channel.id,
|
|
100
|
+
channelName,
|
|
101
|
+
fingerprintHash: null,
|
|
102
|
+
platform: bundle.platform,
|
|
103
|
+
scopeKey,
|
|
104
|
+
strategy: "APP_VERSION",
|
|
105
|
+
},
|
|
106
|
+
updatedAtMs: now,
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
});
|
|
137
110
|
}
|
|
138
111
|
};
|
|
139
112
|
|
|
140
|
-
const putR2Object = async (key: string, value: string, contentType: string) => {
|
|
141
|
-
await env.BUCKET.put(key, value, {
|
|
142
|
-
httpMetadata: {
|
|
143
|
-
contentType,
|
|
144
|
-
},
|
|
145
|
-
});
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
const createCanonicalPath = (args: GetBundlesArgs) => {
|
|
149
|
-
const channel = args.channel ?? "production";
|
|
150
|
-
const minBundleId = args.minBundleId ?? NIL_UUID;
|
|
151
|
-
const cohortSegment = args.cohort
|
|
152
|
-
? `/${encodeURIComponent(args.cohort)}`
|
|
153
|
-
: "";
|
|
154
|
-
|
|
155
|
-
if (args._updateStrategy === "appVersion") {
|
|
156
|
-
return `${HOT_UPDATER_BASE_PATH}/app-version/${encodeURIComponent(args.platform)}/${encodeURIComponent(args.appVersion)}/${encodeURIComponent(channel)}/${encodeURIComponent(minBundleId)}/${encodeURIComponent(args.bundleId)}${cohortSegment}`;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
return `${HOT_UPDATER_BASE_PATH}/fingerprint/${encodeURIComponent(args.platform)}/${encodeURIComponent(args.fingerprintHash)}/${encodeURIComponent(channel)}/${encodeURIComponent(minBundleId)}/${encodeURIComponent(args.bundleId)}${cohortSegment}`;
|
|
160
|
-
};
|
|
161
|
-
|
|
162
113
|
describe.sequential("cloudflare worker runtime acceptance", () => {
|
|
163
114
|
beforeAll(async () => {
|
|
164
115
|
await env.DB.prepare(inject("prepareSql")).run();
|
|
116
|
+
const database = d1Database(env.DB);
|
|
117
|
+
await registerApiKey({
|
|
118
|
+
apiKey: API_KEY,
|
|
119
|
+
apiKeys: database.models.apiKeys,
|
|
120
|
+
name: "Runtime acceptance",
|
|
121
|
+
});
|
|
165
122
|
});
|
|
166
123
|
|
|
167
124
|
beforeEach(async () => {
|
|
168
125
|
await env.DB.prepare("DELETE FROM bundle_patches").run();
|
|
126
|
+
await env.DB.prepare("DELETE FROM release_catalogs").run();
|
|
127
|
+
await env.DB.prepare("DELETE FROM releases").run();
|
|
169
128
|
await env.DB.prepare("DELETE FROM bundles").run();
|
|
129
|
+
await env.DB.prepare("DELETE FROM channels").run();
|
|
170
130
|
});
|
|
171
131
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
new Request(`${PUBLIC_BASE_URL}${createCanonicalPath(args)}`),
|
|
175
|
-
env,
|
|
176
|
-
);
|
|
177
|
-
|
|
178
|
-
return (await response.json()) as any;
|
|
179
|
-
};
|
|
180
|
-
|
|
181
|
-
const getUpdateInfo = async (bundles: Bundle[], args: GetBundlesArgs) => {
|
|
182
|
-
await seedBundles(bundles);
|
|
183
|
-
|
|
184
|
-
return requestUpdateInfo(args);
|
|
185
|
-
};
|
|
186
|
-
|
|
187
|
-
setupGetUpdateInfoTestSuite({
|
|
188
|
-
getUpdateInfo,
|
|
189
|
-
manifestArtifacts: {
|
|
190
|
-
prepareArtifacts: async (fixture) => {
|
|
191
|
-
await Promise.all([
|
|
192
|
-
putR2Object(
|
|
193
|
-
`${fixture.currentBundleId}/manifest.json`,
|
|
194
|
-
JSON.stringify(fixture.currentManifest),
|
|
195
|
-
"application/json",
|
|
196
|
-
),
|
|
197
|
-
putR2Object(
|
|
198
|
-
`${fixture.nextBundleId}/manifest.json`,
|
|
199
|
-
JSON.stringify(fixture.nextManifest),
|
|
200
|
-
"application/json",
|
|
201
|
-
),
|
|
202
|
-
]);
|
|
203
|
-
|
|
204
|
-
vi.stubGlobal(
|
|
205
|
-
"fetch",
|
|
206
|
-
vi.fn<typeof fetch>(async () => {
|
|
207
|
-
return new Response("worker subrequest failed", { status: 502 });
|
|
208
|
-
}),
|
|
209
|
-
);
|
|
210
|
-
|
|
211
|
-
return {
|
|
212
|
-
cleanup: () => {
|
|
213
|
-
vi.unstubAllGlobals();
|
|
214
|
-
},
|
|
215
|
-
currentArtifacts: {
|
|
216
|
-
assetBaseStorageUri: `r2://bundles/${fixture.currentBundleId}/files`,
|
|
217
|
-
manifestFileHash: "sig:manifest-current",
|
|
218
|
-
manifestStorageUri: `r2://bundles/${fixture.currentBundleId}/manifest.json`,
|
|
219
|
-
},
|
|
220
|
-
nextArtifacts: {
|
|
221
|
-
assetBaseStorageUri: `r2://bundles/${fixture.nextBundleId}/files`,
|
|
222
|
-
manifestFileHash: "sig:manifest-next",
|
|
223
|
-
manifestStorageUri: `r2://bundles/${fixture.nextBundleId}/manifest.json`,
|
|
224
|
-
},
|
|
225
|
-
};
|
|
226
|
-
},
|
|
227
|
-
expectFileUrl: (fileUrl, fixture) => {
|
|
228
|
-
expect(fileUrl).toContain(
|
|
229
|
-
`/bundles/${fixture.nextBundleId}/files/${fixture.changedAssetPath}`,
|
|
230
|
-
);
|
|
231
|
-
},
|
|
232
|
-
expectManifestUrl: (manifestUrl, fixture) => {
|
|
233
|
-
expect(manifestUrl).toContain(`/${fixture.nextBundleId}/manifest.json`);
|
|
234
|
-
},
|
|
235
|
-
},
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
setupBsdiffManifestUpdateInfoTestSuite({
|
|
239
|
-
seedBundles,
|
|
240
|
-
getUpdateInfo: requestUpdateInfo,
|
|
241
|
-
prepareArtifacts: async (fixture) => {
|
|
242
|
-
await Promise.all([
|
|
243
|
-
putR2Object(
|
|
244
|
-
`${fixture.currentBundleId}/manifest.json`,
|
|
245
|
-
JSON.stringify(fixture.currentManifest),
|
|
246
|
-
"application/json",
|
|
247
|
-
),
|
|
248
|
-
putR2Object(
|
|
249
|
-
`${fixture.nextBundleId}/manifest.json`,
|
|
250
|
-
JSON.stringify(fixture.nextManifest),
|
|
251
|
-
"application/json",
|
|
252
|
-
),
|
|
253
|
-
putR2Object(
|
|
254
|
-
fixture.patchPath,
|
|
255
|
-
"patch-bytes",
|
|
256
|
-
"application/octet-stream",
|
|
257
|
-
),
|
|
258
|
-
putR2Object(
|
|
259
|
-
`${fixture.currentBundleId}/bundle.zip`,
|
|
260
|
-
"zip",
|
|
261
|
-
"application/zip",
|
|
262
|
-
),
|
|
263
|
-
putR2Object(
|
|
264
|
-
`${fixture.nextBundleId}/bundle.zip`,
|
|
265
|
-
"zip",
|
|
266
|
-
"application/zip",
|
|
267
|
-
),
|
|
268
|
-
]);
|
|
269
|
-
|
|
270
|
-
vi.stubGlobal(
|
|
271
|
-
"fetch",
|
|
272
|
-
vi.fn<typeof fetch>(async () => {
|
|
273
|
-
return new Response("worker subrequest failed", { status: 502 });
|
|
274
|
-
}),
|
|
275
|
-
);
|
|
276
|
-
|
|
277
|
-
return {
|
|
278
|
-
cleanup: () => {
|
|
279
|
-
vi.unstubAllGlobals();
|
|
280
|
-
},
|
|
281
|
-
currentArtifacts: {
|
|
282
|
-
assetBaseStorageUri: `r2://bundles/${fixture.currentBundleId}/files`,
|
|
283
|
-
manifestFileHash: "sig:manifest-current",
|
|
284
|
-
manifestStorageUri: `r2://bundles/${fixture.currentBundleId}/manifest.json`,
|
|
285
|
-
},
|
|
286
|
-
nextArtifacts: {
|
|
287
|
-
assetBaseStorageUri: `r2://bundles/${fixture.nextBundleId}/files`,
|
|
288
|
-
manifestFileHash: "sig:manifest-next",
|
|
289
|
-
manifestStorageUri: `r2://bundles/${fixture.nextBundleId}/manifest.json`,
|
|
290
|
-
patches: [
|
|
291
|
-
{
|
|
292
|
-
baseBundleId: fixture.currentBundleId,
|
|
293
|
-
baseFileHash: "hash-old-bundle",
|
|
294
|
-
patchFileHash: "hash-bsdiff",
|
|
295
|
-
patchStorageUri: `r2://bundles/${fixture.patchPath}`,
|
|
296
|
-
},
|
|
297
|
-
],
|
|
298
|
-
},
|
|
299
|
-
};
|
|
300
|
-
},
|
|
301
|
-
expectPatchUrl: (patchUrl, fixture) => {
|
|
302
|
-
expect(patchUrl).toContain(`/bundles/${fixture.patchPath}`);
|
|
303
|
-
},
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
it("serves canonical routes from the worker entrypoint", async () => {
|
|
132
|
+
it("serves unversioned Release Catalog routes from the worker entrypoint", async () => {
|
|
133
|
+
expect(HOT_UPDATER_BASE_PATH).toBe("/");
|
|
307
134
|
await seedBundles([
|
|
308
135
|
{
|
|
309
136
|
id: "00000000-0000-0000-0000-000000000001",
|
|
310
137
|
platform: "ios",
|
|
311
|
-
targetAppVersion: "1.0",
|
|
312
|
-
shouldForceUpdate: false,
|
|
313
|
-
enabled: true,
|
|
314
138
|
fileHash: "hash",
|
|
315
139
|
gitCommitHash: null,
|
|
316
|
-
message: "hello",
|
|
317
|
-
channel: "production",
|
|
318
140
|
storageUri: "storage://unused",
|
|
319
|
-
|
|
141
|
+
archiveByteSize: 3_000_000_001,
|
|
320
142
|
},
|
|
321
143
|
]);
|
|
322
144
|
|
|
323
|
-
const
|
|
145
|
+
const unauthorized = await worker.fetch(
|
|
324
146
|
new Request(
|
|
325
|
-
`${PUBLIC_BASE_URL}
|
|
326
|
-
appVersion: "1.0",
|
|
327
|
-
bundleId: NIL_UUID,
|
|
328
|
-
platform: "ios",
|
|
329
|
-
_updateStrategy: "appVersion",
|
|
330
|
-
})}`,
|
|
147
|
+
`${PUBLIC_BASE_URL}/release-catalogs/app-version/ios/cHJvZHVjdGlvbg/1.0.0`,
|
|
331
148
|
),
|
|
332
149
|
env,
|
|
333
150
|
);
|
|
334
|
-
|
|
335
|
-
await expect(response.json()).resolves.toMatchObject({
|
|
336
|
-
id: "00000000-0000-0000-0000-000000000001",
|
|
337
|
-
status: "UPDATE",
|
|
338
|
-
});
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
it("serves signed R2 URLs for percent-encoded object keys", async () => {
|
|
342
|
-
const encodedKey = "bundles/assets/bootsplash/logo-ios%402x.png";
|
|
343
|
-
await putR2Object(
|
|
344
|
-
"assets/bootsplash/logo-ios@2x.png",
|
|
345
|
-
"asset",
|
|
346
|
-
"text/plain",
|
|
347
|
-
);
|
|
348
|
-
const token = await signToken(encodedKey, env.JWT_SECRET);
|
|
151
|
+
expect(unauthorized.status).toBe(401);
|
|
349
152
|
|
|
350
153
|
const response = await worker.fetch(
|
|
351
|
-
new Request(
|
|
154
|
+
new Request(
|
|
155
|
+
`${PUBLIC_BASE_URL}/release-catalogs/app-version/ios/cHJvZHVjdGlvbg/1.0.0`,
|
|
156
|
+
{ headers: { "x-api-key": API_KEY } },
|
|
157
|
+
),
|
|
352
158
|
env,
|
|
353
159
|
);
|
|
354
160
|
|
|
355
|
-
expect(response.
|
|
356
|
-
|
|
161
|
+
await expect(response.json()).resolves.toMatchObject({
|
|
162
|
+
catalogId: expect.stringMatching(/^[0-9a-f-]{36}$/),
|
|
163
|
+
releases: [{ bundleId: "00000000-0000-0000-0000-000000000001" }],
|
|
164
|
+
});
|
|
357
165
|
});
|
|
358
166
|
|
|
359
167
|
it("does not support the legacy exact path", async () => {
|
|
360
168
|
const response = await worker.fetch(
|
|
361
|
-
new Request(`${PUBLIC_BASE_URL}
|
|
169
|
+
new Request(`${PUBLIC_BASE_URL}/api/check-update`),
|
|
362
170
|
env,
|
|
363
171
|
);
|
|
364
172
|
|
|
@@ -370,7 +178,7 @@ describe.sequential("cloudflare worker runtime acceptance", () => {
|
|
|
370
178
|
|
|
371
179
|
it("does not expose management routes from the worker entrypoint", async () => {
|
|
372
180
|
const response = await worker.fetch(
|
|
373
|
-
new Request(`${PUBLIC_BASE_URL}
|
|
181
|
+
new Request(`${PUBLIC_BASE_URL}/admin/bundles`),
|
|
374
182
|
env,
|
|
375
183
|
);
|
|
376
184
|
|
package/worker/src/index.ts
CHANGED
|
@@ -1,89 +1,37 @@
|
|
|
1
|
-
import { decodeStorageObjectKey } from "@hot-updater/plugin-core";
|
|
2
1
|
import { createHotUpdater } from "@hot-updater/server";
|
|
2
|
+
import { env } from "cloudflare:workers";
|
|
3
3
|
import { Hono } from "hono";
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
d1Database,
|
|
7
|
-
type RequestEnvContext,
|
|
8
|
-
r2Storage,
|
|
9
|
-
verifyJwtSignedUrl,
|
|
10
|
-
} from "../../src/worker";
|
|
5
|
+
import { d1Database, r2Storage } from "../../src/worker";
|
|
11
6
|
|
|
12
7
|
export type CloudflareWorkerEnv = {
|
|
13
8
|
DB: {
|
|
9
|
+
batch: D1Database["batch"];
|
|
14
10
|
prepare: D1Database["prepare"];
|
|
15
11
|
};
|
|
16
12
|
BUCKET: R2Bucket;
|
|
17
|
-
|
|
13
|
+
BUCKET_NAME: string;
|
|
14
|
+
STORAGE_DOWNLOAD_URL_SIGNING_KEY: string;
|
|
18
15
|
};
|
|
19
16
|
|
|
20
|
-
export const HOT_UPDATER_BASE_PATH = "/
|
|
21
|
-
|
|
22
|
-
const resolveRequestOrigin = (context?: RequestEnvContext) => {
|
|
23
|
-
const request = context?.request;
|
|
24
|
-
|
|
25
|
-
if (!request) {
|
|
26
|
-
throw new Error(
|
|
27
|
-
"r2WorkerStorage requires a request to resolve publicBaseUrl.",
|
|
28
|
-
);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
return new URL(request.url).origin;
|
|
32
|
-
};
|
|
17
|
+
export const HOT_UPDATER_BASE_PATH = "/";
|
|
33
18
|
|
|
34
19
|
const hotUpdater = createHotUpdater({
|
|
35
|
-
database: d1Database(),
|
|
36
|
-
|
|
20
|
+
database: d1Database(env.DB),
|
|
21
|
+
clientAccess: { type: "api-key" },
|
|
22
|
+
storage: [
|
|
37
23
|
r2Storage({
|
|
38
|
-
|
|
24
|
+
bucket: env.BUCKET,
|
|
25
|
+
bucketName: env.BUCKET_NAME,
|
|
26
|
+
downloadUrlSigningKey: env.STORAGE_DOWNLOAD_URL_SIGNING_KEY,
|
|
39
27
|
}),
|
|
40
28
|
],
|
|
41
|
-
basePath: HOT_UPDATER_BASE_PATH,
|
|
42
|
-
routes: {
|
|
43
|
-
updateCheck: true,
|
|
44
|
-
bundles: false,
|
|
45
|
-
},
|
|
46
29
|
});
|
|
47
30
|
|
|
48
31
|
const app = new Hono<{ Bindings: CloudflareWorkerEnv }>();
|
|
49
32
|
|
|
50
|
-
app.mount(
|
|
51
|
-
|
|
52
|
-
(request: Request, env: CloudflareWorkerEnv) => {
|
|
53
|
-
return hotUpdater.handler(request, {
|
|
54
|
-
request,
|
|
55
|
-
env,
|
|
56
|
-
});
|
|
57
|
-
},
|
|
58
|
-
{
|
|
59
|
-
optionHandler: (c) => [c.env],
|
|
60
|
-
},
|
|
33
|
+
app.mount(HOT_UPDATER_BASE_PATH, (request: Request) =>
|
|
34
|
+
hotUpdater.handlers.client(request),
|
|
61
35
|
);
|
|
62
36
|
|
|
63
|
-
app.get("*", async (c) => {
|
|
64
|
-
const result = await verifyJwtSignedUrl({
|
|
65
|
-
path: new URL(c.req.url).pathname,
|
|
66
|
-
token: c.req.query("token"),
|
|
67
|
-
jwtSecret: c.env.JWT_SECRET,
|
|
68
|
-
handler: async (storageUri) => {
|
|
69
|
-
const [, ...key] = decodeStorageObjectKey(storageUri).split("/");
|
|
70
|
-
const object = await c.env.BUCKET.get(key.join("/"));
|
|
71
|
-
if (!object) {
|
|
72
|
-
return null;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return {
|
|
76
|
-
body: object.body,
|
|
77
|
-
contentType: object.httpMetadata?.contentType,
|
|
78
|
-
};
|
|
79
|
-
},
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
if (result.status !== 200) {
|
|
83
|
-
return c.json({ error: result.error }, result.status);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
return c.body(result.responseBody, 200, result.responseHeaders);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
37
|
export default app;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { env } from "cloudflare:test";
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { r2Storage } from "../../src/worker";
|
|
5
|
+
|
|
6
|
+
const UNKNOWN_LENGTH_KEY = "storage-tests/unknown-length.txt";
|
|
7
|
+
const KNOWN_LENGTH_KEY = "storage-tests/known-length.txt";
|
|
8
|
+
|
|
9
|
+
const createChunkedBody = (...chunks: string[]) =>
|
|
10
|
+
new ReadableStream<Uint8Array>({
|
|
11
|
+
start(controller) {
|
|
12
|
+
for (const chunk of chunks) {
|
|
13
|
+
controller.enqueue(new TextEncoder().encode(chunk));
|
|
14
|
+
}
|
|
15
|
+
controller.close();
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const createStorage = () =>
|
|
20
|
+
r2Storage({
|
|
21
|
+
bucket: env.BUCKET,
|
|
22
|
+
bucketName: env.BUCKET_NAME,
|
|
23
|
+
downloadUrlSigningKey: env.STORAGE_DOWNLOAD_URL_SIGNING_KEY,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
await env.BUCKET.delete([UNKNOWN_LENGTH_KEY, KNOWN_LENGTH_KEY]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("Cloudflare Worker R2 storage uploads", () => {
|
|
31
|
+
it("writes exact bytes from an unknown-length stream", async () => {
|
|
32
|
+
const storage = createStorage();
|
|
33
|
+
|
|
34
|
+
await storage.put({
|
|
35
|
+
key: UNKNOWN_LENGTH_KEY,
|
|
36
|
+
body: createChunkedBody("unknown", "-length"),
|
|
37
|
+
contentType: "text/plain",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
await expect(
|
|
41
|
+
env.BUCKET.get(UNKNOWN_LENGTH_KEY).then((item) => item?.text()),
|
|
42
|
+
).resolves.toBe("unknown-length");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("streams known-length bytes through FixedLengthStream", async () => {
|
|
46
|
+
const storage = createStorage();
|
|
47
|
+
|
|
48
|
+
await storage.put({
|
|
49
|
+
key: KNOWN_LENGTH_KEY,
|
|
50
|
+
body: createChunkedBody("known", "-length"),
|
|
51
|
+
contentLength: 12,
|
|
52
|
+
contentType: "text/plain",
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await expect(
|
|
56
|
+
env.BUCKET.get(KNOWN_LENGTH_KEY).then((item) => item?.text()),
|
|
57
|
+
).resolves.toBe("known-length");
|
|
58
|
+
});
|
|
59
|
+
});
|
package/worker/wrangler.json
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
"$schema": "node_modules/wrangler/config-schema.json",
|
|
3
3
|
"name": "hot-updater",
|
|
4
4
|
"main": "./dist/index.js",
|
|
5
|
-
"compatibility_date": "
|
|
5
|
+
"compatibility_date": "2026-08-13",
|
|
6
6
|
"compatibility_flags": ["nodejs_compat"],
|
|
7
|
+
"cache": {
|
|
8
|
+
"enabled": true
|
|
9
|
+
},
|
|
7
10
|
"observability": {
|
|
8
11
|
"enabled": true
|
|
9
12
|
},
|
|
@@ -20,6 +23,6 @@
|
|
|
20
23
|
}
|
|
21
24
|
],
|
|
22
25
|
"vars": {
|
|
23
|
-
"
|
|
26
|
+
"BUCKET_NAME": "user-buckets"
|
|
24
27
|
}
|
|
25
28
|
}
|