@vibecontrols/vibe-plugin-tunnel-cloudflare 2026.509.2 → 2026.509.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of @vibecontrols/vibe-plugin-tunnel-cloudflare might be problematic. Click here for more details.

package/dist/index.d.ts CHANGED
@@ -13,23 +13,12 @@
13
13
  * Migrated to consume `@vibecontrols/plugin-sdk` for the contract,
14
14
  * lifecycle, telemetry, logger and subprocess helpers.
15
15
  */
16
- import type { VibePlugin } from "@vibecontrols/plugin-sdk/contract";
17
- import type { TunnelProvider } from "./types.js";
16
+ import type { VibePluginFactory } from "@vibecontrols/plugin-sdk/contract";
18
17
  /**
19
- * Local extension of the SDK contract `prerequisites` and the
20
- * `providers` slot are agent-host extensions surfaced to the runtime
21
- * registry. The SDK contract leaves these to the host implementation.
18
+ * Plugin contract V2 factory. Builds a fresh VibePlugin (with its own
19
+ * lifecycle/telemetry instances and providers bag) per call. The
20
+ * `provider` module-level binding is reused across calls because
21
+ * cloudflared subprocesses are global OS resources — having two
22
+ * profile-instances spawn duplicate tunnels would be unsafe.
22
23
  */
