@hot-updater/server 0.31.4 → 0.32.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 (28) hide show
  1. package/dist/_virtual/_rolldown/runtime.cjs +1 -1
  2. package/dist/_virtual/_rolldown/runtime.mjs +1 -1
  3. package/dist/db/createBundleDiff.cjs +19 -13
  4. package/dist/db/createBundleDiff.mjs +15 -9
  5. package/dist/db/schemaEnhancements.cjs +1 -1
  6. package/dist/db/updateArtifacts.cjs +6 -6
  7. package/dist/db/updateArtifacts.mjs +6 -6
  8. package/dist/handler.cjs +18 -8
  9. package/dist/handler.d.cts +9 -10
  10. package/dist/handler.d.mts +9 -10
  11. package/dist/handler.mjs +17 -7
  12. package/dist/index.d.cts +1 -1
  13. package/dist/index.d.mts +1 -1
  14. package/dist/node_modules/.pnpm/fumadb@0.2.2_drizzle-orm@0.44.7_@cloudflare_workers-types@4.20260313.1_@electric-sql_pg_c72c8c754becd21f6d6662e8fbd28e7f/node_modules/fumadb/dist/index.d.cts +1 -1
  15. package/dist/node_modules/.pnpm/fumadb@0.2.2_drizzle-orm@0.44.7_@cloudflare_workers-types@4.20260313.1_@electric-sql_pg_c72c8c754becd21f6d6662e8fbd28e7f/node_modules/fumadb/dist/index.d.mts +1 -1
  16. package/dist/node_modules/.pnpm/fumadb@0.2.2_drizzle-orm@0.44.7_@cloudflare_workers-types@4.20260313.1_@electric-sql_pg_c72c8c754becd21f6d6662e8fbd28e7f/node_modules/fumadb/dist/query/index.d.cts +1 -1
  17. package/dist/node_modules/.pnpm/fumadb@0.2.2_drizzle-orm@0.44.7_@cloudflare_workers-types@4.20260313.1_@electric-sql_pg_c72c8c754becd21f6d6662e8fbd28e7f/node_modules/fumadb/dist/query/index.d.mts +1 -1
  18. package/dist/packages/server/package.cjs +1 -1
  19. package/dist/packages/server/package.mjs +1 -1
  20. package/package.json +7 -7
  21. package/src/db/createBundleDiff.spec.ts +3 -0
  22. package/src/db/createBundleDiff.ts +27 -21
  23. package/src/db/pluginCore.spec.ts +109 -0
  24. package/src/db/updateArtifacts.ts +8 -19
  25. package/src/handler-standalone.integration.spec.ts +12 -0
  26. package/src/handler.spec.ts +117 -19
  27. package/src/handler.ts +47 -21
  28. package/src/runtime.spec.ts +7 -4
@@ -5,7 +5,7 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
6
  var __getProtoOf = Object.getPrototypeOf;
7
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
- var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
8
+ var __commonJSMin = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
9
9
  var __copyProps = (to, from, except, desc) => {
10
10
  if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
11
11
  key = keys[i];
@@ -1,6 +1,6 @@
1
1
  import { createRequire } from "node:module";
2
2
  //#region \0rolldown/runtime.js
3
- var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
3
+ var __commonJSMin = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
4
4
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
5
5
  //#endregion
6
6
  export { __commonJSMin, __require };
@@ -1,12 +1,13 @@
1
1
  const require_runtime = require("../_virtual/_rolldown/runtime.cjs");
2
+ let _hot_updater_plugin_core = require("@hot-updater/plugin-core");
2
3
  let node_crypto = require("node:crypto");
3
- node_crypto = require_runtime.__toESM(node_crypto, 1);
4
+ node_crypto = require_runtime.__toESM(node_crypto);
4
5
  let node_fs_promises = require("node:fs/promises");
5
- node_fs_promises = require_runtime.__toESM(node_fs_promises, 1);
6
+ node_fs_promises = require_runtime.__toESM(node_fs_promises);
6
7
  let node_os = require("node:os");
7
- node_os = require_runtime.__toESM(node_os, 1);
8
+ node_os = require_runtime.__toESM(node_os);
8
9
  let node_path = require("node:path");
9
- node_path = require_runtime.__toESM(node_path, 1);
10
+ node_path = require_runtime.__toESM(node_path);
10
11
  let node_util = require("node:util");
11
12
  let node_zlib = require("node:zlib");
12
13
  let _hot_updater_bsdiff = require("@hot-updater/bsdiff");
@@ -27,11 +28,6 @@ const isBundleManifest = (value) => {
27
28
  return typeof manifestAsset.fileHash === "string" && (manifestAsset.signature === void 0 || typeof manifestAsset.signature === "string");
28
29
  });
29
30
  };
