diff --git a/packages/plugin-api/__tests__/tunnel/cloudflare-provider.test.ts b/packages/plugin-api/__tests__/tunnel/cloudflare-provider.test.ts new file mode 100644 index 000000000..be8f5242e --- /dev/null +++ b/packages/plugin-api/__tests__/tunnel/cloudflare-provider.test.ts @@ -0,0 +1,230 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +const mockLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + event: jest.fn(), + ready: jest.fn() +} + +jest.unstable_mockModule('robo.js', () => ({ + logger: { + fork: jest.fn(() => mockLogger) + }, + color: { + bold: jest.fn((value: string) => value), + blue: jest.fn((value: string) => value), + dim: jest.fn((value: string) => value) + }, + composeColors: jest.fn(() => (value: string) => value), + Mode: { + get: jest.fn(() => undefined) + } +})) + +jest.unstable_mockModule('robo.js/unstable.js', () => ({ + Nanocore: { + update: jest.fn() + } +})) + +const { CloudflareProvider } = await import('../../src/core/tunnel/providers/cloudflare.js') + +function cloudflareResponse(result: unknown, success = true) { + return new Response( + JSON.stringify({ + success, + errors: success ? [] : [{ code: 10000, message: 'Authentication error' }], + messages: [], + result + }), + { status: success ? 200 : 403 } + ) +} + +describe('CloudflareProvider', () => { + const originalTunnelId = process.env.CLOUDFLARE_TUNNEL_ID + const originalTunnelToken = process.env.CLOUDFLARE_TUNNEL_TOKEN + const originalCwd = process.cwd() + let tempDir: string + let fetchMock: jest.MockedFunction + let dnsRecords: Array> + + beforeEach(() => { + jest.clearAllMocks() + delete process.env.CLOUDFLARE_TUNNEL_ID + delete process.env.CLOUDFLARE_TUNNEL_TOKEN + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'robo-cloudflare-provider-')) + process.chdir(tempDir) + dnsRecords = [] + + fetchMock = jest.fn(async (input: Parameters[0], init?: Parameters[1]) => { + const url = input.toString() + const method = init?.method ?? 'GET' + + if (url.includes('/configurations/') && method === 'PUT') { + return cloudflareResponse({ version: 1 }) + } + + if (url.includes('/dns_records?') && method === 'GET') { + return cloudflareResponse(dnsRecords) + } + + if (url.endsWith('/dns_records') && method === 'POST') { + return cloudflareResponse({ id: 'dns-record-id', name: 'robo.example.com' }) + } + + if (url.includes('/dns_records/') && method === 'PATCH') { + return cloudflareResponse({ id: 'existing-record-id', name: 'robo.example.com' }) + } + + if (url.includes('/cfd_tunnel?') && method === 'GET') { + return cloudflareResponse([]) + } + + if (url.endsWith('/cfd_tunnel') && method === 'POST') { + return cloudflareResponse({ id: 'new-tunnel-id', name: 'robo' }) + } + + if (url.includes('/token') && method === 'GET') { + return cloudflareResponse('new-tunnel-token') + } + + throw new Error(`Unexpected request: ${method} ${url}`) + }) as unknown as jest.MockedFunction + + global.fetch = fetchMock + }) + + afterEach(() => { + process.chdir(originalCwd) + fs.rmSync(tempDir, { force: true, recursive: true }) + + if (originalTunnelId === undefined) { + delete process.env.CLOUDFLARE_TUNNEL_ID + } else { + process.env.CLOUDFLARE_TUNNEL_ID = originalTunnelId + } + + if (originalTunnelToken === undefined) { + delete process.env.CLOUDFLARE_TUNNEL_TOKEN + } else { + process.env.CLOUDFLARE_TUNNEL_TOKEN = originalTunnelToken + } + }) + + it('reconciles tunnel config and DNS when tunnel credentials already exist', async () => { + process.env.CLOUDFLARE_TUNNEL_ID = 'existing-tunnel-id' + process.env.CLOUDFLARE_TUNNEL_TOKEN = 'existing-tunnel-token' + + const provider = new CloudflareProvider() + const initialized = await provider.initialize({ + domain: 'example.com', + apiKey: 'api-token', + zoneId: 'zone-id', + accountId: 'account-id', + originUrl: 'http://localhost:5173' + }) + + expect(initialized).toBe(true) + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(fetchMock.mock.calls[0][0].toString()).toContain( + '/accounts/account-id/cfd_tunnel/existing-tunnel-id/configurations/' + ) + expect(fetchMock.mock.calls[1][0].toString()).toContain('/zones/zone-id/dns_records?') + expect(fetchMock.mock.calls[1][0].toString()).toContain('name=robo.example.com') + expect(fetchMock.mock.calls[1][0].toString()).not.toContain('%5Bobject+Object%5D') + expect(fetchMock.mock.calls[2][0].toString()).toContain('/zones/zone-id/dns_records') + + const dnsCreateBody = JSON.parse(fetchMock.mock.calls[2][1]?.body as string) + expect(dnsCreateBody).toEqual({ + comment: 'Robo.js Cloudflare Tunnel Proxy', + name: 'robo.example.com', + proxied: true, + content: 'existing-tunnel-id.cfargotunnel.com', + type: 'CNAME' + }) + }) + + it('creates a persistent tunnel and configures ingress for the provided origin URL', async () => { + const provider = new CloudflareProvider() + const initialized = await provider.initialize({ + domain: 'example.com', + apiKey: 'api-token', + zoneId: 'zone-id', + accountId: 'account-id', + originUrl: 'http://localhost:5173' + }) + + expect(initialized).toBe(true) + + const configCall = fetchMock.mock.calls.find((call) => + call[0].toString().includes('/accounts/account-id/cfd_tunnel/new-tunnel-id/configurations/') + ) + + expect(configCall).toBeDefined() + expect(JSON.parse(configCall?.[1]?.body as string)).toEqual({ + config: { + ingress: [ + { + hostname: 'robo.example.com', + service: 'http://localhost:5173' + }, + { + service: 'http_status:404' + } + ] + } + }) + }) + + it('patches an existing DNS record to point at the resolved tunnel', async () => { + dnsRecords = [ + { + id: 'existing-record-id', + name: 'robo.example.com', + type: 'CNAME', + content: 'old-tunnel-id.cfargotunnel.com' + } + ] + + const provider = new CloudflareProvider() + const initialized = await provider.initialize({ + domain: 'example.com', + apiKey: 'api-token', + zoneId: 'zone-id', + accountId: 'account-id', + tunnelId: 'existing-tunnel-id', + tunnelToken: 'existing-tunnel-token', + originUrl: 'http://localhost:5173' + }) + + expect(initialized).toBe(true) + + const dnsPatchCall = fetchMock.mock.calls.find((call) => + call[0].toString().includes('/zones/zone-id/dns_records/existing-record-id') + ) + + expect(dnsPatchCall).toBeDefined() + expect(JSON.parse(dnsPatchCall?.[1]?.body as string)).toEqual({ + comment: 'Robo.js Cloudflare Tunnel Proxy', + name: 'robo.example.com', + proxied: true, + content: 'existing-tunnel-id.cfargotunnel.com', + type: 'CNAME' + }) + }) + + it('returns false without Cloudflare credentials so quick tunnel fallback can be used', async () => { + const provider = new CloudflareProvider() + const initialized = await provider.initialize({}) + + expect(initialized).toBe(false) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/packages/plugin-api/src/core/tunnel/providers/cloudflare.ts b/packages/plugin-api/src/core/tunnel/providers/cloudflare.ts index 4b15a4ba1..7961c9183 100644 --- a/packages/plugin-api/src/core/tunnel/providers/cloudflare.ts +++ b/packages/plugin-api/src/core/tunnel/providers/cloudflare.ts @@ -45,7 +45,6 @@ type CloudflareRequestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' type CloudflareRequestBody = | CloudflareTunnelRequest | CloudflareTunnelConfirationRequest - | CloudflareDNSRecordListRequest | CloudflareDNSRecordCreateRequest | null @@ -76,46 +75,6 @@ interface CloudflareTunnelConfirationRequest { config: CloudflareTunnelConfiguration } -interface CloudflareDNSRecordListRequest { - comment?: { - absent?: string - contains?: string - endswith?: string - exact?: string - present?: string - startswith?: string - } - content?: { - contains?: string - endswith?: string - exact?: string - startswith?: string - } - direction?: 'asc' | 'desc' - match?: 'any' | 'all' - name?: { - contains?: string - endswith?: string - exact?: string - startswith?: string - } - order?: 'type' | 'name' | 'content' | 'ttl' | 'proxied' - page?: number - per_page?: number - proxied?: boolean - search?: string - tag?: { - absent?: string - contains?: string - endswith?: string - exact?: string - present?: string - startswith?: string - } - tag_match?: 'any' | 'all' - type?: RecordType -} - interface CloudflareDNSRecordCreateRequest { name: string content: string @@ -320,19 +279,19 @@ export class CloudflareProvider implements TunnelProvider { const apiKey = config.apiKey ?? process.env.CLOUDFLARE_API_KEY const zoneId = config.zoneId ?? process.env.CLOUDFLARE_ZONE_ID const accountId = config.accountId ?? process.env.CLOUDFLARE_ACCOUNT_ID + const originUrl = config.originUrl ?? `http://localhost:${process.env.PORT || 3000}` if (!domain || !apiKey || !zoneId || !accountId) { return false } - logger.debug('Looking for existing Cloudflare tunnels from .env file') - if (process.env.CLOUDFLARE_TUNNEL_ID && process.env.CLOUDFLARE_TUNNEL_TOKEN) { - logger.info('Using existing tunnel from .env file: ' + process.env.CLOUDFLARE_TUNNEL_ID) - return true - } - logger.debug('No existing tunnel found in .env file') - try { + // Resolve the tunnel id locally — never depend on process.env being updated + // mid-run, since dotenv-style loaders won't re-import freshly written values. + let tunnelId = config.tunnelId ?? process.env.CLOUDFLARE_TUNNEL_ID + let tunnelToken = config.tunnelToken ?? process.env.CLOUDFLARE_TUNNEL_TOKEN + + logger.debug('Looking for existing tunnels from Cloudflare account') const oldRoboTunnels = await this.cloudflareRequest>( @@ -360,10 +319,6 @@ export class CloudflareProvider implements TunnelProvider { ? oldRoboTunnels.result.filter((tunnel) => tunnel.deleted_at === null)[0] : undefined - // Resolve the tunnel id locally — never depend on process.env being updated - // mid-run, since dotenv-style loaders won't re-import freshly written values. - let tunnelId: string | undefined - if (oldRoboTunnelExists?.id) { const oldRoboTunnel = oldRoboTunnelExists @@ -380,9 +335,9 @@ export class CloudflareProvider implements TunnelProvider { } logger.info('Using existing tunnel from Cloudflare account: ' + oldRoboTunnel.id) - await this.updateEnvFile('CLOUDFLARE_TUNNEL_ID', oldRoboTunnel.id!) - await this.updateEnvFile('CLOUDFLARE_TUNNEL_TOKEN', oldRoboTunnelToken.result) tunnelId = oldRoboTunnel.id + tunnelToken = oldRoboTunnelToken.result + } else { logger.debug('Creating new tunnel for Cloudflare account') const newCloudflareTunnel: CloudflareTunnelRequest = { @@ -425,14 +380,16 @@ export class CloudflareProvider implements TunnelProvider { await this.updateEnvFile('CLOUDFLARE_TUNNEL_ID', id) await this.updateEnvFile('CLOUDFLARE_TUNNEL_TOKEN', newRoboTunnelToken.result) tunnelId = id + tunnelToken = newRoboTunnelToken.result } + - if (!tunnelId) { - logger.error('Could not resolve a tunnel id — aborting.') + if (!tunnelId || !tunnelToken) { + logger.error('Could not resolve tunnel credentials — aborting.') return false } - const handeledTunnelConfig = await this.handleTunnelConfig(tunnelId, accountId, domain, apiKey) + const handeledTunnelConfig = await this.handleTunnelConfig(tunnelId, accountId, domain, originUrl, apiKey) logger.debug(`Updated tunnel config for ${tunnelId} with account ${accountId}`) if (!handeledTunnelConfig) { @@ -806,9 +763,10 @@ export class CloudflareProvider implements TunnelProvider { } private async handleTunnelConfig( - id: string, + tunnelId: string, accountId: string, domain: string, + originUrl: string, apiKey: string ): Promise { const tunnelConfig: CloudflareTunnelConfirationRequest = { @@ -816,7 +774,8 @@ export class CloudflareProvider implements TunnelProvider { ingress: [ { hostname: `robo.${domain}`, - service: `http://localhost:${process.env.PORT || 3000}` + originRequest: {}, + service: originUrl }, { service: 'http_status:404' @@ -826,7 +785,7 @@ export class CloudflareProvider implements TunnelProvider { } const tunnelConfigResponse = await this.cloudflareRequest( - `/accounts/${accountId}/cfd_tunnel/${id}/configurations/`, + `/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`, 'PUT', tunnelConfig, apiKey @@ -847,25 +806,15 @@ export class CloudflareProvider implements TunnelProvider { zoneId: string, apiKey: string ): Promise { - const existingDNSRecordFilter: CloudflareDNSRecordListRequest = { - match: 'any', - comment: { - contains: 'robo' - }, - content: { - contains: 'cfargotunnel.com' - }, - name: { - contains: 'robo' - }, + const recordName = `robo.${domain}` + const existingDNSRecordFilterParams = new URLSearchParams({ + name: recordName, type: 'CNAME' - } - - const existingDNSRecordFilterParams = new URLSearchParams(existingDNSRecordFilter as Record) + }) const dnsRecord: CloudflareDNSRecordCreateRequest = { comment: 'Robo.js Cloudflare Tunnel Proxy', - name: 'robo', + name: recordName, proxied: true, content: `${tunnelID}.cfargotunnel.com`, type: 'CNAME' @@ -879,7 +828,7 @@ export class CloudflareProvider implements TunnelProvider { apiKey ) if (existingRecords.success && existingRecords.result.length > 0) { - recordExists = existingRecords.result.find((record) => record.name === `robo.${domain}`) + recordExists = existingRecords.result.find((record) => record.name === recordName) } if (recordExists) { diff --git a/packages/plugin-api/src/core/tunnel/types.ts b/packages/plugin-api/src/core/tunnel/types.ts index 016433c9d..620b71d0f 100644 --- a/packages/plugin-api/src/core/tunnel/types.ts +++ b/packages/plugin-api/src/core/tunnel/types.ts @@ -54,6 +54,8 @@ export interface TunnelProviderConfig { accountId?: string tunnelId?: string tunnelToken?: string + /** Origin URL the persistent tunnel should forward to */ + originUrl?: string /** Run tunnel process in detached mode (for background operation) */ detached?: boolean /** Timeout in ms for waiting for tunnel URL (default: 30000) */ diff --git a/packages/plugin-api/src/robo/start.ts b/packages/plugin-api/src/robo/start.ts index d292e6d23..39b2e7171 100644 --- a/packages/plugin-api/src/robo/start.ts +++ b/packages/plugin-api/src/robo/start.ts @@ -116,9 +116,8 @@ export default async (_context: StartContext) => { if (tunnelEnabled) { if (isDev) { await setupDevTunnel(port, pluginOptions.tunnel) - } else { - await startTunnel(port, pluginOptions.tunnel) } + await startTunnel(port, pluginOptions.tunnel) } } @@ -153,7 +152,8 @@ async function setupDevTunnel(port: number, config?: TunnelConfig): Promise