Skip to content

Instantly share code, notes, and snippets.

@zanona
Last active January 26, 2023 21:52
Show Gist options
  • Save zanona/e077436e9a447f72fda2baf7fd123bc6 to your computer and use it in GitHub Desktop.
Save zanona/e077436e9a447f72fda2baf7fd123bc6 to your computer and use it in GitHub Desktop.
Update specific files on firebase hosting via a google cloud function
/**
* SOURCE: https://github.com/firebase/firebase-tools/tree/master/scripts/examples/hosting/update-single-file
* THIS IS AN EXPERIMENT OF THE ABOVE THAT COULD BE USED BY ANY CLOUD
* FUNCTION TO UPDATE HOSTING FILES (THINK OF A CMS). THE BENEFITS WOULD BE TO
* RUN THE CLOUD FUNCTION ONLY ONCE, WHILE HAVING THE HOSTING FILES UPDATED AND
* SERVED STATICALLY MUCH FASTER THROUGH A CDN.
*
* Usage:
* import {updateHostingFiles} from './lib/hosting';
* await updateHostingFiles([{path: '/foo.json', data: '{version:2}']);
* console.log('success!')
*/
import crypto from "node:crypto";
import zlib from "node:zlib";
import { Compute, GoogleAuth } from "google-auth-library";
import { JSONClient } from "google-auth-library/build/src/auth/googleauth";
const HOSTING_URL = "https://firebasehosting.googleapis.com/v1beta1";
const PROJECT_ID = process.env.CLOUD_FIREBASE_PROJECT;
const SITE_ID = process.env.CLOUD_SITE_ID || PROJECT_ID;
type InputFiles = Array<{ path: FilePath; data: string }>;
type Client = Compute | JSONClient;
type VersionPath = `/sites/${string}/versions/${string}`;
type FilePath = `/${string}`;
type FileList = Array<{ path: FilePath; data: Buffer; hash: string }>;
function toFilePath(path: string) {
return path.replace(/^(\w)/, "/$1") as FilePath;
}
function hashFile(data: string) {
const hasher = crypto.createHash("sha256");
const gzip = zlib.gzipSync(data, { level: 9 });
return { hash: hasher.update(gzip).digest("hex"), data: gzip };
}
async function getSiteVersion(siteId: string, client: Client) {
const site = await client.request<{
url: string;
release: { name: string; version: { name: string } };
}>({
url: `${HOSTING_URL}/sites/${siteId}/channels/live`,
});
//const release = site.data.release.name as `/sites/${string}/channels/live/releases/${string}`;
const currentVersion = site.data.release.version.name as VersionPath;
return currentVersion;
}
async function cloneSite(
siteId: string,
currentVersion: VersionPath,
skip: FileList,
client: Client
) {
const cloneOpPath = (
await client.request<{ name: string }>({
method: "POST",
url: `${HOSTING_URL}/sites/${siteId}/versions:clone`,
body: JSON.stringify({
sourceVersion: currentVersion,
finalize: false,
exclude: {
regexes: skip.map((s) => s.path.replace("/", "\\/")),
},
}),
})
).data.name as `/projects/${string}/operations/${string}`;
let done = false;
let newVersion = "";
while (!done) {
const opRes = await client.request<{
done: boolean;
response: { name: string };
}>({
url: `${HOSTING_URL}/${cloneOpPath}`,
});
done = !!opRes.data.done;
newVersion = opRes.data.response?.name as VersionPath;
console.debug("cloning site...done:", done);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
return newVersion as VersionPath;
}
async function uploadSiteFiles(
version: VersionPath,
files: FileList,
client: Client
) {
const fileList = files.reduce((p, c) => {
p[c.path] = c.hash;
return p;
}, {} as Record<FilePath, string>);
console.debug(
"preparing files",
files.map((f) => f.path)
);
const populateRes = await client.request<{
uploadUrl: string;
uploadRequiredHashes?: string[];
}>({
method: "POST",
url: `${HOSTING_URL}/${version}:populateFiles`,
body: JSON.stringify({ files: fileList }),
});
const uploadURL = populateRes.data.uploadUrl;
const uploadRequiredHashes = populateRes.data.uploadRequiredHashes || [];
if (Array.isArray(uploadRequiredHashes) && uploadRequiredHashes.length) {
for (const h of uploadRequiredHashes) {
const file = files.find((f) => f.hash === h);
console.debug("uploading %s...", file?.path);
const uploadRes = await client.request({
method: "POST",
url: `${uploadURL}/${h}`,
data: file?.data,
});
if (uploadRes.status !== 200) {
throw new Error(`Failed to upload file ${file?.path} (${h})`);
}
}
}
}
async function releaseSiteVersion(
siteId: string,
versionPath: VersionPath,
message: string,
client: Client
) {
console.debug("finalising site version");
await client.request({
method: "PATCH",
url: `${HOSTING_URL}/${versionPath}`,
params: { updateMask: "status" },
body: JSON.stringify({ status: "FINALIZED" }),
});
console.debug("releasing new site version");
await client.request({
method: "POST",
url: `${HOSTING_URL}/sites/${siteId}/releases`,
params: { versionName: versionPath },
body: JSON.stringify({ message }),
});
}
export async function updateHostingFiles(inputFiles: InputFiles) {
if (!PROJECT_ID) throw new Error("missing PROJECT_ID");
if (!SITE_ID) throw new Error("missing SITE_ID");
if (!Array.isArray(inputFiles))
throw new Error("input needs to be an array");
const auth = new GoogleAuth({
scopes: "https://www.googleapis.com/auth/cloud-platform",
projectId: PROJECT_ID,
});
const client = await auth.getClient();
const files = inputFiles.map((f) => ({
...hashFile(f.data),
path: toFilePath(f.path),
}));
const currentVersion = await getSiteVersion(SITE_ID, client);
const newVersionPath = await cloneSite(
SITE_ID,
currentVersion,
files,
client
);
await uploadSiteFiles(newVersionPath, files, client);
await releaseSiteVersion(
SITE_ID,
newVersionPath,
`update ${files.map((f) => f.path)}`,
client
);
return { ok: true };
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment