@hot-updater/server 1.0.0-rc.42 → 1.0.0-rc.44

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.
@@ -19,17 +19,21 @@ const databaseOf = (value) => {
19
19
  }
20
20
  throw new _hot_updater_plugin_core.HotUpdaterConfigError("database must be a Hot Updater 1.0 database, such as kyselyAdapter(...) or postgres(...). Upgrade the provider package that created it.");
21
21
  };
22
- /** `"public"`, or nothing; a `clientAccess` object says what replaced it. */
22
+ /** The configured storage, when it is a storage adapter. */
23
+ const storageOf = (value) => {
24
+ if (isRecord(value) && typeof value.name === "string" && typeof value.protocol === "string") return value;
25
+ throw new _hot_updater_plugin_core.HotUpdaterConfigError("storage must be a storage adapter, such as s3Storage(...): the one hot-updater.config.ts uploads with.");
26
+ };
27
+ /** `"public"`, or nothing. */
23
28
  const isPublic = (value) => {
24
29
  if (value === void 0) return false;
25
30
  if (value === "public") return true;
26
- throw new _hot_updater_plugin_core.HotUpdaterConfigError(isRecord(value) ? "clientAccess objects were removed in 1.0. Set clientAccess: \"public\", or add a plugin that provides clientAuth to plugins." : "clientAccess must be \"public\"; to protect client routes, add a plugin that provides clientAuth to plugins.");
31
+ throw new _hot_updater_plugin_core.HotUpdaterConfigError("clientAccess must be \"public\"; to protect client routes, add a plugin that provides clientAuth to plugins.");
27
32
  };