30
- const createChildStorageUri = (baseStorageUri, relativePath) => {
31
- const baseUrl = new URL(baseStorageUri);
32
- baseUrl.pathname = `${baseUrl.pathname.replace(/\/+$/, "")}/${relativePath.split("/").filter(Boolean).map((segment) => encodeURIComponent(segment)).join("/")}`;
33
- return baseUrl.toString();
34
- };
35
31
  const getRelativeStorageDir = (relativePath) => {
36
32
  const normalized = relativePath.replace(/\\/g, "/");
37
33
  const dirname = node_path.default.posix.dirname(normalized);
@@ -76,18 +72,28 @@ function resolveHbcAssetPath(manifest) {
76
72
  if (candidates.length > 1) throw new Error(`Expected exactly one Hermes bundle asset in manifest, found ${candidates.length}: ${candidates.join(", ")}`);
77
73
  return candidates[0];
78
74
  }
79
- async function fetchAssetBytes(bundle, assetPath, storagePlugin) {
75
+ async function fetchAssetBytes(bundle, assetPath, manifest, storagePlugin) {
80
76
  const assetBaseStorageUri = (0, _hot_updater_core.getAssetBaseStorageUri)(bundle);
81
77
  if (!assetBaseStorageUri) throw new Error(`Bundle ${bundle.id} does not have asset storage metadata`);
78
+ const asset = manifest.assets[assetPath];
79
+ if (!asset) throw new Error(`Asset ${assetPath} is missing from manifest`);
82
80
  if (BR_COMPRESSED_ASSET_PATH_RE.test(assetPath)) {
83
- const compressedAssetStorageUri = createChildStorageUri(assetBaseStorageUri, `${assetPath}.br`);
81
+ const compressedAssetStorageUri = (0, _hot_updater_plugin_core.resolveManifestAssetStorageUri)({
82
+ assetBaseStorageUri,
83
+ assetPath: `${assetPath}.br`,
84
+ fileHash: asset.fileHash
85
+ });
84
86
  let compressedBytes = null;
85
87
  try {
86
88
  compressedBytes = await downloadStorageBytes(compressedAssetStorageUri, storagePlugin);
87
89
  } catch {}
88
90
  if (compressedBytes) return new Uint8Array(await decompressBrotli(compressedBytes));
89
91
  }
90
- return downloadStorageBytes(createChildStorageUri(assetBaseStorageUri, assetPath), storagePlugin);
92
+ return downloadStorageBytes((0, _hot_updater_plugin_core.resolveManifestAssetStorageUri)({
93
+ assetBaseStorageUri,
94
+ assetPath,
95
+ fileHash: asset.fileHash
96
+ }), storagePlugin);
91
97
  }
92
98
  async function getFileHash(filePath) {
93
99
  const file = await node_fs_promises.default.readFile(filePath);
@@ -117,7 +123,7 @@ async function createBundleDiff({ baseBundleId, bundleId }, deps, options = {})
117
123
  const targetAssetHash = targetManifest.assets[targetAssetPath]?.fileHash;
118
124
  if (!baseAssetHash || !targetAssetHash) throw new Error("Hermes asset hash is missing from manifest");
119
125
  if (baseAssetHash === targetAssetHash) throw new Error("Hermes bundle is unchanged; no diff patch is required");
120
- const [baseBytes, targetBytes] = await Promise.all([fetchAssetBytes(baseBundle, baseAssetPath, deps.storagePlugin), fetchAssetBytes(targetBundle, targetAssetPath, deps.storagePlugin)]);
126
+ const [baseBytes, targetBytes] = await Promise.all([fetchAssetBytes(baseBundle, baseAssetPath, baseManifest, deps.storagePlugin), fetchAssetBytes(targetBundle, targetAssetPath, targetManifest, deps.storagePlugin)]);
121
127
  const patchBytes = await (0, _hot_updater_bsdiff.hdiff)(baseBytes, targetBytes);
122
128
  const workDir = await node_fs_promises.default.mkdtemp(node_path.default.join(node_os.default.tmpdir(), "hot-updater-console-bsdiff-"));
123
129
  const patchFilename = `${node_path.default.posix.basename(targetAssetPath)}.bsdiff`;
@@ -1,3 +1,4 @@
1
+ import { resolveManifestAssetStorageUri } from "@hot-updater/plugin-core";
1
2
  import crypto, { randomUUID } from "node:crypto";
2
3
  import fs from "node:fs/promises";
3
4
  import os from "node:os";
@@ -22,11 +23,6 @@ const isBundleManifest = (value) => {
22
23
  return typeof manifestAsset.fileHash === "string" && (manifestAsset.signature === void 0 || typeof manifestAsset.signature === "string");
23
24
  });
24
25
  };
25
- const createChildStorageUri = (baseStorageUri, relativePath) => {
26
- const baseUrl = new URL(baseStorageUri);
27
- baseUrl.pathname = `${baseUrl.pathname.replace(/\/+$/, "")}/${relativePath.split("/").filter(Boolean).map((segment) => encodeURIComponent(segment)).join("/")}`;
28
- return baseUrl.toString();
29
- };
30
26
  const getRelativeStorageDir = (relativePath) => {
31
27
  const normalized = relativePath.replace(/\\/g, "/");
32
28
  const dirname = path.posix.dirname(normalized);
@@ -71,18 +67,28 @@ function resolveHbcAssetPath(manifest) {
71
67
  if (candidates.length > 1) throw new Error(`Expected exactly one Hermes bundle asset in manifest, found ${candidates.length}: ${candidates.join(", ")}`);
72
68
  return candidates[0];
73
69
  }
74
- async function fetchAssetBytes(bundle, assetPath, storagePlugin) {
70
+ async function fetchAssetBytes(bundle, assetPath, manifest, storagePlugin) {
75
71
  const assetBaseStorageUri = getAssetBaseStorageUri(bundle);
76
72
  if (!assetBaseStorageUri) throw new Error(`Bundle ${bundle.id} does not have asset storage metadata`);
73
+ const asset = manifest.assets[assetPath];
74
+ if (!asset) throw new Error(`Asset ${assetPath} is missing from manifest`);
77
75
  if (BR_COMPRESSED_ASSET_PATH_RE.test(assetPath)) {
78
- const compressedAssetStorageUri = createChildStorageUri(assetBaseStorageUri, `${assetPath}.br`);
76
+ const compressedAssetStorageUri = resolveManifestAssetStorageUri({
77
+ assetBaseStorageUri,
78
+ assetPath: `${assetPath}.br`,
79
+ fileHash: asset.fileHash
80
+ });
79
81
  let compressedBytes = null;
80
82
  try {
81
83
  compressedBytes = await downloadStorageBytes(compressedAssetStorageUri, storagePlugin);
82
84
  } catch {}
83
85
  if (compressedBytes) return new Uint8Array(await decompressBrotli(compressedBytes));
84
86
  }
85
- return downloadStorageBytes(createChildStorageUri(assetBaseStorageUri, assetPath), storagePlugin);
87
+ return downloadStorageBytes(resolveManifestAssetStorageUri({
88
+ assetBaseStorageUri,
89
+ assetPath,
90
+ fileHash: asset.fileHash
91
+ }), storagePlugin);
86
92
  }
87
93
  async function getFileHash(filePath) {
88
94
  const file = await fs.readFile(filePath);
@@ -112,7 +118,7 @@ async function createBundleDiff({ baseBundleId, bundleId }, deps, options = {})
112
118
  const targetAssetHash = targetManifest.assets[targetAssetPath]?.fileHash;
113
119
  if (!baseAssetHash || !targetAssetHash) throw new Error("Hermes asset hash is missing from manifest");
114
120
  if (baseAssetHash === targetAssetHash) throw new Error("Hermes bundle is unchanged; no diff patch is required");
115
- const [baseBytes, targetBytes] = await Promise.all([fetchAssetBytes(baseBundle, baseAssetPath, deps.storagePlugin), fetchAssetBytes(targetBundle, targetAssetPath, deps.storagePlugin)]);
121
+ const [baseBytes, targetBytes] = await Promise.all([fetchAssetBytes(baseBundle, baseAssetPath, baseManifest, deps.storagePlugin), fetchAssetBytes(targetBundle, targetAssetPath, targetManifest, deps.storagePlugin)]);
116
122
  const patchBytes = await hdiff(baseBytes, targetBytes);
117
123
  const workDir = await fs.mkdtemp(path.join(os.tmpdir(), "hot-updater-console-bsdiff-"));
118
124
  const patchFilename = `${path.posix.basename(targetAssetPath)}.bsdiff`;
@@ -1,7 +1,7 @@
1
1
  const require_runtime = require("../_virtual/_rolldown/runtime.cjs");
2
2
  let _hot_updater_core = require("@hot-updater/core");
3
3
  let semver = require("semver");
4
- semver = require_runtime.__toESM(semver, 1);
4
+ semver = require_runtime.__toESM(semver);
5
5
  //#region src/db/schemaEnhancements.ts
6
6
  const normalizeNullableString = (value) => {
7
7
  if (value === null || value === void 0) return null;
@@ -1,4 +1,5 @@
1
1
  require("../_virtual/_rolldown/runtime.cjs");
2
+ let _hot_updater_plugin_core = require("@hot-updater/plugin-core");
2
3
  let _hot_updater_core = require("@hot-updater/core");
3
4
  //#region src/db/updateArtifacts.ts
4
5
  const HBC_ASSET_PATH_RE = /\.bundle$/;
@@ -18,11 +19,6 @@ const isBundleManifest = (value) => {
18
19
  return typeof manifestAsset.fileHash === "string" && (manifestAsset.signature === void 0 || typeof manifestAsset.signature === "string");
19
20
  });
20
21
  };
21
- const createChildStorageUri = (baseStorageUri, relativePath) => {
22
- const baseUrl = new URL(baseStorageUri);
23
- baseUrl.pathname = `${baseUrl.pathname.replace(/\/+$/, "")}/${relativePath.split("/").filter(Boolean).map((segment) => encodeURIComponent(segment)).join("/")}`;
24
- return baseUrl.toString();
25
- };
26
22
  const parseBundleMetadata = (value) => {
27
23
  if (!value) return;
28
24
  let parsedValue = value;
@@ -61,7 +57,11 @@ async function resolveChangedAssets({ assetBaseStorageUri, currentManifest, curr
61
57
  const changedEntries = await Promise.all(Object.entries(targetManifest.assets).map(async ([assetPath, asset]) => {
62
58
  if ((currentManifest?.assets[assetPath])?.fileHash === asset.fileHash) return null;
63
59
  const usesBrotliAsset = BR_COMPRESSED_ASSET_PATH_RE.test(assetPath);
64
- const storageUri = createChildStorageUri(assetBaseStorageUri, usesBrotliAsset ? `${assetPath}.br` : assetPath);
60
+ const storageUri = (0, _hot_updater_plugin_core.resolveManifestAssetStorageUri)({
61
+ assetBaseStorageUri,
62
+ assetPath: usesBrotliAsset ? `${assetPath}.br` : assetPath,
63
+ fileHash: asset.fileHash
64
+ });
65
65
  const patch = patchDescriptor?.assetPath === assetPath ? patchDescriptor.patch : null;
66
66
  let fileUrl = null;
67
67
  try {
@@ -1,3 +1,4 @@
1
+ import { resolveManifestAssetStorageUri } from "@hot-updater/plugin-core";
1
2
  import { getAssetBaseStorageUri, getBundlePatch, getManifestFileHash, getManifestStorageUri, stripBundleArtifactMetadata } from "@hot-updater/core";
2
3
  //#region src/db/updateArtifacts.ts
3
4
  const HBC_ASSET_PATH_RE = /\.bundle$/;
@@ -17,11 +18,6 @@ const isBundleManifest = (value) => {
17
18
  return typeof manifestAsset.fileHash === "string" && (manifestAsset.signature === void 0 || typeof manifestAsset.signature === "string");
18
19
  });
19
20
  };
20
- const createChildStorageUri = (baseStorageUri, relativePath) => {
21
- const baseUrl = new URL(baseStorageUri);
22
- baseUrl.pathname = `${baseUrl.pathname.replace(/\/+$/, "")}/${relativePath.split("/").filter(Boolean).map((segment) => encodeURIComponent(segment)).join("/")}`;
23
- return baseUrl.toString();
24
- };
25
21
  const parseBundleMetadata = (value) => {
26
22
  if (!value) return;
27
23
  let parsedValue = value;
@@ -60,7 +56,11 @@ async function resolveChangedAssets({ assetBaseStorageUri, currentManifest, curr
60
56
  const changedEntries = await Promise.all(Object.entries(targetManifest.assets).map(async ([assetPath, asset]) => {
61
57
  if ((currentManifest?.assets[assetPath])?.fileHash === asset.fileHash) return null;
62
58
  const usesBrotliAsset = BR_COMPRESSED_ASSET_PATH_RE.test(assetPath);
63
- const storageUri = createChildStorageUri(assetBaseStorageUri, usesBrotliAsset ? `${assetPath}.br` : assetPath);
59
+ const storageUri = resolveManifestAssetStorageUri({
60
+ assetBaseStorageUri,
61
+ assetPath: usesBrotliAsset ? `${assetPath}.br` : assetPath,
62
+ fileHash: asset.fileHash
63
+ });
64
64
  const patch = patchDescriptor?.assetPath === assetPath ? patchDescriptor.patch : null;
65
65
  let fileUrl = null;
66
66
  try {
package/dist/handler.cjs CHANGED
@@ -2,7 +2,7 @@ const require_runtime = require("./_virtual/_rolldown/runtime.cjs");
2
2
  const require_internalRouter = require("./internalRouter.cjs");
3
3
  const require_version = require("./version.cjs");
4
4
  let semver = require("semver");
5
- semver = require_runtime.__toESM(semver, 1);
5
+ semver = require_runtime.__toESM(semver);
6
6
  //#region src/handler.ts
7
7
  var HandlerBadRequestError = class extends Error {
8
8
  constructor(message) {
@@ -12,6 +12,8 @@ var HandlerBadRequestError = class extends Error {
12
12
  };
13
13
  const SDK_VERSION_HEADER = "Hot-Updater-SDK-Version";
14
14
  const EXPLICIT_NO_UPDATE_MIN_SDK_VERSION = "0.31.0";
15
+ const DEFAULT_BUNDLE_LIST_LIMIT = 50;
16
+ const MAX_BUNDLE_LIST_LIMIT = 100;
15
17
  const supportsExplicitNoUpdateResponse = (request) => {
16
18
  const sdkVersion = request.headers.get(SDK_VERSION_HEADER)?.trim();
17
19
  if (!sdkVersion) return false;
@@ -61,6 +63,13 @@ const parseStringArraySearchParam = (url, key) => {
61
63
  const values = url.searchParams.getAll(key);
62
64
  return values.length > 0 ? values : void 0;
63
65
  };
66
+ const parsePositiveIntegerSearchParam = (url, key, defaultValue, maxValue) => {
67
+ const value = url.searchParams.get(key);
68
+ if (value === null) return defaultValue;
69
+ const parsed = Number(value);
70
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > maxValue) throw new HandlerBadRequestError(`The '${key}' query parameter must be a positive integer between 1 and ${maxValue}.`);
71
+ return parsed;
72
+ };
64
73
  const requirePlatformParam = (params) => {
65
74
  const platform = requireRouteParam(params, "platform");
66
75
  if (!isPlatform(platform)) throw new HandlerBadRequestError(`Invalid platform: ${platform}. Expected 'ios' or 'android'.`);
@@ -129,7 +138,7 @@ const handleGetBundles = async (_params, request, api, context) => {
129
138
  const url = new URL(request.url);
130
139
  const channel = url.searchParams.get("channel") ?? void 0;
131
140
  const platform = url.searchParams.get("platform");
132
- const limit = Number(url.searchParams.get("limit")) || 50;
141
+ const limit = parsePositiveIntegerSearchParam(url, "limit", DEFAULT_BUNDLE_LIST_LIMIT, MAX_BUNDLE_LIST_LIMIT);
133
142
  const pageParam = url.searchParams.get("page");
134
143
  const offset = url.searchParams.get("offset");
135
144
  const after = url.searchParams.get("after") ?? void 0;
@@ -231,18 +240,19 @@ const routes = {
231
240
  */
232
241
  function createHandler(api, options = {}) {
233
242
  const basePath = options.basePath ?? "/api";
234
- const updateCheckEnabled = options.routes?.updateCheck ?? true;
235
- const versionEnabled = options.routes?.version ?? true;
236
- const bundlesEnabled = options.routes?.bundles ?? true;
243
+ const routeOptions = {
244
+ updateCheck: options.routes?.updateCheck ?? true,
245
+ bundles: options.routes?.bundles ?? false
246
+ };
237
247
  const router = require_internalRouter.createRouter();
238
- if (versionEnabled) require_internalRouter.addRoute(router, "GET", "/version", "version");
239
- if (updateCheckEnabled) {
248
+ require_internalRouter.addRoute(router, "GET", "/version", "version");
249
+ if (routeOptions.updateCheck) {
240
250
  require_internalRouter.addRoute(router, "GET", "/fingerprint/:platform/:fingerprintHash/:channel/:minBundleId/:bundleId", "fingerprintUpdateWithCohort");
241
251
  require_internalRouter.addRoute(router, "GET", "/fingerprint/:platform/:fingerprintHash/:channel/:minBundleId/:bundleId/:cohort", "fingerprintUpdateWithCohort");
242
252
  require_internalRouter.addRoute(router, "GET", "/app-version/:platform/:appVersion/:channel/:minBundleId/:bundleId", "appVersionUpdateWithCohort");
243
253
  require_internalRouter.addRoute(router, "GET", "/app-version/:platform/:appVersion/:channel/:minBundleId/:bundleId/:cohort", "appVersionUpdateWithCohort");
244
254
  }
245
- if (bundlesEnabled) {
255
+ if (routeOptions.bundles) {
246
256
  require_internalRouter.addRoute(router, "GET", "/api/bundles/channels", "getChannels");
247
257
  require_internalRouter.addRoute(router, "GET", "/api/bundles/:id", "getBundle");
248
258
  require_internalRouter.addRoute(router, "GET", "/api/bundles", "getBundles");
@@ -18,27 +18,26 @@ interface HandlerOptions {
18
18
  * @default "/api"
19
19
  */
20
20
  basePath?: string;
21
+ /**
22
+ * Route groups to mount. Omit this option to use the default route groups.
23
+ * When provided, both route groups must be specified explicitly.
24
+ * The `/version` endpoint is always mounted for diagnostics.
25
+ */
21
26
  routes?: HandlerRoutes;
22
27
  }
23
28
  interface HandlerRoutes {
24
29
  /**
25
30
  * Controls whether update-check routes are mounted.
26
- * @default true
27
- */
28
- updateCheck?: boolean;
29
- /**
30
- * Controls whether the `/version` endpoint is mounted.
31
- * Useful for diagnostics and lightweight health/version checks.
32
- * @default true
31
+ * Defaults to `true` only when `routes` is omitted.
33
32
  */
34
- version?: boolean;
33
+ updateCheck: boolean;
35
34
  /**
36
35
  * Controls whether bundle management routes are mounted.
37
36
  * This includes `/api/bundles*`, which are used by the
38
37
  * CLI `standaloneRepository` plugin.
39
- * @default true
38
+ * Defaults to `false` only when `routes` is omitted.
40
39
  */
41
- bundles?: boolean;
40
+ bundles: boolean;
42
41
  }
43
42
  /**
44
43
  * Creates a Web Standard Request handler for Hot Updater API
@@ -18,27 +18,26 @@ interface HandlerOptions {
18
18
  * @default "/api"
19
19
  */
20
20
  basePath?: string;
21
+ /**
22
+ * Route groups to mount. Omit this option to use the default route groups.
23
+ * When provided, both route groups must be specified explicitly.
24
+ * The `/version` endpoint is always mounted for diagnostics.
25
+ */
21
26
  routes?: HandlerRoutes;
22
27
  }
23
28
  interface HandlerRoutes {
24
29
  /**
25
30
  * Controls whether update-check routes are mounted.
26
- * @default true
27
- */
28
- updateCheck?: boolean;
29
- /**
30
- * Controls whether the `/version` endpoint is mounted.
31
- * Useful for diagnostics and lightweight health/version checks.
32
- * @default true
31
+ * Defaults to `true` only when `routes` is omitted.
33
32
  */
34
- version?: boolean;
33
+ updateCheck: boolean;
35
34
  /**
36
35
  * Controls whether bundle management routes are mounted.
37
36
  * This includes `/api/bundles*`, which are used by the
38
37
  * CLI `standaloneRepository` plugin.
39
- * @default true
38
+ * Defaults to `false` only when `routes` is omitted.
40
39
  */
41
- bundles?: boolean;
40
+ bundles: boolean;
42
41
  }
43
42
  /**
44
43
  * Creates a Web Standard Request handler for Hot Updater API
package/dist/handler.mjs CHANGED
@@ -10,6 +10,8 @@ var HandlerBadRequestError = class extends Error {
10
10
  };
11
11
  const SDK_VERSION_HEADER = "Hot-Updater-SDK-Version";
12
12
  const EXPLICIT_NO_UPDATE_MIN_SDK_VERSION = "0.31.0";
13
+ const DEFAULT_BUNDLE_LIST_LIMIT = 50;
14
+ const MAX_BUNDLE_LIST_LIMIT = 100;
13
15
  const supportsExplicitNoUpdateResponse = (request) => {
14
16
  const sdkVersion = request.headers.get(SDK_VERSION_HEADER)?.trim();
15
17
  if (!sdkVersion) return false;
@@ -59,6 +61,13 @@ const parseStringArraySearchParam = (url, key) => {
59
61
  const values = url.searchParams.getAll(key);
60
62
  return values.length > 0 ? values : void 0;
61
63
  };
64
+ const parsePositiveIntegerSearchParam = (url, key, defaultValue, maxValue) => {
65
+ const value = url.searchParams.get(key);
66
+ if (value === null) return defaultValue;
67
+ const parsed = Number(value);
68
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > maxValue) throw new HandlerBadRequestError(`The '${key}' query parameter must be a positive integer between 1 and ${maxValue}.`);
69
+ return parsed;
70
+ };
62
71
  const requirePlatformParam = (params) => {
63
72
  const platform = requireRouteParam(params, "platform");
64
73
  if (!isPlatform(platform)) throw new HandlerBadRequestError(`Invalid platform: ${platform}. Expected 'ios' or 'android'.`);
@@ -127,7 +136,7 @@ const handleGetBundles = async (_params, request, api, context) => {
127
136
  const url = new URL(request.url);
128
137
  const channel = url.searchParams.get("channel") ?? void 0;
129
138
  const platform = url.searchParams.get("platform");
130
- const limit = Number(url.searchParams.get("limit")) || 50;
139
+ const limit = parsePositiveIntegerSearchParam(url, "limit", DEFAULT_BUNDLE_LIST_LIMIT, MAX_BUNDLE_LIST_LIMIT);
131
140
  const pageParam = url.searchParams.get("page");
132
141
  const offset = url.searchParams.get("offset");
133
142
  const after = url.searchParams.get("after") ?? void 0;
@@ -229,18 +238,19 @@ const routes = {
229
238
  */
230
239
  function createHandler(api, options = {}) {
231
240
  const basePath = options.basePath ?? "/api";
232
- const updateCheckEnabled = options.routes?.updateCheck ?? true;
233
- const versionEnabled = options.routes?.version ?? true;
234
- const bundlesEnabled = options.routes?.bundles ?? true;
241
+ const routeOptions = {
242
+ updateCheck: options.routes?.updateCheck ?? true,
243
+ bundles: options.routes?.bundles ?? false
244
+ };
235
245
  const router = createRouter();
236
- if (versionEnabled) addRoute(router, "GET", "/version", "version");
237
- if (updateCheckEnabled) {
246
+ addRoute(router, "GET", "/version", "version");
247
+ if (routeOptions.updateCheck) {
238
248
  addRoute(router, "GET", "/fingerprint/:platform/:fingerprintHash/:channel/:minBundleId/:bundleId", "fingerprintUpdateWithCohort");
239
249
  addRoute(router, "GET", "/fingerprint/:platform/:fingerprintHash/:channel/:minBundleId/:bundleId/:cohort", "fingerprintUpdateWithCohort");
240
250
  addRoute(router, "GET", "/app-version/:platform/:appVersion/:channel/:minBundleId/:bundleId", "appVersionUpdateWithCohort");
241
251
  addRoute(router, "GET", "/app-version/:platform/:appVersion/:channel/:minBundleId/:bundleId/:cohort", "appVersionUpdateWithCohort");
242
252
  }
243
- if (bundlesEnabled) {
253
+ if (routeOptions.bundles) {
244
254
  addRoute(router, "GET", "/api/bundles/channels", "getChannels");
245
255
  addRoute(router, "GET", "/api/bundles/:id", "getBundle");
246
256
  addRoute(router, "GET", "/api/bundles", "getBundles");
package/dist/index.d.cts CHANGED
@@ -4,4 +4,4 @@ import { HotUpdaterClient, HotUpdaterDB, Migrator } from "./db/ormCore.cjs";
4
4
  import { HOT_UPDATER_SERVER_VERSION } from "./version.cjs";
5
5
  import { CreateHotUpdaterOptions, HotUpdaterAPI, createHotUpdater } from "./db/index.cjs";
6
6
  import { Bundle, ChannelsResponse, DataResponse, Paginated, PaginatedResult, PaginationInfo, PaginationOptions } from "./types/index.cjs";
7
- export { type Bundle, ChannelsResponse, CreateBundleDiffDependencies, CreateBundleDiffInput, CreateBundleDiffOptions, CreateHotUpdaterOptions, DataResponse, HOT_UPDATER_SERVER_VERSION, HandlerAPI, HandlerOptions, HandlerRoutes, HotUpdaterAPI, type HotUpdaterClient, HotUpdaterDB, type Migrator, Paginated, PaginatedResult, PaginationInfo, PaginationOptions, createBundleDiff, createHandler, createHotUpdater };
7
+ export { Bundle, ChannelsResponse, CreateBundleDiffDependencies, CreateBundleDiffInput, CreateBundleDiffOptions, CreateHotUpdaterOptions, DataResponse, HOT_UPDATER_SERVER_VERSION, HandlerAPI, HandlerOptions, HandlerRoutes, HotUpdaterAPI, HotUpdaterClient, HotUpdaterDB, Migrator, Paginated, PaginatedResult, PaginationInfo, PaginationOptions, createBundleDiff, createHandler, createHotUpdater };
package/dist/index.d.mts CHANGED
@@ -4,4 +4,4 @@ import { HotUpdaterClient, HotUpdaterDB, Migrator } from "./db/ormCore.mjs";
4
4
  import { HOT_UPDATER_SERVER_VERSION } from "./version.mjs";
5
5
  import { CreateHotUpdaterOptions, HotUpdaterAPI, createHotUpdater } from "./db/index.mjs";
6
6
  import { Bundle, ChannelsResponse, DataResponse, Paginated, PaginatedResult, PaginationInfo, PaginationOptions } from "./types/index.mjs";
7
- export { type Bundle, ChannelsResponse, CreateBundleDiffDependencies, CreateBundleDiffInput, CreateBundleDiffOptions, CreateHotUpdaterOptions, DataResponse, HOT_UPDATER_SERVER_VERSION, HandlerAPI, HandlerOptions, HandlerRoutes, HotUpdaterAPI, type HotUpdaterClient, HotUpdaterDB, type Migrator, Paginated, PaginatedResult, PaginationInfo, PaginationOptions, createBundleDiff, createHandler, createHotUpdater };
7
+ export { Bundle, ChannelsResponse, CreateBundleDiffDependencies, CreateBundleDiffInput, CreateBundleDiffOptions, CreateHotUpdaterOptions, DataResponse, HOT_UPDATER_SERVER_VERSION, HandlerAPI, HandlerOptions, HandlerRoutes, HotUpdaterAPI, HotUpdaterClient, HotUpdaterDB, Migrator, Paginated, PaginatedResult, PaginationInfo, PaginationOptions, createBundleDiff, createHandler, createHotUpdater };
@@ -46,4 +46,4 @@ interface FumaDBFactory<Schemas extends AnySchema[]> {
46
46
  }
47
47
  type InferFumaDB<Factory extends FumaDBFactory<any>> = Factory extends FumaDBFactory<infer Schemas> ? FumaDB<Schemas> : never;
48
48
  //#endregion
49
- export type { FumaDBFactory, InferFumaDB };
49
+ export { type FumaDBFactory, type InferFumaDB };
@@ -46,4 +46,4 @@ interface FumaDBFactory<Schemas extends AnySchema[]> {
46
46
  }
47
47
  type InferFumaDB<Factory extends FumaDBFactory<any>> = Factory extends FumaDBFactory<infer Schemas> ? FumaDB<Schemas> : never;
48
48
  //#endregion
49
- export type { FumaDBFactory, InferFumaDB };
49
+ export { type FumaDBFactory, type InferFumaDB };
@@ -153,4 +153,4 @@ interface AbstractQuery<S extends AnySchema> {
153
153
  }) => Promise<void>;
154
154
  }
155
155
  //#endregion
156
- export type { AbstractQuery };
156
+ export { type AbstractQuery };
@@ -153,4 +153,4 @@ interface AbstractQuery<S extends AnySchema> {
153
153
  }) => Promise<void>;
154
154
  }
155
155
  //#endregion
156
- export type { AbstractQuery };
156
+ export { type AbstractQuery };
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.31.4";
2
+ var version = "0.32.0";
3
3
  //#endregion
4
4
  Object.defineProperty(exports, "version", {
5
5
  enumerable: true,
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.31.4";
2
+ var version = "0.32.0";
3
3
  //#endregion
4
4
  export { version };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hot-updater/server",
3
- "version": "0.31.4",
3
+ "version": "0.32.0",
4
4
  "type": "module",
5
5
  "description": "React Native OTA solution for self-hosted",
6
6
  "sideEffects": false,
@@ -55,10 +55,10 @@
55
55
  "mongodb": "6.20.0",
56
56
  "rou3": "0.7.9",
57
57
  "semver": "^7.7.2",
58
- "@hot-updater/bsdiff": "0.31.4",
59
- "@hot-updater/plugin-core": "0.31.4",
60
- "@hot-updater/js": "0.31.4",
61
- "@hot-updater/core": "0.31.4"
58
+ "@hot-updater/bsdiff": "0.32.0",
59
+ "@hot-updater/core": "0.32.0",
60
+ "@hot-updater/plugin-core": "0.32.0",
61
+ "@hot-updater/js": "0.32.0"
62
62
  },
63
63
  "devDependencies": {
64
64
  "@electric-sql/pglite": "0.2.17",
@@ -68,8 +68,8 @@
68
68
  "kysely-pglite-dialect": "1.2.0",
69
69
  "msw": "^2.7.0",
70
70
  "uuidv7": "^1.0.2",
71
- "@hot-updater/standalone": "0.31.4",
72
- "@hot-updater/test-utils": "0.31.4"
71
+ "@hot-updater/standalone": "0.32.0",
72
+ "@hot-updater/test-utils": "0.32.0"
73
73
  },
74
74
  "inlinedDependencies": {
75
75
  "@noble/hashes": "1.8.0",
@@ -93,6 +93,9 @@ const createStoragePlugin = (
93
93
  new Uint8Array(await response.arrayBuffer()),
94
94
  );
95
95
  },
96
+ async exists() {
97
+ return false;
98
+ },
96
99
  upload,
97
100
  },
98
101
  },
@@ -17,6 +17,7 @@ import type {
17
17
  DatabasePlugin,
18
18
  NodeStoragePlugin,
19
19
  } from "@hot-updater/plugin-core";
20
+ import { resolveManifestAssetStorageUri } from "@hot-updater/plugin-core";
20
21
 
21
22
  type BundleManifest = {
22
23
  bundleId: string;
@@ -80,21 +81,6 @@ const isBundleManifest = (value: unknown): value is BundleManifest => {
80
81
  );
81
82
  };
82
83
 
83
- const createChildStorageUri = (
84
- baseStorageUri: string,
85
- relativePath: string,
86
- ) => {
87
- const baseUrl = new URL(baseStorageUri);
88
- const normalizedBasePath = baseUrl.pathname.replace(/\/+$/, "");
89
- const relativeSegments = relativePath
90
- .split("/")
91
- .filter(Boolean)
92
- .map((segment) => encodeURIComponent(segment));
93
-
94
- baseUrl.pathname = `${normalizedBasePath}/${relativeSegments.join("/")}`;
95
- return baseUrl.toString();
96
- };
97
-
98
84
  const getRelativeStorageDir = (relativePath: string) => {
99
85
  const normalized = relativePath.replace(/\\/g, "/");
100
86
  const dirname = path.posix.dirname(normalized);
@@ -188,18 +174,24 @@ function resolveHbcAssetPath(manifest: BundleManifest) {
188
174
  async function fetchAssetBytes(
189
175
  bundle: Bundle,
190
176
  assetPath: string,
177
+ manifest: BundleManifest,
191
178
  storagePlugin: NodeStoragePlugin | null,
192
179
  ) {
193
180
  const assetBaseStorageUri = getAssetBaseStorageUri(bundle);
194
181
  if (!assetBaseStorageUri) {
195
182
  throw new Error(`Bundle ${bundle.id} does not have asset storage metadata`);
196
183
  }
184
+ const asset = manifest.assets[assetPath];
185
+ if (!asset) {
186
+ throw new Error(`Asset ${assetPath} is missing from manifest`);
187
+ }
197
188
 
198
189
  if (BR_COMPRESSED_ASSET_PATH_RE.test(assetPath)) {
199
- const compressedAssetStorageUri = createChildStorageUri(
190
+ const compressedAssetStorageUri = resolveManifestAssetStorageUri({
200
191
  assetBaseStorageUri,
201
- `${assetPath}.br`,
202
- );
192
+ assetPath: `${assetPath}.br`,
193
+ fileHash: asset.fileHash,
194
+ });
203
195
 
204
196
  let compressedBytes: Uint8Array | null = null;
205
197
  try {
@@ -216,7 +208,11 @@ async function fetchAssetBytes(
216
208
  }
217
209
  }
218
210
 
219
- const assetStorageUri = createChildStorageUri(assetBaseStorageUri, assetPath);
211
+ const assetStorageUri = resolveManifestAssetStorageUri({
212
+ assetBaseStorageUri,
213
+ assetPath,
214
+ fileHash: asset.fileHash,
215
+ });
220
216
  return downloadStorageBytes(assetStorageUri, storagePlugin);
221
217
  }
222
218
 
@@ -300,8 +296,18 @@ export async function createBundleDiff(
300
296
  }
301
297
 
302
298
  const [baseBytes, targetBytes] = await Promise.all([
303
- fetchAssetBytes(baseBundle, baseAssetPath, deps.storagePlugin),
304
- fetchAssetBytes(targetBundle, targetAssetPath, deps.storagePlugin),
299
+ fetchAssetBytes(
300
+ baseBundle,
301
+ baseAssetPath,
302
+ baseManifest,
303
+ deps.storagePlugin,
304
+ ),
305
+ fetchAssetBytes(
306
+ targetBundle,
307
+ targetAssetPath,
308
+ targetManifest,
309
+ deps.storagePlugin,
310
+ ),
305
311
  ]);
306
312
 
307
313
  const patchBytes = await hdiff(baseBytes, targetBytes);
@@ -195,6 +195,115 @@ describe("createPluginDatabaseCore", () => {
195
195
  });
196
196
  });
197
197
 
198
+ it("resolves manifest changed assets from deterministic content-addressed storage", async () => {
199
+ const currentBundle = {
200
+ ...baseBundle,
201
+ id: "00000000-0000-0000-0000-000000000001",
202
+ manifestStorageUri: "r2://bucket/current/manifest.json",
203
+ manifestFileHash: "sig:current-manifest",
204
+ assetBaseStorageUri: "r2://bucket/assets",
205
+ };
206
+ const targetBundle = {
207
+ ...baseBundle,
208
+ id: "00000000-0000-0000-0000-000000000002",
209
+ fileHash: "hash-2",
210
+ manifestStorageUri: "r2://bucket/target/manifest.json",
211
+ manifestFileHash: "sig:target-manifest",
212
+ assetBaseStorageUri: "r2://bucket/assets",
213
+ };
214
+ const manifests = new Map([
215
+ [
216
+ currentBundle.manifestStorageUri,
217
+ JSON.stringify({
218
+ bundleId: currentBundle.id,
219
+ assets: {
220
+ "index.ios.bundle": {
221
+ fileHash: "old-bundle-hash",
222
+ },
223
+ },
224
+ }),
225
+ ],
226
+ [
227
+ targetBundle.manifestStorageUri,
228
+ JSON.stringify({
229
+ bundleId: targetBundle.id,
230
+ assets: {
231
+ "index.ios.bundle": {
232
+ fileHash: "new-bundle-hash",
233
+ },
234
+ },
235
+ }),
236
+ ],
237
+ ]);
238
+ const plugin: DatabasePlugin<TestContext> = {
239
+ name: "content-addressed-manifest-plugin",
240
+ async appendBundle() {},
241
+ async commitBundle() {},
242
+ async deleteBundle() {},
243
+ async getBundleById(bundleId) {
244
+ if (bundleId === currentBundle.id) return currentBundle;
245
+ if (bundleId === targetBundle.id) return targetBundle;
246
+ return null;
247
+ },
248
+ async getUpdateInfo() {
249
+ return {
250
+ fileHash: targetBundle.fileHash,
251
+ id: targetBundle.id,
252
+ message: targetBundle.message,
253
+ shouldForceUpdate: targetBundle.shouldForceUpdate,
254
+ status: "UPDATE",
255
+ storageUri: targetBundle.storageUri,
256
+ };
257
+ },
258
+ async getBundles() {
259
+ return {
260
+ data: [targetBundle],
261
+ pagination: {
262
+ currentPage: 1,
263
+ hasNextPage: false,
264
+ hasPreviousPage: false,
265
+ total: 1,
266
+ totalPages: 1,
267
+ },
268
+ };
269
+ },
270
+ async getChannels() {
271
+ return ["production"];
272
+ },
273
+ async updateBundle() {},
274
+ };
275
+
276
+ const core = createPluginDatabaseCore(
277
+ () => plugin,
278
+ async (storageUri) => {
279
+ if (!storageUri) return null;
280
+ const url = new URL(storageUri);
281
+ return `https://assets.example.com/${url.host}${url.pathname}`;
282
+ },
283
+ {
284
+ readStorageText: async (storageUri) =>
285
+ manifests.get(storageUri) ?? null,
286
+ },
287
+ );
288
+
289
+ await expect(
290
+ core.api.getAppUpdateInfo({
291
+ ...updateArgs,
292
+ bundleId: currentBundle.id,
293
+ }),
294
+ ).resolves.toMatchObject({
295
+ changedAssets: {
296
+ "index.ios.bundle": {
297
+ file: {
298
+ compression: "br",
299
+ url: "https://assets.example.com/bucket/assets/sha256/ne/new-bundle-hash.br",
300
+ },
301
+ fileHash: "new-bundle-hash",
302
+ },
303
+ },
304
+ });
305
+ });
306
+
198
307
  it("falls back to archive metadata when manifest changed assets cannot be resolved", async () => {
199
308
  const currentBundle = {
200
309
  ...baseBundle,
@@ -8,7 +8,10 @@ import {
8
8
  type Bundle,
9
9
  type ChangedAsset,
10
10
  } from "@hot-updater/core";
11
- import type { HotUpdaterContext } from "@hot-updater/plugin-core";
11
+ import {
12
+ resolveManifestAssetStorageUri,
13
+ type HotUpdaterContext,
14
+ } from "@hot-updater/plugin-core";
12
15
 
13
16
  type BundleManifest = {
14
17
  bundleId: string;
@@ -74,21 +77,6 @@ const isBundleManifest = (value: unknown): value is BundleManifest => {
74
77
  );
75
78
  };
76
79
 
77
- const createChildStorageUri = (
78
- baseStorageUri: string,
79
- relativePath: string,
80
- ) => {
81
- const baseUrl = new URL(baseStorageUri);
82
- const normalizedBasePath = baseUrl.pathname.replace(/\/+$/, "");
83
- const relativeSegments = relativePath
84
- .split("/")
85
- .filter(Boolean)
86
- .map((segment) => encodeURIComponent(segment));
87
-
88
- baseUrl.pathname = `${normalizedBasePath}/${relativeSegments.join("/")}`;
89
- return baseUrl.toString();
90
- };
91
-
92
80
  export const parseBundleMetadata = (
93
81
  value: unknown,
94
82
  ): Bundle["metadata"] | undefined => {
@@ -213,10 +201,11 @@ async function resolveChangedAssets<TContext>({
213
201
 
214
202
  const usesBrotliAsset = BR_COMPRESSED_ASSET_PATH_RE.test(assetPath);
215
203
  const downloadPath = usesBrotliAsset ? `${assetPath}.br` : assetPath;
216
- const storageUri = createChildStorageUri(
204
+ const storageUri = resolveManifestAssetStorageUri({
217
205
  assetBaseStorageUri,
218
- downloadPath,
219
- );
206
+ assetPath: downloadPath,
207
+ fileHash: asset.fileHash,
208
+ });
220
209
  const patch =
221
210
  patchDescriptor?.assetPath === assetPath ? patchDescriptor.patch : null;
222
211
 
@@ -34,6 +34,10 @@ const api = createHotUpdater({
34
34
  provider: "postgresql",
35
35
  }),
36
36
  basePath: "/hot-updater",
37
+ routes: {
38
+ updateCheck: true,
39
+ bundles: true,
40
+ },
37
41
  });
38
42
 
39
43
  // Setup MSW server to intercept HTTP requests
@@ -341,6 +345,10 @@ describe("Handler <-> Standalone Repository Integration", () => {
341
345
  provider: "postgresql",
342
346
  }),
343
347
  basePath: "/api/v2",
348
+ routes: {
349
+ updateCheck: true,
350
+ bundles: true,
351
+ },
344
352
  });
345
353
 
346
354
  // Setup MSW for custom basePath
@@ -411,6 +419,10 @@ describe("Handler <-> Standalone Repository Integration", () => {
411
419
  const blobApi = createHotUpdater({
412
420
  database: createInMemoryBlobDatabase(store),
413
421
  basePath: "/blob-hot-updater",
422
+ routes: {
423
+ updateCheck: true,
424
+ bundles: true,
425
+ },
414
426
  });
415
427
  const handleBlobRequest = async (request: Request) => {
416
428
  const response = await blobApi.handler(request);
@@ -1,7 +1,7 @@
1
1
  import { type Bundle, NIL_UUID } from "@hot-updater/core";
2
2
  import { describe, expect, it, vi } from "vitest";
3
3
 
4
- import { createHandler, type HandlerAPI } from "./handler";
4
+ import { createHandler, type HandlerAPI, type HandlerRoutes } from "./handler";
5
5
  import { HOT_UPDATER_SERVER_VERSION } from "./version";
6
6
 
7
7
  const NEXT_SDK_VERSION_FOR_TEST = "0.31.0";
@@ -51,6 +51,19 @@ const createApi = () =>
51
51
  deleteBundleById: vi.fn<HandlerAPI<TestContext>["deleteBundleById"]>(),
52
52
  }) satisfies HandlerAPI<TestContext>;
53
53
 
54
+ const createManagementHandler = (
55
+ api: HandlerAPI<TestContext>,
56
+ routes: Partial<HandlerRoutes> = {},
57
+ ) =>
58
+ createHandler(api, {
59
+ basePath: "/hot-updater",
60
+ routes: {
61
+ updateCheck: true,
62
+ bundles: true,
63
+ ...routes,
64
+ },
65
+ });
66
+
54
67
  describe("createHandler", () => {
55
68
  it("supports the app-version route without a cohort segment", async () => {
56
69
  const api = createApi();
@@ -218,44 +231,113 @@ describe("createHandler", () => {
218
231
  expect(updateResponse.status).toBe(200);
219
232
  });
220
233
 
221
- it("can disable the version route independently", async () => {
234
+ it("does not mount bundle routes by default", async () => {
235
+ const api = createApi();
236
+ const handler = createHandler(api, { basePath: "/hot-updater" });
237
+
238
+ const versionResponse = await handler(
239
+ new Request("http://localhost/hot-updater/version"),
240
+ );
241
+ const bundlesResponse = await handler(
242
+ new Request("http://localhost/hot-updater/api/bundles"),
243
+ );
244
+ const updateResponse = await handler(
245
+ new Request(
246
+ "http://localhost/hot-updater/app-version/ios/1.0.0/production/default/default",
247
+ ),
248
+ );
249
+
250
+ expect(versionResponse.status).toBe(200);
251
+ expect(bundlesResponse.status).toBe(404);
252
+ expect(updateResponse.status).toBe(200);
253
+ });
254
+
255
+ it("mounts bundle routes when explicitly enabled", async () => {
222
256
  const api = createApi();
257
+ api.getBundles.mockResolvedValueOnce({
258
+ data: [],
259
+ pagination: {
260
+ total: 0,
261
+ hasNextPage: false,
262
+ hasPreviousPage: false,
263
+ currentPage: 1,
264
+ totalPages: 0,
265
+ },
266
+ });
223
267
  const handler = createHandler(api, {
224
268
  basePath: "/hot-updater",
225
269
  routes: {
226
270
  updateCheck: true,
227
- version: false,
228
- bundles: false,
271
+ bundles: true,
229
272
  },
230
273
  });
231
274
 
232
- const versionResponse = await handler(
233
- new Request("http://localhost/hot-updater/version"),
234
- );
235
- const bundlesResponse = await handler(
275
+ const response = await handler(
236
276
  new Request("http://localhost/hot-updater/api/bundles"),
237
277
  );
278
+
279
+ expect(response.status).toBe(200);
280
+ expect(api.getBundles).toHaveBeenCalledWith(
281
+ {
282
+ cursor: undefined,
283
+ limit: 50,
284
+ page: undefined,
285
+ where: {},
286
+ },
287
+ undefined,
288
+ );
289
+ });
290
+
291
+ it("keeps update-check routes mounted for partial runtime route config", async () => {
292
+ const api = createApi();
293
+ const handler = createHandler(api, {
294
+ basePath: "/hot-updater",
295
+ routes: JSON.parse('{"bundles":true}') as HandlerRoutes,
296
+ });
297
+
238
298
  const updateResponse = await handler(
239
299
  new Request(
240
300
  "http://localhost/hot-updater/app-version/ios/1.0.0/production/default/default",
241
301
  ),
242
302
  );
243
303
 
244
- expect(versionResponse.status).toBe(404);
245
- expect(bundlesResponse.status).toBe(404);
246
304
  expect(updateResponse.status).toBe(200);
247
305
  });
248
306
 
249
- it("can mount bundle routes without update-check routes", async () => {
307
+ it("keeps the version route mounted when update-check routes are disabled", async () => {
250
308
  const api = createApi();
251
309
  const handler = createHandler(api, {
252
310
  basePath: "/hot-updater",
253
311
  routes: {
254
312
  updateCheck: false,
255
- bundles: true,
313
+ bundles: false,
256
314
  },
257
315
  });
258
316
 
317
+ const versionResponse = await handler(
318
+ new Request("http://localhost/hot-updater/version"),
319
+ );
320
+ const bundlesResponse = await handler(
321
+ new Request("http://localhost/hot-updater/api/bundles"),
322
+ );
323
+ const updateResponse = await handler(
324
+ new Request(
325
+ "http://localhost/hot-updater/app-version/ios/1.0.0/production/default/default",
326
+ ),
327
+ );
328
+
329
+ expect(versionResponse.status).toBe(200);
330
+ await expect(versionResponse.json()).resolves.toEqual({
331
+ version: HOT_UPDATER_SERVER_VERSION,
332
+ });
333
+ expect(bundlesResponse.status).toBe(404);
334
+ expect(updateResponse.status).toBe(404);
335
+ });
336
+
337
+ it("can mount bundle routes without update-check routes", async () => {
338
+ const api = createApi();
339
+ const handler = createManagementHandler(api, { updateCheck: false });
340
+
259
341
  const versionResponse = await handler(
260
342
  new Request("http://localhost/hot-updater/version"),
261
343
  );
@@ -294,7 +376,7 @@ describe("createHandler", () => {
294
376
  totalPages: 26,
295
377
  },
296
378
  });
297
- const handler = createHandler(api, { basePath: "/hot-updater" });
379
+ const handler = createManagementHandler(api);
298
380
 
299
381
  const response = await handler(
300
382
  new Request(
@@ -339,7 +421,7 @@ describe("createHandler", () => {
339
421
  totalPages: 1,
340
422
  },
341
423
  });
342
- const handler = createHandler(api, { basePath: "/hot-updater" });
424
+ const handler = createManagementHandler(api);
343
425
 
344
426
  const response = await handler(
345
427
  new Request(
@@ -382,7 +464,7 @@ describe("createHandler", () => {
382
464
  previousCursor: "bundle-9",
383
465
  },
384
466
  });
385
- const handler = createHandler(api, { basePath: "/hot-updater" });
467
+ const handler = createManagementHandler(api);
386
468
 
387
469
  const response = await handler(
388
470
  new Request(
@@ -421,7 +503,7 @@ describe("createHandler", () => {
421
503
  previousCursor: null,
422
504
  },
423
505
  });
424
- const handler = createHandler(api, { basePath: "/hot-updater" });
506
+ const handler = createManagementHandler(api);
425
507
 
426
508
  const response = await handler(
427
509
  new Request(
@@ -448,7 +530,7 @@ describe("createHandler", () => {
448
530
 
449
531
  it("returns 400 when bundle list requests still send offset pagination", async () => {
450
532
  const api = createApi();
451
- const handler = createHandler(api, { basePath: "/hot-updater" });
533
+ const handler = createManagementHandler(api);
452
534
 
453
535
  const response = await handler(
454
536
  new Request(
@@ -478,7 +560,7 @@ describe("createHandler", () => {
478
560
  previousCursor: "bundle-9",
479
561
  },
480
562
  });
481
- const handler = createHandler(api, { basePath: "/hot-updater" });
563
+ const handler = createManagementHandler(api);
482
564
 
483
565
  const response = await handler(
484
566
  new Request(
@@ -505,7 +587,7 @@ describe("createHandler", () => {
505
587
 
506
588
  it("returns 400 when bundle list requests send an invalid page", async () => {
507
589
  const api = createApi();
508
- const handler = createHandler(api, { basePath: "/hot-updater" });
590
+ const handler = createManagementHandler(api);
509
591
 
510
592
  const response = await handler(
511
593
  new Request("http://localhost/hot-updater/api/bundles?limit=20&page=0"),
@@ -518,6 +600,22 @@ describe("createHandler", () => {
518
600
  expect(api.getBundles).not.toHaveBeenCalled();
519
601
  });
520
602
 
603
+ it("returns 400 when bundle list limit exceeds the maximum", async () => {
604
+ const api = createApi();
605
+ const handler = createManagementHandler(api);
606
+
607
+ const response = await handler(
608
+ new Request("http://localhost/hot-updater/api/bundles?limit=101"),
609
+ );
610
+
611
+ expect(response.status).toBe(400);
612
+ await expect(response.json()).resolves.toEqual({
613
+ error:
614
+ "The 'limit' query parameter must be a positive integer between 1 and 100.",
615
+ });
616
+ expect(api.getBundles).not.toHaveBeenCalled();
617
+ });
618
+
521
619
  it("returns 400 when the platform route parameter is invalid", async () => {
522
620
  const api = createApi();
523
621
  const handler = createHandler(api, { basePath: "/hot-updater" });
package/src/handler.ts CHANGED
@@ -52,28 +52,27 @@ export interface HandlerOptions {
52
52
  * @default "/api"
53
53
  */
54
54
  basePath?: string;
55
+ /**
56
+ * Route groups to mount. Omit this option to use the default route groups.
57
+ * When provided, both route groups must be specified explicitly.
58
+ * The `/version` endpoint is always mounted for diagnostics.
59
+ */
55
60
  routes?: HandlerRoutes;
56
61
  }
57
62
 
58
63
  export interface HandlerRoutes {
59
64
  /**
60
65
  * Controls whether update-check routes are mounted.
61
- * @default true
66
+ * Defaults to `true` only when `routes` is omitted.
62
67
  */
63
- updateCheck?: boolean;
64
- /**
65
- * Controls whether the `/version` endpoint is mounted.
66
- * Useful for diagnostics and lightweight health/version checks.
67
- * @default true
68
- */
69
- version?: boolean;
68
+ updateCheck: boolean;
70
69
  /**
71
70
  * Controls whether bundle management routes are mounted.
72
71
  * This includes `/api/bundles*`, which are used by the
73
72
  * CLI `standaloneRepository` plugin.
74
- * @default true
73
+ * Defaults to `false` only when `routes` is omitted.
75
74
  */
76
- bundles?: boolean;
75
+ bundles: boolean;
77
76
  }
78
77
 
79
78
  type RouteHandler<TContext = unknown> = (
@@ -92,6 +91,8 @@ class HandlerBadRequestError extends Error {
92
91
 
93
92
  const SDK_VERSION_HEADER = "Hot-Updater-SDK-Version";
94
93
  const EXPLICIT_NO_UPDATE_MIN_SDK_VERSION = "0.31.0";
94
+ const DEFAULT_BUNDLE_LIST_LIMIT = 50;
95
+ const MAX_BUNDLE_LIST_LIMIT = 100;
95
96
 
96
97
  const supportsExplicitNoUpdateResponse = (request: Request) => {
97
98
  const sdkVersion = request.headers.get(SDK_VERSION_HEADER)?.trim();
@@ -191,6 +192,27 @@ const parseStringArraySearchParam = (url: URL, key: string) => {
191
192
  return values.length > 0 ? values : undefined;
192
193
  };
193
194
 
195
+ const parsePositiveIntegerSearchParam = (
196
+ url: URL,
197
+ key: string,
198
+ defaultValue: number,
199
+ maxValue: number,
200
+ ): number => {
201
+ const value = url.searchParams.get(key);
202
+ if (value === null) {
203
+ return defaultValue;
204
+ }
205
+
206
+ const parsed = Number(value);
207
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > maxValue) {
208
+ throw new HandlerBadRequestError(
209
+ `The '${key}' query parameter must be a positive integer between 1 and ${maxValue}.`,
210
+ );
211
+ }
212
+
213
+ return parsed;
214
+ };
215
+
194
216
  const requirePlatformParam = (params: Record<string, string>): Platform => {
195
217
  const platform = requireRouteParam(params, "platform");
196
218
 
@@ -317,7 +339,12 @@ const handleGetBundles: RouteHandler = async (
317
339
  const url = new URL(request.url);
318
340
  const channel = url.searchParams.get("channel") ?? undefined;
319
341
  const platform = url.searchParams.get("platform");
320
- const limit = Number(url.searchParams.get("limit")) || 50;
342
+ const limit = parsePositiveIntegerSearchParam(
343
+ url,
344
+ "limit",
345
+ DEFAULT_BUNDLE_LIST_LIMIT,
346
+ MAX_BUNDLE_LIST_LIMIT,
347
+ );
321
348
  const pageParam = url.searchParams.get("page");
322
349
  const offset = url.searchParams.get("offset");
323
350
  const after = url.searchParams.get("after") ?? undefined;
@@ -511,19 +538,18 @@ export function createHandler<TContext = unknown>(
511
538
  context?: HotUpdaterContext<TContext>,
512
539
  ) => Promise<Response> {
513
540
  const basePath = options.basePath ?? "/api";
514
- const updateCheckEnabled = options.routes?.updateCheck ?? true;
515
- const versionEnabled = options.routes?.version ?? true;
516
- const bundlesEnabled = options.routes?.bundles ?? true;
541
+ const routeOptions = {
542
+ updateCheck: options.routes?.updateCheck ?? true,
543
+ bundles: options.routes?.bundles ?? false,
544
+ };
517
545
 
518
546
  // Create and configure router
519
547
  const router = createRouter();
520
548
 
521
549
  // Register routes
522
- if (versionEnabled) {
523
- addRoute(router, "GET", "/version", "version");
524
- }
550
+ addRoute(router, "GET", "/version", "version");
525
551
 
526
- if (updateCheckEnabled) {
552
+ if (routeOptions.updateCheck) {
527
553
  addRoute(
528
554
  router,
529
555
  "GET",
@@ -550,7 +576,7 @@ export function createHandler<TContext = unknown>(
550
576
  );
551
577
  }
552
578
 
553
- if (bundlesEnabled) {
579
+ if (routeOptions.bundles) {
554
580
  addRoute(router, "GET", "/api/bundles/channels", "getChannels");
555
581
  addRoute(router, "GET", "/api/bundles/:id", "getBundle");
556
582
  addRoute(router, "GET", "/api/bundles", "getBundles");
@@ -583,8 +609,8 @@ export function createHandler<TContext = unknown>(
583
609
  });
584
610
  }
585
611
 
586
- // Get handler and execute
587
- const handler = routes[match.data as string] as RouteHandler<TContext>;
612
+ const routeName = match.data as string;
613
+ const handler = routes[routeName] as RouteHandler<TContext>;
588
614
  if (!handler) {
589
615
  return new Response(JSON.stringify({ error: "Handler not found" }), {
590
616
  status: 500,
@@ -80,6 +80,7 @@ describe("runtime createHotUpdater", () => {
80
80
  node: {
81
81
  delete: vi.fn(),
82
82
  downloadFile: vi.fn(),
83
+ exists: vi.fn(async () => false),
83
84
  upload: vi.fn(),
84
85
  },
85
86
  },
@@ -660,7 +661,7 @@ describe("runtime createHotUpdater", () => {
660
661
  });
661
662
  });
662
663
 
663
- it("can disable the version route independently", async () => {
664
+ it("keeps the version route mounted when update-check routes are disabled", async () => {
664
665
  const database = createDatabasePlugin({
665
666
  name: "version-disabled-plugin",
666
667
  factory: () => ({
@@ -690,8 +691,7 @@ describe("runtime createHotUpdater", () => {
690
691
  database,
691
692
  basePath: "/api/check-update",
692
693
  routes: {
693
- updateCheck: true,
694
- version: false,
694
+ updateCheck: false,
695
695
  bundles: false,
696
696
  },
697
697
  });
@@ -700,7 +700,10 @@ describe("runtime createHotUpdater", () => {
700
700
  new Request("https://updates.example.com/api/check-update/version"),
701
701
  );
702
702
 
703
- expect(response.status).toBe(404);
703
+ expect(response.status).toBe(200);
704
+ await expect(response.json()).resolves.toEqual({
705
+ version: HOT_UPDATER_SERVER_VERSION,
706
+ });
704
707
  });
705
708
 
706
709
  it("clears pending plugin changes after a failed mutation commit", async () => {