23
- type CloudflareVibePlugin = VibePlugin & {
24
- prerequisites?: Array<{
25
- name: string;
26
- kind: "binary" | "npm" | "pip" | "cargo" | "manual";
27
- requiresSudo: boolean;
28
- description?: string;
29
- }>;
30
- providers?: {
31
- tunnel?: TunnelProvider;
32
- };
33
- };
34
- export declare const vibePlugin: CloudflareVibePlugin;
35
- export {};
24
+ export declare const createPlugin: VibePluginFactory;
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  // @bun
2
2
  import{Elysia as Elysia2}from"elysia";import{Elysia}from"elysia";function createLifecycleHooks(spec){let{name,onInit,onShutdown,telemetryEventName,skipPlatforms}=spec;return{onServerStart:async(_app,hostServices)=>{if(skipPlatforms&&skipPlatforms.includes(process.platform)){process.stderr.write(`[${name}] skipping init on unsupported platform '${process.platform}'
3
- `);return}if(onInit)await onInit(hostServices);if(telemetryEventName)hostServices.telemetry?.emit(telemetryEventName,{plugin:name})},onServerStop:async(hostServices)=>{if(onShutdown)await onShutdown(hostServices)}}}var TelemetryEmitter=class{constructor(pluginName,pluginVersion,hostServices){this.pluginName=pluginName,this.pluginVersion=pluginVersion,this.hostServices=hostServices}pluginName;pluginVersion;hostServices;emit(eventName,payload){let target=this.hostServices?.telemetry;if(!target)return;target.emit(eventName,{plugin:this.pluginName,version:this.pluginVersion,timestamp:new Date().toISOString(),...payload??{}})}emitReady(context){this.emit(`${this.pluginName}.ready`,context)}emitError(error,context){this.emit(`${this.pluginName}.error`,{message:error.message,...context??{}})}emitEvent(type,payload){this.emit(type,payload)}},BoundLogger=class{constructor(logger,source){this.logger=logger,this.source=source}logger;source;info(message,meta){this.logger?.info?.(this.source,message,meta)}warn(message,meta){this.logger?.warn?.(this.source,message,meta)}error(message,meta){this.logger?.error?.(this.source,message,meta)}debug(message,meta){this.logger?.debug?.(this.source,message,meta)}};function sleep(ms){return new Promise((resolve)=>setTimeout(resolve,ms))}function isProcessAlive(pid){try{return process.kill(pid,0),!0}catch{return!1}}async function gracefulKill(pid,timeoutMs=3000,logger){if(!isProcessAlive(pid))return;try{process.kill(pid,"SIGTERM")}catch(err){logger?.warn?.("subprocess","SIGTERM failed",{pid,message:err instanceof Error?err.message:String(err)});return}let pollMs=50,elapsed=0;while(elapsed<timeoutMs)if(await sleep(pollMs),elapsed+=pollMs,!isProcessAlive(pid))return;try{process.kill(pid,"SIGKILL")}catch(err){logger?.warn?.("subprocess","SIGKILL failed",{pid,message:err instanceof Error?err.message:String(err)})}}var ProviderRegistry=class{constructor(hostServices){this.hostServices=hostServices}hostServices;getServiceRegistry(){return this.hostServices?.serviceRegistry}registerProvider(type,name,provider){this.hostServices?.serviceRegistry?.registerService(type,name,provider)}getProvider(type,name){return this.hostServices?.serviceRegistry?.getService(type,name)}listProviders(type){return this.hostServices?.serviceRegistry?.listProvidersForType?.(type)??[]}withCliContribution(contribution){let contributors=this.hostServices?.cliContributors;if(!contributors)return;for(let section of contribution.statusSections??[])contributors.addStatusSection?.(section);for(let check of contribution.doctorChecks??[])contributors.addDoctorCheck?.(check)}};function resolveCloudflaredCmd(){let bare=typeof Bun<"u"&&typeof Bun.which==="function"?Bun.which("cloudflared"):null;if(bare)return bare;return process.platform==="win32"?"cloudflared.exe":"cloudflared"}var PLUGIN_NAME="tunnel-cloudflare",PLUGIN_VERSION="2026.509.2",PROVIDER_NAME="tunnel-cloudflare",STORAGE_NS="tunnel-cloudflare",KEY_TUNNELS="tunnels",KEY_AGENT_URL="agent-tunnel-url",KEY_AGENT_PID="agent-tunnel-pid",TUNNEL_URL_RE=/(https:\/\/(?!api\.)[a-zA-Z0-9-]+\.trycloudflare\.com)/,URL_EXTRACT_TIMEOUT_MS=30000,KILL_GRACE_MS=3000;class CloudflaredRateLimitedError extends Error{constructor(detail){super(`cloudflared rate-limited by trycloudflare.com: ${detail}`);this.name="CloudflaredRateLimitedError"}}async function extractTunnelUrl(proc){let combined="",readStream=async(stream)=>{if(!stream)return;let reader=stream.getReader(),decoder=new TextDecoder;try{while(!0){let{done,value}=await reader.read();if(done)break;combined+=decoder.decode(value,{stream:!0});let match=TUNNEL_URL_RE.exec(combined);if(match)return match[1]}}finally{reader.releaseLock()}return},result=await Promise.race([readStream(proc.stdout),readStream(proc.stderr),new Promise((_,reject)=>setTimeout(()=>reject(Error(`Timed out after ${URL_EXTRACT_TIMEOUT_MS}ms waiting for tunnel URL`)),URL_EXTRACT_TIMEOUT_MS))]);if(!result){if(/429|too many requests|error code: 1015/i.test(combined))throw new CloudflaredRateLimitedError(combined.split(`
4
- `).find((l)=>/429|1015/i.test(l))??combined);throw Error("cloudflared exited before producing a URL")}return result}class CloudflareTunnelProvider{name=PROVIDER_NAME;processes=new Map;storage;log;hostServices;constructor(hostServices){this.hostServices=hostServices,this.storage=hostServices.storage,this.log=new BoundLogger(hostServices.logger,PROVIDER_NAME)}async loadTunnels(){let raw=await this.storage.get(STORAGE_NS,KEY_TUNNELS);if(!raw)return[];try{return JSON.parse(raw)}catch{return this.log.warn("Corrupt tunnel list in storage \u2014 resetting"),[]}}async saveTunnels(tunnels){await this.storage.set(STORAGE_NS,KEY_TUNNELS,JSON.stringify(tunnels))}async upsertTunnel(info){let tunnels=await this.loadTunnels(),idx=tunnels.findIndex((t)=>t.id===info.id);if(idx>=0)tunnels[idx]=info;else tunnels.push(info);await this.saveTunnels(tunnels)}async removeTunnelRecord(tunnelId){let tunnels=await this.loadTunnels();await this.saveTunnels(tunnels.filter((t)=>t.id!==tunnelId))}getCapabilities(){return{provider:PROVIDER_NAME,supportsHttp:!0,supportsHttps:!0,supportsTcp:!1,supportsUdp:!1,supportsCustomDomains:!1,supportsManagedSubdomains:!0,supportsSessionTokens:!1,supportsLiveLogs:!0,supportsUsageMetrics:!1,supportsRotateCredentials:!0,platforms:["darwin","linux","win32"]}}async issueSession(req){let tunnelId=(typeof req.metadata?.backendTunnelId==="string"?req.metadata.backendTunnelId:void 0)??crypto.randomUUID(),sessionId=crypto.randomUUID(),localHost=req.localHost??"127.0.0.1",protocol=req.protocol,now=new Date().toISOString(),info={id:tunnelId,providerName:PROVIDER_NAME,status:"stopped",protocol,localPort:req.localPort,localHost,url:"",sessionId,createdAt:now,metadata:{...req.metadata??{},localUrl:`${protocol}://${localHost}:${req.localPort}`}};await this.upsertTunnel(info);let session={sessionId,tunnelId,provider:PROVIDER_NAME,expiresAt:req.ttlSeconds?new Date(Date.now()+req.ttlSeconds*1000).toISOString():void 0,credentials:{localUrl:info.metadata.localUrl}};return await this.storage.set(STORAGE_NS,`sessions:${tunnelId}`,JSON.stringify([session])),session}async start(tunnelId){let info=(await this.loadTunnels()).find((t)=>t.id===tunnelId);if(!info)throw Error(`Tunnel ${tunnelId} not found (issueSession first)`);if(this.processes.has(tunnelId))return info;let localUrl=info.metadata?.localUrl??`${info.protocol}://${info.localHost}:${info.localPort}`;await this.upsertTunnel({...info,status:"starting",updatedAt:new Date().toISOString()}),this.log.info(`Starting tunnel ${tunnelId} \u2192 ${localUrl}`);let proc=Bun.spawn([resolveCloudflaredCmd(),"tunnel","--url",localUrl],{stdin:"ignore",stdout:"pipe",stderr:"pipe"});this.processes.set(tunnelId,proc),proc.exited.then((code)=>{if(this.processes.has(tunnelId))this.log.warn(`Tunnel ${tunnelId} process exited unexpectedly (code=${code})`),this.processes.delete(tunnelId),this.loadTunnels().then((current)=>{let idx=current.findIndex((t)=>t.id===tunnelId);if(idx>=0)current[idx]={...current[idx],status:"error",updatedAt:new Date().toISOString(),metadata:{...current[idx].metadata,exitCode:code}},this.saveTunnels(current)})});try{let url=await extractTunnelUrl(proc),activeInfo={...info,url,managedHostname:new URL(url).host,status:"active",pid:proc.pid,updatedAt:new Date().toISOString()};return await this.upsertTunnel(activeInfo),this.log.info(`Tunnel ${tunnelId} active at ${url} (PID ${proc.pid})`),activeInfo}catch(err){if(this.processes.delete(tunnelId),isProcessAlive(proc.pid))await gracefulKill(proc.pid,KILL_GRACE_MS);if(err instanceof CloudflaredRateLimitedError){let placeholderHost=`rate-limited-${tunnelId}.trycloudflare.com`,placeholderUrl=`https://${placeholderHost}`,degraded={...info,url:placeholderUrl,managedHostname:placeholderHost,status:"active",updatedAt:new Date().toISOString(),metadata:{...info.metadata,degraded:!0,degradedReason:err.message}};return await this.upsertTunnel(degraded),this.log.warn(`Tunnel ${tunnelId} rate-limited; returning placeholder URL`),degraded}let errorInfo={...info,status:"error",updatedAt:new Date().toISOString(),metadata:{...info.metadata,error:err instanceof Error?err.message:String(err)}};throw await this.upsertTunnel(errorInfo),err}}async rotate(tunnelId){if(!(await this.loadTunnels()).find((t)=>t.id===tunnelId))throw Error(`Tunnel ${tunnelId} not found`);await this.stop(tunnelId),await this.start(tunnelId);let updated=(await this.loadTunnels()).find((t)=>t.id===tunnelId);return{sessionId:updated?.sessionId??crypto.randomUUID(),tunnelId,provider:PROVIDER_NAME,credentials:{localUrl:updated?.metadata?.localUrl}}}async attachCustomDomain(_tunnelId,_domain){throw Error("Custom domains are not supported by the cloudflared quick-tunnel provider. Use the vibetunnels provider or wait for named-tunnel support.")}async detachCustomDomain(_tunnelId,_domain){throw Error("Custom domains are not supported by the cloudflared quick-tunnel provider.")}async listSessions(tunnelId){let raw=await this.storage.get(STORAGE_NS,`sessions:${tunnelId}`);if(!raw)return[];try{return JSON.parse(raw)}catch{return[]}}async stop(tunnelId){this.log.info(`Stopping tunnel ${tunnelId}`);let tunnel=(await this.loadTunnels()).find((t)=>t.id===tunnelId);if(tunnel)tunnel.status="stopping",tunnel.updatedAt=new Date().toISOString(),await this.upsertTunnel(tunnel);let proc=this.processes.get(tunnelId);if(proc)await gracefulKill(proc.pid,KILL_GRACE_MS),this.processes.delete(tunnelId);else if(tunnel?.pid&&isProcessAlive(tunnel.pid))await gracefulKill(tunnel.pid,KILL_GRACE_MS);if(tunnel)tunnel.status="stopped",tunnel.pid=void 0,tunnel.updatedAt=new Date().toISOString(),await this.upsertTunnel(tunnel);let agentPidRaw=await this.storage.get(STORAGE_NS,KEY_AGENT_PID);if(agentPidRaw&&tunnel?.pid&&String(tunnel.pid)===agentPidRaw)await this.storage.delete(STORAGE_NS,KEY_AGENT_URL),await this.storage.delete(STORAGE_NS,KEY_AGENT_PID);this.log.info(`Tunnel ${tunnelId} stopped`)}async getStatus(tunnelId){let tunnel=(await this.loadTunnels()).find((t)=>t.id===tunnelId)??null;if(!tunnel)return null;if(tunnel.status==="active"&&tunnel.pid){if(!isProcessAlive(tunnel.pid))tunnel.status="stopped",tunnel.updatedAt=new Date().toISOString(),await this.upsertTunnel(tunnel),this.processes.delete(tunnelId)}return tunnel}async getActiveTunnelUrl(){let envUrl=process.env.AGENT_TUNNEL_URL;if(envUrl)return envUrl;return this.storage.get(STORAGE_NS,KEY_AGENT_URL)}async list(){return this.loadTunnels()}async delete(tunnelId){this.log.info(`Deleting tunnel ${tunnelId}`);let tunnel=(await this.loadTunnels()).find((t)=>t.id===tunnelId);if(tunnel&&(tunnel.status==="active"||tunnel.status==="starting"))await this.stop(tunnelId);await this.removeTunnelRecord(tunnelId),this.log.info(`Tunnel ${tunnelId} deleted`)}detachAll(){this.log.info("Detaching from all tunnels (processes left running)"),this.processes.clear()}async healthCheck(){try{let result=Bun.spawnSync([resolveCloudflaredCmd(),"--version"],{stdout:"pipe",stderr:"pipe",timeout:1e4});if(result.exitCode!==0)return{ok:!1,message:"cloudflared not available"};return{ok:!0,message:result.stdout.toString().trim()}}catch(err){return{ok:!1,message:err instanceof Error?`cloudflared not available: ${err.message}`:"cloudflared not available"}}}async startAgentTunnel(agentPort){let profileSuffix=(process.env.VIBECONTROLS_PROFILE??"default").replace(/[^A-Za-z0-9_]/g,"_"),envBootstrapPid=process.env[`AGENT_TUNNEL_PID_${profileSuffix}`],envBootstrapUrl=process.env[`AGENT_TUNNEL_URL_${profileSuffix}`];if(envBootstrapPid&&envBootstrapUrl){let pid=parseInt(envBootstrapPid,10);if(!isNaN(pid)&&isProcessAlive(pid)){this.log.info(`Adopting bootstrap cloudflared (pid=${pid}) at ${envBootstrapUrl}`),await this.storage.set(STORAGE_NS,KEY_AGENT_URL,envBootstrapUrl),await this.storage.set(STORAGE_NS,KEY_AGENT_PID,String(pid));let adopted={id:`agent-bootstrap-${pid}`,providerName:"cloudflare",status:"active",protocol:"http",localPort:agentPort,localHost:"localhost",url:envBootstrapUrl,pid,createdAt:new Date().toISOString(),metadata:{isAgentTunnel:!0,name:"agent",adopted:!0}};return delete process.env[`AGENT_TUNNEL_PID_${profileSuffix}`],delete process.env[`AGENT_TUNNEL_URL_${profileSuffix}`],adopted}}let storedPidStr=await this.storage.get(STORAGE_NS,KEY_AGENT_PID),storedUrl=await this.storage.get(STORAGE_NS,KEY_AGENT_URL);if(storedPidStr&&storedUrl){let storedPid=parseInt(storedPidStr,10);if(!isNaN(storedPid)&&isProcessAlive(storedPid)){this.log.info(`Reusing existing agent tunnel (pid=${storedPid}) at ${storedUrl}`);let existing=(await this.loadTunnels()).find((t)=>t.pid===storedPid&&t.metadata?.isAgentTunnel);if(existing)return existing;return{id:`agent-stored-${storedPid}`,providerName:"cloudflare",status:"active",protocol:"http",localPort:agentPort,localHost:"localhost",url:storedUrl,pid:storedPid,createdAt:new Date().toISOString(),metadata:{isAgentTunnel:!0,name:"agent",adopted:!0}}}}this.log.info(`Starting agent tunnel on port ${agentPort}`);let session=await this.issueSession({protocol:"http",localPort:agentPort,localHost:"localhost",metadata:{isAgentTunnel:!0,name:"agent"}}),info=await this.start(session.tunnelId);if(await this.storage.set(STORAGE_NS,KEY_AGENT_URL,info.url),info.pid!==void 0)await this.storage.set(STORAGE_NS,KEY_AGENT_PID,String(info.pid));return this.log.info(`Agent tunnel active at ${info.url}`),info}async stopAll(){this.log.info("Stopping all tunnels");let tunnels=await this.loadTunnels();await Promise.allSettled(tunnels.filter((t)=>t.status==="active"||t.status==="starting").map((t)=>this.stop(t.id)));for(let[id,proc]of this.processes)if(isProcessAlive(proc.pid))this.log.warn(`Killing orphaned process ${proc.pid} (tunnel ${id})`),await gracefulKill(proc.pid,KILL_GRACE_MS);this.processes.clear(),await this.storage.deleteAll(STORAGE_NS),this.log.info("All tunnels stopped and storage cleared")}}var provider=null;function whichSync(bin){return Bun.which(bin)??null}function createPrereqsRoutes(){return new Elysia2({prefix:"/prereqs"}).get("/status",()=>{let cf=whichSync("cloudflared");return{satisfied:!!cf,missing:cf?[]:[{name:"cloudflared",kind:"binary",requiresSudo:!0,detected:void 0}]}}).post("/install",()=>{if(whichSync("cloudflared"))return{ok:!0,installed:[],pendingSudo:[],errors:[]};let cmd=process.platform==="darwin"?"brew install cloudflared":process.platform==="linux"?"curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o /usr/local/bin/cloudflared && sudo chmod +x /usr/local/bin/cloudflared":process.platform==="win32"?"winget install Cloudflare.cloudflared # or: scoop install cloudflared # or download from https://github.com/cloudflare/cloudflared/releases":"see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/";return{ok:!0,installed:[],pendingSudo:[{name:"cloudflared",command:cmd,reason:"cloudflared is required to start tunnels."}],errors:[]}}).post("/uninstall",()=>({ok:!0}))}var telemetry=new TelemetryEmitter(PLUGIN_NAME,PLUGIN_VERSION),lifecycle=createLifecycleHooks({name:PLUGIN_NAME,telemetryEventName:"tunnel.provider.ready",onInit:async(hostServices)=>{let log=new BoundLogger(hostServices.logger,PROVIDER_NAME);if(log.info("Plugin initialising"),provider=new CloudflareTunnelProvider(hostServices),vibePlugin.providers={tunnel:provider},new ProviderRegistry(hostServices).registerProvider("tunnel",PROVIDER_NAME,provider),telemetry.emit("tunnel.provider.ready",{provider:"cloudflare"}),process.env.AGENT_TUNNEL==="false"){log.info("AGENT_TUNNEL=false \u2014 provider registered, tunnel managed externally");return}let health=await provider.healthCheck();if(!health.ok){log.warn(`cloudflared is not available \u2014 tunnel features will fail. ${health.message??""}`);return}log.info(health.message??"cloudflared available");try{let baseUrl=hostServices.getAgentBaseUrl?.()??"",parsed=new URL(baseUrl),agentPort=parseInt(parsed.port,10);if(!Number.isFinite(agentPort)||agentPort<=0){log.warn(`Cannot determine agent port from "${baseUrl}" \u2014 skipping agent tunnel`);return}await provider.startAgentTunnel(agentPort)}catch(err){log.error(`Failed to start agent tunnel: ${err instanceof Error?err.message:String(err)}`)}},onShutdown:async()=>{if(!provider)return;if(process.env.AGENT_TUNNEL==="false"||process.env.AGENT_TUNNEL_DETACH==="true"){provider.detachAll(),provider=null;return}await provider.stopAll(),provider=null}}),vibePlugin={capabilities:{storage:"rw",subprocess:!0,telemetry:!0},name:PLUGIN_NAME,version:PLUGIN_VERSION,description:"Cloudflare Tunnel provider for remote access",tags:["backend","provider"],apiPrefix:"/api/tunnel-cloudflare",prerequisites:[{name:"cloudflared",kind:"binary",requiresSudo:!0,description:"Cloudflare tunnel daemon"}],providers:{},createRoutes:()=>createPrereqsRoutes(),onServerStart:lifecycle.onServerStart,onServerStop:lifecycle.onServerStop};export{vibePlugin};
3
+ `);return}if(onInit)await onInit(hostServices);if(telemetryEventName)hostServices.telemetry?.emit(telemetryEventName,{plugin:name})},onServerStop:async(hostServices)=>{if(onShutdown)await onShutdown(hostServices)}}}var TelemetryEmitter=class{constructor(pluginName,pluginVersion,hostServices){this.pluginName=pluginName,this.pluginVersion=pluginVersion,this.hostServices=hostServices}pluginName;pluginVersion;hostServices;emit(eventName,payload){let target=this.hostServices?.telemetry;if(!target)return;target.emit(eventName,{plugin:this.pluginName,version:this.pluginVersion,timestamp:new Date().toISOString(),...payload??{}})}emitReady(context){this.emit(`${this.pluginName}.ready`,context)}emitError(error,context){this.emit(`${this.pluginName}.error`,{message:error.message,...context??{}})}emitEvent(type,payload){this.emit(type,payload)}},BoundLogger=class{constructor(logger,source){this.logger=logger,this.source=source}logger;source;info(message,meta){this.logger?.info?.(this.source,message,meta)}warn(message,meta){this.logger?.warn?.(this.source,message,meta)}error(message,meta){this.logger?.error?.(this.source,message,meta)}debug(message,meta){this.logger?.debug?.(this.source,message,meta)}};function sleep(ms){return new Promise((resolve)=>setTimeout(resolve,ms))}function isProcessAlive(pid){try{return process.kill(pid,0),!0}catch{return!1}}async function gracefulKill(pid,timeoutMs=3000,logger){if(!isProcessAlive(pid))return;try{process.kill(pid,"SIGTERM")}catch(err){logger?.warn?.("subprocess","SIGTERM failed",{pid,message:err instanceof Error?err.message:String(err)});return}let pollMs=50,elapsed=0;while(elapsed<timeoutMs)if(await sleep(pollMs),elapsed+=pollMs,!isProcessAlive(pid))return;try{process.kill(pid,"SIGKILL")}catch(err){logger?.warn?.("subprocess","SIGKILL failed",{pid,message:err instanceof Error?err.message:String(err)})}}var ProviderRegistry=class{constructor(hostServices){this.hostServices=hostServices}hostServices;getServiceRegistry(){return this.hostServices?.serviceRegistry}registerProvider(type,name,provider){this.hostServices?.serviceRegistry?.registerService(type,name,provider)}getProvider(type,name){return this.hostServices?.serviceRegistry?.getService(type,name)}listProviders(type){return this.hostServices?.serviceRegistry?.listProvidersForType?.(type)??[]}withCliContribution(contribution){let contributors=this.hostServices?.cliContributors;if(!contributors)return;for(let section of contribution.statusSections??[])contributors.addStatusSection?.(section);for(let check of contribution.doctorChecks??[])contributors.addDoctorCheck?.(check)}};function resolveCloudflaredCmd(){let bare=typeof Bun<"u"&&typeof Bun.which==="function"?Bun.which("cloudflared"):null;if(bare)return bare;return process.platform==="win32"?"cloudflared.exe":"cloudflared"}var PLUGIN_NAME="tunnel-cloudflare",PLUGIN_VERSION="2026.509.3",PROVIDER_NAME="tunnel-cloudflare",STORAGE_NS="tunnel-cloudflare",KEY_TUNNELS="tunnels",KEY_AGENT_URL="agent-tunnel-url",KEY_AGENT_PID="agent-tunnel-pid",TUNNEL_URL_RE=/(https:\/\/(?!api\.)[a-zA-Z0-9-]+\.trycloudflare\.com)/,URL_EXTRACT_TIMEOUT_MS=30000,KILL_GRACE_MS=3000;class CloudflaredRateLimitedError extends Error{constructor(detail){super(`cloudflared rate-limited by trycloudflare.com: ${detail}`);this.name="CloudflaredRateLimitedError"}}async function extractTunnelUrl(proc){let combined="",readStream=async(stream)=>{if(!stream)return;let reader=stream.getReader(),decoder=new TextDecoder;try{while(!0){let{done,value}=await reader.read();if(done)break;combined+=decoder.decode(value,{stream:!0});let match=TUNNEL_URL_RE.exec(combined);if(match)return match[1]}}finally{reader.releaseLock()}return},result=await Promise.race([readStream(proc.stdout),readStream(proc.stderr),new Promise((_,reject)=>setTimeout(()=>reject(Error(`Timed out after ${URL_EXTRACT_TIMEOUT_MS}ms waiting for tunnel URL`)),URL_EXTRACT_TIMEOUT_MS))]);if(!result){if(/429|too many requests|error code: 1015/i.test(combined))throw new CloudflaredRateLimitedError(combined.split(`
4
+ `).find((l)=>/429|1015/i.test(l))??combined);throw Error("cloudflared exited before producing a URL")}return result}class CloudflareTunnelProvider{name=PROVIDER_NAME;processes=new Map;storage;log;hostServices;constructor(hostServices){this.hostServices=hostServices,this.storage=hostServices.storage,this.log=new BoundLogger(hostServices.logger,PROVIDER_NAME)}async loadTunnels(){let raw=await this.storage.get(STORAGE_NS,KEY_TUNNELS);if(!raw)return[];try{return JSON.parse(raw)}catch{return this.log.warn("Corrupt tunnel list in storage \u2014 resetting"),[]}}async saveTunnels(tunnels){await this.storage.set(STORAGE_NS,KEY_TUNNELS,JSON.stringify(tunnels))}async upsertTunnel(info){let tunnels=await this.loadTunnels(),idx=tunnels.findIndex((t)=>t.id===info.id);if(idx>=0)tunnels[idx]=info;else tunnels.push(info);await this.saveTunnels(tunnels)}async removeTunnelRecord(tunnelId){let tunnels=await this.loadTunnels();await this.saveTunnels(tunnels.filter((t)=>t.id!==tunnelId))}getCapabilities(){return{provider:PROVIDER_NAME,supportsHttp:!0,supportsHttps:!0,supportsTcp:!1,supportsUdp:!1,supportsCustomDomains:!1,supportsManagedSubdomains:!0,supportsSessionTokens:!1,supportsLiveLogs:!0,supportsUsageMetrics:!1,supportsRotateCredentials:!0,platforms:["darwin","linux","win32"]}}async issueSession(req){let tunnelId=(typeof req.metadata?.backendTunnelId==="string"?req.metadata.backendTunnelId:void 0)??crypto.randomUUID(),sessionId=crypto.randomUUID(),localHost=req.localHost??"127.0.0.1",protocol=req.protocol,now=new Date().toISOString(),info={id:tunnelId,providerName:PROVIDER_NAME,status:"stopped",protocol,localPort:req.localPort,localHost,url:"",sessionId,createdAt:now,metadata:{...req.metadata??{},localUrl:`${protocol}://${localHost}:${req.localPort}`}};await this.upsertTunnel(info);let session={sessionId,tunnelId,provider:PROVIDER_NAME,expiresAt:req.ttlSeconds?new Date(Date.now()+req.ttlSeconds*1000).toISOString():void 0,credentials:{localUrl:info.metadata.localUrl}};return await this.storage.set(STORAGE_NS,`sessions:${tunnelId}`,JSON.stringify([session])),session}async start(tunnelId){let info=(await this.loadTunnels()).find((t)=>t.id===tunnelId);if(!info)throw Error(`Tunnel ${tunnelId} not found (issueSession first)`);if(this.processes.has(tunnelId))return info;let localUrl=info.metadata?.localUrl??`${info.protocol}://${info.localHost}:${info.localPort}`;await this.upsertTunnel({...info,status:"starting",updatedAt:new Date().toISOString()}),this.log.info(`Starting tunnel ${tunnelId} \u2192 ${localUrl}`);let proc=Bun.spawn([resolveCloudflaredCmd(),"tunnel","--url",localUrl],{stdin:"ignore",stdout:"pipe",stderr:"pipe"});this.processes.set(tunnelId,proc),proc.exited.then((code)=>{if(this.processes.has(tunnelId))this.log.warn(`Tunnel ${tunnelId} process exited unexpectedly (code=${code})`),this.processes.delete(tunnelId),this.loadTunnels().then((current)=>{let idx=current.findIndex((t)=>t.id===tunnelId);if(idx>=0)current[idx]={...current[idx],status:"error",updatedAt:new Date().toISOString(),metadata:{...current[idx].metadata,exitCode:code}},this.saveTunnels(current)})});try{let url=await extractTunnelUrl(proc),activeInfo={...info,url,managedHostname:new URL(url).host,status:"active",pid:proc.pid,updatedAt:new Date().toISOString()};return await this.upsertTunnel(activeInfo),this.log.info(`Tunnel ${tunnelId} active at ${url} (PID ${proc.pid})`),activeInfo}catch(err){if(this.processes.delete(tunnelId),isProcessAlive(proc.pid))await gracefulKill(proc.pid,KILL_GRACE_MS);if(err instanceof CloudflaredRateLimitedError){let placeholderHost=`rate-limited-${tunnelId}.trycloudflare.com`,placeholderUrl=`https://${placeholderHost}`,degraded={...info,url:placeholderUrl,managedHostname:placeholderHost,status:"active",updatedAt:new Date().toISOString(),metadata:{...info.metadata,degraded:!0,degradedReason:err.message}};return await this.upsertTunnel(degraded),this.log.warn(`Tunnel ${tunnelId} rate-limited; returning placeholder URL`),degraded}let errorInfo={...info,status:"error",updatedAt:new Date().toISOString(),metadata:{...info.metadata,error:err instanceof Error?err.message:String(err)}};throw await this.upsertTunnel(errorInfo),err}}async rotate(tunnelId){if(!(await this.loadTunnels()).find((t)=>t.id===tunnelId))throw Error(`Tunnel ${tunnelId} not found`);await this.stop(tunnelId),await this.start(tunnelId);let updated=(await this.loadTunnels()).find((t)=>t.id===tunnelId);return{sessionId:updated?.sessionId??crypto.randomUUID(),tunnelId,provider:PROVIDER_NAME,credentials:{localUrl:updated?.metadata?.localUrl}}}async attachCustomDomain(_tunnelId,_domain){throw Error("Custom domains are not supported by the cloudflared quick-tunnel provider. Use the vibetunnels provider or wait for named-tunnel support.")}async detachCustomDomain(_tunnelId,_domain){throw Error("Custom domains are not supported by the cloudflared quick-tunnel provider.")}async listSessions(tunnelId){let raw=await this.storage.get(STORAGE_NS,`sessions:${tunnelId}`);if(!raw)return[];try{return JSON.parse(raw)}catch{return[]}}async stop(tunnelId){this.log.info(`Stopping tunnel ${tunnelId}`);let tunnel=(await this.loadTunnels()).find((t)=>t.id===tunnelId);if(tunnel)tunnel.status="stopping",tunnel.updatedAt=new Date().toISOString(),await this.upsertTunnel(tunnel);let proc=this.processes.get(tunnelId);if(proc)await gracefulKill(proc.pid,KILL_GRACE_MS),this.processes.delete(tunnelId);else if(tunnel?.pid&&isProcessAlive(tunnel.pid))await gracefulKill(tunnel.pid,KILL_GRACE_MS);if(tunnel)tunnel.status="stopped",tunnel.pid=void 0,tunnel.updatedAt=new Date().toISOString(),await this.upsertTunnel(tunnel);let agentPidRaw=await this.storage.get(STORAGE_NS,KEY_AGENT_PID);if(agentPidRaw&&tunnel?.pid&&String(tunnel.pid)===agentPidRaw)await this.storage.delete(STORAGE_NS,KEY_AGENT_URL),await this.storage.delete(STORAGE_NS,KEY_AGENT_PID);this.log.info(`Tunnel ${tunnelId} stopped`)}async getStatus(tunnelId){let tunnel=(await this.loadTunnels()).find((t)=>t.id===tunnelId)??null;if(!tunnel)return null;if(tunnel.status==="active"&&tunnel.pid){if(!isProcessAlive(tunnel.pid))tunnel.status="stopped",tunnel.updatedAt=new Date().toISOString(),await this.upsertTunnel(tunnel),this.processes.delete(tunnelId)}return tunnel}async getActiveTunnelUrl(){let envUrl=process.env.AGENT_TUNNEL_URL;if(envUrl)return envUrl;return this.storage.get(STORAGE_NS,KEY_AGENT_URL)}async list(){return this.loadTunnels()}async delete(tunnelId){this.log.info(`Deleting tunnel ${tunnelId}`);let tunnel=(await this.loadTunnels()).find((t)=>t.id===tunnelId);if(tunnel&&(tunnel.status==="active"||tunnel.status==="starting"))await this.stop(tunnelId);await this.removeTunnelRecord(tunnelId),this.log.info(`Tunnel ${tunnelId} deleted`)}detachAll(){this.log.info("Detaching from all tunnels (processes left running)"),this.processes.clear()}async healthCheck(){try{let result=Bun.spawnSync([resolveCloudflaredCmd(),"--version"],{stdout:"pipe",stderr:"pipe",timeout:1e4});if(result.exitCode!==0)return{ok:!1,message:"cloudflared not available"};return{ok:!0,message:result.stdout.toString().trim()}}catch(err){return{ok:!1,message:err instanceof Error?`cloudflared not available: ${err.message}`:"cloudflared not available"}}}async startAgentTunnel(agentPort){let profileSuffix=(process.env.VIBECONTROLS_PROFILE??"default").replace(/[^A-Za-z0-9_]/g,"_"),envBootstrapPid=process.env[`AGENT_TUNNEL_PID_${profileSuffix}`],envBootstrapUrl=process.env[`AGENT_TUNNEL_URL_${profileSuffix}`];if(envBootstrapPid&&envBootstrapUrl){let pid=parseInt(envBootstrapPid,10);if(!isNaN(pid)&&isProcessAlive(pid)){this.log.info(`Adopting bootstrap cloudflared (pid=${pid}) at ${envBootstrapUrl}`),await this.storage.set(STORAGE_NS,KEY_AGENT_URL,envBootstrapUrl),await this.storage.set(STORAGE_NS,KEY_AGENT_PID,String(pid));let adopted={id:`agent-bootstrap-${pid}`,providerName:"cloudflare",status:"active",protocol:"http",localPort:agentPort,localHost:"localhost",url:envBootstrapUrl,pid,createdAt:new Date().toISOString(),metadata:{isAgentTunnel:!0,name:"agent",adopted:!0}};return delete process.env[`AGENT_TUNNEL_PID_${profileSuffix}`],delete process.env[`AGENT_TUNNEL_URL_${profileSuffix}`],adopted}}let storedPidStr=await this.storage.get(STORAGE_NS,KEY_AGENT_PID),storedUrl=await this.storage.get(STORAGE_NS,KEY_AGENT_URL);if(storedPidStr&&storedUrl){let storedPid=parseInt(storedPidStr,10);if(!isNaN(storedPid)&&isProcessAlive(storedPid)){this.log.info(`Reusing existing agent tunnel (pid=${storedPid}) at ${storedUrl}`);let existing=(await this.loadTunnels()).find((t)=>t.pid===storedPid&&t.metadata?.isAgentTunnel);if(existing)return existing;return{id:`agent-stored-${storedPid}`,providerName:"cloudflare",status:"active",protocol:"http",localPort:agentPort,localHost:"localhost",url:storedUrl,pid:storedPid,createdAt:new Date().toISOString(),metadata:{isAgentTunnel:!0,name:"agent",adopted:!0}}}}this.log.info(`Starting agent tunnel on port ${agentPort}`);let session=await this.issueSession({protocol:"http",localPort:agentPort,localHost:"localhost",metadata:{isAgentTunnel:!0,name:"agent"}}),info=await this.start(session.tunnelId);if(await this.storage.set(STORAGE_NS,KEY_AGENT_URL,info.url),info.pid!==void 0)await this.storage.set(STORAGE_NS,KEY_AGENT_PID,String(info.pid));return this.log.info(`Agent tunnel active at ${info.url}`),info}async stopAll(){this.log.info("Stopping all tunnels");let tunnels=await this.loadTunnels();await Promise.allSettled(tunnels.filter((t)=>t.status==="active"||t.status==="starting").map((t)=>this.stop(t.id)));for(let[id,proc]of this.processes)if(isProcessAlive(proc.pid))this.log.warn(`Killing orphaned process ${proc.pid} (tunnel ${id})`),await gracefulKill(proc.pid,KILL_GRACE_MS);this.processes.clear(),await this.storage.deleteAll(STORAGE_NS),this.log.info("All tunnels stopped and storage cleared")}}var provider=null;function whichSync(bin){return Bun.which(bin)??null}function createPrereqsRoutes(){return new Elysia2({prefix:"/prereqs"}).get("/status",()=>{let cf=whichSync("cloudflared");return{satisfied:!!cf,missing:cf?[]:[{name:"cloudflared",kind:"binary",requiresSudo:!0,detected:void 0}]}}).post("/install",()=>{if(whichSync("cloudflared"))return{ok:!0,installed:[],pendingSudo:[],errors:[]};let cmd=process.platform==="darwin"?"brew install cloudflared":process.platform==="linux"?"curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o /usr/local/bin/cloudflared && sudo chmod +x /usr/local/bin/cloudflared":process.platform==="win32"?"winget install Cloudflare.cloudflared # or: scoop install cloudflared # or download from https://github.com/cloudflare/cloudflared/releases":"see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/";return{ok:!0,installed:[],pendingSudo:[{name:"cloudflared",command:cmd,reason:"cloudflared is required to start tunnels."}],errors:[]}}).post("/uninstall",()=>({ok:!0}))}var createPlugin=(_ctx)=>{let telemetry=new TelemetryEmitter(PLUGIN_NAME,PLUGIN_VERSION),plugin={capabilities:{storage:"rw",subprocess:!0,telemetry:!0},name:PLUGIN_NAME,version:PLUGIN_VERSION,description:"Cloudflare Tunnel provider for remote access",tags:["backend","provider"],apiPrefix:"/api/tunnel-cloudflare",prerequisites:[{name:"cloudflared",kind:"binary",requiresSudo:!0,description:"Cloudflare tunnel daemon"}],providers:{},createRoutes:()=>createPrereqsRoutes(),onServerStart:void 0,onServerStop:void 0},lifecycle=createLifecycleHooks({name:PLUGIN_NAME,telemetryEventName:"tunnel.provider.ready",onInit:async(hostServices)=>{let log=new BoundLogger(hostServices.logger,PROVIDER_NAME);if(log.info("Plugin initialising"),provider=new CloudflareTunnelProvider(hostServices),plugin.providers={tunnel:provider},new ProviderRegistry(hostServices).registerProvider("tunnel",PROVIDER_NAME,provider),telemetry.emit("tunnel.provider.ready",{provider:"cloudflare"}),process.env.AGENT_TUNNEL==="false"){log.info("AGENT_TUNNEL=false \u2014 provider registered, tunnel managed externally");return}let health=await provider.healthCheck();if(!health.ok){log.warn(`cloudflared is not available \u2014 tunnel features will fail. ${health.message??""}`);return}log.info(health.message??"cloudflared available");try{let baseUrl=hostServices.getAgentBaseUrl?.()??"",parsed=new URL(baseUrl),agentPort=parseInt(parsed.port,10);if(!Number.isFinite(agentPort)||agentPort<=0){log.warn(`Cannot determine agent port from "${baseUrl}" \u2014 skipping agent tunnel`);return}await provider.startAgentTunnel(agentPort)}catch(err){log.error(`Failed to start agent tunnel: ${err instanceof Error?err.message:String(err)}`)}},onShutdown:async()=>{if(!provider)return;if(process.env.AGENT_TUNNEL==="false"||process.env.AGENT_TUNNEL_DETACH==="true"){provider.detachAll(),provider=null;return}await provider.stopAll(),provider=null}});return plugin.onServerStart=lifecycle.onServerStart,plugin.onServerStop=lifecycle.onServerStop,plugin};export{createPlugin};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibecontrols/vibe-plugin-tunnel-cloudflare",
3
- "version": "2026.509.2",
3
+ "version": "2026.509.3",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "description": "Cloudflare Tunnel provider for VibeControls Agent",