Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 56 additions & 8 deletions packages/cli/postinstall.js → packages/cli/installCloudflared.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
*/
const { execSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const https = require('https');
const path = require('path');

const RELEASE_BASE = 'https://github.com/cloudflare/cloudflared/releases/';
const CLOUDFLARED_VERSION = '2026.3.0';

const LINUX_URL = {
arm64: 'cloudflared-linux-arm64',
Expand All @@ -33,11 +35,61 @@ function resolveBase(version) {
return `${RELEASE_BASE}download/${version}/`;
}

function getGitBookDataDirectory() {
if (process.platform === 'darwin') {
return path.join(os.homedir(), 'Library', 'Application Support', 'gitbook');
}

if (process.platform === 'win32') {
return path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'gitbook',
);
}

return path.join(
process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'),
'gitbook',
);
}

function getCloudflaredBinaryPath() {
return path.join(
getGitBookDataDirectory(),
'bin',
process.platform === 'win32' ? 'cloudflared.exe' : 'cloudflared',
);
}

async function hasCloudflared(binaryPath) {
const mode = process.platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK;

try {
await fs.promises.access(binaryPath, mode);
return true;
} catch {
return false;
}
}

async function ensureCloudflaredInstalled(onInstall = () => {}, onComplete = () => {}) {
const binaryPath = getCloudflaredBinaryPath();

if (await hasCloudflared(binaryPath)) {
return binaryPath;
}

onInstall();
await installCloudflared(binaryPath, CLOUDFLARED_VERSION);
onComplete();
return binaryPath;
}

/**
* Install cloudflared to the given path.
* @param to The path to the binary to install.
* @param version The version of cloudflared to install.
* @returns The path to the binary that was installed.
* @param {string} to The path to the binary to install.
* @param {string} version The version of cloudflared to install.
* @returns {Promise<string>} The path to the binary that was installed.
*/
async function installCloudflared(to, version = 'latest') {
if (process.platform === 'linux') {
Expand Down Expand Up @@ -136,8 +188,4 @@ function download(url, to, redirect = 0) {
});
}

/**
* Install the cloudflared binary inside the dist folder.
* Locking the version to 2023.4.1 instead of latest to avoid breaking changes.
*/
installCloudflared(path.join(__dirname, 'dist', 'cloudflared'), '2023.4.1');
module.exports = ensureCloudflaredInstalled;
3 changes: 1 addition & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,10 @@
},
"files": [
"dist/**",
"postinstall.js"
"installCloudflared.js"
],
"scripts": {
"build": "./build.sh",
"postinstall": "node postinstall.js",
"typecheck": "tsc --noEmit"
},
"engines": {
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/cloudflared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as path from 'path';

type EnsureCloudflaredInstalled = (
onInstall?: () => void,
onComplete?: () => void,
) => Promise<string>;

export async function ensureCloudflaredInstalled(
onInstall?: () => void,
onComplete?: () => void,
): Promise<string> {
// This file is bundled into dist/cli.js, so resolve the helper from the
// published package root at runtime rather than bundling it into the CLI.
const installer = require(
path.resolve(__dirname, '../installCloudflared.js'),
) as EnsureCloudflaredInstalled;
return installer(onInstall, onComplete);
}
20 changes: 15 additions & 5 deletions packages/cli/src/dev.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import chokidar from 'chokidar';
import getPort from 'get-port';
import { Log, LogLevel, Miniflare, MiniflareOptions } from 'miniflare';
import os from 'os';
import ora from 'ora';
import * as path from 'path';

import { buildScriptFromManifest } from './build';
import { ensureCloudflaredInstalled } from './cloudflared';
import { resolveFile } from './manifest';
import { getAPIClient } from './remote';
import { createDevTunnel } from './tunnel';
Expand All @@ -17,6 +19,7 @@ export function getMiniflareOptions(scriptPath: string): MiniflareOptions {
scriptPath,
modules: true,
modulesRoot: path.dirname(scriptPath),
cf: path.join(os.tmpdir(), '.gitbook', 'miniflare', 'cf.json'),
compatibilityDate: '2025-05-25',
compatibilityFlags: ['nodejs_compat'],
};
Expand Down Expand Up @@ -49,13 +52,18 @@ export async function startIntegrationsDevServer(
const port = await getPort();
spinner.succeed(`Local port ${port} allocated`);

const cloudflaredPath = await ensureCloudflaredInstalled(
() => spinner.start('Installing cloudflared...'),
() => spinner.succeed(`cloudflared ready`),
);

/**
* Create a tunnel to allow the dev server to receive integration events
* from the GitBook platform
*/
spinner.start('Creating HTTPS tunnel...');
const tunnelUrl = await createDevTunnel(port);
spinner.succeed(`Tunnel created ${tunnelUrl}`);
const tunnel = await createDevTunnel(port, cloudflaredPath);
spinner.succeed(`Tunnel created ${tunnel.tunnelUrl}`);

/**
* Start the miniflare dev server. It will automatically reload the script
Expand All @@ -73,17 +81,19 @@ export async function startIntegrationsDevServer(
};
const mf = new Miniflare(miniflareOptions);
await mf.ready;
spinner.succeed(`Dev server started`);
spinner.succeed(`Dev server started 🔥`);

/**
* Add the tunnel to the integration for the dev space events in the GitBook platform
*/
spinner.start(`Enabling development mode for ${manifest.name}...`);
const api = await getAPIClient(true);
await api.integrations.setIntegrationDevelopmentMode(manifest.name, { tunnelUrl, all });
await api.integrations.setIntegrationDevelopmentMode(manifest.name, {
tunnelUrl: tunnel.tunnelUrl,
all,
});
spinner.succeed(`Development mode enabled`);

spinner.succeed(`Dev server 🔥`);
spinner.info(
`Integration events${all ? ` originating from the organization ${manifest.organization}` : ''} will be dispatched to your locally running version of the integration.`,
);
Expand Down
43 changes: 35 additions & 8 deletions packages/cli/src/tunnel.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,43 @@
import { spawn } from 'child_process';
import * as path from 'path';

export interface DevTunnel {
tunnelUrl: string;
close(): void;
}

/**
* Create a tunnel using `cloudflared` mapped to `localhost:{port}`
*/
export function createDevTunnel(port: number): Promise<string> {
export function createDevTunnel(port: number, cloudflaredPath: string): Promise<DevTunnel> {
return new Promise((resolve, reject) => {
let tunnelUrl: string;
let connectionsCount = 0;
const cloudflared = spawn(path.join(__dirname, 'cloudflared'), [
let resolved = false;
const cloudflared = spawn(cloudflaredPath, [
'tunnel',
'--no-autoupdate',
'--url',
`http://localhost:${port}`,
]);

const close = () => {
if (cloudflared.exitCode === null && !cloudflared.killed) {
cloudflared.kill('SIGTERM');
}
};

const resolveWhenReady = () => {
if (resolved || !tunnelUrl || connectionsCount < 1) {
return;
}

resolved = true;
resolve({
tunnelUrl,
close,
});
};

cloudflared.stderr.on('data', (data) => {
const output = data.toString();

Expand All @@ -31,14 +54,18 @@ export function createDevTunnel(port: number): Promise<string> {
connectionsCount++;
}

if (connectionsCount >= 1) {
resolve(tunnelUrl);
}
resolveWhenReady();
});

cloudflared.on('close', (code) => {
if (code !== 0) {
throw new Error(`cloudflared exited with code ${code}`);
if (!resolved && code !== 0) {
reject(new Error(`cloudflared exited with code ${code}`));
}
});

cloudflared.on('error', (error) => {
if (!resolved) {
reject(error);
}
});
});
Expand Down
Loading