@hot-updater/firebase 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/firebase/functions/index.cjs +189 -10
- package/dist/index.cjs +35 -6
- package/dist/index.mjs +35 -6
- package/package.json +7 -7
|
@@ -4525,7 +4525,7 @@ const createKvAdapter = ({ store, tablePrefix = "" }) => {
|
|
|
4525
4525
|
};
|
|
4526
4526
|
//#endregion
|
|
4527
4527
|
//#region ../../packages/server/dist/version.mjs
|
|
4528
|
-
const HOT_UPDATER_SERVER_VERSION = "1.0.
|
|
4528
|
+
const HOT_UPDATER_SERVER_VERSION = "1.0.6";
|
|
4529
4529
|
//#endregion
|
|
4530
4530
|
//#region ../../packages/server/dist/handlerVersionRoutes.mjs
|
|
4531
4531
|
/**
|
|
@@ -4816,6 +4816,11 @@ async function resolveAssets(plan, resolveFileUrl) {
|
|
|
4816
4816
|
const resolvedEntries = entries.filter((entry) => entry !== null);
|
|
4817
4817
|
return { assets: Object.fromEntries(resolvedEntries.map((entry) => entry.asset)) };
|
|
4818
4818
|
}
|
|
4819
|
+
/**
|
|
4820
|
+
* Legacy: artifact protocol v1. Signs a URL for every file of the target, which grows with the
|
|
4821
|
+
* Bundle's file count; apps whose SDK predates protocol v2 still ask for it.
|
|
4822
|
+
* Remove with the `/artifacts/v1` route once no supported app uses it.
|
|
4823
|
+
*/
|
|
4819
4824
|
async function resolveManifestArtifacts({ currentBundle, resolveFileUrl, readStorageText, targetBundle }) {
|
|
4820
4825
|
const manifestStorageUri = targetBundle ? getManifestStorageUri(targetBundle) : null;
|
|
4821
4826
|
const manifestFileHash = targetBundle ? getManifestFileHash(targetBundle) : null;
|
|
@@ -4849,6 +4854,98 @@ async function resolveManifestArtifacts({ currentBundle, resolveFileUrl, readSto
|
|
|
4849
4854
|
...archiveUrl ? { archiveUrl } : {}
|
|
4850
4855
|
};
|
|
4851
4856
|
}
|
|
4857
|
+
/** The target's manifest and its plan, or null for a Bundle without one. */
|
|
4858
|
+
async function loadTargetPlan({ currentBundle, readStorageText, targetBundle }) {
|
|
4859
|
+
const manifestStorageUri = getManifestStorageUri(targetBundle);
|
|
4860
|
+
const manifestFileHash = getManifestFileHash(targetBundle);
|
|
4861
|
+
const assetBaseStorageUri = getAssetBaseStorageUri(targetBundle);
|
|
4862
|
+
if (!manifestStorageUri || !manifestFileHash || !assetBaseStorageUri) return null;
|
|
4863
|
+
const targetManifest = await fetchBundleManifest(manifestStorageUri, readStorageText);
|
|
4864
|
+
if (!targetManifest) return null;
|
|
4865
|
+
return {
|
|
4866
|
+
manifestFileHash,
|
|
4867
|
+
manifestStorageUri,
|
|
4868
|
+
plan: createManifestArtifactPlan({
|
|
4869
|
+
assetBaseStorageUri,
|
|
4870
|
+
currentBundle,
|
|
4871
|
+
targetBundle,
|
|
4872
|
+
targetManifest
|
|
4873
|
+
}),
|
|
4874
|
+
targetManifest
|
|
4875
|
+
};
|
|
4876
|
+
}
|
|
4877
|
+
/**
|
|
4878
|
+
* Artifact protocol v2: the manifest, the archive, and the one Hermes patch,
|
|
4879
|
+
* but no URL per file. A device asks {@link resolveManifestArtifactFiles}
|
|
4880
|
+
* for the files it downloads one by one, so signing and the response stay
|
|
4881
|
+
* the same size however many files the Bundle has.
|
|
4882
|
+
*/
|
|
4883
|
+
async function resolveManifestArtifactsV2({ currentBundle, resolveFileUrl, readStorageText, targetBundle }) {
|
|
4884
|
+
const target = await loadTargetPlan({
|
|
4885
|
+
currentBundle,
|
|
4886
|
+
readStorageText,
|
|
4887
|
+
targetBundle
|
|
4888
|
+
});
|
|
4889
|
+
if (!target) return null;
|
|
4890
|
+
const manifestUrl = await resolveFileUrl(target.manifestStorageUri);
|
|
4891
|
+
if (!manifestUrl) return null;
|
|
4892
|
+
const patched = target.plan.assets.find((asset) => asset.patch !== null);
|
|
4893
|
+
let patch;
|
|
4894
|
+
if (patched?.patch && !(patched.file.byteSize !== null && patched.patch.byteSize !== null && patched.patch.byteSize >= patched.file.byteSize)) try {
|
|
4895
|
+
const patchUrl = await resolveFileUrl(patched.patch.storageUri);
|
|
4896
|
+
if (patchUrl) patch = {
|
|
4897
|
+
algorithm: "bsdiff",
|
|
4898
|
+
assetPath: patched.assetPath,
|
|
4899
|
+
baseBundleId: patched.patch.baseBundleId,
|
|
4900
|
+
baseFileHash: patched.patch.baseFileHash,
|
|
4901
|
+
patchFileHash: patched.patch.patchFileHash,
|
|
4902
|
+
patchUrl,
|
|
4903
|
+
...patched.patch.byteSize === null ? {} : { byteSize: patched.patch.byteSize }
|
|
4904
|
+
};
|
|
4905
|
+
} catch {}
|
|
4906
|
+
let archiveUrl = null;
|
|
4907
|
+
const archive = target.targetManifest.archive;
|
|
4908
|
+
if (archive && isContentAddressedAssetFileHash(archive.downloadFileHash) && (asByteSize(archive.downloadByteSize) ?? 0) > 0 && (asByteSize(archive.tarByteSize) ?? 0) > 0) try {
|
|
4909
|
+
archiveUrl = await resolveFileUrl(getBundleArchiveStorageUri({
|
|
4910
|
+
manifestStorageUri: target.manifestStorageUri,
|
|
4911
|
+
bundleId: targetBundle.id
|
|
4912
|
+
}));
|
|
4913
|
+
} catch {}
|
|
4914
|
+
return {
|
|
4915
|
+
artifactProtocolVersion: 2,
|
|
4916
|
+
manifestFileHash: target.manifestFileHash,
|
|
4917
|
+
manifestUrl,
|
|
4918
|
+
...archiveUrl ? { archiveUrl } : {},
|
|
4919
|
+
...patch ? { patch } : {}
|
|
4920
|
+
};
|
|
4921
|
+
}
|
|
4922
|
+
/**
|
|
4923
|
+
* Download URLs for the files a device downloads one by one. Only paths the
|
|
4924
|
+
* target's manifest names are signed, at most
|
|
4925
|
+
* {@link ARTIFACT_FILES_REQUEST_LIMIT}; null when any path is unknown or a
|
|
4926
|
+
* URL cannot be resolved.
|
|
4927
|
+
*/
|
|
4928
|
+
async function resolveManifestArtifactFiles({ paths, resolveFileUrl, readStorageText, targetBundle }) {
|
|
4929
|
+
if (paths.length === 0 || paths.length > 100 || new Set(paths).size !== paths.length) return null;
|
|
4930
|
+
const target = await loadTargetPlan({
|
|
4931
|
+
currentBundle: null,
|
|
4932
|
+
readStorageText,
|
|
4933
|
+
targetBundle
|
|
4934
|
+
});
|
|
4935
|
+
if (!target) return null;
|
|
4936
|
+
const planned = new Map(target.plan.assets.map((asset) => [asset.assetPath, asset]));
|
|
4937
|
+
const requested = paths.map((path) => planned.get(path));
|
|
4938
|
+
if (requested.some((asset) => asset === void 0)) return null;
|
|
4939
|
+
const files = await Promise.all(requested.map(async (asset) => {
|
|
4940
|
+
const url = await resolveFileUrl(asset.file.storageUri);
|
|
4941
|
+
if (!url) return null;
|
|
4942
|
+
const file = { url };
|
|
4943
|
+
if (asset.file.compression) file.compression = asset.file.compression;
|
|
4944
|
+
return [asset.assetPath, file];
|
|
4945
|
+
}));
|
|
4946
|
+
if (files.some((file) => file === null)) return null;
|
|
4947
|
+
return { files: Object.fromEntries(files.filter((file) => file !== null)) };
|
|
4948
|
+
}
|
|
4852
4949
|
//#endregion
|
|
4853
4950
|
//#region ../../node_modules/.pnpm/verkit@0.4.0/node_modules/verkit/dist/comparison-DenM3wCn.js
|
|
4854
4951
|
const LETTER_DASH_NUMBER = "[a-zA-Z0-9-]";
|
|
@@ -5322,20 +5419,34 @@ const createCoreReads = (db, storage) => {
|
|
|
5322
5419
|
return projectReleaseCatalogRow(row === null ? null : toCatalogRow(row), input, scopeKey);
|
|
5323
5420
|
},
|
|
5324
5421
|
/** Artifact resolution: one batch read of both bundles and one unique read of their patch. */
|
|
5325
|
-
async
|
|
5422
|
+
getArtifactInfo: (async (targetBundleId, currentBundleId, artifactProtocolVersion) => {
|
|
5326
5423
|
const { readStorageText } = storage;
|
|
5327
|
-
if (artifactProtocolVersion !== 1 || readStorageText === void 0) return null;
|
|
5424
|
+
if (artifactProtocolVersion !== 1 && artifactProtocolVersion !== 2 || readStorageText === void 0) return null;
|
|
5328
5425
|
const withCurrent = currentBundleId !== NIL_UUID;
|
|
5329
5426
|
const [[target, current], patch] = await Promise.all([db.findByKeys("bundles", withCurrent ? [{ id: targetBundleId }, { id: currentBundleId }] : [{ id: targetBundleId }]), withCurrent ? db.findOne("bundle_patches", {
|
|
5330
5427
|
bundle_id: targetBundleId,
|
|
5331
5428
|
base_bundle_id: currentBundleId
|
|
5332
5429
|
}) : null]);
|
|
5333
5430
|
if (target === null || target === void 0) return null;
|
|
5334
|
-
|
|
5431
|
+
const input = {
|
|
5335
5432
|
currentBundle: current ? rowToBundle(toBundleRow(current)) : null,
|
|
5336
5433
|
readStorageText,
|
|
5337
5434
|
resolveFileUrl: storage.resolveFileUrl,
|
|
5338
5435
|
targetBundle: rowToBundle(toBundleRow(target), patch === null ? [] : [toPatchRow(patch)])
|
|
5436
|
+
};
|
|
5437
|
+
return artifactProtocolVersion === 2 ? resolveManifestArtifactsV2(input) : resolveManifestArtifacts(input);
|
|
5438
|
+
}),
|
|
5439
|
+
/** A Bundle's file URLs: one point read of the bundle and its manifest. */
|
|
5440
|
+
async getArtifactFiles(bundleId, paths) {
|
|
5441
|
+
const { readStorageText } = storage;
|
|
5442
|
+
if (readStorageText === void 0) return null;
|
|
5443
|
+
const row = await db.findOne("bundles", { id: bundleId });
|
|
5444
|
+
if (row === null) return null;
|
|
5445
|
+
return resolveManifestArtifactFiles({
|
|
5446
|
+
paths,
|
|
5447
|
+
readStorageText,
|
|
5448
|
+
resolveFileUrl: storage.resolveFileUrl,
|
|
5449
|
+
targetBundle: rowToBundle(toBundleRow(row))
|
|
5339
5450
|
});
|
|
5340
5451
|
},
|
|
5341
5452
|
/** A bundle, its patches, and how many patches start from it. */
|
|
@@ -6684,6 +6795,7 @@ const ADMIN_ROUTES = [
|
|
|
6684
6795
|
//#region ../../packages/server/dist/handlerReleaseCatalogRoutes.mjs
|
|
6685
6796
|
const CATALOG_CONTENT_TYPE = "application/vnd.hot-updater.release-catalog+json; version=1";
|
|
6686
6797
|
const ARTIFACT_CONTENT_TYPE = "application/vnd.hot-updater.artifact+json; version=1";
|
|
6798
|
+
const ARTIFACT_V2_CONTENT_TYPE = "application/vnd.hot-updater.artifact+json; version=2";
|
|
6687
6799
|
const ORIGIN_CACHE_TTL_MS = 5e3;
|
|
6688
6800
|
const ORIGIN_CACHE_MAX_ENTRIES = 128;
|
|
6689
6801
|
/** Marks the 404 of a scope that has no catalog. */
|
|
@@ -6706,6 +6818,18 @@ const CATALOG_CACHE_CONTROL = "public, max-age=0, s-maxage=5";
|
|
|
6706
6818
|
* `Cache-Control`.
|
|
6707
6819
|
*/
|
|
6708
6820
|
const CATALOG_CDN_CACHE_CONTROL = "public, max-age=5, stale-while-revalidate=5, stale-if-error=0";
|
|
6821
|
+
/** The paths of an artifact files request, or null when malformed. */
|
|
6822
|
+
const readArtifactFilePaths = async (request) => {
|
|
6823
|
+
let body;
|
|
6824
|
+
try {
|
|
6825
|
+
body = await request.json();
|
|
6826
|
+
} catch {
|
|
6827
|
+
return null;
|
|
6828
|
+
}
|
|
6829
|
+
const paths = body?.paths;
|
|
6830
|
+
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;
|
|
6831
|
+
return paths;
|
|
6832
|
+
};
|
|
6709
6833
|
const privateNotFound = () => Response.json({ error: "Not found" }, {
|
|
6710
6834
|
status: 404,
|
|
6711
6835
|
headers: { "cache-control": "private, no-store" }
|
|
@@ -6826,6 +6950,30 @@ const createReleaseCatalogRouteHandlers = () => {
|
|
|
6826
6950
|
if (invalid) return invalid;
|
|
6827
6951
|
return catalogResponse(await loadCatalog(`fingerprint:${JSON.stringify(input)}`, () => core.getReleaseCatalog(input)), request);
|
|
6828
6952
|
},
|
|
6953
|
+
artifactV2: async (params, _request, { core }) => {
|
|
6954
|
+
const info = await core.getArtifactInfo(requireRouteParam(params, "targetBundleId"), requireRouteParam(params, "currentBundleId"), 2);
|
|
6955
|
+
if (info === null) return privateNotFound();
|
|
6956
|
+
return new Response(JSON.stringify(info), {
|
|
6957
|
+
status: 200,
|
|
6958
|
+
headers: {
|
|
6959
|
+
"cache-control": "private, no-store",
|
|
6960
|
+
"content-type": ARTIFACT_V2_CONTENT_TYPE
|
|
6961
|
+
}
|
|
6962
|
+
});
|
|
6963
|
+
},
|
|
6964
|
+
artifactFilesV2: async (params, request, { core }) => {
|
|
6965
|
+
const paths = await readArtifactFilePaths(request);
|
|
6966
|
+
if (paths === null) return badRequest(`paths must be 1 to 100 unique strings`);
|
|
6967
|
+
const files = await core.getArtifactFiles(requireRouteParam(params, "targetBundleId"), paths);
|
|
6968
|
+
if (files === null) return privateNotFound();
|
|
6969
|
+
return new Response(JSON.stringify(files), {
|
|
6970
|
+
status: 200,
|
|
6971
|
+
headers: {
|
|
6972
|
+
"cache-control": "private, no-store",
|
|
6973
|
+
"content-type": ARTIFACT_V2_CONTENT_TYPE
|
|
6974
|
+
}
|
|
6975
|
+
});
|
|
6976
|
+
},
|
|
6829
6977
|
artifactV1: async (params, _request, { core }) => {
|
|
6830
6978
|
const info = await core.getArtifactInfo(requireRouteParam(params, "targetBundleId"), requireRouteParam(params, "currentBundleId"), 1);
|
|
6831
6979
|
if (info === null) return privateNotFound();
|
|
@@ -6977,6 +7125,8 @@ function createHotUpdaterHandlers({ api, clientPolicy, endpoints = [], plugins =
|
|
|
6977
7125
|
addClientRoute("GET", "/release-catalogs/app-version/:platform/:channelKey/:appVersion", "appVersionReleaseCatalog");
|
|
6978
7126
|
addClientRoute("GET", "/release-catalogs/fingerprint/:platform/:channelKey/:fingerprintHash", "fingerprintReleaseCatalog");
|
|
6979
7127
|
addClientRoute("GET", "/artifacts/v1/:targetBundleId/from/:currentBundleId", "artifactV1");
|
|
7128
|
+
addClientRoute("GET", "/artifacts/v2/:targetBundleId/from/:currentBundleId", "artifactV2");
|
|
7129
|
+
addClientRoute("POST", "/artifacts/v2/:targetBundleId/files", "artifactFilesV2");
|
|
6980
7130
|
const adminRouter = createRouter();
|
|
6981
7131
|
const addAdminRoute = mount(adminRouter, true);
|
|
6982
7132
|
addAdminRoute("GET", "/version", "adminVersion");
|
|
@@ -11476,10 +11626,44 @@ const firebaseDatabase = (config) => createEngineDatabase({
|
|
|
11476
11626
|
});
|
|
11477
11627
|
//#endregion
|
|
11478
11628
|
//#region src/firebaseStorage.ts
|
|
11629
|
+
/** Signed URLs kept per adapter, enough for a few Bundles' assets. */
|
|
11630
|
+
const MAX_CACHED_SIGNED_URLS = 1e4;
|
|
11479
11631
|
const firebaseStorage = (config) => {
|
|
11480
11632
|
const app = (0, firebase_admin_app.getApps)().length ? (0, firebase_admin_app.getApp)() : (0, firebase_admin_app.initializeApp)(config);
|
|
11481
11633
|
const bucket = (0, firebase_admin_storage.getStorage)(app).bucket(config.storageBucket);
|
|
11482
11634
|
const getStorageKey = createStorageKeyBuilder(config.basePath);
|
|
11635
|
+
/**
|
|
11636
|
+
* Signed URLs by object. Without a private key, as in Cloud Functions, each
|
|
11637
|
+
* signature is an IAM signBlob request, and an update check returns a URL
|
|
11638
|
+
* for every asset of its Bundle: each URL is reused until half its lifetime
|
|
11639
|
+
* has passed.
|
|
11640
|
+
*/
|
|
11641
|
+
const signedUrls = /* @__PURE__ */ new Map();
|
|
11642
|
+
const signUrl = async (key) => {
|
|
11643
|
+
const expiresAt = Date.now() + (config.signedUrlExpiresIn ?? 3600) * 1e3;
|
|
11644
|
+
const [url] = await bucket.file(key).getSignedUrl({
|
|
11645
|
+
action: "read",
|
|
11646
|
+
expires: expiresAt
|
|
11647
|
+
});
|
|
11648
|
+
if (!url) throw new Error("Failed to generate Firebase download URL");
|
|
11649
|
+
return url;
|
|
11650
|
+
};
|
|
11651
|
+
const getSignedUrl = (key) => {
|
|
11652
|
+
const cached = signedUrls.get(key);
|
|
11653
|
+
if (cached && Date.now() < cached.renewAt) return cached.url;
|
|
11654
|
+
const url = signUrl(key);
|
|
11655
|
+
const entry = {
|
|
11656
|
+
url,
|
|
11657
|
+
renewAt: Date.now() + (config.signedUrlExpiresIn ?? 3600) * 1e3 / 2
|
|
11658
|
+
};
|
|
11659
|
+
signedUrls.delete(key);
|
|
11660
|
+
signedUrls.set(key, entry);
|
|
11661
|
+
if (signedUrls.size > MAX_CACHED_SIGNED_URLS) signedUrls.delete(signedUrls.keys().next().value);
|
|
11662
|
+
url.catch(() => {
|
|
11663
|
+
if (signedUrls.get(key) === entry) signedUrls.delete(key);
|
|
11664
|
+
});
|
|
11665
|
+
return url;
|
|
11666
|
+
};
|
|
11483
11667
|
const parseAndValidate = (storageUri) => {
|
|
11484
11668
|
const parsed = parseStorageUri(storageUri, "gs");
|
|
11485
11669
|
if (parsed.bucket !== config.storageBucket) throw new Error(`Bucket name mismatch: expected "${config.storageBucket}", but found "${parsed.bucket}".`);
|
|
@@ -11525,12 +11709,7 @@ const firebaseStorage = (config) => {
|
|
|
11525
11709
|
downloadUrl.hash = "";
|
|
11526
11710
|
return { url: downloadUrl.toString() };
|
|
11527
11711
|
}
|
|
11528
|
-
|
|
11529
|
-
action: "read",
|
|
11530
|
-
expires: Date.now() + (config.signedUrlExpiresIn ?? 3600) * 1e3
|
|
11531
|
-
});
|
|
11532
|
-
if (!url) throw new Error("Failed to generate Firebase download URL");
|
|
11533
|
-
return { url };
|
|
11712
|
+
return { url: await getSignedUrl(key) };
|
|
11534
11713
|
},
|
|
11535
11714
|
async exists({ storageUri }) {
|
|
11536
11715
|
const { key } = parseAndValidate(storageUri);
|
package/dist/index.cjs
CHANGED
|
@@ -191,10 +191,44 @@ const firebaseDatabase = (config) => (0, _hot_updater_plugin_core.createEngineDa
|
|
|
191
191
|
});
|
|
192
192
|
//#endregion
|
|
193
193
|
//#region src/firebaseStorage.ts
|
|
194
|
+
/** Signed URLs kept per adapter, enough for a few Bundles' assets. */
|
|
195
|
+
const MAX_CACHED_SIGNED_URLS = 1e4;
|
|
194
196
|
const firebaseStorage = (config) => {
|
|
195
197
|
const app = (0, firebase_admin_app.getApps)().length ? (0, firebase_admin_app.getApp)() : (0, firebase_admin_app.initializeApp)(config);
|
|
196
198
|
const bucket = (0, firebase_admin_storage.getStorage)(app).bucket(config.storageBucket);
|
|
197
199
|
const getStorageKey = (0, _hot_updater_plugin_core.createStorageKeyBuilder)(config.basePath);
|
|
200
|
+
/**
|
|
201
|
+
* Signed URLs by object. Without a private key, as in Cloud Functions, each
|
|
202
|
+
* signature is an IAM signBlob request, and an update check returns a URL
|
|
203
|
+
* for every asset of its Bundle: each URL is reused until half its lifetime
|
|
204
|
+
* has passed.
|
|
205
|
+
*/
|
|
206
|
+
const signedUrls = /* @__PURE__ */ new Map();
|
|
207
|
+
const signUrl = async (key) => {
|
|
208
|
+
const expiresAt = Date.now() + (config.signedUrlExpiresIn ?? 3600) * 1e3;
|
|
209
|
+
const [url] = await bucket.file(key).getSignedUrl({
|
|
210
|
+
action: "read",
|
|
211
|
+
expires: expiresAt
|
|
212
|
+
});
|
|
213
|
+
if (!url) throw new Error("Failed to generate Firebase download URL");
|
|
214
|
+
return url;
|
|
215
|
+
};
|
|
216
|
+
const getSignedUrl = (key) => {
|
|
217
|
+
const cached = signedUrls.get(key);
|
|
218
|
+
if (cached && Date.now() < cached.renewAt) return cached.url;
|
|
219
|
+
const url = signUrl(key);
|
|
220
|
+
const entry = {
|
|
221
|
+
url,
|
|
222
|
+
renewAt: Date.now() + (config.signedUrlExpiresIn ?? 3600) * 1e3 / 2
|
|
223
|
+
};
|
|
224
|
+
signedUrls.delete(key);
|
|
225
|
+
signedUrls.set(key, entry);
|
|
226
|
+
if (signedUrls.size > MAX_CACHED_SIGNED_URLS) signedUrls.delete(signedUrls.keys().next().value);
|
|
227
|
+
url.catch(() => {
|
|
228
|
+
if (signedUrls.get(key) === entry) signedUrls.delete(key);
|
|
229
|
+
});
|
|
230
|
+
return url;
|
|
231
|
+
};
|
|
198
232
|
const parseAndValidate = (storageUri) => {
|
|
199
233
|
const parsed = (0, _hot_updater_plugin_core.parseStorageUri)(storageUri, "gs");
|
|
200
234
|
if (parsed.bucket !== config.storageBucket) throw new Error(`Bucket name mismatch: expected "${config.storageBucket}", but found "${parsed.bucket}".`);
|
|
@@ -240,12 +274,7 @@ const firebaseStorage = (config) => {
|
|
|
240
274
|
downloadUrl.hash = "";
|
|
241
275
|
return { url: downloadUrl.toString() };
|
|
242
276
|
}
|
|
243
|
-
|
|
244
|
-
action: "read",
|
|
245
|
-
expires: Date.now() + (config.signedUrlExpiresIn ?? 3600) * 1e3
|
|
246
|
-
});
|
|
247
|
-
if (!url) throw new Error("Failed to generate Firebase download URL");
|
|
248
|
-
return { url };
|
|
277
|
+
return { url: await getSignedUrl(key) };
|
|
249
278
|
},
|
|
250
279
|
async exists({ storageUri }) {
|
|
251
280
|
const { key } = parseAndValidate(storageUri);
|
package/dist/index.mjs
CHANGED
|
@@ -190,10 +190,44 @@ const firebaseDatabase = (config) => createEngineDatabase({
|
|
|
190
190
|
});
|
|
191
191
|
//#endregion
|
|
192
192
|
//#region src/firebaseStorage.ts
|
|
193
|
+
/** Signed URLs kept per adapter, enough for a few Bundles' assets. */
|
|
194
|
+
const MAX_CACHED_SIGNED_URLS = 1e4;
|
|
193
195
|
const firebaseStorage = (config) => {
|
|
194
196
|
const app = getApps().length ? getApp() : initializeApp(config);
|
|
195
197
|
const bucket = getStorage(app).bucket(config.storageBucket);
|
|
196
198
|
const getStorageKey = createStorageKeyBuilder(config.basePath);
|
|
199
|
+
/**
|
|
200
|
+
* Signed URLs by object. Without a private key, as in Cloud Functions, each
|
|
201
|
+
* signature is an IAM signBlob request, and an update check returns a URL
|
|
202
|
+
* for every asset of its Bundle: each URL is reused until half its lifetime
|
|
203
|
+
* has passed.
|
|
204
|
+
*/
|
|
205
|
+
const signedUrls = /* @__PURE__ */ new Map();
|
|
206
|
+
const signUrl = async (key) => {
|
|
207
|
+
const expiresAt = Date.now() + (config.signedUrlExpiresIn ?? 3600) * 1e3;
|
|
208
|
+
const [url] = await bucket.file(key).getSignedUrl({
|
|
209
|
+
action: "read",
|
|
210
|
+
expires: expiresAt
|
|
211
|
+
});
|
|
212
|
+
if (!url) throw new Error("Failed to generate Firebase download URL");
|
|
213
|
+
return url;
|
|
214
|
+
};
|
|
215
|
+
const getSignedUrl = (key) => {
|
|
216
|
+
const cached = signedUrls.get(key);
|
|
217
|
+
if (cached && Date.now() < cached.renewAt) return cached.url;
|
|
218
|
+
const url = signUrl(key);
|
|
219
|
+
const entry = {
|
|
220
|
+
url,
|
|
221
|
+
renewAt: Date.now() + (config.signedUrlExpiresIn ?? 3600) * 1e3 / 2
|
|
222
|
+
};
|
|
223
|
+
signedUrls.delete(key);
|
|
224
|
+
signedUrls.set(key, entry);
|
|
225
|
+
if (signedUrls.size > MAX_CACHED_SIGNED_URLS) signedUrls.delete(signedUrls.keys().next().value);
|
|
226
|
+
url.catch(() => {
|
|
227
|
+
if (signedUrls.get(key) === entry) signedUrls.delete(key);
|
|
228
|
+
});
|
|
229
|
+
return url;
|
|
230
|
+
};
|
|
197
231
|
const parseAndValidate = (storageUri) => {
|
|
198
232
|
const parsed = parseStorageUri(storageUri, "gs");
|
|
199
233
|
if (parsed.bucket !== config.storageBucket) throw new Error(`Bucket name mismatch: expected "${config.storageBucket}", but found "${parsed.bucket}".`);
|
|
@@ -239,12 +273,7 @@ const firebaseStorage = (config) => {
|
|
|
239
273
|
downloadUrl.hash = "";
|
|
240
274
|
return { url: downloadUrl.toString() };
|
|
241
275
|
}
|
|
242
|
-
|
|
243
|
-
action: "read",
|
|
244
|
-
expires: Date.now() + (config.signedUrlExpiresIn ?? 3600) * 1e3
|
|
245
|
-
});
|
|
246
|
-
if (!url) throw new Error("Failed to generate Firebase download URL");
|
|
247
|
-
return { url };
|
|
276
|
+
return { url: await getSignedUrl(key) };
|
|
248
277
|
},
|
|
249
278
|
async exists({ storageUri }) {
|
|
250
279
|
const { key } = parseAndValidate(storageUri);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hot-updater/firebase",
|
|
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
|
"types": "dist/index.d.cts",
|
|
@@ -37,10 +37,10 @@
|
|
|
37
37
|
],
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"hono": "4.12.34",
|
|
40
|
-
"@hot-updater/
|
|
41
|
-
"@hot-updater/protocol": "1.0.
|
|
42
|
-
"@hot-updater/
|
|
43
|
-
"@hot-updater/
|
|
40
|
+
"@hot-updater/plugin-core": "1.0.6",
|
|
41
|
+
"@hot-updater/protocol": "1.0.6",
|
|
42
|
+
"@hot-updater/server": "1.0.6",
|
|
43
|
+
"@hot-updater/cli-tools": "1.0.6"
|
|
44
44
|
},
|
|
45
45
|
"publishConfig": {
|
|
46
46
|
"access": "public"
|
|
@@ -55,8 +55,8 @@
|
|
|
55
55
|
"firebase-tools": "15.29.0",
|
|
56
56
|
"fkill": "^9.0.0",
|
|
57
57
|
"mime": "^4.0.4",
|
|
58
|
-
"@hot-updater/mock": "1.0.
|
|
59
|
-
"@hot-updater/test-utils": "1.0.
|
|
58
|
+
"@hot-updater/mock": "1.0.6",
|
|
59
|
+
"@hot-updater/test-utils": "1.0.6"
|
|
60
60
|
},
|
|
61
61
|
"peerDependencies": {
|
|
62
62
|
"firebase-admin": ">=11.10.0",
|