From 091df8b493dbabaafa4cc1193deaf1aaed68e383 Mon Sep 17 00:00:00 2001 From: Alexander <96749659+Asys-sh@users.noreply.github.com> Date: Tue, 19 May 2026 17:33:13 +0200 Subject: [PATCH 1/2] fix(@robojs/server): tunnels creation Fixing tunnels not creating the actual re routing for the tunnel. This also fixed another issue where if credentials were set in .env, it would return early, it does not anymore and proceeds to check everything still to make sure tunnel is correctly setup. Also ran into a bug for the DNSRecord where passing nested parameters would turn it into [Object object] using URLSearchParams (nested fields are not supported) flattened it. I also removed the DNSRecordFilter, it seemed to broad to fetch everything that contained robo (let me know if I am wrong) but people could have other stuff with the same ? --- .../tunnel/cloudflare-provider.test.ts | 230 ++++++++++++++++++ .../src/core/tunnel/providers/cloudflare.ts | 101 ++------ packages/plugin-api/src/core/tunnel/types.ts | 2 + packages/plugin-api/src/robo/start.ts | 3 +- 4 files changed, 259 insertions(+), 77 deletions(-) create mode 100644 packages/plugin-api/__tests__/tunnel/cloudflare-provider.test.ts 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..946e21f9c 100644 --- a/packages/plugin-api/src/robo/start.ts +++ b/packages/plugin-api/src/robo/start.ts @@ -153,7 +153,8 @@ async function setupDevTunnel(port: number, config?: TunnelConfig): Promise Date: Tue, 19 May 2026 17:57:49 +0200 Subject: [PATCH 2/2] chore(@robojs/tunnels): moved function call Moved function call as you mentionned in another PR :3 --- packages/plugin-api/src/robo/start.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/plugin-api/src/robo/start.ts b/packages/plugin-api/src/robo/start.ts index 946e21f9c..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) } } @@ -173,8 +172,6 @@ async function setupDevTunnel(port: number, config?: TunnelConfig): Promise