@hot-updater/cloudflare 0.36.7 → 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.
Files changed (49) hide show
  1. package/dist/iac/index.cjs +988 -10
  2. package/dist/iac/index.mjs +991 -14
  3. package/dist/index.cjs +919 -6989
  4. package/dist/index.d.cts +11 -47
  5. package/dist/index.d.mts +11 -47
  6. package/dist/index.mjs +917 -7006
  7. package/dist/worker/index.cjs +898 -336
  8. package/dist/worker/index.d.cts +18 -33
  9. package/dist/worker/index.d.mts +18 -33
  10. package/dist/worker/index.mjs +899 -331
  11. package/package.json +7 -10
  12. package/sql/bundles.sql +183 -35
  13. package/src/d1Database.spec.ts +143 -392
  14. package/src/d1Database.ts +55 -650
  15. package/src/d1Implementation.spec.ts +252 -0
  16. package/src/d1Implementation.ts +742 -0
  17. package/src/d1Query.spec.ts +46 -0
  18. package/src/d1Query.ts +89 -0
  19. package/src/d1Rows.spec.ts +111 -0
  20. package/src/d1Rows.ts +326 -0
  21. package/src/d1Sql.spec.ts +30 -0
  22. package/src/d1Sql.ts +175 -0
  23. package/src/r2S3Storage.ts +93 -137
  24. package/src/r2Storage.spec.ts +120 -218
  25. package/src/r2Storage.ts +13 -61
  26. package/src/r2WorkerStorage.spec.ts +161 -50
  27. package/src/r2WorkerStorage.ts +85 -118
  28. package/src/worker/d1Database.spec.ts +33 -0
  29. package/src/worker/d1Database.ts +52 -0
  30. package/src/worker/index.ts +3 -31
  31. package/worker/dist/README.md +1 -1
  32. package/worker/dist/index.js +5968 -4701
  33. package/worker/dist/index.js.map +4 -4
  34. package/worker/migrations/0001_hot-updater_1.0.0.sql +197 -0
  35. package/worker/migrations/0002_hot-updater_artifact_byte_sizes.sql +11 -0
  36. package/worker/src/databaseMigration.integration.spec.ts +114 -0
  37. package/worker/src/databasePlugins.integration.spec.ts +292 -0
  38. package/worker/src/index.integration.spec.ts +102 -294
  39. package/worker/src/index.ts +14 -66
  40. package/worker/src/storagePlugin.integration.spec.ts +59 -0
  41. package/worker/wrangler.json +5 -2
  42. package/src/cloudflareWorkerDatabase.spec.ts +0 -260
  43. package/src/cloudflareWorkerDatabase.ts +0 -651
  44. package/src/r2WranglerStorage.ts +0 -193
  45. package/worker/migrations/0001_hot-updater_init.sql +0 -16
  46. package/worker/migrations/0002_hot-updater_0.13.0.sql +0 -8
  47. package/worker/migrations/0003_hot-updater_0.18.0.sql +0 -38
  48. package/worker/migrations/0004_hot-updater_0.29.0.sql +0 -11
  49. 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
- type GetBundlesArgs,
5
- NIL_UUID,
3
+ createReleaseCatalogScopeKey,
4
+ encodeChannelKey,
6
5
  } from "@hot-updater/core";
7
- import { signToken } from "@hot-updater/js";
8
6
  import {
9
- setupBsdiffManifestUpdateInfoTestSuite,
10
- setupGetUpdateInfoTestSuite,
11
- } from "@hot-updater/test-utils";
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
- JWT_SECRET: string;
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://bundles/${bundle.id}/bundle.zip`,
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 env.DB.prepare(createInsertBundleQuery(bundle)).run();
134
- for (const patchSql of createInsertBundlePatchQueries(bundle)) {
135
- await env.DB.prepare(patchSql).run();
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
- const requestUpdateInfo = async (args: GetBundlesArgs) => {
173
- const response = await worker.fetch(
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
- fingerprintHash: null,
141
+ archiveByteSize: 3_000_000_001,
320
142
  },
321
143
  ]);
322
144
 
323
- const response = await worker.fetch(
145
+ const unauthorized = await worker.fetch(
324
146
  new Request(
325
- `${PUBLIC_BASE_URL}${createCanonicalPath({
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(`${PUBLIC_BASE_URL}/${encodedKey}?token=${token}`),
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.status).toBe(200);
356
- await expect(response.text()).resolves.toBe("asset");
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}${HOT_UPDATER_BASE_PATH}`),
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}${HOT_UPDATER_BASE_PATH}/api/bundles`),
181
+ new Request(`${PUBLIC_BASE_URL}/admin/bundles`),
374
182
  env,
375
183
  );
376
184
 
@@ -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
- JWT_SECRET: string;
13
+ BUCKET_NAME: string;
14
+ STORAGE_DOWNLOAD_URL_SIGNING_KEY: string;
18
15
  };
19
16
 
20
- export const HOT_UPDATER_BASE_PATH = "/api/check-update";
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
- storages: [
20
+ database: d1Database(env.DB),
21
+ clientAccess: { type: "api-key" },
22
+ storage: [
37
23
  r2Storage({
38
- publicBaseUrl: resolveRequestOrigin,
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
- HOT_UPDATER_BASE_PATH,
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
+ });
@@ -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": "2025-01-24",
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
- "JWT_SECRET": "USER_JWT_SECRET"
26
+ "BUCKET_NAME": "user-buckets"
24
27
  }
25
28
  }