28
33
  function createHotUpdater(options) {
29
- for (const key of ["authorityId", "catalogId"]) if (Object.hasOwn(options, key)) throw new TypeError(`Remove ${key} from createHotUpdater options. Catalog identity is managed internally.`);
30
34
  const database = databaseOf(options.database);
31
- const storage = Object.freeze([...options.storage ?? []]);
32
- const { downloadStorageObject, readStorageText, resolveFileUrl } = require_storageAccess.createStorageAccess(storage);
35
+ const storage = storageOf(options.storage);
36
+ const { readStorageText, resolveFileUrl } = require_storageAccess.createStorageAccess(storage);
33
37
  const publicClients = isPublic(options.clientAccess);
34
38
  const configured = Object.freeze([...options.plugins ?? []]);
35
39
  const plugins = require_assemblePlugins.assemblePlugins(configured, database, { storage: {
@@ -48,7 +52,6 @@ function createHotUpdater(options) {
48
52
  const handlers = require_handler.createHotUpdaterHandlers({
49
53
  api: { core: plugins.core },
50
54
  ...clientPolicy === void 0 ? {} : { clientPolicy },
51
- downloadStorageObject,
52
55
  endpoints: plugins.endpoints,
53
56
  plugins: Object.keys(plugins.api)
54
57
  });
@@ -57,7 +60,7 @@ function createHotUpdater(options) {
57
60
  adapterName: database.name,
58
61
  get handlers() {
59
62
  if (!serving) {
60
- for (const adapter of storage) (0, _hot_updater_plugin_core.assertStorageOperations)(adapter, ["get", "getDownloadUrl"]);
63
+ (0, _hot_updater_plugin_core.assertStorageOperations)(storage, ["get", "getDownloadUrl"]);
61
64
  serving = true;
62
65
  }
63
66
  return handlers;
@@ -24,10 +24,10 @@ interface ClientAuthProvider {
24
24
  }
25
25
  type RuntimeHotUpdaterAPI<TPlugins extends readonly AnyHotUpdaterPlugin[] = readonly AnyHotUpdaterPlugin[]> = {
26
26
  /**
27
- * The routes a host mounts. On first access every storage adapter must
28
- * serve downloads (`get` and `getDownloadUrl`), so a server fails where it
27
+ * The routes a host mounts. On first access the storage must serve
28
+ * downloads (`get` and `getDownloadUrl`), so a server fails where it
29
29
  * mounts them, while tooling that only reads the definition, such as the
30
- * CLI, can list storage that only uploads.
30
+ * CLI, can configure storage that only uploads.
31
31
  */
32
32
  readonly handlers: HotUpdaterHandlers;
33
33
  /**
@@ -54,8 +54,8 @@ type RuntimeHotUpdaterAPI<TPlugins extends readonly AnyHotUpdaterPlugin[] = read
54
54
  * closes it (`dispose`).
55
55
  */
56
56
  readonly database: ToolingDatabase;
57
- /** The storage as configured, in order, as a frozen copy. */
58
- readonly storage: readonly StorageAdapter[];
57
+ /** The storage as configured. */
58
+ readonly storage: StorageAdapter;
59
59
  /** The plugins as configured, as a frozen copy, whose tables tooling creates. */
60
60
  readonly plugins: TPlugins;
61
61
  /**
@@ -72,18 +72,8 @@ type RuntimeHotUpdaterAPI<TPlugins extends readonly AnyHotUpdaterPlugin[] = read
72
72
  readonly clientAuth?: ClientAuthProvider;
73
73
  };
74
74
  type HotUpdaterAPI = RuntimeHotUpdaterAPI;
75
- declare const REMOVED_CLIENT_ACCESS = "clientAccess objects were removed in 1.0: set clientAccess: \"public\", or add a plugin that provides clientAuth";
76
- /**
77
- * A `clientAccess` object from before 1.0. Its `type` names the fix, so
78
- * TypeScript reports it where the object is written.
79
- */
80
- interface RemovedClientAccess {
81
- readonly type: typeof REMOVED_CLIENT_ACCESS;
82
- readonly headerName?: string;
83
- }
84
- type ClientAccessPolicy =
85
75
  /** Leaves client routes public; required when no plugin provides clientAuth. */
86
- "public" | RemovedClientAccess;
76
+ type ClientAccessPolicy = "public";
87
77
  type ProvidesClientAuth<TPlugin> = TPlugin extends {
88
78
  readonly provides?: infer TProvides;
89
79
  } ? TProvides extends {
@@ -113,13 +103,14 @@ type CreateHotUpdaterOptions<TPlugins extends readonly AnyHotUpdaterPlugin[] = r
113
103
  /** A provider's database on the storage engine, such as `kyselyAdapter(...)` or `postgres(...)`. */
114
104
  readonly database: ToolingDatabase;
115
105
  /**
116
- * Where bundles are stored: the server reads and signs the URIs of each
117
- * adapter's protocol.
106
+ * Where bundles are stored, the same adapter `hot-updater.config.ts`
107
+ * uploads with: the server reads the URIs of its protocol and returns
108
+ * their download URLs.
118
109
  */
119
- readonly storage?: readonly StorageAdapter[];
110
+ readonly storage: StorageAdapter;
120
111
  /** The plugins the server runs; at most one provides clientAuth. Defaults to none. */
121
112
  readonly plugins?: TPlugins;
122
113
  } & ClientAccessRule<TPlugins>;
123
114
  declare function createHotUpdater<const TPlugins extends readonly AnyHotUpdaterPlugin[] = readonly []>(options: CreateHotUpdaterOptions<TPlugins>): RuntimeHotUpdaterAPI<NoInfer<TPlugins>>;
124
115
  //#endregion
125
- export { ClientAccessPolicy, ClientAccessRule, ClientAuthProvider, ClientEndpoint, CreateHotUpdaterOptions, HotUpdaterAPI, RemovedClientAccess, RuntimeHotUpdaterAPI, createHotUpdater };
116
+ export { ClientAccessPolicy, ClientAccessRule, ClientAuthProvider, ClientEndpoint, CreateHotUpdaterOptions, HotUpdaterAPI, RuntimeHotUpdaterAPI, createHotUpdater };
@@ -24,10 +24,10 @@ interface ClientAuthProvider {
24
24
  }
25
25
  type RuntimeHotUpdaterAPI<TPlugins extends readonly AnyHotUpdaterPlugin[] = readonly AnyHotUpdaterPlugin[]> = {
26
26
  /**
27
- * The routes a host mounts. On first access every storage adapter must
28
- * serve downloads (`get` and `getDownloadUrl`), so a server fails where it
27
+ * The routes a host mounts. On first access the storage must serve
28
+ * downloads (`get` and `getDownloadUrl`), so a server fails where it
29
29
  * mounts them, while tooling that only reads the definition, such as the
30
- * CLI, can list storage that only uploads.
30
+ * CLI, can configure storage that only uploads.
31
31
  */
32
32
  readonly handlers: HotUpdaterHandlers;
33
33
  /**
@@ -54,8 +54,8 @@ type RuntimeHotUpdaterAPI<TPlugins extends readonly AnyHotUpdaterPlugin[] = read
54
54
  * closes it (`dispose`).
55
55
  */
56
56
  readonly database: ToolingDatabase;
57
- /** The storage as configured, in order, as a frozen copy. */
58
- readonly storage: readonly StorageAdapter[];
57
+ /** The storage as configured. */
58
+ readonly storage: StorageAdapter;
59
59
  /** The plugins as configured, as a frozen copy, whose tables tooling creates. */
60
60
  readonly plugins: TPlugins;
61
61
  /**
@@ -72,18 +72,8 @@ type RuntimeHotUpdaterAPI<TPlugins extends readonly AnyHotUpdaterPlugin[] = read
72
72
  readonly clientAuth?: ClientAuthProvider;
73
73
  };
74
74
  type HotUpdaterAPI = RuntimeHotUpdaterAPI;
75
- declare const REMOVED_CLIENT_ACCESS = "clientAccess objects were removed in 1.0: set clientAccess: \"public\", or add a plugin that provides clientAuth";
76
- /**
77
- * A `clientAccess` object from before 1.0. Its `type` names the fix, so
78
- * TypeScript reports it where the object is written.
79
- */
80
- interface RemovedClientAccess {
81
- readonly type: typeof REMOVED_CLIENT_ACCESS;
82
- readonly headerName?: string;
83
- }
84
- type ClientAccessPolicy =
85
75
  /** Leaves client routes public; required when no plugin provides clientAuth. */
86
- "public" | RemovedClientAccess;
76
+ type ClientAccessPolicy = "public";
87
77
  type ProvidesClientAuth<TPlugin> = TPlugin extends {
88
78
  readonly provides?: infer TProvides;
89
79
  } ? TProvides extends {
@@ -113,13 +103,14 @@ type CreateHotUpdaterOptions<TPlugins extends readonly AnyHotUpdaterPlugin[] = r
113
103
  /** A provider's database on the storage engine, such as `kyselyAdapter(...)` or `postgres(...)`. */
114
104
  readonly database: ToolingDatabase;
115
105
  /**
116
- * Where bundles are stored: the server reads and signs the URIs of each
117
- * adapter's protocol.
106
+ * Where bundles are stored, the same adapter `hot-updater.config.ts`
107
+ * uploads with: the server reads the URIs of its protocol and returns
108
+ * their download URLs.
118
109
  */
119
- readonly storage?: readonly StorageAdapter[];
110
+ readonly storage: StorageAdapter;
120
111
  /** The plugins the server runs; at most one provides clientAuth. Defaults to none. */
121
112
  readonly plugins?: TPlugins;
122
113
  } & ClientAccessRule<TPlugins>;
123
114
  declare function createHotUpdater<const TPlugins extends readonly AnyHotUpdaterPlugin[] = readonly []>(options: CreateHotUpdaterOptions<TPlugins>): RuntimeHotUpdaterAPI<NoInfer<TPlugins>>;
124
115
  //#endregion
125
- export { ClientAccessPolicy, ClientAccessRule, ClientAuthProvider, ClientEndpoint, CreateHotUpdaterOptions, HotUpdaterAPI, RemovedClientAccess, RuntimeHotUpdaterAPI, createHotUpdater };
116
+ export { ClientAccessPolicy, ClientAccessRule, ClientAuthProvider, ClientEndpoint, CreateHotUpdaterOptions, HotUpdaterAPI, RuntimeHotUpdaterAPI, createHotUpdater };
@@ -20,17 +20,21 @@ const databaseOf = (value) => {
20
20
  }
21
21
  throw new HotUpdaterConfigError("database must be a Hot Updater 1.0 database, such as kyselyAdapter(...) or postgres(...). Upgrade the provider package that created it.");
22
22
  };
23
- /** `"public"`, or nothing; a `clientAccess` object says what replaced it. */
23
+ /** The configured storage, when it is a storage adapter. */
24
+ const storageOf = (value) => {
25
+ if (isRecord(value) && typeof value.name === "string" && typeof value.protocol === "string") return value;
26
+ throw new HotUpdaterConfigError("storage must be a storage adapter, such as s3Storage(...): the one hot-updater.config.ts uploads with.");
27
+ };
28
+ /** `"public"`, or nothing. */
24
29
  const isPublic = (value) => {
25
30
  if (value === void 0) return false;
26
31
  if (value === "public") return true;
27
- throw new HotUpdaterConfigError(isRecord(value) ? "clientAccess objects were removed in 1.0. Set clientAccess: \"public\", or add a plugin that provides clientAuth to plugins." : "clientAccess must be \"public\"; to protect client routes, add a plugin that provides clientAuth to plugins.");
32
+ throw new HotUpdaterConfigError("clientAccess must be \"public\"; to protect client routes, add a plugin that provides clientAuth to plugins.");
28
33
  };
29
34
  function createHotUpdater(options) {
30
- for (const key of ["authorityId", "catalogId"]) if (Object.hasOwn(options, key)) throw new TypeError(`Remove ${key} from createHotUpdater options. Catalog identity is managed internally.`);
31
35
  const database = databaseOf(options.database);
32
- const storage = Object.freeze([...options.storage ?? []]);
33
- const { downloadStorageObject, readStorageText, resolveFileUrl } = createStorageAccess(storage);
36
+ const storage = storageOf(options.storage);
37
+ const { readStorageText, resolveFileUrl } = createStorageAccess(storage);
34
38
  const publicClients = isPublic(options.clientAccess);
35
39
  const configured = Object.freeze([...options.plugins ?? []]);
36
40
  const plugins = assemblePlugins(configured, database, { storage: {
@@ -49,7 +53,6 @@ function createHotUpdater(options) {
49
53
  const handlers = createHotUpdaterHandlers({
50
54
  api: { core: plugins.core },
51
55
  ...clientPolicy === void 0 ? {} : { clientPolicy },
52
- downloadStorageObject,
53
56
  endpoints: plugins.endpoints,
54
57
  plugins: Object.keys(plugins.api)
55
58
  });
@@ -58,7 +61,7 @@ function createHotUpdater(options) {
58
61
  adapterName: database.name,
59
62
  get handlers() {
60
63
  if (!serving) {
61
- for (const adapter of storage) assertStorageOperations(adapter, ["get", "getDownloadUrl"]);
64
+ assertStorageOperations(storage, ["get", "getDownloadUrl"]);
62
65
  serving = true;
63
66
  }
64
67
  return handlers;
package/dist/handler.cjs CHANGED
@@ -18,8 +18,8 @@ const errorResponse = (error, status) => Response.json({ error }, {
18
18
  headers: { "cache-control": "private, no-store" },
19
19
  status
20
20
  });
21
- /** `/version` and storage downloads never pass through the client policy. */
22
- const PUBLIC_CLIENT_ROUTES = /* @__PURE__ */ new Set(["version", "downloadStorageObject"]);
21
+ /** `/version` never passes through the client policy. */
22
+ const PUBLIC_CLIENT_ROUTES = /* @__PURE__ */ new Set(["version"]);
23
23
  const routesOf = Symbol.for("@hot-updater/server/routes");
24
24
  const withVary = (response, varyHeaders) => {
25
25
  const cacheControl = response.headers.get("cache-control") ?? "";
@@ -64,26 +64,11 @@ const createRequestHandler = ({ api, clientPolicy, privateResponses = false, rou
64
64
  });
65
65
  }
66
66
  };
67
- const createDownloadStorageRouteHandler = (downloadStorageObject) => async (params) => {
68
- const token = params.token;
69
- const signature = params.signature;
70
- if (!token || !signature) return errorResponse("Not found", 404);
71
- const response = await downloadStorageObject(token, signature);
72
- if (!response) return errorResponse("Not found", 404);
73
- const headers = new Headers(response.headers);
74
- if (!headers.has("cache-control")) headers.set("cache-control", "public, max-age=31536000, immutable");
75
- return new Response(response.body, {
76
- headers,
77
- status: response.status,
78
- statusText: response.statusText
79
- });
80
- };
81
- function createHotUpdaterHandlers({ api, clientPolicy, downloadStorageObject, endpoints = [], plugins = [] }) {
67
+ function createHotUpdaterHandlers({ api, clientPolicy, endpoints = [], plugins = [] }) {
82
68
  const routeHandlers = {
83
69
  ...require_handlerVersionRoutes.createVersionRouteHandlers(plugins),
84
70
  ...require_handlerReleaseCatalogRoutes.createReleaseCatalogRouteHandlers(),
85
- ...require_handlerAdminRoutes.createAdminRouteHandlers(),
86
- ...downloadStorageObject === void 0 ? {} : { downloadStorageObject: createDownloadStorageRouteHandler(downloadStorageObject) }
71
+ ...require_handlerAdminRoutes.createAdminRouteHandlers()
87
72
  };
88
73
  const routes = [];
89
74
  const shapes = /* @__PURE__ */ new Map();
@@ -102,7 +87,6 @@ function createHotUpdaterHandlers({ api, clientPolicy, downloadStorageObject, en
102
87
  const clientRouter = require_internalRouter.createRouter();
103
88
  const addClientRoute = mount(clientRouter, false);
104
89
  addClientRoute("GET", "/version", "version");
105
- if (downloadStorageObject !== void 0) addClientRoute("GET", "/storage/:token/:signature", "downloadStorageObject");
106
90
  addClientRoute("GET", "/release-catalogs/app-version/:platform/:channelKey/:appVersion", "appVersionReleaseCatalog");
107
91
  addClientRoute("GET", "/release-catalogs/fingerprint/:platform/:channelKey/:fingerprintHash", "fingerprintReleaseCatalog");
108
92
  addClientRoute("GET", "/artifacts/v1/:targetBundleId/from/:currentBundleId", "artifactV1");
package/dist/handler.mjs CHANGED
@@ -19,8 +19,8 @@ const errorResponse = (error, status) => Response.json({ error }, {
19
19
  headers: { "cache-control": "private, no-store" },
20
20
  status
21
21
  });
22
- /** `/version` and storage downloads never pass through the client policy. */
23
- const PUBLIC_CLIENT_ROUTES = /* @__PURE__ */ new Set(["version", "downloadStorageObject"]);
22
+ /** `/version` never passes through the client policy. */
23
+ const PUBLIC_CLIENT_ROUTES = /* @__PURE__ */ new Set(["version"]);
24
24
  const routesOf = Symbol.for("@hot-updater/server/routes");
25
25
  const withVary = (response, varyHeaders) => {
26
26
  const cacheControl = response.headers.get("cache-control") ?? "";
@@ -65,26 +65,11 @@ const createRequestHandler = ({ api, clientPolicy, privateResponses = false, rou
65
65
  });
66
66
  }
67
67
  };
68
- const createDownloadStorageRouteHandler = (downloadStorageObject) => async (params) => {
69
- const token = params.token;
70
- const signature = params.signature;
71
- if (!token || !signature) return errorResponse("Not found", 404);
72
- const response = await downloadStorageObject(token, signature);
73
- if (!response) return errorResponse("Not found", 404);
74
- const headers = new Headers(response.headers);
75
- if (!headers.has("cache-control")) headers.set("cache-control", "public, max-age=31536000, immutable");
76
- return new Response(response.body, {
77
- headers,
78
- status: response.status,
79
- statusText: response.statusText
80
- });
81
- };
82
- function createHotUpdaterHandlers({ api, clientPolicy, downloadStorageObject, endpoints = [], plugins = [] }) {
68
+ function createHotUpdaterHandlers({ api, clientPolicy, endpoints = [], plugins = [] }) {
83
69
  const routeHandlers = {
84
70
  ...createVersionRouteHandlers(plugins),
85
71
  ...createReleaseCatalogRouteHandlers(),
86
- ...createAdminRouteHandlers(),
87
- ...downloadStorageObject === void 0 ? {} : { downloadStorageObject: createDownloadStorageRouteHandler(downloadStorageObject) }
72
+ ...createAdminRouteHandlers()
88
73
  };
89
74
  const routes = [];
90
75
  const shapes = /* @__PURE__ */ new Map();
@@ -103,7 +88,6 @@ function createHotUpdaterHandlers({ api, clientPolicy, downloadStorageObject, en
103
88
  const clientRouter = createRouter();
104
89
  const addClientRoute = mount(clientRouter, false);
105
90
  addClientRoute("GET", "/version", "version");
106
- if (downloadStorageObject !== void 0) addClientRoute("GET", "/storage/:token/:signature", "downloadStorageObject");
107
91
  addClientRoute("GET", "/release-catalogs/app-version/:platform/:channelKey/:appVersion", "appVersionReleaseCatalog");
108
92
  addClientRoute("GET", "/release-catalogs/fingerprint/:platform/:channelKey/:fingerprintHash", "fingerprintReleaseCatalog");
109
93
  addClientRoute("GET", "/artifacts/v1/:targetBundleId/from/:currentBundleId", "artifactV1");
@@ -1,5 +1,6 @@
1
1
  const require_handlerParameters = require("./handlerParameters.cjs");
2
2
  let _hot_updater_plugin_core = require("@hot-updater/plugin-core");
3
+ let _hot_updater_protocol = require("@hot-updater/protocol");
3
4
  //#region src/handlerReleaseCatalogRoutes.ts
4
5
  const CATALOG_CONTENT_TYPE = "application/vnd.hot-updater.release-catalog+json; version=1";
5
6
  const ARTIFACT_CONTENT_TYPE = "application/vnd.hot-updater.artifact+json; version=1";
@@ -9,10 +10,42 @@ const ORIGIN_CACHE_MAX_ENTRIES = 128;
9
10
  const NO_CATALOG_HEADER = "x-hot-updater-catalog";
10
11
  /** A shared cache keeps a catalog, or a scope's lack of one, five seconds. */
11
12
  const CATALOG_CACHE_CONTROL = "public, max-age=0, s-maxage=5";
13
+ /**
14
+ * The same five seconds for a CDN that reads `CDN-Cache-Control` (RFC 9213),
15
+ * such as Cloudflare's Workers Cache, which then ignores `Cache-Control`.
16
+ * `s-maxage` forbids serving stale, so there an expired catalog held requests
17
+ * at that location until the origin answered. Here, for five more seconds,
18
+ * the CDN answers with the expired copy while it revalidates in the
19
+ * background. `stale-if-error=0` keeps origin errors visible: without it,
20
+ * Cloudflare serves a stale copy on error indefinitely.
21
+ *
22
+ * A device can see a catalog up to 15 seconds after a deploy or a Roll back
23
+ * replaced it: the origin's 5-second catalog memo, then 5 seconds fresh and
24
+ * 5 stale at the CDN. A revoked API key can read a cached catalog up to
25
+ * 10 seconds. Devices and caches that don't read the header keep
26
+ * `Cache-Control`.
27
+ */
28
+ const CATALOG_CDN_CACHE_CONTROL = "public, max-age=5, stale-while-revalidate=5, stale-if-error=0";
12
29
  const privateNotFound = () => Response.json({ error: "Not found" }, {
13
30
  status: 404,
14
31
  headers: { "cache-control": "private, no-store" }
15
32
  });
33
+ const badRequest = (error) => Response.json({ error }, {
34
+ status: 400,
35
+ headers: { "cache-control": "private, no-store" }
36
+ });
37
+ /**
38
+ * A 400 for a catalog path whose scope is malformed, such as a channel name
39
+ * where its base64url key belongs; null for a well-formed scope.
40
+ */
41
+ const invalidScope = (input) => {
42
+ try {
43
+ (0, _hot_updater_protocol.createReleaseCatalogScopeKey)(input);
44
+ return null;
45
+ } catch (error) {
46
+ return badRequest(error instanceof Error ? error.message : "Invalid Release Catalog scope");
47
+ }
48
+ };
16
49
  /**
17
50
  * A scope with no catalog yet, such as a store version before its first
18
51
  * OTA release: cached like a catalog, so its update checks don't all reach
@@ -24,6 +57,7 @@ const missingCatalog = () => Response.json({ error: "Not found" }, {
24
57
  status: 404,
25
58
  headers: {
26
59
  "cache-control": CATALOG_CACHE_CONTROL,
60
+ "cdn-cache-control": CATALOG_CDN_CACHE_CONTROL,
27
61
  vary: "Accept-Encoding",
28
62
  [NO_CATALOG_HEADER]: "none"
29
63
  }
@@ -38,6 +72,7 @@ const catalogResponse = async (catalog, request) => {
38
72
  const etag = `"sha256:${await responseHash(body)}"`;
39
73
  const headers = {
40
74
  "cache-control": CATALOG_CACHE_CONTROL,
75
+ "cdn-cache-control": CATALOG_CDN_CACHE_CONTROL,
41
76
  "content-type": CATALOG_CONTENT_TYPE,
42
77
  etag,
43
78
  vary: "Accept-Encoding"
@@ -89,16 +124,15 @@ const createReleaseCatalogRouteHandlers = () => {
89
124
  appVersionReleaseCatalog: async (params, request, { core }) => {
90
125
  const rawAppVersion = require_handlerParameters.requireRouteParam(params, "appVersion");
91
126
  const appVersion = (0, _hot_updater_plugin_core.canonicalizeAppVersion)(rawAppVersion);
92
- if (appVersion === null || appVersion !== rawAppVersion) return Response.json({ error: "Invalid app version" }, {
93
- status: 400,
94
- headers: { "cache-control": "private, no-store" }
95
- });
127
+ if (appVersion === null || appVersion !== rawAppVersion) return badRequest("Invalid app version");
96
128
  const input = {
97
129
  appVersion,
98
130
  channelKey: require_handlerParameters.requireRouteParam(params, "channelKey"),
99
131
  platform: require_handlerParameters.requirePlatformParam(params),
100
132
  strategy: "APP_VERSION"
101
133
  };
134
+ const invalid = invalidScope(input);
135
+ if (invalid) return invalid;
102
136
  return catalogResponse(await loadCatalog(`app-version:${JSON.stringify(input)}`, () => core.getReleaseCatalog(input)), request);
103
137
  },
104
138
  fingerprintReleaseCatalog: async (params, request, { core }) => {
@@ -108,6 +142,8 @@ const createReleaseCatalogRouteHandlers = () => {
108
142
  platform: require_handlerParameters.requirePlatformParam(params),
109
143
  strategy: "FINGERPRINT"
110
144
  };
145
+ const invalid = invalidScope(input);
146
+ if (invalid) return invalid;
111
147
  return catalogResponse(await loadCatalog(`fingerprint:${JSON.stringify(input)}`, () => core.getReleaseCatalog(input)), request);
112
148
  },
113
149
  artifactV1: async (params, _request, { core }) => {
@@ -1,5 +1,6 @@
1
1
  import { requirePlatformParam, requireRouteParam } from "./handlerParameters.mjs";
2
2
  import { canonicalizeAppVersion } from "@hot-updater/plugin-core";
3
+ import { createReleaseCatalogScopeKey } from "@hot-updater/protocol";
3
4
  //#region src/handlerReleaseCatalogRoutes.ts
4
5
  const CATALOG_CONTENT_TYPE = "application/vnd.hot-updater.release-catalog+json; version=1";
5
6
  const ARTIFACT_CONTENT_TYPE = "application/vnd.hot-updater.artifact+json; version=1";
@@ -9,10 +10,42 @@ const ORIGIN_CACHE_MAX_ENTRIES = 128;
9
10
  const NO_CATALOG_HEADER = "x-hot-updater-catalog";
10
11
  /** A shared cache keeps a catalog, or a scope's lack of one, five seconds. */
11
12
  const CATALOG_CACHE_CONTROL = "public, max-age=0, s-maxage=5";
13
+ /**
14
+ * The same five seconds for a CDN that reads `CDN-Cache-Control` (RFC 9213),
15
+ * such as Cloudflare's Workers Cache, which then ignores `Cache-Control`.
16
+ * `s-maxage` forbids serving stale, so there an expired catalog held requests
17
+ * at that location until the origin answered. Here, for five more seconds,
18
+ * the CDN answers with the expired copy while it revalidates in the
19
+ * background. `stale-if-error=0` keeps origin errors visible: without it,
20
+ * Cloudflare serves a stale copy on error indefinitely.
21
+ *
22
+ * A device can see a catalog up to 15 seconds after a deploy or a Roll back
23
+ * replaced it: the origin's 5-second catalog memo, then 5 seconds fresh and
24
+ * 5 stale at the CDN. A revoked API key can read a cached catalog up to
25
+ * 10 seconds. Devices and caches that don't read the header keep
26
+ * `Cache-Control`.
27
+ */
28
+ const CATALOG_CDN_CACHE_CONTROL = "public, max-age=5, stale-while-revalidate=5, stale-if-error=0";
12
29
  const privateNotFound = () => Response.json({ error: "Not found" }, {
13
30
  status: 404,
14
31
  headers: { "cache-control": "private, no-store" }
15
32
  });
33
+ const badRequest = (error) => Response.json({ error }, {
34
+ status: 400,
35
+ headers: { "cache-control": "private, no-store" }
36
+ });
37
+ /**
38
+ * A 400 for a catalog path whose scope is malformed, such as a channel name
39
+ * where its base64url key belongs; null for a well-formed scope.
40
+ */
41
+ const invalidScope = (input) => {
42
+ try {
43
+ createReleaseCatalogScopeKey(input);
44
+ return null;
45
+ } catch (error) {
46
+ return badRequest(error instanceof Error ? error.message : "Invalid Release Catalog scope");
47
+ }
48
+ };
16
49
  /**
17
50
  * A scope with no catalog yet, such as a store version before its first
18
51
  * OTA release: cached like a catalog, so its update checks don't all reach
@@ -24,6 +57,7 @@ const missingCatalog = () => Response.json({ error: "Not found" }, {
24
57
  status: 404,
25
58
  headers: {
26
59
  "cache-control": CATALOG_CACHE_CONTROL,
60
+ "cdn-cache-control": CATALOG_CDN_CACHE_CONTROL,
27
61
  vary: "Accept-Encoding",
28
62
  [NO_CATALOG_HEADER]: "none"
29
63
  }
@@ -38,6 +72,7 @@ const catalogResponse = async (catalog, request) => {
38
72
  const etag = `"sha256:${await responseHash(body)}"`;
39
73
  const headers = {
40
74
  "cache-control": CATALOG_CACHE_CONTROL,
75
+ "cdn-cache-control": CATALOG_CDN_CACHE_CONTROL,
41
76
  "content-type": CATALOG_CONTENT_TYPE,
42
77
  etag,
43
78
  vary: "Accept-Encoding"
@@ -89,16 +124,15 @@ const createReleaseCatalogRouteHandlers = () => {
89
124
  appVersionReleaseCatalog: async (params, request, { core }) => {
90
125
  const rawAppVersion = requireRouteParam(params, "appVersion");
91
126
  const appVersion = canonicalizeAppVersion(rawAppVersion);
92
- if (appVersion === null || appVersion !== rawAppVersion) return Response.json({ error: "Invalid app version" }, {
93
- status: 400,
94
- headers: { "cache-control": "private, no-store" }
95
- });
127
+ if (appVersion === null || appVersion !== rawAppVersion) return badRequest("Invalid app version");
96
128
  const input = {
97
129
  appVersion,
98
130
  channelKey: requireRouteParam(params, "channelKey"),
99
131
  platform: requirePlatformParam(params),
100
132
  strategy: "APP_VERSION"
101
133
  };
134
+ const invalid = invalidScope(input);
135
+ if (invalid) return invalid;
102
136
  return catalogResponse(await loadCatalog(`app-version:${JSON.stringify(input)}`, () => core.getReleaseCatalog(input)), request);
103
137
  },
104
138
  fingerprintReleaseCatalog: async (params, request, { core }) => {
@@ -108,6 +142,8 @@ const createReleaseCatalogRouteHandlers = () => {
108
142
  platform: requirePlatformParam(params),
109
143
  strategy: "FINGERPRINT"
110
144
  };
145
+ const invalid = invalidScope(input);
146
+ if (invalid) return invalid;
111
147
  return catalogResponse(await loadCatalog(`fingerprint:${JSON.stringify(input)}`, () => core.getReleaseCatalog(input)), request);
112
148
  },
113
149
  artifactV1: async (params, _request, { core }) => {
package/dist/package.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "1.0.0-rc.42";
2
+ var version = "1.0.0-rc.44";
3
3
  //#endregion
4
4
  Object.defineProperty(exports, "version", {
5
5
  enumerable: true,
package/dist/package.mjs CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "1.0.0-rc.42";
2
+ var version = "1.0.0-rc.44";
3
3
  //#endregion
4
4
  export { version };
@@ -0,0 +1,27 @@
1
+ var _hot_updater_plugin_api_keys_server = require("@hot-updater/plugin-api-keys/server");
2
+ Object.keys(_hot_updater_plugin_api_keys_server).forEach(function(k) {
3
+ if (k !== "default" && !Object.prototype.hasOwnProperty.call(exports, k)) Object.defineProperty(exports, k, {
4
+ enumerable: true,
5
+ get: function() {
6
+ return _hot_updater_plugin_api_keys_server[k];
7
+ }
8
+ });
9
+ });
10
+ var _hot_updater_plugin_insights_server = require("@hot-updater/plugin-insights/server");
11
+ Object.keys(_hot_updater_plugin_insights_server).forEach(function(k) {
12
+ if (k !== "default" && !Object.prototype.hasOwnProperty.call(exports, k)) Object.defineProperty(exports, k, {
13
+ enumerable: true,
14
+ get: function() {
15
+ return _hot_updater_plugin_insights_server[k];
16
+ }
17
+ });
18
+ });
19
+ var _hot_updater_plugin_remote_config_server = require("@hot-updater/plugin-remote-config/server");
20
+ Object.keys(_hot_updater_plugin_remote_config_server).forEach(function(k) {
21
+ if (k !== "default" && !Object.prototype.hasOwnProperty.call(exports, k)) Object.defineProperty(exports, k, {
22
+ enumerable: true,
23
+ get: function() {
24
+ return _hot_updater_plugin_remote_config_server[k];
25
+ }
26
+ });
27
+ });
@@ -0,0 +1,6 @@
1
+ import "./api-keys/index.cjs";
2
+ import "./insights/index.cjs";
3
+ import "./remote-config/index.cjs";
4
+ export * from "@hot-updater/plugin-api-keys/server";
5
+ export * from "@hot-updater/plugin-insights/server";
6
+ export * from "@hot-updater/plugin-remote-config/server";
@@ -0,0 +1,6 @@
1
+ import "./api-keys/index.mjs";
2
+ import "./insights/index.mjs";
3
+ import "./remote-config/index.mjs";
4
+ export * from "@hot-updater/plugin-api-keys/server";
5
+ export * from "@hot-updater/plugin-insights/server";
6
+ export * from "@hot-updater/plugin-remote-config/server";
@@ -0,0 +1,4 @@
1
+ export * from "@hot-updater/plugin-api-keys/server";
2
+ export * from "@hot-updater/plugin-insights/server";
3
+ export * from "@hot-updater/plugin-remote-config/server";
4
+ export {};
@@ -1,32 +1,21 @@
1
- let _hot_updater_plugin_core = require("@hot-updater/plugin-core");
2
1
  //#region src/storageAccess.ts
2
+ /** A URL devices download from, which only an absolute HTTP(S) URL can be. */
3
3
  const assertRemoteUrl = (value) => {
4
- const protocol = new URL(value).protocol;
5
- if (protocol !== "http:" && protocol !== "https:") throw new Error("Storage getDownloadUrl must resolve to an HTTP(S) URL.");
4
+ let protocol;
5
+ try {
6
+ protocol = new URL(value).protocol;
7
+ } catch {}
8
+ if (protocol !== "http:" && protocol !== "https:") throw new Error("Storage getDownloadUrl must resolve to an absolute HTTP(S) URL.");
6
9
  return value;
7
10
  };
8
11
  const getStorageProtocol = (storageUri) => new URL(storageUri).protocol.replace(":", "");
9
12
  const isRemoteUrlProtocol = (protocol) => protocol === "http" || protocol === "https";
10
- const resolveDownloadPath = (value, storageUri) => {
11
- const parsed = (0, _hot_updater_plugin_core.parseStorageDownloadPath)(value);
12
- if (!parsed || parsed.storageUri !== storageUri) throw new Error("Storage getDownloadUrl must return an HTTP(S) URL or a valid storage download path.");
13
- return value;
14
- };
15
- const tokensEqual = (left, right) => {
16
- const leftBytes = new TextEncoder().encode(left);
17
- const rightBytes = new TextEncoder().encode(right);
18
- let difference = leftBytes.length ^ rightBytes.length;
19
- const length = Math.max(leftBytes.length, rightBytes.length);
20
- for (let index = 0; index < length; index += 1) difference |= (leftBytes[index] ?? 0) ^ (rightBytes[index] ?? 0);
21
- return difference === 0;
22
- };
23
- const createStorageAccess = (storageAdapters) => {
24
- const protocols = /* @__PURE__ */ new Set();
25
- for (const storage of storageAdapters) {
26
- if (protocols.has(storage.protocol)) throw new Error(`Multiple storage adapters handle protocol: ${storage.protocol}`);
27
- protocols.add(storage.protocol);
28
- }
29
- const findStorage = (protocol) => storageAdapters.find((item) => item.protocol === protocol);
13
+ /**
14
+ * Reads and download URLs over the server's storage, for the URIs of its
15
+ * protocol; an HTTP(S) URI is used as it is.
16
+ */
17
+ const createStorageAccess = (storageAdapter) => {
18
+ const findStorage = (protocol) => storageAdapter.protocol === protocol ? storageAdapter : void 0;
30
19
  const readStorageResponse = async (storageUri) => {
31
20
  const protocol = getStorageProtocol(storageUri);
32
21
  const storage = findStorage(protocol);
@@ -50,38 +39,12 @@ const createStorageAccess = (storageAdapters) => {
50
39
  }
51
40
  if (!storage.getDownloadUrl) return null;
52
41
  const { url: downloadUrl } = await storage.getDownloadUrl({ storageUri });
53
- try {
54
- return assertRemoteUrl(downloadUrl);
55
- } catch (error) {
56
- if (/^[a-z][a-z\d+.-]*:/i.test(downloadUrl)) throw error;
57
- }
58
- return resolveDownloadPath(downloadUrl, storageUri);
42
+ return assertRemoteUrl(downloadUrl);
59
43
  };
60
44
  const readStorageText = async (storageUri) => {
61
45
  return (await readStorageResponse(storageUri))?.text() ?? null;
62
46
  };
63
47
  return {
64
- downloadStorageObject: storageAdapters.some((storage) => storage.getDownloadUrl !== void 0) ? async (storageUriToken, encodedSignature) => {
65
- const requestedPath = `/storage/${storageUriToken}/${encodedSignature}`;
66
- const requested = (0, _hot_updater_plugin_core.parseStorageDownloadPath)(requestedPath);
67
- if (!requested) return null;
68
- let storage;
69
- try {
70
- storage = findStorage(getStorageProtocol(requested.storageUri));
71
- } catch {
72
- return null;
73
- }
74
- if (!storage?.getDownloadUrl || !storage.get) return null;
75
- const { url: downloadUrl } = await storage.getDownloadUrl({ storageUri: requested.storageUri });
76
- try {
77
- new URL(downloadUrl);
78
- return null;
79
- } catch {
80
- if (/^[a-z][a-z\d+.-]*:/i.test(downloadUrl)) return null;
81
- }
82
- if (!tokensEqual(downloadUrl, requestedPath)) return null;
83
- return (await storage.get({ storageUri: requested.storageUri })).response;
84
- } : void 0,
85
48
  readStorageText,
86
49
  resolveFileUrl
87
50
  };
@@ -1,32 +1,21 @@
1
- import { parseStorageDownloadPath } from "@hot-updater/plugin-core";
2
1
  //#region src/storageAccess.ts
2
+ /** A URL devices download from, which only an absolute HTTP(S) URL can be. */
3
3
  const assertRemoteUrl = (value) => {
4
- const protocol = new URL(value).protocol;
5
- if (protocol !== "http:" && protocol !== "https:") throw new Error("Storage getDownloadUrl must resolve to an HTTP(S) URL.");
4
+ let protocol;
5
+ try {
6
+ protocol = new URL(value).protocol;
7
+ } catch {}
8
+ if (protocol !== "http:" && protocol !== "https:") throw new Error("Storage getDownloadUrl must resolve to an absolute HTTP(S) URL.");
6
9
  return value;
7
10
  };
8
11
  const getStorageProtocol = (storageUri) => new URL(storageUri).protocol.replace(":", "");
9
12
  const isRemoteUrlProtocol = (protocol) => protocol === "http" || protocol === "https";
10
- const resolveDownloadPath = (value, storageUri) => {
11
- const parsed = parseStorageDownloadPath(value);
12
- if (!parsed || parsed.storageUri !== storageUri) throw new Error("Storage getDownloadUrl must return an HTTP(S) URL or a valid storage download path.");
13
- return value;
14
- };
15
- const tokensEqual = (left, right) => {
16
- const leftBytes = new TextEncoder().encode(left);
17
- const rightBytes = new TextEncoder().encode(right);
18
- let difference = leftBytes.length ^ rightBytes.length;
19
- const length = Math.max(leftBytes.length, rightBytes.length);
20
- for (let index = 0; index < length; index += 1) difference |= (leftBytes[index] ?? 0) ^ (rightBytes[index] ?? 0);
21
- return difference === 0;
22
- };
23
- const createStorageAccess = (storageAdapters) => {
24
- const protocols = /* @__PURE__ */ new Set();
25
- for (const storage of storageAdapters) {
26
- if (protocols.has(storage.protocol)) throw new Error(`Multiple storage adapters handle protocol: ${storage.protocol}`);
27
- protocols.add(storage.protocol);
28
- }
29
- const findStorage = (protocol) => storageAdapters.find((item) => item.protocol === protocol);
13
+ /**
14
+ * Reads and download URLs over the server's storage, for the URIs of its
15
+ * protocol; an HTTP(S) URI is used as it is.
16
+ */
17
+ const createStorageAccess = (storageAdapter) => {
18
+ const findStorage = (protocol) => storageAdapter.protocol === protocol ? storageAdapter : void 0;
30
19
  const readStorageResponse = async (storageUri) => {
31
20
  const protocol = getStorageProtocol(storageUri);
32
21
  const storage = findStorage(protocol);
@@ -50,38 +39,12 @@ const createStorageAccess = (storageAdapters) => {
50
39
  }
51
40
  if (!storage.getDownloadUrl) return null;
52
41
  const { url: downloadUrl } = await storage.getDownloadUrl({ storageUri });
53
- try {
54
- return assertRemoteUrl(downloadUrl);
55
- } catch (error) {
56
- if (/^[a-z][a-z\d+.-]*:/i.test(downloadUrl)) throw error;
57
- }
58
- return resolveDownloadPath(downloadUrl, storageUri);
42
+ return assertRemoteUrl(downloadUrl);
59
43
  };
60
44
  const readStorageText = async (storageUri) => {
61
45
  return (await readStorageResponse(storageUri))?.text() ?? null;
62
46
  };
63
47
  return {
64
- downloadStorageObject: storageAdapters.some((storage) => storage.getDownloadUrl !== void 0) ? async (storageUriToken, encodedSignature) => {
65
- const requestedPath = `/storage/${storageUriToken}/${encodedSignature}`;
66
- const requested = parseStorageDownloadPath(requestedPath);
67
- if (!requested) return null;
68
- let storage;
69
- try {
70
- storage = findStorage(getStorageProtocol(requested.storageUri));
71
- } catch {
72
- return null;
73
- }
74
- if (!storage?.getDownloadUrl || !storage.get) return null;
75
- const { url: downloadUrl } = await storage.getDownloadUrl({ storageUri: requested.storageUri });
76
- try {
77
- new URL(downloadUrl);
78
- return null;
79
- } catch {
80
- if (/^[a-z][a-z\d+.-]*:/i.test(downloadUrl)) return null;
81
- }
82
- if (!tokensEqual(downloadUrl, requestedPath)) return null;
83
- return (await storage.get({ storageUri: requested.storageUri })).response;
84
- } : void 0,
85
48
  readStorageText,
86
49
  resolveFileUrl
87
50
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hot-updater/server",
3
- "version": "1.0.0-rc.42",
3
+ "version": "1.0.0-rc.44",
4
4
  "type": "module",
5
5
  "description": "React Native OTA solution for self-hosted",
6
6
  "sideEffects": false,
@@ -28,17 +28,9 @@
28
28
  "import": "./dist/adapters/prisma.mjs",
29
29
  "require": "./dist/adapters/prisma.cjs"
30
30
  },
31
- "./plugins/api-keys": {
32
- "import": "./dist/plugins/api-keys/index.mjs",
33
- "require": "./dist/plugins/api-keys/index.cjs"
34
- },
35
- "./plugins/insights": {
36
- "import": "./dist/plugins/insights/index.mjs",
37
- "require": "./dist/plugins/insights/index.cjs"
38
- },
39
- "./plugins/remote-config": {
40
- "import": "./dist/plugins/remote-config/index.mjs",
41
- "require": "./dist/plugins/remote-config/index.cjs"
31
+ "./plugins": {
32
+ "import": "./dist/plugins/index.mjs",
33
+ "require": "./dist/plugins/index.cjs"
42
34
  },
43
35
  "./package.json": "./package.json"
44
36
  },
@@ -58,11 +50,11 @@
58
50
  "dependencies": {
59
51
  "rou3": "0.7.9",
60
52
  "verkit": "0.4.0",
61
- "@hot-updater/plugin-api-keys": "1.0.0-rc.42",
62
- "@hot-updater/plugin-insights": "1.0.0-rc.42",
63
- "@hot-updater/plugin-remote-config": "1.0.0-rc.42",
64
- "@hot-updater/protocol": "1.0.0-rc.42",
65
- "@hot-updater/plugin-core": "1.0.0-rc.42"
53
+ "@hot-updater/plugin-insights": "1.0.0-rc.44",
54
+ "@hot-updater/protocol": "1.0.0-rc.44",
55
+ "@hot-updater/plugin-api-keys": "1.0.0-rc.44",
56
+ "@hot-updater/plugin-remote-config": "1.0.0-rc.44",
57
+ "@hot-updater/plugin-core": "1.0.0-rc.44"
66
58
  },
67
59
  "peerDependencies": {
68
60
  "@prisma/client": "*",
@@ -103,8 +95,8 @@
103
95
  "prisma": "6.19.3",
104
96
  "uuidv7": "^1.0.2",
105
97
  "wrangler": "^4.69.0",
106
- "@hot-updater/standalone": "1.0.0-rc.42",
107
- "@hot-updater/test-utils": "1.0.0-rc.42"
98
+ "@hot-updater/standalone": "1.0.0-rc.44",
99
+ "@hot-updater/test-utils": "1.0.0-rc.44"
108
100
  },
109
101
  "nx": {
110
102
  "implicitDependencies": [