From 7a434a489dbaffb3bfdfa0770675ef43aacaa2fb Mon Sep 17 00:00:00 2001
From: Robert Coleman <154176+rjocoleman@users.noreply.github.com>
Date: Wed, 28 May 2025 14:28:13 +1200
Subject: [PATCH] Add Cloudflare Workers deployment method
---
app/back-end/events/site.js | 20 +
.../modules/deploy/cloudflare-workers.js | 409 ++++++++++++++++++
app/back-end/modules/deploy/deployment.js | 6 +-
.../default-languages/en-gb/translations.json | 9 +
app/src/assets/svg/svg-map-server.svg | 7 +
app/src/components/ServerSettings.vue | 106 ++++-
app/src/components/SyncPopup.vue | 16 +
.../configs/defaultDeploymentSettings.js | 5 +
8 files changed, 575 insertions(+), 3 deletions(-)
create mode 100644 app/back-end/modules/deploy/cloudflare-workers.js
diff --git a/app/back-end/events/site.js b/app/back-end/events/site.js
index aad6e0e8..5e5e12a1 100644
--- a/app/back-end/events/site.js
+++ b/app/back-end/events/site.js
@@ -290,6 +290,26 @@ class SiteEvents {
config.settings.deployment.netlify.id = netlifyIdData.toSave;
config.settings.deployment.netlify.token = netlifyTokenData.toSave;
}
+
+ if (
+ config.settings.deployment.cloudflareWorkers &&
+ config.settings.deployment.cloudflareWorkers.accountId !== '' &&
+ config.settings.deployment.cloudflareWorkers.apiToken !== '' &&
+ config.settings.deployment.cloudflareWorkers.accountId !== 'publii-cf-account-id ' + siteID
+ ) {
+ let cfAccountIdData = await self.loadPassword(
+ config.settings,
+ 'publii-cf-account-id',
+ config.settings.deployment.cloudflareWorkers.accountId
+ );
+ let cfApiTokenData = await self.loadPassword(
+ config.settings,
+ 'publii-cf-api-token',
+ config.settings.deployment.cloudflareWorkers.apiToken
+ );
+ config.settings.deployment.cloudflareWorkers.accountId = cfAccountIdData.toSave;
+ config.settings.deployment.cloudflareWorkers.apiToken = cfApiTokenData.toSave;
+ }
} catch (error) {
event.sender.send('app-site-config-saved', {
status: false,
diff --git a/app/back-end/modules/deploy/cloudflare-workers.js b/app/back-end/modules/deploy/cloudflare-workers.js
new file mode 100644
index 00000000..fe72568b
--- /dev/null
+++ b/app/back-end/modules/deploy/cloudflare-workers.js
@@ -0,0 +1,409 @@
+/*
+ * Class used to upload files to Cloudflare Workers Static Assets
+ */
+
+const fs = require('fs-extra');
+const path = require('path');
+const crypto = require('crypto');
+const mime = require('mime');
+const FormData = require('form-data');
+const fetch = require('node-fetch');
+const passwordSafeStorage = require('keytar');
+const slug = require('./../../helpers/slug');
+const stripTags = require('striptags');
+
+class CloudflareWorkers {
+ constructor(deploymentInstance = false) {
+ this.deployment = deploymentInstance;
+ this.uploadToken = null;
+ this.completionToken = null;
+ this.fileMetadata = {};
+ }
+
+ async initConnection() {
+ let accountId = this.deployment.siteConfig.deployment.cloudflareWorkers.accountId;
+ let apiToken = this.deployment.siteConfig.deployment.cloudflareWorkers.apiToken;
+ let scriptName = this.deployment.siteConfig.deployment.cloudflareWorkers.scriptName;
+ let account = slug(this.deployment.siteConfig.name);
+
+ if (this.deployment.siteConfig.uuid) {
+ account = this.deployment.siteConfig.uuid;
+ }
+
+ // Retrieve credentials from secure storage if needed
+ if (accountId === 'publii-cf-account-id ' + account) {
+ accountId = await passwordSafeStorage.getPassword('publii-cf-account-id', account);
+ }
+
+ if (apiToken === 'publii-cf-api-token ' + account) {
+ apiToken = await passwordSafeStorage.getPassword('publii-cf-api-token', account);
+ }
+
+ this.accountId = (accountId || '').toString().trim();
+ this.apiToken = (apiToken || '').toString().trim();
+ this.scriptName = (scriptName || '').toString().trim();
+
+ if (!this.accountId || !this.apiToken || !this.scriptName) {
+ this.onError({
+ message: 'Missing required configuration: Account ID, API Token, or Script Name'
+ });
+ return;
+ }
+
+ this.deployment.setInput();
+ this.deployment.setOutput(true);
+ this.localDir = this.deployment.inputDir;
+
+ process.send({
+ type: 'web-contents',
+ message: 'app-uploading-progress',
+ value: {
+ progress: 6,
+ operations: false
+ }
+ });
+
+ process.send({
+ type: 'web-contents',
+ message: 'app-connection-in-progress'
+ });
+
+ try {
+ // Start the deployment process
+ await this.deploy();
+ } catch (err) {
+ console.log(`[${new Date().toUTCString()}] Cloudflare Workers ERROR: ${err}`);
+ this.onError(err);
+ }
+ }
+
+ async deploy() {
+ // Step 1: Gather file metadata
+ this.gatherFileMetadata();
+
+ // Step 2: Start upload session
+ const { uploadToken, buckets } = await this.startUploadSession();
+ this.uploadToken = uploadToken;
+
+ // Step 3: Upload files if needed
+ if (buckets && buckets.length > 0) {
+ this.completionToken = await this.uploadFiles(buckets);
+ } else {
+ // All files already uploaded, use the token as completion token
+ this.completionToken = uploadToken;
+ }
+
+ // Step 4: Deploy the worker
+ await this.deployWorker();
+
+ // Success
+ process.send({
+ type: 'web-contents',
+ message: 'app-uploading-progress',
+ value: {
+ progress: 100,
+ operations: false
+ }
+ });
+
+ process.send({
+ type: 'sender',
+ message: 'app-deploy-uploaded',
+ value: {
+ status: true
+ }
+ });
+
+ setTimeout(function () {
+ process.kill(process.pid, 'SIGTERM');
+ }, 1000);
+ }
+
+ gatherFileMetadata() {
+ const files = this.getAllFiles(this.localDir);
+ this.fileMetadata = {};
+ this.deployment.operationsCounter = files.length + 3; // +3 for upload session, worker deploy, and completion
+ this.deployment.progressPerFile = 90.0 / this.deployment.operationsCounter;
+ this.deployment.currentOperationNumber = 0;
+
+ files.forEach(file => {
+ const relativePath = '/' + path.relative(this.localDir, file).replace(/\\/g, '/');
+ const { hash, size } = this.calculateFileHash(file);
+ this.fileMetadata[relativePath] = { hash, size };
+ });
+ }
+
+ getAllFiles(dir, fileList = []) {
+ const files = fs.readdirSync(dir);
+
+ files.forEach(file => {
+ const filePath = path.join(dir, file);
+ const stat = fs.statSync(filePath);
+
+ if (stat.isDirectory()) {
+ this.getAllFiles(filePath, fileList);
+ } else if (file !== '.DS_Store' && file !== 'Thumbs.db' && !file.startsWith('.')) {
+ fileList.push(filePath);
+ }
+ });
+
+ return fileList;
+ }
+
+ calculateFileHash(filePath) {
+ const fileBuffer = fs.readFileSync(filePath);
+ const hash = crypto.createHash('sha256');
+ hash.update(fileBuffer);
+ return {
+ hash: hash.digest('hex').slice(0, 32), // First 32 chars
+ size: fileBuffer.length
+ };
+ }
+
+ async startUploadSession() {
+ const url = `https://api.cloudflare.com/client/v4/accounts/${this.accountId}/workers/scripts/${this.scriptName}/assets-upload-session`;
+
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${this.apiToken}`,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ manifest: this.fileMetadata
+ })
+ });
+
+ const data = await response.json();
+
+ if (!response.ok || !data.success) {
+ throw new Error(data.errors?.[0]?.message || 'Failed to start upload session');
+ }
+
+ this.updateProgress();
+ return {
+ uploadToken: data.result.jwt,
+ buckets: data.result.buckets
+ };
+ }
+
+ async uploadFiles(buckets) {
+ let completionToken = null;
+
+ for (const bucket of buckets) {
+ const form = new FormData();
+
+ for (const fileHash of bucket) {
+ const filePath = this.findFileByHash(fileHash);
+ if (filePath) {
+ const absolutePath = path.join(this.localDir, filePath.substring(1));
+ const fileBuffer = fs.readFileSync(absolutePath);
+ const base64Data = fileBuffer.toString('base64');
+
+ // Determine content type
+ const contentType = mime.getType(filePath) || 'application/octet-stream';
+
+ form.append(fileHash, base64Data, {
+ filename: fileHash,
+ contentType: contentType
+ });
+ }
+ }
+
+ const response = await fetch(
+ `https://api.cloudflare.com/client/v4/accounts/${this.accountId}/workers/assets/upload?base64=true`,
+ {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${this.uploadToken}`
+ },
+ body: form
+ }
+ );
+
+ const data = await response.json();
+
+ if (!response.ok || !data.success) {
+ throw new Error(data.errors?.[0]?.message || 'Failed to upload files');
+ }
+
+ this.updateProgress();
+
+ if (data.result.jwt) {
+ completionToken = data.result.jwt;
+ }
+ }
+
+ return completionToken;
+ }
+
+ findFileByHash(hash) {
+ for (const [path, metadata] of Object.entries(this.fileMetadata)) {
+ if (metadata.hash === hash) {
+ return path;
+ }
+ }
+ return null;
+ }
+
+
+ async deployWorker() {
+ const form = new FormData();
+
+ // Worker configuration for static assets only
+ const metadata = {
+ main_module: 'index.js',
+ compatibility_date: '2024-01-01',
+ assets: {
+ jwt: this.completionToken,
+ config: {
+ "html_handling": "auto-trailing-slash",
+ "not_found_handling": "404-page"
+ }
+ },
+ bindings: [
+ {
+ name: 'ASSETS',
+ type: 'assets'
+ }
+ ]
+ };
+
+ console.log(`[${new Date().toUTCString()}] Cloudflare Workers metadata:`, JSON.stringify(metadata, null, 2));
+ form.append('metadata', JSON.stringify(metadata));
+
+ // Worker that serves static assets using the ASSETS binding
+ const workerCode = `export default {
+ async fetch(request, env) {
+ // Pass all requests to the static assets
+ // The ASSETS binding handles routing with our configured html_handling and not_found_handling
+ return env.ASSETS.fetch(request);
+ }
+};`;
+
+ form.append('index.js', workerCode, {
+ filename: 'index.js',
+ contentType: 'application/javascript+module'
+ });
+
+ const url = `https://api.cloudflare.com/client/v4/accounts/${this.accountId}/workers/scripts/${this.scriptName}`;
+
+ const response = await fetch(url, {
+ method: 'PUT',
+ headers: {
+ 'Authorization': `Bearer ${this.apiToken}`
+ },
+ body: form
+ });
+
+ const data = await response.json();
+
+ if (!response.ok || !data.success) {
+ console.log(`[${new Date().toUTCString()}] Cloudflare Workers deployment error:`, JSON.stringify(data, null, 2));
+ throw new Error(data.errors?.[0]?.message || 'Failed to deploy worker');
+ }
+
+ console.log(`[${new Date().toUTCString()}] Cloudflare Workers deployment successful`);
+ this.updateProgress();
+ }
+
+ updateProgress() {
+ this.deployment.currentOperationNumber++;
+ const progress = this.deployment.currentOperationNumber * this.deployment.progressPerFile;
+
+ process.send({
+ type: 'web-contents',
+ message: 'app-uploading-progress',
+ value: {
+ progress: 8 + Math.floor(progress),
+ operations: [this.deployment.currentOperationNumber, this.deployment.operationsCounter]
+ }
+ });
+ }
+
+ onError(error) {
+ const message = error.message || error.toString();
+
+ process.send({
+ type: 'web-contents',
+ message: 'app-connection-error',
+ value: {
+ additionalMessage: stripTags(message)
+ }
+ });
+
+ setTimeout(function () {
+ process.kill(process.pid, 'SIGTERM');
+ }, 1000);
+ }
+
+ async testConnection(app, deploymentConfig, siteName, uuid) {
+ let accountId = deploymentConfig.cloudflareWorkers.accountId;
+ let apiToken = deploymentConfig.cloudflareWorkers.apiToken;
+ let scriptName = deploymentConfig.cloudflareWorkers.scriptName;
+ let account = slug(siteName);
+ let waitForTimeout = true;
+
+ if (uuid) {
+ account = uuid;
+ }
+
+ // Retrieve credentials from secure storage if needed
+ if (accountId === 'publii-cf-account-id ' + account) {
+ accountId = await passwordSafeStorage.getPassword('publii-cf-account-id', account);
+ }
+
+ if (apiToken === 'publii-cf-api-token ' + account) {
+ apiToken = await passwordSafeStorage.getPassword('publii-cf-api-token', account);
+ }
+
+ if (!accountId || !apiToken || !scriptName) {
+ app.mainWindow.webContents.send('app-deploy-test-error', {
+ message: 'Missing required configuration: Account ID, API Token, or Script Name'
+ });
+ return;
+ }
+
+ try {
+ // Test API access by fetching account details
+ const response = await fetch(
+ `https://api.cloudflare.com/client/v4/accounts/${accountId}`,
+ {
+ method: 'GET',
+ headers: {
+ 'Authorization': `Bearer ${apiToken}`
+ }
+ }
+ );
+
+ const data = await response.json();
+
+ if (response.ok && data.success) {
+ waitForTimeout = false;
+ app.mainWindow.webContents.send('app-deploy-test-success');
+ } else {
+ waitForTimeout = false;
+ app.mainWindow.webContents.send('app-deploy-test-error', {
+ message: stripTags(data.errors?.[0]?.message || 'Failed to connect to Cloudflare API')
+ });
+ }
+ } catch (err) {
+ waitForTimeout = false;
+ app.mainWindow.webContents.send('app-deploy-test-error', {
+ message: stripTags(err.message || 'Connection test failed')
+ });
+ }
+
+ setTimeout(function() {
+ if (waitForTimeout === true) {
+ app.mainWindow.webContents.send('app-deploy-test-error', {
+ message: {
+ translation: 'core.server.requestTimeout'
+ }
+ });
+ }
+ }, 10000);
+ }
+}
+
+module.exports = CloudflareWorkers;
diff --git a/app/back-end/modules/deploy/deployment.js b/app/back-end/modules/deploy/deployment.js
index f0a7b7a0..e21b6369 100644
--- a/app/back-end/modules/deploy/deployment.js
+++ b/app/back-end/modules/deploy/deployment.js
@@ -16,6 +16,7 @@ const GitlabPages = require('./gitlab-pages.js');
const Netlify = require('./netlify.js');
const GoogleCloud = require('./google-cloud.js');
const ManualDeployment = require('./manual.js');
+const CloudflareWorkers = require('./cloudflare-workers.js');
/**
*
@@ -28,6 +29,7 @@ const ManualDeployment = require('./manual.js');
* Gitlab Pages,
* Netlify,
* Google Cloud,
+ * Cloudflare Workers,
* Manually
*
*/
@@ -70,6 +72,7 @@ class Deployment {
case 's3': connection = new S3(); break;
case 'netlify': connection = new Netlify(); break;
case 'google-cloud': connection = new GoogleCloud(); break;
+ case 'cloudflare-workers': connection = new CloudflareWorkers(); break;
case 'git': connection = new Git(); break;
case 'github-pages': connection = new GithubPages(deploymentConfig); break;
case 'gitlab-pages': connection = new GitlabPages(); break;
@@ -100,6 +103,7 @@ class Deployment {
case 'gitlab-pages': this.client = new GitlabPages(this); break;
case 'netlify': this.client = new Netlify(this); break;
case 'google-cloud': this.client = new GoogleCloud(this); break;
+ case 'cloudflare-workers': this.client = new CloudflareWorkers(this); break;
case 'manual': this.client = new ManualDeployment(this); break;
default:
if (this.useAltFtp) {
@@ -147,7 +151,7 @@ class Deployment {
}
if (filePath === '.htaccess' || filePath === '.htpasswd' || filePath === '_redirects') {
- let excludedProtocols = ['s3', 'github-pages', 'google-cloud', 'netlify'];
+ let excludedProtocols = ['s3', 'github-pages', 'google-cloud', 'netlify', 'cloudflare-workers'];
if (excludedProtocols.indexOf(this.siteConfig.deployment.protocol) === -1) {
fileList.push({
diff --git a/app/default-files/default-languages/en-gb/translations.json b/app/default-files/default-languages/en-gb/translations.json
index 62644170..e1cf4ce9 100644
--- a/app/default-files/default-languages/en-gb/translations.json
+++ b/app/default-files/default-languages/en-gb/translations.json
@@ -1125,6 +1125,7 @@
"deploymentMethodGitlabPagesMsg": "For detailed information about how to configure a website using GitLab Pages, check Publii's online documentation.",
"deploymentMethodGoogleCloudMsg": "For detailed information about how to configure a website using Google Cloud, check Publii's online documentation.",
"deploymentMethodNetlifyMsg": "For detailed information about how to configure a website using Netlify, check Publii's online documentation.",
+ "deploymentMethodCloudflareWorkersMsg": "Deploy your static site to Cloudflare Workers for global edge hosting. You'll need your Account ID, an API Token with Workers permissions, and a Worker name.",
"deploymentMethodS3Msg": "For detailed information about how to configure a website using S3, check Publii's online documentation.",
"deploymentSettingDatHyperIpfsProtocolNote": "The \"dat://\", \"hyper://\", \"dweb://\" and the \"ipfs://\" protocol is useful only if you have plans to use your website on P2P networks. Read more about dat://, hyper://, dweb:// and IPFS",
"deploymentSettingDoubleSlashProtocolNote": "Note: while using \"//\" as protocol, some features like Open Graph tags, sharing buttons etc. will not work properly.",
@@ -1168,6 +1169,14 @@
"ManualUpload": "Manual upload",
"netlify": "Netlify",
"netlifyToken": "Netlify token",
+ "cloudflareWorkers": "Cloudflare Workers",
+ "accountID": "Account ID",
+ "accountIDFieldCantBeEmpty": "The Account ID field cannot be empty",
+ "apiToken": "API Token",
+ "apiTokenFieldCantBeEmpty": "The API Token field cannot be empty",
+ "scriptName": "Worker name",
+ "scriptNameFieldCantBeEmpty": "The Worker name field cannot be empty",
+ "scriptNameNote": "The name of your Cloudflare Worker (e.g., my-site)",
"nonCompressedCatalog": "Non-compressed catalog",
"note": "Note:",
"operationsDone": "operations done",
diff --git a/app/src/assets/svg/svg-map-server.svg b/app/src/assets/svg/svg-map-server.svg
index d1eb3256..f46a1471 100755
--- a/app/src/assets/svg/svg-map-server.svg
+++ b/app/src/assets/svg/svg-map-server.svg
@@ -21,6 +21,13 @@