Repository navigation
feat(dev): add OpenTunnel as a --tunnel provider
#1611
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
atinux
wants to merge
5
commits into
main
Choose a base branch
from
feat/opentunnel
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
5da8850
feat(dev): add OpenTunnel as a `--tunnel` provider
atinux 7f0584b
test(dev): stop building module source from data in the OpenTunnel SD…
atinux e2aa90a
docs: add JSDoc for CLI startup and dev tunnel helpers
coderabbitai[bot] ccc4824
fix(dev): forward tunnels to the address the dev server is bound to
atinux 4b7736a
fix(dev): fall back to Cloudflare for HTTPS before installing the Ope…
coderabbitai[bot] File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import type { Tunnel, TunnelOptions } from './types' | ||
|
|
||
| import { logger } from '../../utils/logger' | ||
|
|
||
| export type { Tunnel, TunnelOptions } from './types' | ||
|
|
||
| export interface TunnelTarget { | ||
| protocol: 'http' | 'https' | ||
| /** The address the dev server is bound to: empty, `0.0.0.0` or `::` for every interface. */ | ||
| hostname: string | ||
| port: number | ||
| /** The server is taking over from a process that still serves the port. */ | ||
| handover?: boolean | ||
| } | ||
|
|
||
| const ANY_HOSTS = new Set(['', '0.0.0.0', '::']) | ||
|
|
||
| /** | ||
| * Where the tunnel reaches the dev server, as `host:port`: `localhost` when it | ||
| * listens on every interface, and otherwise the one address it is bound to, as | ||
| * `localhost` may not resolve to it. | ||
| */ | ||
| export function formatTunnelTarget({ hostname, port }: Pick<TunnelTarget, 'hostname' | 'port'>): string { | ||
| const host = ANY_HOSTS.has(hostname) ? 'localhost' : hostname | ||
| return `${host.includes(':') ? `[${host}]` : host}:${port}` | ||
| } | ||
|
|
||
| /** | ||
| * Start the tunnel `options` asks for, in front of the dev server at `target`. | ||
| * Each provider is imported on demand: `cloudflared` pulls in the download and | ||
| * consent prompts, and OpenTunnel loads its SDK from the project. | ||
| */ | ||
| export async function startTunnel(options: TunnelOptions, target: TunnelTarget): Promise<Tunnel | undefined> { | ||
| const local = formatTunnelTarget(target) | ||
| if (options.provider === 'opentunnel') { | ||
| // OpenTunnel terminates TLS itself and forwards plain TCP, which an HTTPS | ||
| // dev server cannot read. | ||
| if (target.protocol === 'https') { | ||
| logger.warn('OpenTunnel cannot forward to an HTTPS dev server. Using a Cloudflare quick tunnel instead.') | ||
| } | ||
| else { | ||
| const { startOpenTunnel } = await import('./opentunnel') | ||
| return startOpenTunnel(options.route, local, { handover: target.handover, rootDir: options.rootDir }) | ||
| } | ||
| } | ||
| const { startCloudflaredTunnel } = await import('./cloudflared') | ||
| return startCloudflaredTunnel(`${target.protocol}://${local}`, target.protocol === 'https') | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| import { styleText } from 'node:util' | ||
|
|
||
| import { confirm, isCancel } from '@clack/prompts' | ||
| import { resolveCommand } from 'package-manager-detector/commands' | ||
| import { satisfies } from 'verkit' | ||
|
|
||
| import { restoreRawMode, withDirectStdout } from '../../utils/console' | ||
| import { createInstallLog, runInstall, takeUnreportedIgnoredBuilds } from '../../utils/install' | ||
| import { logger } from '../../utils/logger' | ||
| import { defaultPackageManager, detectPackageManager } from '../../utils/package-managers' | ||
| import { createSpinner } from '../../utils/spinner' | ||
| import { withUserAttention } from '../../utils/startup-clock' | ||
| import { isInteractive } from '../../utils/stdout' | ||
| import { findOpenTunnelSDK, OPENTUNNEL_SDK, OPENTUNNEL_SDK_PEERS, OPENTUNNEL_SDK_RANGE, resolveProjectPackage } from './opentunnel-loader' | ||
|
|
||
| /** | ||
| * Make sure the project can load the OpenTunnel SDK, offering to install it as | ||
| * a dev dependency with the project's package manager, the way | ||
| * `nuxt module add` installs modules. Runs before the dev server takes the | ||
| * terminal. Returns `false` when the tunnel cannot start. | ||
| */ | ||
| export async function ensureOpenTunnelSDK(rootDir: string): Promise<boolean> { | ||
| const state = findOpenTunnelSDK(rootDir) | ||
| if (state.status === 'installed') { | ||
| return true | ||
| } | ||
|
|
||
| const peers = resolveMissingPeers(rootDir) | ||
| if (!peers) { | ||
| return false | ||
| } | ||
| // An incompatible copy is replaced by the range this CLI works with. | ||
| const dependencies = [`${OPENTUNNEL_SDK}@${OPENTUNNEL_SDK_RANGE}`, ...peers] | ||
| const packageManager = await detectPackageManager(rootDir) ?? defaultPackageManager | ||
| const install = resolveCommand(packageManager.agent, 'add', ['-D', ...dependencies]) | ||
| const command = install ? [install.command, ...install.args].join(' ') : `npm install -D ${dependencies.join(' ')}` | ||
| const reason = state.status === 'incompatible' | ||
| ? `\`--tunnel=opentunnel\` needs \`${OPENTUNNEL_SDK}@${OPENTUNNEL_SDK_RANGE}\`, and your project has ${state.version}.` | ||
| : `\`--tunnel=opentunnel\` needs \`${OPENTUNNEL_SDK}\` in your project.` | ||
|
|
||
| if (!isInteractive()) { | ||
| logger.warn(`${reason} Run \`${command}\` to install it. Starting without a tunnel.`) | ||
| return false | ||
| } | ||
|
|
||
| logger.info(reason) | ||
| const answer = await withUserAttention(() => withDirectStdout(() => confirm({ | ||
| message: `Install ${dependencies.map(name => styleText('cyan', name)).join(', ')} as dev dependencies with ${styleText('cyan', packageManager.name)}?`, | ||
| initialValue: true, | ||
| }))) | ||
| restoreRawMode() | ||
| if (isCancel(answer) || !answer) { | ||
| logger.info(`Starting without a tunnel. Run \`${command}\` to install it later.`) | ||
| return false | ||
| } | ||
|
|
||
| const controller = new AbortController() | ||
| const installLog = createInstallLog() | ||
| const spinner = createSpinner({ indicator: 'timer', onCancel: () => controller.abort() }) | ||
| spinner.start(`Installing with ${styleText('cyan', packageManager.name)}`) | ||
| const result = await withUserAttention(() => runInstall({ | ||
| cwd: rootDir, | ||
| packageManager, | ||
| dependencies, | ||
| dev: true, | ||
| onOutput: installLog.onOutput, | ||
| onStatus: message => spinner.message(message), | ||
| signal: controller.signal, | ||
| })) | ||
| if (!result.success) { | ||
| spinner.error(result.error ?? 'Install failed') | ||
| installLog.finish(result) | ||
| logger.info('Starting without a tunnel.') | ||
| return false | ||
| } | ||
| spinner.stop(`Installed ${styleText('cyan', OPENTUNNEL_SDK)}`) | ||
| installLog.finish(result) | ||
| const ignoredBuilds = takeUnreportedIgnoredBuilds(result.ignoredBuilds) | ||
| if (ignoredBuilds.length > 0 && packageManager.name === 'pnpm') { | ||
| logger.warn(`${styleText('cyan', 'pnpm')} did not run build scripts for ${ignoredBuilds.map(name => styleText('cyan', name)).join(', ')}. Run ${styleText('cyan', 'pnpm approve-builds')} if your project needs them.`) | ||
| } | ||
|
|
||
| if (findOpenTunnelSDK(rootDir).status !== 'installed') { | ||
| logger.warn(`\`${OPENTUNNEL_SDK}\` was installed but cannot be resolved from \`${rootDir}\`. Starting without a tunnel.`) | ||
| return false | ||
| } | ||
| return true | ||
| } | ||
|
|
||
| /** | ||
| * Required peers of the SDK the project does not already provide. Returns | ||
| * `undefined` when the project has a version that conflicts: installing ours | ||
| * would replace the one the app itself uses. | ||
| */ | ||
| function resolveMissingPeers(rootDir: string): string[] | undefined { | ||
| const missing: string[] = [] | ||
| for (const [name, range] of Object.entries(OPENTUNNEL_SDK_PEERS)) { | ||
| const installed = resolveProjectPackage(name, rootDir) | ||
| if (!installed) { | ||
| missing.push(`${name}@${range}`) | ||
| } | ||
| else if (!satisfies(installed.version, range)) { | ||
| logger.warn(`\`--tunnel=opentunnel\` needs \`${name}@${range}\`, and your project uses ${installed.version || 'another version'}. Starting without a tunnel.`) | ||
| return undefined | ||
| } | ||
| } | ||
| return missing | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.