@hot-updater/aws 1.0.5 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -397,6 +397,8 @@ const applySsmRuntimeAwsConfig = (config) => {
397
397
  //#endregion
398
398
  //#region src/cloudFrontDownloadUrl.ts
399
399
  const ONE_YEAR_IN_SECONDS = 31536e3;
400
+ /** Signatures kept per process; one per Bundle plus one for shared assets. */
401
+ const MAX_CACHED_SIGNATURES = 1e3;
400
402
  const privateKeyCache = /* @__PURE__ */ new Map();
401
403
  const getPrivateKeyFromSsm = async (region, parameterName) => {
402
404
  const value = (await new _aws_sdk_client_ssm.SSM(applySsmRuntimeAwsConfig({ region })).getParameter({
@@ -420,18 +422,67 @@ const resolvePrivateKey = (config) => {
420
422
  privateKeyCache.set(cacheKey, pending);
421
423
  return pending;
422
424
  };
423
- const cloudFrontDownloadUrl = (config) => async ({ storageUri }) => {
424
- const storageUrl = new URL(storageUri);
425
- if (storageUrl.protocol !== "s3:") throw new Error("CloudFront download URLs require an s3 storage URI.");
426
- const url = new URL(config.publicBaseUrl);
427
- url.pathname = storageUrl.pathname;
428
- url.search = "";
429
- return { url: (0, _aws_sdk_cloudfront_signer.getSignedUrl)({
430
- url: url.toString(),
431
- keyPairId: config.keyPairId,
432
- privateKey: await resolvePrivateKey(config),
433
- dateLessThan: new Date(Date.now() + (config.expiresSeconds ?? ONE_YEAR_IN_SECONDS) * 1e3).toISOString()
434
- }) };
425
+ /**
426
+ * The URL path one signature covers: every file of one Bundle
427
+ * (`bundles/<bundleId>/*`), or every content-addressed asset (`assets/*`),
428
+ * whose SHA-256 names cannot be listed without a manifest that names them.
429
+ * Any other object is signed alone.
430
+ */
431
+ const getSignedPathScope = (pathname) => {
432
+ const segments = pathname.split("/");
433
+ const assets = segments.length - 4;
434
+ if (segments[assets] === _hot_updater_plugin_core.CONTENT_ADDRESSED_ASSET_PREFIX && segments[assets + 1] === "sha256") return `${segments.slice(0, assets + 1).join("/")}/*`;
435
+ const bundles = segments.lastIndexOf(_hot_updater_plugin_core.BUNDLE_STORAGE_PREFIX);
436
+ if (bundles >= 0 && segments.length - bundles > 2) return `${segments.slice(0, bundles + 2).join("/")}/*`;
437
+ return pathname;
438
+ };
439
+ /**
440
+ * Download URLs signed with a CloudFront key pair. An update check returns a
441
+ * URL for every file of the Bundle, hundreds for an app with many assets, so
442
+ * one custom-policy signature covers a Bundle's files and another the shared
443
+ * assets: each is reused for every URL under it until half its lifetime has
444
+ * passed, instead of one RSA signature per file.
445
+ */
446
+ const cloudFrontDownloadUrl = (config) => {
447
+ /**
448
+ * Signed query strings by policy resource, for this configuration only.
449
+ * A signing in flight is shared, so a batch of URLs asks the private key
450
+ * once per scope, and a cached signature needs no key at all.
451
+ */
452
+ const signatureCache = /* @__PURE__ */ new Map();
453
+ return async ({ storageUri }) => {
454
+ const storageUrl = new URL(storageUri);
455
+ if (storageUrl.protocol !== "s3:") throw new Error("CloudFront download URLs require an s3 storage URI.");
456
+ const url = new URL(config.publicBaseUrl);
457
+ url.pathname = storageUrl.pathname;
458
+ url.search = "";
459
+ url.hash = "";
460
+ const resource = `${url.origin}${getSignedPathScope(url.pathname)}`;
461
+ const cached = signatureCache.get(resource);
462
+ if (cached && Date.now() < cached.renewAt) return { url: `${url}${await cached.query}` };
463
+ const expiresSeconds = config.expiresSeconds ?? ONE_YEAR_IN_SECONDS;
464
+ const signedAt = Date.now();
465
+ const query = resolvePrivateKey(config).then((privateKey) => new URL((0, _aws_sdk_cloudfront_signer.getSignedUrl)({
466
+ url: url.toString(),
467
+ keyPairId: config.keyPairId,
468
+ privateKey,
469
+ policy: JSON.stringify({ Statement: [{
470
+ Resource: resource,
471
+ Condition: { DateLessThan: { "AWS:EpochTime": Math.floor((signedAt + expiresSeconds * 1e3) / 1e3) } }
472
+ }] })
473
+ })).search);
474
+ const entry = {
475
+ query,
476
+ renewAt: signedAt + expiresSeconds * 1e3 / 2
477
+ };
478
+ signatureCache.delete(resource);
479
+ signatureCache.set(resource, entry);
480
+ if (signatureCache.size > MAX_CACHED_SIGNATURES) signatureCache.delete(signatureCache.keys().next().value);
481
+ query.catch(() => {
482
+ if (signatureCache.get(resource) === entry) signatureCache.delete(resource);
483
+ });
484
+ return { url: `${url}${await query}` };
485
+ };
435
486
  };
436
487
  //#endregion
437
488
  //#region src/s3Storage.ts
package/dist/index.d.cts CHANGED
@@ -45,6 +45,13 @@ type CloudFrontDownloadUrlOptions = (CloudFrontPrivateKeyFromGetter | CloudFront
45
45
  publicBaseUrl: string;
46
46
  expiresSeconds?: number;
47
47
  };
48
+ /**
49
+ * Download URLs signed with a CloudFront key pair. An update check returns a
50
+ * URL for every file of the Bundle, hundreds for an app with many assets, so
51
+ * one custom-policy signature covers a Bundle's files and another the shared
52
+ * assets: each is reused for every URL under it until half its lifetime has
53
+ * passed, instead of one RSA signature per file.
54
+ */
48
55
  declare const cloudFrontDownloadUrl: (config: CloudFrontDownloadUrlOptions) => ({ storageUri }: {
49
56
  storageUri: string;
50
57
  }) => Promise<{
package/dist/index.d.mts CHANGED
@@ -45,6 +45,13 @@ type CloudFrontDownloadUrlOptions = (CloudFrontPrivateKeyFromGetter | CloudFront
45
45
  publicBaseUrl: string;
46
46
  expiresSeconds?: number;
47
47
  };
48
+ /**
49
+ * Download URLs signed with a CloudFront key pair. An update check returns a
50
+ * URL for every file of the Bundle, hundreds for an app with many assets, so
51
+ * one custom-policy signature covers a Bundle's files and another the shared
52
+ * assets: each is reused for every URL under it until half its lifetime has
53
+ * passed, instead of one RSA signature per file.
54
+ */
48
55
  declare const cloudFrontDownloadUrl: (config: CloudFrontDownloadUrlOptions) => ({ storageUri }: {
49
56
  storageUri: string;
50
57
  }) => Promise<{
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { CreateTableCommand, DescribeTableCommand, DescribeTimeToLiveCommand, DynamoDBClient, UpdateTimeToLiveCommand, waitUntilTableExists } from "@aws-sdk/client-dynamodb";
2
- import { createEngineDatabase, createKvAdapter, createStorageAdapter, createStorageKeyBuilder, createStorageUri, migrateCoreSchema, parseStorageUri } from "@hot-updater/plugin-core";
2
+ import { BUNDLE_STORAGE_PREFIX, CONTENT_ADDRESSED_ASSET_PREFIX, createEngineDatabase, createKvAdapter, createStorageAdapter, createStorageKeyBuilder, createStorageUri, migrateCoreSchema, parseStorageUri } from "@hot-updater/plugin-core";
3
3
  import { CloudFrontClient, CreateInvalidationCommand, GetInvalidationCommand } from "@aws-sdk/client-cloudfront";
4
4
  import { BatchGetCommand, BatchWriteCommand, DynamoDBDocumentClient, QueryCommand, TransactWriteCommand } from "@aws-sdk/lib-dynamodb";
5
5
  import { SSM } from "@aws-sdk/client-ssm";
@@ -396,6 +396,8 @@ const applySsmRuntimeAwsConfig = (config) => {
396
396
  //#endregion
397
397
  //#region src/cloudFrontDownloadUrl.ts
398
398
  const ONE_YEAR_IN_SECONDS = 31536e3;
399
+ /** Signatures kept per process; one per Bundle plus one for shared assets. */
400
+ const MAX_CACHED_SIGNATURES = 1e3;
399
401
  const privateKeyCache = /* @__PURE__ */ new Map();
400
402
  const getPrivateKeyFromSsm = async (region, parameterName) => {
401
403
  const value = (await new SSM(applySsmRuntimeAwsConfig({ region })).getParameter({
@@ -419,18 +421,67 @@ const resolvePrivateKey = (config) => {
419
421
  privateKeyCache.set(cacheKey, pending);
420
422
  return pending;
421
423
  };
422
- const cloudFrontDownloadUrl = (config) => async ({ storageUri }) => {
423
- const storageUrl = new URL(storageUri);
424
- if (storageUrl.protocol !== "s3:") throw new Error("CloudFront download URLs require an s3 storage URI.");
425
- const url = new URL(config.publicBaseUrl);
426
- url.pathname = storageUrl.pathname;
427
- url.search = "";
428
- return { url: getSignedUrl({
429
- url: url.toString(),
430
- keyPairId: config.keyPairId,
431
- privateKey: await resolvePrivateKey(config),
432
- dateLessThan: new Date(Date.now() + (config.expiresSeconds ?? ONE_YEAR_IN_SECONDS) * 1e3).toISOString()
433
- }) };
424
+ /**
425
+ * The URL path one signature covers: every file of one Bundle
426
+ * (`bundles/<bundleId>/*`), or every content-addressed asset (`assets/*`),
427
+ * whose SHA-256 names cannot be listed without a manifest that names them.
428
+ * Any other object is signed alone.
429
+ */
430
+ const getSignedPathScope = (pathname) => {
431
+ const segments = pathname.split("/");
432
+ const assets = segments.length - 4;
433
+ if (segments[assets] === CONTENT_ADDRESSED_ASSET_PREFIX && segments[assets + 1] === "sha256") return `${segments.slice(0, assets + 1).join("/")}/*`;
434
+ const bundles = segments.lastIndexOf(BUNDLE_STORAGE_PREFIX);
435
+ if (bundles >= 0 && segments.length - bundles > 2) return `${segments.slice(0, bundles + 2).join("/")}/*`;
436
+ return pathname;
437
+ };
438
+ /**
439
+ * Download URLs signed with a CloudFront key pair. An update check returns a
440
+ * URL for every file of the Bundle, hundreds for an app with many assets, so
441
+ * one custom-policy signature covers a Bundle's files and another the shared
442
+ * assets: each is reused for every URL under it until half its lifetime has
443
+ * passed, instead of one RSA signature per file.
444
+ */
445
+ const cloudFrontDownloadUrl = (config) => {
446
+ /**
447
+ * Signed query strings by policy resource, for this configuration only.
448
+ * A signing in flight is shared, so a batch of URLs asks the private key
449
+ * once per scope, and a cached signature needs no key at all.
450
+ */
451
+ const signatureCache = /* @__PURE__ */ new Map();
452
+ return async ({ storageUri }) => {
453
+ const storageUrl = new URL(storageUri);
454
+ if (storageUrl.protocol !== "s3:") throw new Error("CloudFront download URLs require an s3 storage URI.");
455
+ const url = new URL(config.publicBaseUrl);
456
+ url.pathname = storageUrl.pathname;
457
+ url.search = "";
458
+ url.hash = "";
459
+ const resource = `${url.origin}${getSignedPathScope(url.pathname)}`;
460
+ const cached = signatureCache.get(resource);
461
+ if (cached && Date.now() < cached.renewAt) return { url: `${url}${await cached.query}` };
462
+ const expiresSeconds = config.expiresSeconds ?? ONE_YEAR_IN_SECONDS;
463
+ const signedAt = Date.now();
464
+ const query = resolvePrivateKey(config).then((privateKey) => new URL(getSignedUrl({
465
+ url: url.toString(),
466
+ keyPairId: config.keyPairId,
467
+ privateKey,
468
+ policy: JSON.stringify({ Statement: [{
469
+ Resource: resource,
470
+ Condition: { DateLessThan: { "AWS:EpochTime": Math.floor((signedAt + expiresSeconds * 1e3) / 1e3) } }
471
+ }] })
472
+ })).search);
473
+ const entry = {
474
+ query,
475
+ renewAt: signedAt + expiresSeconds * 1e3 / 2
476
+ };
477
+ signatureCache.delete(resource);
478
+ signatureCache.set(resource, entry);
479
+ if (signatureCache.size > MAX_CACHED_SIGNATURES) signatureCache.delete(signatureCache.keys().next().value);
480
+ query.catch(() => {
481
+ if (signatureCache.get(resource) === entry) signatureCache.delete(resource);
482
+ });
483
+ return { url: `${url}${await query}` };
484
+ };
434
485
  };
435
486
  //#endregion
436
487
  //#region src/s3Storage.ts
@@ -30,7 +30,6 @@ let _aws_sdk_client_cloudfront = require("@aws-sdk/client-cloudfront");
30
30
  let _aws_sdk_lib_dynamodb = require("@aws-sdk/lib-dynamodb");
31
31
  let _aws_sdk_client_s3 = require("@aws-sdk/client-s3");
32
32
  let _aws_sdk_lib_storage = require("@aws-sdk/lib-storage");
33
- //#region ../plugin-core/dist/contentAddressedAssets.mjs
34
33
  const SHA256_FILE_HASH_RE = /^[0-9a-f]{64}$/;
35
34
  const isContentAddressedAssetFileHash = (value) => {
36
35
  return typeof value === "string" && SHA256_FILE_HASH_RE.test(value);
@@ -4550,7 +4549,7 @@ const createKvAdapter = ({ store, tablePrefix = "" }) => {
4550
4549
  };
4551
4550
  //#endregion
4552
4551
  //#region ../../packages/server/dist/version.mjs
4553
- const HOT_UPDATER_SERVER_VERSION = "1.0.5";
4552
+ const HOT_UPDATER_SERVER_VERSION = "1.0.6";
4554
4553
  //#endregion
4555
4554
  //#region ../../packages/server/dist/handlerVersionRoutes.mjs
4556
4555
  /**
@@ -4841,6 +4840,11 @@ async function resolveAssets(plan, resolveFileUrl) {
4841
4840
  const resolvedEntries = entries.filter((entry) => entry !== null);
4842
4841
  return { assets: Object.fromEntries(resolvedEntries.map((entry) => entry.asset)) };
4843
4842
  }
4843
+ /**
4844
+ * Legacy: artifact protocol v1. Signs a URL for every file of the target, which grows with the
4845
+ * Bundle's file count; apps whose SDK predates protocol v2 still ask for it.
4846
+ * Remove with the `/artifacts/v1` route once no supported app uses it.
4847
+ */
4844
4848
  async function resolveManifestArtifacts({ currentBundle, resolveFileUrl, readStorageText, targetBundle }) {
4845
4849
  const manifestStorageUri = targetBundle ? getManifestStorageUri(targetBundle) : null;
4846
4850
  const manifestFileHash = targetBundle ? getManifestFileHash(targetBundle) : null;
@@ -4874,6 +4878,98 @@ async function resolveManifestArtifacts({ currentBundle, resolveFileUrl, readSto
4874
4878
  ...archiveUrl ? { archiveUrl } : {}
4875
4879
  };
4876
4880
  }
4881
+ /** The target's manifest and its plan, or null for a Bundle without one. */
4882
+ async function loadTargetPlan({ currentBundle, readStorageText, targetBundle }) {
4883
+ const manifestStorageUri = getManifestStorageUri(targetBundle);
4884
+ const manifestFileHash = getManifestFileHash(targetBundle);
4885
+ const assetBaseStorageUri = getAssetBaseStorageUri(targetBundle);
4886
+ if (!manifestStorageUri || !manifestFileHash || !assetBaseStorageUri) return null;
4887
+ const targetManifest = await fetchBundleManifest(manifestStorageUri, readStorageText);
4888
+ if (!targetManifest) return null;
4889
+ return {
4890
+ manifestFileHash,
4891
+ manifestStorageUri,
4892
+ plan: createManifestArtifactPlan({
4893
+ assetBaseStorageUri,
4894
+ currentBundle,
4895
+ targetBundle,
4896
+ targetManifest
4897
+ }),
4898
+ targetManifest
4899
+ };
4900
+ }
4901
+ /**
4902
+ * Artifact protocol v2: the manifest, the archive, and the one Hermes patch,
4903
+ * but no URL per file. A device asks {@link resolveManifestArtifactFiles}
4904
+ * for the files it downloads one by one, so signing and the response stay
4905
+ * the same size however many files the Bundle has.
4906
+ */
4907
+ async function resolveManifestArtifactsV2({ currentBundle, resolveFileUrl, readStorageText, targetBundle }) {
4908
+ const target = await loadTargetPlan({
4909
+ currentBundle,
4910
+ readStorageText,
4911
+ targetBundle
4912
+ });
4913
+ if (!target) return null;
4914
+ const manifestUrl = await resolveFileUrl(target.manifestStorageUri);
4915
+ if (!manifestUrl) return null;
4916
+ const patched = target.plan.assets.find((asset) => asset.patch !== null);
4917
+ let patch;
4918
+ if (patched?.patch && !(patched.file.byteSize !== null && patched.patch.byteSize !== null && patched.patch.byteSize >= patched.file.byteSize)) try {
4919
+ const patchUrl = await resolveFileUrl(patched.patch.storageUri);
4920
+ if (patchUrl) patch = {
4921
+ algorithm: "bsdiff",
4922
+ assetPath: patched.assetPath,
4923
+ baseBundleId: patched.patch.baseBundleId,
4924
+ baseFileHash: patched.patch.baseFileHash,
4925
+ patchFileHash: patched.patch.patchFileHash,
4926
+ patchUrl,
4927
+ ...patched.patch.byteSize === null ? {} : { byteSize: patched.patch.byteSize }
4928
+ };
4929
+ } catch {}
4930
+ let archiveUrl = null;
4931
+ const archive = target.targetManifest.archive;
4932
+ if (archive && isContentAddressedAssetFileHash(archive.downloadFileHash) && (asByteSize(archive.downloadByteSize) ?? 0) > 0 && (asByteSize(archive.tarByteSize) ?? 0) > 0) try {
4933
+ archiveUrl = await resolveFileUrl(getBundleArchiveStorageUri({
4934
+ manifestStorageUri: target.manifestStorageUri,
4935
+ bundleId: targetBundle.id
4936
+ }));
4937
+ } catch {}
4938
+ return {
4939
+ artifactProtocolVersion: 2,
4940
+ manifestFileHash: target.manifestFileHash,
4941
+ manifestUrl,
4942
+ ...archiveUrl ? { archiveUrl } : {},
4943
+ ...patch ? { patch } : {}
4944
+ };
4945
+ }
4946
+ /**
4947
+ * Download URLs for the files a device downloads one by one. Only paths the
4948
+ * target's manifest names are signed, at most
4949
+ * {@link ARTIFACT_FILES_REQUEST_LIMIT}; null when any path is unknown or a
4950
+ * URL cannot be resolved.
4951
+ */
4952
+ async function resolveManifestArtifactFiles({ paths, resolveFileUrl, readStorageText, targetBundle }) {
4953
+ if (paths.length === 0 || paths.length > 100 || new Set(paths).size !== paths.length) return null;
4954
+ const target = await loadTargetPlan({
4955
+ currentBundle: null,
4956
+ readStorageText,
4957
+ targetBundle
4958
+ });
4959
+ if (!target) return null;
4960
+ const planned = new Map(target.plan.assets.map((asset) => [asset.assetPath, asset]));
4961
+ const requested = paths.map((path) => planned.get(path));
4962
+ if (requested.some((asset) => asset === void 0)) return null;
4963
+ const files = await Promise.all(requested.map(async (asset) => {
4964
+ const url = await resolveFileUrl(asset.file.storageUri);
4965
+ if (!url) return null;
4966
+ const file = { url };
4967
+ if (asset.file.compression) file.compression = asset.file.compression;
4968
+ return [asset.assetPath, file];
4969
+ }));
4970
+ if (files.some((file) => file === null)) return null;
4971
+ return { files: Object.fromEntries(files.filter((file) => file !== null)) };
4972
+ }
4877
4973
  //#endregion
4878
4974
  //#region ../../node_modules/.pnpm/verkit@0.4.0/node_modules/verkit/dist/comparison-DenM3wCn.js
4879
4975
  const LETTER_DASH_NUMBER = "[a-zA-Z0-9-]";
@@ -5347,20 +5443,34 @@ const createCoreReads = (db, storage) => {
5347
5443
  return projectReleaseCatalogRow(row === null ? null : toCatalogRow(row), input, scopeKey);
5348
5444
  },
5349
5445
  /** Artifact resolution: one batch read of both bundles and one unique read of their patch. */
5350
- async getArtifactInfo(targetBundleId, currentBundleId, artifactProtocolVersion) {
5446
+ getArtifactInfo: (async (targetBundleId, currentBundleId, artifactProtocolVersion) => {
5351
5447
  const { readStorageText } = storage;
5352
- if (artifactProtocolVersion !== 1 || readStorageText === void 0) return null;
5448
+ if (artifactProtocolVersion !== 1 && artifactProtocolVersion !== 2 || readStorageText === void 0) return null;
5353
5449
  const withCurrent = currentBundleId !== NIL_UUID;
5354
5450
  const [[target, current], patch] = await Promise.all([db.findByKeys("bundles", withCurrent ? [{ id: targetBundleId }, { id: currentBundleId }] : [{ id: targetBundleId }]), withCurrent ? db.findOne("bundle_patches", {
5355
5451
  bundle_id: targetBundleId,
5356
5452
  base_bundle_id: currentBundleId
5357
5453
  }) : null]);
5358
5454
  if (target === null || target === void 0) return null;
5359
- return resolveManifestArtifacts({
5455
+ const input = {
5360
5456
  currentBundle: current ? rowToBundle(toBundleRow(current)) : null,
5361
5457
  readStorageText,
5362
5458
  resolveFileUrl: storage.resolveFileUrl,
5363
5459
  targetBundle: rowToBundle(toBundleRow(target), patch === null ? [] : [toPatchRow(patch)])
5460
+ };
5461
+ return artifactProtocolVersion === 2 ? resolveManifestArtifactsV2(input) : resolveManifestArtifacts(input);
5462
+ }),
5463
+ /** A Bundle's file URLs: one point read of the bundle and its manifest. */
5464
+ async getArtifactFiles(bundleId, paths) {
5465
+ const { readStorageText } = storage;
5466
+ if (readStorageText === void 0) return null;
5467
+ const row = await db.findOne("bundles", { id: bundleId });
5468
+ if (row === null) return null;
5469
+ return resolveManifestArtifactFiles({
5470
+ paths,
5471
+ readStorageText,
5472
+ resolveFileUrl: storage.resolveFileUrl,
5473
+ targetBundle: rowToBundle(toBundleRow(row))
5364
5474
  });
5365
5475
  },
5366
5476
  /** A bundle, its patches, and how many patches start from it. */
@@ -6709,6 +6819,7 @@ const ADMIN_ROUTES = [
6709
6819
  //#region ../../packages/server/dist/handlerReleaseCatalogRoutes.mjs
6710
6820
  const CATALOG_CONTENT_TYPE = "application/vnd.hot-updater.release-catalog+json; version=1";
6711
6821
  const ARTIFACT_CONTENT_TYPE = "application/vnd.hot-updater.artifact+json; version=1";
6822
+ const ARTIFACT_V2_CONTENT_TYPE = "application/vnd.hot-updater.artifact+json; version=2";
6712
6823
  const ORIGIN_CACHE_TTL_MS = 5e3;
6713
6824
  const ORIGIN_CACHE_MAX_ENTRIES = 128;
6714
6825
  /** Marks the 404 of a scope that has no catalog. */
@@ -6731,6 +6842,18 @@ const CATALOG_CACHE_CONTROL = "public, max-age=0, s-maxage=5";
6731
6842
  * `Cache-Control`.
6732
6843
  */
6733
6844
  const CATALOG_CDN_CACHE_CONTROL = "public, max-age=5, stale-while-revalidate=5, stale-if-error=0";
6845
+ /** The paths of an artifact files request, or null when malformed. */
6846
+ const readArtifactFilePaths = async (request) => {
6847
+ let body;
6848
+ try {
6849
+ body = await request.json();
6850
+ } catch {
6851
+ return null;
6852
+ }
6853
+ const paths = body?.paths;
6854
+ if (!Array.isArray(paths) || paths.length === 0 || paths.length > 100 || !paths.every((path) => typeof path === "string" && path.length > 0) || new Set(paths).size !== paths.length) return null;
6855
+ return paths;
6856
+ };
6734
6857
  const privateNotFound = () => Response.json({ error: "Not found" }, {
6735
6858
  status: 404,
6736
6859
  headers: { "cache-control": "private, no-store" }
@@ -6851,6 +6974,30 @@ const createReleaseCatalogRouteHandlers = () => {
6851
6974
  if (invalid) return invalid;
6852
6975
  return catalogResponse(await loadCatalog(`fingerprint:${JSON.stringify(input)}`, () => core.getReleaseCatalog(input)), request);
6853
6976
  },
6977
+ artifactV2: async (params, _request, { core }) => {
6978
+ const info = await core.getArtifactInfo(requireRouteParam(params, "targetBundleId"), requireRouteParam(params, "currentBundleId"), 2);
6979
+ if (info === null) return privateNotFound();
6980
+ return new Response(JSON.stringify(info), {
6981
+ status: 200,
6982
+ headers: {
6983
+ "cache-control": "private, no-store",
6984
+ "content-type": ARTIFACT_V2_CONTENT_TYPE
6985
+ }
6986
+ });
6987
+ },
6988
+ artifactFilesV2: async (params, request, { core }) => {
6989
+ const paths = await readArtifactFilePaths(request);
6990
+ if (paths === null) return badRequest(`paths must be 1 to 100 unique strings`);
6991
+ const files = await core.getArtifactFiles(requireRouteParam(params, "targetBundleId"), paths);
6992
+ if (files === null) return privateNotFound();
6993
+ return new Response(JSON.stringify(files), {
6994
+ status: 200,
6995
+ headers: {
6996
+ "cache-control": "private, no-store",
6997
+ "content-type": ARTIFACT_V2_CONTENT_TYPE
6998
+ }
6999
+ });
7000
+ },
6854
7001
  artifactV1: async (params, _request, { core }) => {
6855
7002
  const info = await core.getArtifactInfo(requireRouteParam(params, "targetBundleId"), requireRouteParam(params, "currentBundleId"), 1);
6856
7003
  if (info === null) return privateNotFound();
@@ -7002,6 +7149,8 @@ function createHotUpdaterHandlers({ api, clientPolicy, endpoints = [], plugins =
7002
7149
  addClientRoute("GET", "/release-catalogs/app-version/:platform/:channelKey/:appVersion", "appVersionReleaseCatalog");
7003
7150
  addClientRoute("GET", "/release-catalogs/fingerprint/:platform/:channelKey/:fingerprintHash", "fingerprintReleaseCatalog");
7004
7151
  addClientRoute("GET", "/artifacts/v1/:targetBundleId/from/:currentBundleId", "artifactV1");
7152
+ addClientRoute("GET", "/artifacts/v2/:targetBundleId/from/:currentBundleId", "artifactV2");
7153
+ addClientRoute("POST", "/artifacts/v2/:targetBundleId/files", "artifactFilesV2");
7005
7154
  const adminRouter = createRouter();
7006
7155
  const addAdminRoute = mount(adminRouter, true);
7007
7156
  addAdminRoute("GET", "/version", "adminVersion");
@@ -13249,6 +13398,8 @@ const applySsmRuntimeAwsConfig = (config) => {
13249
13398
  //#endregion
13250
13399
  //#region src/cloudFrontDownloadUrl.ts
13251
13400
  const ONE_YEAR_IN_SECONDS = 31536e3;
13401
+ /** Signatures kept per process; one per Bundle plus one for shared assets. */
13402
+ const MAX_CACHED_SIGNATURES = 1e3;
13252
13403
  const privateKeyCache = /* @__PURE__ */ new Map();
13253
13404
  const getPrivateKeyFromSsm = async (region, parameterName) => {
13254
13405
  const value = (await new _aws_sdk_client_ssm.SSM(applySsmRuntimeAwsConfig({ region })).getParameter({
@@ -13272,18 +13423,67 @@ const resolvePrivateKey = (config) => {
13272
13423
  privateKeyCache.set(cacheKey, pending);
13273
13424
  return pending;
13274
13425
  };
13275
- const cloudFrontDownloadUrl = (config) => async ({ storageUri }) => {
13276
- const storageUrl = new URL(storageUri);
13277
- if (storageUrl.protocol !== "s3:") throw new Error("CloudFront download URLs require an s3 storage URI.");
13278
- const url = new URL(config.publicBaseUrl);
13279
- url.pathname = storageUrl.pathname;
13280
- url.search = "";
13281
- return { url: (0, _aws_sdk_cloudfront_signer.getSignedUrl)({
13282
- url: url.toString(),
13283
- keyPairId: config.keyPairId,
13284
- privateKey: await resolvePrivateKey(config),
13285
- dateLessThan: new Date(Date.now() + (config.expiresSeconds ?? ONE_YEAR_IN_SECONDS) * 1e3).toISOString()
13286
- }) };
13426
+ /**
13427
+ * The URL path one signature covers: every file of one Bundle
13428
+ * (`bundles/<bundleId>/*`), or every content-addressed asset (`assets/*`),
13429
+ * whose SHA-256 names cannot be listed without a manifest that names them.
13430
+ * Any other object is signed alone.
13431
+ */
13432
+ const getSignedPathScope = (pathname) => {
13433
+ const segments = pathname.split("/");
13434
+ const assets = segments.length - 4;
13435
+ if (segments[assets] === "assets" && segments[assets + 1] === "sha256") return `${segments.slice(0, assets + 1).join("/")}/*`;
13436
+ const bundles = segments.lastIndexOf(BUNDLE_STORAGE_PREFIX);
13437
+ if (bundles >= 0 && segments.length - bundles > 2) return `${segments.slice(0, bundles + 2).join("/")}/*`;
13438
+ return pathname;
13439
+ };
13440
+ /**
13441
+ * Download URLs signed with a CloudFront key pair. An update check returns a
13442
+ * URL for every file of the Bundle, hundreds for an app with many assets, so
13443
+ * one custom-policy signature covers a Bundle's files and another the shared
13444
+ * assets: each is reused for every URL under it until half its lifetime has
13445
+ * passed, instead of one RSA signature per file.
13446
+ */
13447
+ const cloudFrontDownloadUrl = (config) => {
13448
+ /**
13449
+ * Signed query strings by policy resource, for this configuration only.
13450
+ * A signing in flight is shared, so a batch of URLs asks the private key
13451
+ * once per scope, and a cached signature needs no key at all.
13452
+ */
13453
+ const signatureCache = /* @__PURE__ */ new Map();
13454
+ return async ({ storageUri }) => {
13455
+ const storageUrl = new URL(storageUri);
13456
+ if (storageUrl.protocol !== "s3:") throw new Error("CloudFront download URLs require an s3 storage URI.");
13457
+ const url = new URL(config.publicBaseUrl);
13458
+ url.pathname = storageUrl.pathname;
13459
+ url.search = "";
13460
+ url.hash = "";
13461
+ const resource = `${url.origin}${getSignedPathScope(url.pathname)}`;
13462
+ const cached = signatureCache.get(resource);
13463
+ if (cached && Date.now() < cached.renewAt) return { url: `${url}${await cached.query}` };
13464
+ const expiresSeconds = config.expiresSeconds ?? ONE_YEAR_IN_SECONDS;
13465
+ const signedAt = Date.now();
13466
+ const query = resolvePrivateKey(config).then((privateKey) => new URL((0, _aws_sdk_cloudfront_signer.getSignedUrl)({
13467
+ url: url.toString(),
13468
+ keyPairId: config.keyPairId,
13469
+ privateKey,
13470
+ policy: JSON.stringify({ Statement: [{
13471
+ Resource: resource,
13472
+ Condition: { DateLessThan: { "AWS:EpochTime": Math.floor((signedAt + expiresSeconds * 1e3) / 1e3) } }
13473
+ }] })
13474
+ })).search);
13475
+ const entry = {
13476
+ query,
13477
+ renewAt: signedAt + expiresSeconds * 1e3 / 2
13478
+ };
13479
+ signatureCache.delete(resource);
13480
+ signatureCache.set(resource, entry);
13481
+ if (signatureCache.size > MAX_CACHED_SIGNATURES) signatureCache.delete(signatureCache.keys().next().value);
13482
+ query.catch(() => {
13483
+ if (signatureCache.get(resource) === entry) signatureCache.delete(resource);
13484
+ });
13485
+ return { url: `${url}${await query}` };
13486
+ };
13287
13487
  };
13288
13488
  //#endregion
13289
13489
  //#region src/cloudFrontInvalidation.ts
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hot-updater/aws",
3
3
  "type": "module",
4
- "version": "1.0.5",
4
+ "version": "1.0.6",
5
5
  "description": "React Native OTA solution for self-hosted",
6
6
  "main": "dist/index.cjs",
7
7
  "module": "dist/index.mjs",
@@ -45,9 +45,9 @@
45
45
  "es-toolkit": "^1.32.0",
46
46
  "execa": "9.5.2",
47
47
  "mime": "^4.0.4",
48
- "@hot-updater/protocol": "1.0.5",
49
- "@hot-updater/mock": "1.0.5",
50
- "@hot-updater/test-utils": "1.0.5"
48
+ "@hot-updater/protocol": "1.0.6",
49
+ "@hot-updater/mock": "1.0.6",
50
+ "@hot-updater/test-utils": "1.0.6"
51
51
  },
52
52
  "dependencies": {
53
53
  "@aws-sdk/client-cloudfront": "3.1066.0",
@@ -63,9 +63,9 @@
63
63
  "@aws-sdk/lib-dynamodb": "3.1066.0",
64
64
  "@aws-sdk/s3-request-presigner": "3.1066.0",
65
65
  "hono": "4.12.34",
66
- "@hot-updater/cli-tools": "1.0.5",
67
- "@hot-updater/plugin-core": "1.0.5",
68
- "@hot-updater/server": "1.0.5"
66
+ "@hot-updater/plugin-core": "1.0.6",
67
+ "@hot-updater/server": "1.0.6",
68
+ "@hot-updater/cli-tools": "1.0.6"
69
69
  },
70
70
  "scripts": {
71
71
  "build": "tsdown",