@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.
@@ -0,0 +1,11 @@
1
+ -- Migration number: 0004 2025-12-21T00:00:00.000Z
2
+
3
+ -- HotUpdater.bundles
4
+
5
+ ALTER TABLE bundles ADD COLUMN rollout_cohort_count INTEGER DEFAULT 1000
6
+ CHECK (rollout_cohort_count >= 0 AND rollout_cohort_count <= 1000);
7
+
8
+ CREATE INDEX IF NOT EXISTS bundles_rollout_idx ON bundles(rollout_cohort_count);
9
+
10
+ -- SQLite doesn't have array type, store as JSON text
11
+ ALTER TABLE bundles ADD COLUMN target_cohorts TEXT;
@@ -0,0 +1,200 @@
1
+ import {
2
+ type AppVersionGetBundlesArgs,
3
+ type Bundle,
4
+ DEFAULT_ROLLOUT_COHORT_COUNT,
5
+ type FingerprintGetBundlesArgs,
6
+ type GetBundlesArgs,
7
+ NIL_UUID,
8
+ } from "@hot-updater/core";
9
+ import {
10
+ filterCompatibleAppVersions,
11
+ getUpdateInfo as getUpdateInfoJS,
12
+ } from "@hot-updater/js";
13
+
14
+ const parseTargetCohorts = (value: string | null): string[] | null => {
15
+ if (!value) return null;
16
+ try {
17
+ const parsed = JSON.parse(value) as unknown;
18
+ if (Array.isArray(parsed)) {
19
+ return parsed.filter((v): v is string => typeof v === "string");
20
+ }
21
+ } catch {
22
+ return null;
23
+ }
24
+ return null;
25
+ };
26
+
27
+ type BundleRow = {
28
+ id: string;
29
+ platform: Bundle["platform"];
30
+ should_force_update: number;
31
+ enabled: number;
32
+ file_hash: string;
33
+ git_commit_hash: string | null;
34
+ message: string | null;
35
+ channel: string;
36
+ storage_uri: string;
37
+ target_app_version: string | null;
38
+ fingerprint_hash: string | null;
39
+ rollout_cohort_count: number | null;
40
+ target_cohorts: string | null;
41
+ };
42
+
43
+ const convertToBundle = (row: BundleRow): Bundle => ({
44
+ id: row.id,
45
+ platform: row.platform,
46
+ shouldForceUpdate: Boolean(row.should_force_update),
47
+ enabled: Boolean(row.enabled),
48
+ fileHash: row.file_hash,
49
+ gitCommitHash: row.git_commit_hash,
50
+ message: row.message,
51
+ channel: row.channel,
52
+ storageUri: row.storage_uri,
53
+ targetAppVersion: row.target_app_version,
54
+ fingerprintHash: row.fingerprint_hash,
55
+ rolloutCohortCount: row.rollout_cohort_count ?? DEFAULT_ROLLOUT_COHORT_COUNT,
56
+ targetCohorts: parseTargetCohorts(row.target_cohorts),
57
+ });
58
+
59
+ const appVersionStrategy = async (
60
+ DB: D1Database,
61
+ {
62
+ platform,
63
+ appVersion,
64
+ bundleId,
65
+ minBundleId = NIL_UUID,
66
+ channel = "production",
67
+ cohort,
68
+ }: AppVersionGetBundlesArgs,
69
+ ) => {
70
+ const appVersionList = await DB.prepare(
71
+ /* sql */ `
72
+ SELECT
73
+ target_app_version
74
+ FROM bundles
75
+ WHERE platform = ?
76
+ AND channel = ?
77
+ AND enabled = 1
78
+ AND id >= ?
79
+ AND target_app_version IS NOT NULL
80
+ GROUP BY target_app_version
81
+ `,
82
+ )
83
+ .bind(platform, channel, minBundleId)
84
+ .all<{ target_app_version: string; count: number }>();
85
+
86
+ const targetAppVersionList = filterCompatibleAppVersions(
87
+ appVersionList.results.map((group) => group.target_app_version),
88
+ appVersion,
89
+ );
90
+
91
+ if (targetAppVersionList.length === 0) {
92
+ return getUpdateInfoJS([], {
93
+ platform,
94
+ appVersion,
95
+ bundleId,
96
+ minBundleId,
97
+ channel,
98
+ cohort,
99
+ _updateStrategy: "appVersion",
100
+ });
101
+ }
102
+
103
+ const placeholders = targetAppVersionList.map(() => "?").join(", ");
104
+ const rows = await DB.prepare(
105
+ /* sql */ `
106
+ SELECT
107
+ id,
108
+ platform,
109
+ should_force_update,
110
+ enabled,
111
+ file_hash,
112
+ git_commit_hash,
113
+ message,
114
+ channel,
115
+ storage_uri,
116
+ target_app_version,
117
+ fingerprint_hash,
118
+ rollout_cohort_count,
119
+ target_cohorts
120
+ FROM bundles
121
+ WHERE enabled = 1
122
+ AND platform = ?
123
+ AND id >= ?
124
+ AND channel = ?
125
+ AND target_app_version IN (${placeholders})
126
+ `,
127
+ )
128
+ .bind(platform, minBundleId, channel, ...targetAppVersionList)
129
+ .all<BundleRow>();
130
+
131
+ return getUpdateInfoJS(rows.results.map(convertToBundle), {
132
+ platform,
133
+ appVersion,
134
+ bundleId,
135
+ minBundleId,
136
+ channel,
137
+ cohort,
138
+ _updateStrategy: "appVersion",
139
+ });
140
+ };
141
+
142
+ export const fingerprintStrategy = async (
143
+ DB: D1Database,
144
+ {
145
+ platform,
146
+ fingerprintHash,
147
+ bundleId,
148
+ minBundleId = NIL_UUID,
149
+ channel = "production",
150
+ cohort,
151
+ }: FingerprintGetBundlesArgs,
152
+ ) => {
153
+ const rows = await DB.prepare(
154
+ /* sql */ `
155
+ SELECT
156
+ id,
157
+ platform,
158
+ should_force_update,
159
+ enabled,
160
+ file_hash,
161
+ git_commit_hash,
162
+ message,
163
+ channel,
164
+ storage_uri,
165
+ target_app_version,
166
+ fingerprint_hash,
167
+ rollout_cohort_count,
168
+ target_cohorts
169
+ FROM bundles
170
+ WHERE enabled = 1
171
+ AND platform = ?
172
+ AND id >= ?
173
+ AND channel = ?
174
+ AND fingerprint_hash = ?
175
+ `,
176
+ )
177
+ .bind(platform, minBundleId, channel, fingerprintHash)
178
+ .all<BundleRow>();
179
+
180
+ return getUpdateInfoJS(rows.results.map(convertToBundle), {
181
+ platform,
182
+ fingerprintHash,
183
+ bundleId,
184
+ minBundleId,
185
+ channel,
186
+ cohort,
187
+ _updateStrategy: "fingerprint",
188
+ });
189
+ };
190
+
191
+ export const getUpdateInfo = (DB: D1Database, args: GetBundlesArgs) => {
192
+ switch (args._updateStrategy) {
193
+ case "appVersion":
194
+ return appVersionStrategy(DB, args);
195
+ case "fingerprint":
196
+ return fingerprintStrategy(DB, args);
197
+ default:
198
+ return null;
199
+ }
200
+ };
@@ -0,0 +1,171 @@
1
+ import { env } from "cloudflare:test";
2
+ import { type Bundle, type GetBundlesArgs, NIL_UUID } from "@hot-updater/core";
3
+ import { setupGetUpdateInfoTestSuite } from "@hot-updater/test-utils";
4
+ import { beforeAll, beforeEach, describe, expect, inject, it } from "vitest";
5
+ import worker, { HOT_UPDATER_BASE_PATH } from "./index";
6
+
7
+ declare module "vitest" {
8
+ export interface ProvidedContext {
9
+ prepareSql: string;
10
+ }
11
+ }
12
+
13
+ declare module "cloudflare:test" {
14
+ interface ProvidedEnv {
15
+ DB: D1Database;
16
+ BUCKET: R2Bucket;
17
+ JWT_SECRET: string;
18
+ }
19
+ }
20
+
21
+ const PUBLIC_BASE_URL = "https://updates.example.com";
22
+
23
+ const createInsertBundleQuery = (bundle: Bundle) => {
24
+ const rolloutCohortCount = bundle.rolloutCohortCount ?? 1000;
25
+ const targetCohorts = bundle.targetCohorts
26
+ ? `'${JSON.stringify(bundle.targetCohorts)}'`
27
+ : "null";
28
+
29
+ return `
30
+ INSERT INTO bundles (
31
+ id, file_hash, platform, target_app_version,
32
+ should_force_update, enabled, git_commit_hash, message, channel,
33
+ storage_uri, fingerprint_hash, rollout_cohort_count, target_cohorts
34
+ ) VALUES (
35
+ '${bundle.id}',
36
+ '${bundle.fileHash}',
37
+ '${bundle.platform}',
38
+ ${bundle.targetAppVersion ? `'${bundle.targetAppVersion}'` : "null"},
39
+ ${bundle.shouldForceUpdate},
40
+ ${bundle.enabled},
41
+ ${bundle.gitCommitHash ? `'${bundle.gitCommitHash}'` : "null"},
42
+ ${bundle.message ? `'${bundle.message}'` : "null"},
43
+ '${bundle.channel}',
44
+ ${bundle.storageUri ? `'${bundle.storageUri}'` : "null"},
45
+ ${bundle.fingerprintHash ? `'${bundle.fingerprintHash}'` : "null"},
46
+ ${rolloutCohortCount},
47
+ ${targetCohorts}
48
+ ) ON CONFLICT(id) DO UPDATE SET
49
+ file_hash = excluded.file_hash,
50
+ platform = excluded.platform,
51
+ target_app_version = excluded.target_app_version,
52
+ should_force_update = excluded.should_force_update,
53
+ enabled = excluded.enabled,
54
+ git_commit_hash = excluded.git_commit_hash,
55
+ message = excluded.message,
56
+ channel = excluded.channel,
57
+ storage_uri = excluded.storage_uri,
58
+ fingerprint_hash = excluded.fingerprint_hash,
59
+ rollout_cohort_count = excluded.rollout_cohort_count,
60
+ target_cohorts = excluded.target_cohorts;
61
+ `;
62
+ };
63
+
64
+ const toRuntimeBundle = (bundle: Bundle): Bundle => {
65
+ return {
66
+ ...bundle,
67
+ storageUri: `r2://bundles/${bundle.id}/bundle.zip`,
68
+ };
69
+ };
70
+
71
+ const seedBundles = async (bundles: Bundle[]) => {
72
+ for (const bundle of bundles.map(toRuntimeBundle)) {
73
+ await env.DB.prepare(createInsertBundleQuery(bundle)).run();
74
+ }
75
+ };
76
+
77
+ const createCanonicalPath = (args: GetBundlesArgs) => {
78
+ const channel = args.channel ?? "production";
79
+ const minBundleId = args.minBundleId ?? NIL_UUID;
80
+ const cohortSegment = args.cohort
81
+ ? `/${encodeURIComponent(args.cohort)}`
82
+ : "";
83
+
84
+ if (args._updateStrategy === "appVersion") {
85
+ return `${HOT_UPDATER_BASE_PATH}/app-version/${encodeURIComponent(args.platform)}/${encodeURIComponent(args.appVersion)}/${encodeURIComponent(channel)}/${encodeURIComponent(minBundleId)}/${encodeURIComponent(args.bundleId)}${cohortSegment}`;
86
+ }
87
+
88
+ return `${HOT_UPDATER_BASE_PATH}/fingerprint/${encodeURIComponent(args.platform)}/${encodeURIComponent(args.fingerprintHash)}/${encodeURIComponent(channel)}/${encodeURIComponent(minBundleId)}/${encodeURIComponent(args.bundleId)}${cohortSegment}`;
89
+ };
90
+
91
+ describe.sequential("cloudflare worker runtime acceptance", () => {
92
+ beforeAll(async () => {
93
+ await env.DB.prepare(inject("prepareSql")).run();
94
+ });
95
+
96
+ beforeEach(async () => {
97
+ await env.DB.prepare("DELETE FROM bundles").run();
98
+ });
99
+
100
+ const getUpdateInfo = async (bundles: Bundle[], args: GetBundlesArgs) => {
101
+ await seedBundles(bundles);
102
+
103
+ const response = await worker.fetch(
104
+ new Request(`${PUBLIC_BASE_URL}${createCanonicalPath(args)}`),
105
+ env,
106
+ );
107
+
108
+ return (await response.json()) as any;
109
+ };
110
+
111
+ setupGetUpdateInfoTestSuite({ getUpdateInfo });
112
+
113
+ it("serves canonical routes from the worker entrypoint", async () => {
114
+ await seedBundles([
115
+ {
116
+ id: "00000000-0000-0000-0000-000000000001",
117
+ platform: "ios",
118
+ targetAppVersion: "1.0",
119
+ shouldForceUpdate: false,
120
+ enabled: true,
121
+ fileHash: "hash",
122
+ gitCommitHash: null,
123
+ message: "hello",
124
+ channel: "production",
125
+ storageUri: "storage://unused",
126
+ fingerprintHash: null,
127
+ },
128
+ ]);
129
+
130
+ const response = await worker.fetch(
131
+ new Request(
132
+ `${PUBLIC_BASE_URL}${createCanonicalPath({
133
+ appVersion: "1.0",
134
+ bundleId: NIL_UUID,
135
+ platform: "ios",
136
+ _updateStrategy: "appVersion",
137
+ })}`,
138
+ ),
139
+ env,
140
+ );
141
+
142
+ await expect(response.json()).resolves.toMatchObject({
143
+ id: "00000000-0000-0000-0000-000000000001",
144
+ status: "UPDATE",
145
+ });
146
+ });
147
+
148
+ it("does not support the legacy exact path", async () => {
149
+ const response = await worker.fetch(
150
+ new Request(`${PUBLIC_BASE_URL}${HOT_UPDATER_BASE_PATH}`),
151
+ env,
152
+ );
153
+
154
+ expect(response.status).toBe(404);
155
+ await expect(response.json()).resolves.toEqual({
156
+ error: "Not found",
157
+ });
158
+ });
159
+
160
+ it("does not expose management routes from the worker entrypoint", async () => {
161
+ const response = await worker.fetch(
162
+ new Request(`${PUBLIC_BASE_URL}${HOT_UPDATER_BASE_PATH}/api/bundles`),
163
+ env,
164
+ );
165
+
166
+ expect(response.status).toBe(404);
167
+ await expect(response.json()).resolves.toEqual({
168
+ error: "Not found",
169
+ });
170
+ });
171
+ });
@@ -0,0 +1,87 @@
1
+ import { createHotUpdater } from "@hot-updater/server/runtime";
2
+ import { Hono } from "hono";
3
+ import {
4
+ d1Database,
5
+ type RequestEnvContext,
6
+ r2Storage,
7
+ verifyJwtSignedUrl,
8
+ } from "../../src/worker";
9
+
10
+ export type CloudflareWorkerEnv = {
11
+ DB: {
12
+ prepare: D1Database["prepare"];
13
+ };
14
+ BUCKET: R2Bucket;
15
+ JWT_SECRET: string;
16
+ };
17
+
18
+ export const HOT_UPDATER_BASE_PATH = "/api/check-update";
19
+
20
+ const resolveRequestOrigin = (context?: RequestEnvContext) => {
21
+ const request = context?.request;
22
+
23
+ if (!request) {
24
+ throw new Error(
25
+ "r2WorkerStorage requires a request to resolve publicBaseUrl.",
26
+ );
27
+ }
28
+
29
+ return new URL(request.url).origin;
30
+ };
31
+
32
+ const hotUpdater = createHotUpdater({
33
+ database: d1Database(),
34
+ storages: [
35
+ r2Storage({
36
+ publicBaseUrl: resolveRequestOrigin,
37
+ }),
38
+ ],
39
+ basePath: HOT_UPDATER_BASE_PATH,
40
+ routes: {
41
+ updateCheck: true,
42
+ bundles: false,
43
+ },
44
+ });
45
+
46
+ const app = new Hono<{ Bindings: CloudflareWorkerEnv }>();
47
+
48
+ app.mount(
49
+ HOT_UPDATER_BASE_PATH,
50
+ (request: Request, env: CloudflareWorkerEnv) => {
51
+ return hotUpdater.handler(request, {
52
+ request,
53
+ env,
54
+ });
55
+ },
56
+ {
57
+ optionHandler: (c) => [c.env],
58
+ },
59
+ );
60
+
61
+ app.get("*", async (c) => {
62
+ const result = await verifyJwtSignedUrl({
63
+ path: c.req.path,
64
+ token: c.req.query("token"),
65
+ jwtSecret: c.env.JWT_SECRET,
66
+ handler: async (storageUri) => {
67
+ const [, ...key] = storageUri.split("/");
68
+ const object = await c.env.BUCKET.get(key.join("/"));
69
+ if (!object) {
70
+ return null;
71
+ }
72
+
73
+ return {
74
+ body: object.body,
75
+ contentType: object.httpMetadata?.contentType,
76
+ };
77
+ },
78
+ });
79
+
80
+ if (result.status !== 200) {
81
+ return c.json({ error: result.error }, result.status);
82
+ }
83
+
84
+ return c.body(result.responseBody, 200, result.responseHeaders);
85
+ });
86
+
87
+ export default app;
@@ -0,0 +1,5 @@
1
+ export default {
2
+ fetch() {
3
+ return new Response("ok");
4
+ },
5
+ };
File without changes