@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 +63 -12
- package/dist/index.d.cts +7 -0
- package/dist/index.d.mts +7 -0
- package/dist/index.mjs +64 -13
- package/dist/lambda/index.cjs +217 -17
- package/package.json +7 -7
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
package/dist/lambda/index.cjs
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
13276
|
-
|
|
13277
|
-
|
|
13278
|
-
|
|
13279
|
-
|
|
13280
|
-
|
|
13281
|
-
|
|
13282
|
-
|
|
13283
|
-
|
|
13284
|
-
|
|
13285
|
-
|
|
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.
|
|
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.
|
|
49
|
-
"@hot-updater/mock": "1.0.
|
|
50
|
-
"@hot-updater/test-utils": "1.0.
|
|
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/
|
|
67
|
-
"@hot-updater/
|
|
68
|
-
"@hot-updater/
|
|
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",
|