πŸ—ΊοΈ Architecture Overview

TunnelFlow Architecture Flow

TunnelFlow enables exposing local development servers to the internet securely via a public URL (https://<tunnelId>.tunnel.rajeshbandi.site). This document provides the complete end-to-end flow of the client-server architecture, detailing every component and step.

1. System Overview

The system consists of three main components:

2. Complete Sequence Flow Diagram

Rendering Mermaid…
sequenceDiagram autonumber actor User as External User participant Filter as Server: TunnelIngressFilter participant Pending as Server: PendingRequestManager participant Outbound as Server: OutboundMessageSender participant ServerWS as Server: TunnelWebSocketHandler participant ClientWS as Client: TunnelWebSocketClient participant Dispatcher as Client: TunnelMessageDispatcher participant HttpHandler as Client: HttpRequestMessageHandler participant Forwarder as Client: LocalHttpForwarder participant LocalApp as Local Web App %% Registration and Setup Note over ClientWS, ServerWS: 1. Registration & Tunnel Creation ClientWS->>ServerWS: WS CONNECT ClientWS->>ServerWS: CLIENT_REGISTER (machine, os, version) ServerWS->>ServerWS: Register in ClientManager ServerWS-->>ClientWS: CLIENT_REGISTERED (clientId) ClientWS->>ServerWS: CREATE_TUNNEL (localPort) ServerWS->>ServerWS: Register in TunnelManager ServerWS-->>ClientWS: TUNNEL_CREATED (tunnelId, publicUrl) %% Request Ingress Note over User, Filter: 2. External HTTP Request Ingress User->>Filter: HTTP Request https://<tunnelId>.tunnel.rajeshbandi.site/path Filter->>Pending: register(requestId) -> CompletableFuture Filter->>Outbound: Queue TunnelMessage (type: HTTP_REQUEST) Outbound->>ClientWS: Send over WebSocket %% Client Handling Note over ClientWS, LocalApp: 3. Local HTTP Forwarding ClientWS->>Dispatcher: receive(json) Dispatcher->>HttpHandler: dispatch(TunnelMessage) HttpHandler->>Forwarder: forward(HttpRequestMessage, tunnelId) Forwarder->>LocalApp: HttpClient Request to http://localhost:<port>/path LocalApp-->>Forwarder: HttpClient Response Forwarder-->>HttpHandler: HttpResponseMessage %% Response Egress Note over HttpHandler, User: 4. External HTTP Response Egress HttpHandler->>ServerWS: Send TunnelMessage (type: HTTP_RESPONSE) ServerWS->>Pending: complete(requestId, HttpResponseMessage) Pending-->>Filter: CompletableFuture resolves Filter-->>User: Write HttpServletResponse (Headers + Body)

3. Step-by-Step Flow with Code Snippets

Step 1: Tunnel Message Protocol

Everything flows through a standard wrapper class called TunnelMessage.

// [protocol/protocol/TunnelMessage.java]
public class TunnelMessage {
    private String requestId;
    private MessageType type;    // e.g. HTTP_REQUEST, HTTP_RESPONSE, CREATE_TUNNEL
    private String clientId;
    private String tunnelId;
    private String payload;      // JSON string of the actual payload
}

Step 2: Client Connection & Registration

The client establishes a WebSocket connection and registers itself. The TunnelWebSocketHandler on the server handles this request and spins up a dedicated OutboundMessageSender for the client.

// [server/websocket/TunnelWebSocketHandler.java]
case CLIENT_REGISTER -> {
    ClientRegisterRequest request = objectMapper.readValue(tunnelMessage.getPayload(), ClientRegisterRequest.class);
    String clientId = UUID.randomUUID().toString();
    
    // Register the client and session
    clientManager.register(clientId, session);
    ClientConnection connection = clientManager.getConnection(clientId);
    
    // Start background sender thread for this client
    outboundMessageSender.start(connection);
    
    TunnelMessage response = tunnelProtocolService.createClientRegisteredMessage(clientId);
    session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
}

Step 3: Tunnel Creation

Once registered, the client requests a tunnel to expose a specific local port. The server generates a random tunnelId mapping it to the clientId.

// [server/websocket/TunnelWebSocketHandler.java]
case CREATE_TUNNEL -> {
    CreateTunnelRequest request = objectMapper.readValue(tunnelMessage.getPayload(), CreateTunnelRequest.class);
    String clientId = clientManager.getClientId(session);
    
    TunnelInfo tunnel = tunnelManager.createTunnel(clientId, request.getLocalPort());
    String publicUrl = "https://" + tunnel.getTunnelId() + ".tunnel.rajeshbandi.site";
    
    TunnelMessage response = tunnelProtocolService.createTunnelCreatedMessage(
            tunnelMessage.getRequestId(), tunnel.getTunnelId(), publicUrl);
    session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
}

Step 4: External HTTP Request Ingress

When an external user visits the public URL, the TunnelIngressFilter intercepts it. It resolves the tunnelId from the Host header, creates a CompletableFuture to pause the HTTP request thread, and queues the message.

// [server/filter/TunnelIngressFilter.java]
String tunnelId = host.substring(0, host.indexOf(".tunnel.rajeshbandi.site"));
TunnelInfo tunnel = tunnelManager.getTunnel(tunnelId);

String requestId = UUID.randomUUID().toString();

// Register request to wait for async response
CompletableFuture<HttpResponseMessage> future = pendingRequestManager.register(requestId);

// Create HTTP_REQUEST message
TunnelMessage message = tunnelProtocolService.createHttpRequestTunnelMessage(
        httpRequestMapper.map(request, body), requestId, tunnel.getTunnelId());

// Queue message for OutboundMessageSender to send over WebSocket
ClientConnection connection = clientManager.getConnection(tunnel.getClientId());
connection.getOutboundQueue().offer(message);

try {
    // Blocking wait for the client to return a response
    HttpResponseMessage tunnelResponse = future.get(30, TimeUnit.SECONDS);
    // Write headers and body back to user
    response.setStatus(tunnelResponse.getStatus());
    response.getOutputStream().write(tunnelResponse.getBody());
} catch (TimeoutException e) {
    response.sendError(HttpServletResponse.SC_GATEWAY_TIMEOUT, "Tunnel request timed out.");
}

Step 5: Client Dispatching

The client receives the WebSocket message and dispatches it. TunnelMessageDispatcher maps MessageType.HTTP_REQUEST to HttpRequestMessageHandler.

// [client/service/TunnelMessageDispatcher.java]
public void dispatch(TunnelMessage message) {
    MessageHandler handler = handlers.get(message.getType());
    if(handler != null){
        handler.handle(message);
    }
}

Step 6: Client HTTP Forwarding

The HttpRequestMessageHandler spawns a thread and asks LocalHttpForwarder to send the request locally.

// [client/handler/HttpRequestMessageHandler.java]
public void handle(TunnelMessage message) {
    executor.submit(() -> {
        HttpRequestMessage request = objectMapper.readValue(message.getPayload(), HttpRequestMessage.class);
        
        // Block until local app responds
        HttpResponseMessage response = forwarder.forward(request, message.getTunnelId());
        
        TunnelMessage tunnelMessage = protocolService.createHttpResponseMessage(message.getRequestId(), response);
        tunnelSender.send(tunnelMessage); // Send back over WebSocket
    });
}

Inside LocalHttpForwarder, Java HttpClient makes the real call, taking care to filter hop-by-hop headers.

// [client/service/LocalHttpForwarder.java]
TunnelRuntime runtime = tunnelRuntimeRegistry.get(tunnelId);
StringBuilder targetUrl = new StringBuilder("http://localhost:" + runtime.getLocalPort() + path);

HttpRequest.Builder builder = HttpRequest.newBuilder().uri(URI.create(targetUrl.toString()));
// ... apply headers and body ...

HttpResponse<byte[]> response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray());

return HttpResponseMessage.builder()
        .status(response.statusCode())
        .headers(new HashMap<>(response.headers().map()))
        .body(response.body())
        .build();

Step 7: Server Completes the Request

The client sends the HTTP_RESPONSE back via WebSocket. The Server receives it in TunnelWebSocketHandler and completes the CompletableFuture, which unblocks TunnelIngressFilter in Step 4.

// [server/websocket/TunnelWebSocketHandler.java]
case HTTP_RESPONSE -> {
    HttpResponseMessage response = objectMapper.readValue(
            tunnelMessage.getPayload(), HttpResponseMessage.class);

    // This resolves the future.get() in TunnelIngressFilter
    pendingRequestManager.complete(tunnelMessage.getRequestId(), response);
}
// [server/service/PendingRequestManager.java]
public void complete(String requestId, HttpResponseMessage response) {
    CompletableFuture<HttpResponseMessage> future = pendingRequests.remove(requestId);
    if (future != null) {
        future.complete(response);
    }
}

Summary

  1. The Client sets up a continuous WebSocket connection.
  2. An external HTTP request arrives at the Server.
  3. The Server converts the HTTP request to JSON and queues it.
  4. The Server blocks the incoming HTTP thread via CompletableFuture.
  5. The Client receives the JSON, makes a local HTTP call to the user's running app.
  6. The Client converts the local HTTP response to JSON and sends it back.
  7. The Server receives the response JSON over WebSocket, resolves the CompletableFuture.
  8. The unblocked HTTP thread on the Server writes the final response to the external user.

πŸ’» Client Module

TunnelFlow β€” Complete CLIENT Flow

Every single class, interface, model, and config in the client module is documented here with code snippets and flow diagrams.


1. Package Map

org.tunnelflow.client
β”œβ”€β”€ cli
β”‚   β”œβ”€β”€ commands
β”‚   β”‚   β”œβ”€β”€ VersionCommand
β”‚   β”‚   β”œβ”€β”€ app
β”‚   β”‚   β”‚   β”œβ”€β”€ AppCommand
β”‚   β”‚   β”‚   β”œβ”€β”€ InitCommand
β”‚   β”‚   β”‚   └── UpCommand
β”‚   β”‚   └── expose
β”‚   β”‚       └── ExposeCommand
β”‚   └── config
β”‚       └── PicocliFactory
β”œβ”€β”€ config
β”‚   β”œβ”€β”€ BeansConfig
β”‚   β”œβ”€β”€ loader
β”‚   β”‚   └── ConfigLoader
β”‚   β”œβ”€β”€ model
β”‚   β”‚   β”œβ”€β”€ ApplicationConfig
β”‚   β”‚   β”œβ”€β”€ ApplicationDeployment
β”‚   β”‚   β”œβ”€β”€ ProjectConfig
β”‚   β”‚   └── TunnelFlowConfig
β”‚   β”œβ”€β”€ service
β”‚   β”‚   └── ConfigTemplateService
β”‚   └── validation
β”‚       β”œβ”€β”€ ConfigValidator
β”‚       β”œβ”€β”€ ValidationError
β”‚       └── ValidationResult
β”œβ”€β”€ handler
β”‚   β”œβ”€β”€ MessageHandler                  (interface)
β”‚   β”œβ”€β”€ ClientRegisteredHandler
β”‚   β”œβ”€β”€ HttpRequestMessageHandler
β”‚   β”œβ”€β”€ PingMessageHandler
β”‚   └── TunnelCreatedHandler
β”œβ”€β”€ runtime
β”‚   β”œβ”€β”€ ApplicationRuntime
β”‚   β”œβ”€β”€ ApplicationRuntimeRegistry
β”‚   β”œβ”€β”€ TunnelRuntime
β”‚   └── TunnelRuntimeRegistry
β”œβ”€β”€ service
β”‚   β”œβ”€β”€ ApplicationManager
β”‚   β”œβ”€β”€ ClientRegistrationService
β”‚   β”œβ”€β”€ ClientTunnelManager
β”‚   β”œβ”€β”€ LocalHttpForwarder
β”‚   β”œβ”€β”€ PendingRegistrationManager
β”‚   β”œβ”€β”€ PendingTunnelRequestManager
β”‚   β”œβ”€β”€ PlaceholderResolver
β”‚   β”œβ”€β”€ ProcessLauncher
β”‚   β”œβ”€β”€ TunnelConnectionManager
β”‚   β”œβ”€β”€ TunnelCreationService
β”‚   β”œβ”€β”€ TunnelMessageDispatcher
β”‚   β”œβ”€β”€ TunnelMessageReceiver
β”‚   β”œβ”€β”€ TunnelProtocolService
β”‚   β”œβ”€β”€ TunnelProvisioner
β”‚   └── TunnelSender
└── websocket
    └── TunnelWebSocketClient

2. Startup Flow β€” tunnelflow app up

Rendering Mermaid…
flowchart TD A["User runs: tunnelflow app up"] --> B["PicocliFactory\nCreates command beans from Spring context"] B --> C["AppCommand\nRoutes to subcommand"] C --> D["UpCommand.run()\nReads tunnelflow.yaml path"] D --> E["ApplicationManager.up(configPath)"] E --> E1["TunnelConnectionManager.connect()"] E1 --> E1a["new TunnelWebSocketClient(serverUri)"] E1a --> E1b["TunnelSender.attach(client)"] E1b --> E1c["client.connectBlocking()"] E1c --> E1d["ClientRegistrationService.register()"] E1d --> E1e["PendingRegistrationManager.register()\nCreates CompletableFuture<String>"] E1e --> E1f["TunnelSender.send(CLIENT_REGISTER message)"] E1f --> E1g["TunnelWebSocketClient.send(json)\nSends over WebSocket"] E1g --> E1h["future.get() β€” blocks until CLIENT_REGISTERED arrives"] E --> E2["ConfigLoader.load(path)\nParsed via YAML ObjectMapper"] E2 --> E2a["TunnelFlowConfig\n{ version, ProjectConfig, Map<name, ApplicationConfig> }"] E --> E3["ConfigValidator.validate(config)\nvalidateVersion + validateProject + validateApps"] E3 --> E3a["ValidationResult\nList<ValidationError>"] E --> E4["TunnelProvisioner.provision(config)"] E4 --> E4a["For each ApplicationConfig"] E4a --> E4b["ApplicationDeployment\n{ config, tunnelRuntime, resolvedEnvironment }"] E4b --> E4c["TunnelCreationService.createTunnel(port)"] E4c --> E4d["PendingTunnelRequestManager.register(requestId)\nCompletableFuture<TunnelCreatedResponse>"] E4d --> E4e["TunnelProtocolService.createCreateTunnelMessage(requestId, port)"] E4e --> E4f["TunnelSender.send(CREATE_TUNNEL message)"] E4f --> E4g["future.get() β€” blocks until TUNNEL_CREATED arrives"] E4g --> E4h["TunnelRuntime built\n{ tunnelId, localPort, publicUrl }"] E --> E5["PlaceholderResolver.resolve(deployments)\nReplace ${app.url} with actual public URL"] E --> E6["ApplicationManager.launchApplications(deployments)"] E6 --> E6a["ProcessLauncher.launch(deployment)\nProcessBuilder with env + command"] E6a --> E6b["ApplicationRuntime\n{ config, tunnelRuntime, Process }"] E6b --> E6c["ApplicationRuntimeRegistry.register(name, runtime)"] E6b --> E6d["TunnelRuntimeRegistry.register(tunnelRuntime)\nKeyed by tunnelId"]

3. Startup Flow β€” tunnelflow expose <port>

Rendering Mermaid…
flowchart TD A["User runs: tunnelflow expose 3000"] --> B["ExposeCommand.run()"] B --> C["ClientTunnelManager.expose(port)"] C --> C1["TunnelConnectionManager.connect()\n(same as above β€” WS + registration)"] C --> C2["TunnelCreationService.createTunnel(port)"] C2 --> C3["TunnelRuntime built\n{ tunnelId, localPort, publicUrl }"] C3 --> C4["TunnelRuntimeRegistry.register(runtime)"] C4 --> D["Prints: Tunnel URL: https://abc123.tunnel.rajeshbandi.site"]

4. Inbound Message Handling Flow

When the server sends a WebSocket message to the client:

Rendering Mermaid…
flowchart TD A["Server pushes JSON text message over WebSocket"] A --> B["TunnelWebSocketClient.onMessage(json)"] B --> C["TunnelMessageReceiver.receive(json)"] C --> D["ObjectMapper.readValue → TunnelMessage\n{ requestId, type, clientId, tunnelId, payload }"] D --> E["TunnelMessageDispatcher.dispatch(message)"] E --> F{message.type?} F -->|CLIENT_REGISTERED| G["ClientRegisteredHandler.handle(message)"] G --> G1["ObjectMapper: payload → ClientRegisteredResponse\n{ clientId }"] G1 --> G2["PendingRegistrationManager.complete(clientId)\nResolves CompletableFuture<String> in ClientRegistrationService"] F -->|TUNNEL_CREATED| H["TunnelCreatedHandler.handle(message)"] H --> H1["ObjectMapper: payload → TunnelCreatedResponse\n{ tunnelId, publicUrl }"] H1 --> H2["PendingTunnelRequestManager.complete(requestId, response)\nResolves CompletableFuture<TunnelCreatedResponse> in TunnelCreationService"] F -->|PING| I["PingMessageHandler.handle(message)"] I --> I1["TunnelProtocolService.createPongMessage(requestId)"] I1 --> I2["TunnelSender.send(PONG message)"] I2 --> I3["TunnelWebSocketClient.send(json) — replies to server"] F -->|HTTP_REQUEST| J["HttpRequestMessageHandler.handle(message)"] J --> J1["Submit to ExecutorService\n(FixedThreadPool of 10)"] J1 --> J2["ObjectMapper: payload → HttpRequestMessage\n{ method, path, headers, body, query }"] J2 --> J3["LocalHttpForwarder.forward(request, tunnelId)"] J3 --> J4["TunnelRuntimeRegistry.get(tunnelId)\n→ TunnelRuntime { localPort }"] J4 --> J5["Build URL: http://localhost:{port}{path}?{query}"] J5 --> J6["HttpRequest.Builder\nFilter hop-by-hop headers\nSet method + body publisher"] J6 --> J7["HttpClient.send() → HttpResponse<byte[]>"] J7 --> J8["Build HttpResponseMessage\n{ status, headers, body }"] J8 --> J9["TunnelProtocolService.createHttpResponseMessage(requestId, response)"] J9 --> J10["TunnelSender.send(HTTP_RESPONSE TunnelMessage)"] J10 --> J11["TunnelWebSocketClient.send(json)\nSent back to server over WebSocket"]

5. Class-by-Class Reference

CLI Layer

PicocliFactory β€” cli/config

Spring-backed factory for Picocli. Resolves all command beans from ApplicationContext.

public <K> K create(Class<K> cls) throws Exception {
    return applicationContext.getBean(cls);
}

AppCommand β€” cli/commands/app

Top-level app command. Sub-commands: init, up.

@Command(name = "app", subcommands = { InitCommand.class, UpCommand.class })
public class AppCommand implements Runnable { ... }

InitCommand β€” cli/commands/app

Creates tunnelflow.yaml by calling ConfigTemplateService.createTemplate().

UpCommand β€” cli/commands/app

Entry point for the full managed startup. Calls ApplicationManager.up(Path.of("tunnelflow.yaml")).

ExposeCommand β€” cli/commands/expose

Exposes a single port. Reads @Parameters port. Calls ClientTunnelManager.expose(port).

VersionCommand β€” cli/commands

Prints version, Java version, OS name, and architecture.


Config Layer

BeansConfig β€” config

Declares:

  • ObjectMapper (JSON)
  • ObjectMapper yamlObjectMapper (YAML via YAMLFactory)
  • HttpClient (Java 11, HTTP/1.1, 10s connect timeout, destroyMethod="")

ConfigLoader β€” config/loader

Reads tunnelflow.yaml from a Path. Uses yamlObjectMapper. Throws RuntimeException if file doesn't exist or parsing fails.

TunnelFlowConfig β€” config/model

public class TunnelFlowConfig {
    Integer version;
    ProjectConfig project;              // { name }
    Map<String, ApplicationConfig> apps;
}

ProjectConfig β€” config/model

public class ProjectConfig { String name; }

ApplicationConfig β€” config/model

public class ApplicationConfig {
    String name; boolean enabled;
    String path; String command;
    int port;
    Map<String, String> env;
}

ApplicationDeployment β€” config/model

Mutable wrapper around ApplicationConfig that also holds the provisioned TunnelRuntime and a resolvedEnvironment map.

public class ApplicationDeployment {
    final ApplicationConfig config;
    TunnelRuntime tunnelRuntime;
    Map<String, String> resolvedEnvironment = new LinkedHashMap<>();
}

ConfigTemplateService β€” config/service

Writes a starter tunnelflow.yaml template to the current directory. Skips if file already exists.

ConfigValidator β€” config/validation

Validates TunnelFlowConfig. Checks version, project name, and for each app: port > 0, command not blank, path not blank. Returns a ValidationResult.

ValidationResult β€” config/validation

public class ValidationResult {
    List<ValidationError> errors;
    boolean isValid() { return errors.isEmpty(); }
    void addError(String field, String message) { ... }
}

ValidationError β€” config/validation

public class ValidationError { String field; String message; }

Handler Layer

MessageHandler (interface) β€” handler

public interface MessageHandler {
    MessageType getSupportedType();
    void handle(TunnelMessage message) throws Exception;
}

All four concrete handlers implement this. TunnelMessageDispatcher collects them at startup via constructor injection.

ClientRegisteredHandler β€” handler

Field Value
Supported Type CLIENT_REGISTERED
Action Deserializes ClientRegisteredResponse, calls PendingRegistrationManager.complete(clientId)

TunnelCreatedHandler β€” handler

Field Value
Supported Type TUNNEL_CREATED
Action Deserializes TunnelCreatedResponse, calls PendingTunnelRequestManager.complete(requestId, response)

PingMessageHandler β€” handler

Field Value
Supported Type PING
Action Creates PONG via TunnelProtocolService, sends via TunnelSender

HttpRequestMessageHandler β€” handler

Field Value
Supported Type HTTP_REQUEST
Action Submits to fixed thread pool (10 threads), deserializes HttpRequestMessage, calls LocalHttpForwarder.forward(), wraps response, sends HTTP_RESPONSE via TunnelSender

Runtime Layer

TunnelRuntime β€” runtime

@Builder @Data
public class TunnelRuntime {
    String tunnelId;
    int localPort;
    String publicUrl;
}

TunnelRuntimeRegistry β€” runtime

ConcurrentHashMap<String, TunnelRuntime> keyed by tunnelId. Used by LocalHttpForwarder to resolve the local port for forwarding.

ApplicationRuntime β€” runtime

@Data @AllArgsConstructor
public class ApplicationRuntime {
    ApplicationConfig config;
    TunnelRuntime tunnelRuntime;
    Process process;         // the OS process of the started app
}

ApplicationRuntimeRegistry β€” runtime

ConcurrentHashMap<String, ApplicationRuntime> keyed by app name.


Service Layer

ApplicationManager β€” service

Orchestrates the full up lifecycle:

  1. TunnelConnectionManager.connect()
  2. ConfigLoader.load(path)
  3. ConfigValidator.validate(config)
  4. TunnelProvisioner.provision(config) β†’ Map<name, ApplicationDeployment>
  5. PlaceholderResolver.resolve(deployments)
  6. ProcessLauncher.launch(deployment) per app β†’ ApplicationRuntime
  7. Registers into both registries.

TunnelConnectionManager β€” service

Creates and manages the TunnelWebSocketClient. After connectBlocking(), calls TunnelSender.attach(client) and ClientRegistrationService.register().

@Value("${tunnelflow.server.websocket-url}") String serverUrl;

ClientRegistrationService β€” service

Builds ClientRegisterRequest (machine name, OS, version 1.0.0), sends as CLIENT_REGISTER TunnelMessage. Parks on PendingRegistrationManager.register().get() until server responds.

PendingRegistrationManager β€” service

Holds a single CompletableFuture<String>. complete(clientId) is called by ClientRegisteredHandler.

ClientTunnelManager β€” service

Used by ExposeCommand. Calls connect(), createTunnel(), builds TunnelRuntime, registers it.

TunnelCreationService β€” service

Sends CREATE_TUNNEL TunnelMessage. Parks on PendingTunnelRequestManager.register(requestId).get() until TUNNEL_CREATED arrives. Returns TunnelCreatedResponse.

PendingTunnelRequestManager β€” service

ConcurrentHashMap<requestId, CompletableFuture<TunnelCreatedResponse>>. complete(requestId, response) called by TunnelCreatedHandler.

TunnelProvisioner β€” service

Iterates apps in config, creates ApplicationDeployment per app, calls TunnelCreationService.createTunnel(port) for each, builds TunnelRuntime.

PlaceholderResolver β€” service

Resolves ${appName.url} placeholders in env maps of each deployment using the publicUrl from the corresponding app's TunnelRuntime.

ProcessLauncher β€” service

Uses ProcessBuilder. Sets env variables from resolvedEnvironment. On Windows: cmd /c <command>, on Linux: sh -c <command>. Returns ApplicationRuntime.

TunnelMessageReceiver β€” service

Called by TunnelWebSocketClient.onMessage(json). Deserializes TunnelMessage JSON and delegates to TunnelMessageDispatcher.

TunnelMessageDispatcher β€” service

Collects all MessageHandler beans at startup into a Map<MessageType, MessageHandler>. Routes incoming messages to the correct handler.

public TunnelMessageDispatcher(List<MessageHandler> messageHandlers) {
    this.handlers = messageHandlers.stream()
        .collect(Collectors.toMap(MessageHandler::getSupportedType, Function.identity()));
}

TunnelProtocolService β€” service

Factory for all outgoing TunnelMessage objects:

  • createPongMessage(requestId) β†’ PONG
  • createHttpResponseMessage(requestId, response) β†’ HTTP_RESPONSE
  • createCreateTunnelMessage(requestId, port) β†’ CREATE_TUNNEL

TunnelSender β€” service

Holds a volatile TunnelWebSocketClient. attach() / detach() called by TunnelConnectionManager. send(TunnelMessage) guards with isConnected() check.

LocalHttpForwarder β€” service

The actual reverse proxy logic:

  1. Looks up TunnelRuntime from TunnelRuntimeRegistry by tunnelId
  2. Builds target URL: http://localhost:{port}{path}?{query}
  3. Filters hop-by-hop headers (connection, host, content-length, etc.)
  4. Calls Java HttpClient.send() with BodyHandlers.ofByteArray()
  5. Returns HttpResponseMessage { status, headers, body }

WebSocket Layer

TunnelWebSocketClient β€” websocket

Extends org.java_websocket.client.WebSocketClient.

Callback Action
onOpen(handshake) Logs "Connected to tunnel server"
onMessage(json) Delegates to TunnelMessageReceiver.receive(json)
onClose(code, reason) Logs close info
onError(ex) Logs error
send(TunnelMessage) Serializes via ObjectMapper.writeValueAsString(), calls super.send(json)

6. Async Coordination β€” CompletableFuture Map

Manager Future Type Who Creates Who Resolves
PendingRegistrationManager CompletableFuture<String> ClientRegistrationService.register() ClientRegisteredHandler.handle()
PendingTunnelRequestManager CompletableFuture<TunnelCreatedResponse> TunnelCreationService.createTunnel() TunnelCreatedHandler.handle()

7. Data Models (Protocol Layer used by Client)

Class Fields
TunnelMessage requestId, type (MessageType), clientId, tunnelId, payload (JSON String)
MessageType CLIENT_REGISTER, CLIENT_REGISTERED, CREATE_TUNNEL, TUNNEL_CREATED, DELETE_TUNNEL, TUNNEL_DELETED, PING, PONG, HTTP_REQUEST, HTTP_RESPONSE
ClientRegisterRequest machineName, os, version
ClientRegisteredResponse clientId
CreateTunnelRequest localPort
TunnelCreatedResponse tunnelId, publicUrl
HttpRequestMessage method, path, headers Map<String,List<String>>, body byte[], query
HttpResponseMessage status, headers Map<String,List<String>>, body byte[]

πŸ–₯️ Server Module

TunnelFlow β€” Complete SERVER Flow

Every single class, interface, model, and config in the server module is documented here with code snippets and flow diagrams.


1. Package Map

org.tunnelflow.tunnelflowserver
β”œβ”€β”€ TunnelFlowServerApplication        (main + CommandLineRunner)
β”œβ”€β”€ config
β”‚   β”œβ”€β”€ BeansConfig
β”‚   β”œβ”€β”€ WebSocketBufferConfig
β”‚   └── WebSocketConfig
β”œβ”€β”€ filter
β”‚   └── TunnelIngressFilter
β”œβ”€β”€ handler
β”‚   └── MessageHandler                 (interface β€” unused in production)
β”œβ”€β”€ model
β”‚   └── TunnelInfo
β”œβ”€β”€ service
β”‚   β”œβ”€β”€ ClientConnection
β”‚   β”œβ”€β”€ ClientManager
β”‚   β”œβ”€β”€ HttpRequestMapper
β”‚   β”œβ”€β”€ OutboundMessageSender
β”‚   β”œβ”€β”€ PendingRequestManager
β”‚   β”œβ”€β”€ TunnelManager
β”‚   └── TunnelProtocolService
└── websocket
    └── TunnelWebSocketHandler

2. Server Boot Flow

Rendering Mermaid…
flowchart TD A["JVM starts: SpringApplication.run()"] A --> B["TunnelFlowServerApplication\n@SpringBootApplication"] B --> C1["BeansConfig\nRegisters ObjectMapper bean"] B --> C2["WebSocketBufferConfig\nServletServerContainerFactoryBean\nmaxTextMessageBuffer = 10 MB\nmaxBinaryMessageBuffer = 10 MB"] B --> C3["WebSocketConfig (@EnableWebSocket)\nRegisters TunnelWebSocketHandler at /ws\nsetAllowedOrigins(*)"] B --> C4["TunnelIngressFilter\nOncePerRequestFilter β€” intercepts all HTTP\nexcept /ws and /error paths"] B --> D["CommandLineRunner: Print all registered\nRequestMappingHandlerMapping routes"] C3 --> E["TunnelWebSocketHandler\nextends TextWebSocketHandler\nReady to accept client WS connections"] C4 --> F["Server ready\nAccepting HTTP on port 443 (public)\nand WS on /ws"]

3. Client WebSocket Connection & Registration Flow

Rendering Mermaid…
flowchart TD A["Client opens WebSocket to wss://server/ws"] A --> B["TunnelWebSocketHandler\n.afterConnectionEstablished(session)"] B --> C["Logs: WebSocket connected, waiting for registration..."] C --> D["Client sends CLIENT_REGISTER TunnelMessage (JSON)"] D --> E["handleTextMessage(session, message)"] E --> F["ObjectMapper.readValue β†’ TunnelMessage\n{ type: CLIENT_REGISTER, payload: ClientRegisterRequest JSON }"] F --> G{Switch on type} G -->|CLIENT_REGISTER| H["ObjectMapper: payload β†’ ClientRegisterRequest\n{ machineName, os, version }"] H --> I["Logs machine, OS, version"] I --> J["clientId = UUID.randomUUID().toString()"] J --> K["ClientManager.register(clientId, session)"] K --> K1["new ClientConnection(session)\n{ WebSocketSession, BlockingQueue<TunnelMessage> }"] K1 --> K2["clients.put(clientId, connection)\nsessionToClient.put(session, clientId)"] K2 --> L["ClientManager.getConnection(clientId)"] L --> M["OutboundMessageSender.start(connection)"] M --> M1["Thread.startVirtualThread()\nInfinite loop:\n take() from BlockingQueue\n serialize to JSON\n session.sendMessage(TextMessage)"] M1 --> N["TunnelProtocolService.createClientRegisteredMessage(clientId)"] N --> N1["ClientRegisteredResponse { clientId }"] N1 --> N2["TunnelMessage { type: CLIENT_REGISTERED, payload: JSON }"] N2 --> O["session.sendMessage(TextMessage JSON)\nSent directly from registration handler"] O --> P["Client is now registered and ready"]

4. Tunnel Creation Flow

Rendering Mermaid…
flowchart TD A["Client sends CREATE_TUNNEL TunnelMessage\n{ type: CREATE_TUNNEL, payload: CreateTunnelRequest{ localPort } }"] A --> B["handleTextMessage(session, message)"] B --> C["ObjectMapper: payload β†’ CreateTunnelRequest\n{ localPort }"] C --> D["clientId = ClientManager.getClientId(session)\nLookup via sessionToClient map"] D -->|null| E["Log warn: Unregistered client\nbreak β€” ignore message"] D -->|clientId found| F["TunnelManager.createTunnel(clientId, localPort)"] F --> G["generateTunnelId()\nUUID.randomUUID() β†’ replace('-','') β†’ first 8 chars"] G --> H["TunnelInfo { tunnelId, clientId, localPort }"] H --> I["tunnels.put(tunnelId, tunnelInfo)\nConcurrentHashMap"] I --> J["publicUrl = https://{tunnelId}.tunnel.rajeshbandi.site"] J --> K["TunnelProtocolService.createTunnelCreatedMessage(requestId, tunnelId, publicUrl)"] K --> K1["TunnelCreatedResponse { tunnelId, publicUrl }"] K1 --> K2["TunnelMessage { type: TUNNEL_CREATED, requestId, payload: JSON }"] K2 --> L["session.sendMessage(TextMessage JSON)"] L --> M["Tunnel is live β€” external traffic can now be routed"]

5. External HTTP Request Ingress Flow (The Core)

Rendering Mermaid…
flowchart TD A["External user hits\nhttps://abc123.tunnel.rajeshbandi.site/api/users"] A --> B["TunnelIngressFilter.doFilterInternal(request, response, chain)"] B --> C["path = request.getRequestURI()"] C --> D{path starts with /ws?} D -->|yes| Z1["filterChain.doFilter() β€” pass through"] D -->|no| E{path starts with /error?} E -->|yes| Z2["filterChain.doFilter() β€” pass through"] E -->|no| F["host = request.getServerName()\ne.g. abc123.tunnel.rajeshbandi.site"] F --> G{host ends with\n.tunnel.rajeshbandi.site?} G -->|no| Z3["filterChain.doFilter() β€” not a tunnel request"] G -->|yes| H["Extract tunnelId\n= host.substring(0, host.indexOf('.tunnel...'))"] H --> I["TunnelManager.getTunnel(tunnelId)"] I -->|null| ERR1["response.sendError(404, Tunnel not found)"] I -->|found TunnelInfo| J["ClientManager.getConnection(tunnelInfo.clientId)"] J -->|connection null or session closed| ERR2["response.sendError(503, Tunnel client is offline)"] J -->|connection open| K["requestId = UUID.randomUUID().toString()"] K --> L["PendingRequestManager.register(requestId)\nnew CompletableFuture<HttpResponseMessage>\nstored in ConcurrentHashMap"] L --> M["HttpRequestMapper.map(request, body)\nbody = request.getInputStream().readAllBytes()"] M --> M1["HttpRequestMessage\n{ method, path, headers Map<String,List<String>>, body byte[], query }"] M1 --> N["TunnelProtocolService.createHttpRequestTunnelMessage\n(httpRequestMessage, requestId, tunnelId)"] N --> N1["TunnelMessage { type:HTTP_REQUEST, requestId, tunnelId, payload:JSON }"] N1 --> O["connection.getOutboundQueue().offer(message)\nLinkedBlockingQueue β€” non-blocking enqueue"] O -->|offer failed| ERR3["response.sendError(500, Failed to enqueue)"] O -->|queued| P["future.get(30, TimeUnit.SECONDS)\nCURRENT THREAD BLOCKS"] P -->|TimeoutException| ERR4["response.sendError(504, Tunnel request timed out)"] P -->|Exception| ERR5["response.sendError(500, ex.getMessage())"] P -->|resolved HttpResponseMessage| Q["response.setStatus(tunnelResponse.status)"] Q --> R["Copy headers\nSkip hop-by-hop: Transfer-Encoding, Connection,\nKeep-Alive, Upgrade, Proxy-Authenticate, TE, Trailer"] R --> S["response.getOutputStream().write(body)"] S --> T["response.getOutputStream().flush()"] T --> U["HTTP response delivered to external user βœ“"]

6. OutboundMessageSender β€” Virtual Thread Drain Loop

Rendering Mermaid…
flowchart TD A["OutboundMessageSender.start(connection)\ncalled once per client on registration"] A --> B["Thread.startVirtualThread(lambda)"] B --> C["Loop: while(true)"] C --> D{session.isOpen()?} D -->|no| EXIT["break β€” virtual thread exits"] D -->|yes| E["message = connection.getOutboundQueue().take()\nblocks if queue is empty"] E --> F["json = objectMapper.writeValueAsString(message)"] F --> G["connection.getSession().sendMessage(new TextMessage(json))"] G --> H{Exception?} H -->|InterruptedException| I["Thread.currentThread().interrupt()\nbreak"] H -->|Other Exception| J["Log error\nbreak"] H -->|no exception| C

7. HTTP Response Return β€” Completing the Future

Rendering Mermaid…
flowchart TD A["Client sends HTTP_RESPONSE TunnelMessage over WebSocket\n{ type:HTTP_RESPONSE, requestId, payload: HttpResponseMessage JSON }"] A --> B["TunnelWebSocketHandler.handleTextMessage()"] B --> C["ObjectMapper: TunnelMessage from JSON"] C --> D{Switch on type} D -->|PONG| E["Log: Received PONG β€” no further action"] D -->|HTTP_RESPONSE| F["ObjectMapper: payload β†’ HttpResponseMessage\n{ status, headers, body }"] F --> G["PendingRequestManager.complete(requestId, response)"] G --> H["pendingRequests.remove(requestId)\nConcurrentHashMap lookup"] H --> I["future.complete(response)\nCompletableFuture resolved!"] I --> J["TunnelIngressFilter unblocks from future.get()\nReturns HttpResponseMessage to filter"] J --> K["Filter writes response to HttpServletResponse\nExternal user gets response"]

8. Client Disconnect Flow

Rendering Mermaid…
flowchart TD A["Client WebSocket closes\n(network drop, client exit, etc.)"] A --> B["TunnelWebSocketHandler\n.afterConnectionClosed(session, status)"] B --> C["Log: Client disconnected + status code"] C --> D["clientId = ClientManager.getClientId(session)"] D -->|null| E["Session was never registered β€” skip"] D -->|found| F["TunnelManager.removeTunnelByClientId(clientId)"] F --> F1["Remove all TunnelInfo entries\nwhere tunnelInfo.clientId == clientId\nfrom ConcurrentHashMap"] F1 --> G["ClientManager.unregister(session)"] G --> G1["sessionToClient.remove(session) β†’ clientId"] G1 --> G2["clients.remove(clientId)"] G2 --> H["All resources cleaned up\nSubsequent HTTP requests get 404 Tunnel not found"]

9. Class-by-Class Reference

Boot & Config

TunnelFlowServerApplication

@SpringBootApplication entry point. Also registers a CommandLineRunner that prints all mapped HTTP routes via RequestMappingHandlerMapping at startup.

BeansConfig β€” config

Declares a single ObjectMapper bean (JSON serialization for WebSocket message handling).

WebSocketBufferConfig β€” config

Configures a ServletServerContainerFactoryBean with 10 MB max text and binary WebSocket buffer sizes. Required to handle large HTTP body payloads tunnelled over WebSocket.

container.setMaxTextMessageBufferSize(10 * 1024 * 1024);
container.setMaxBinaryMessageBufferSize(10 * 1024 * 1024);

WebSocketConfig β€” config

@EnableWebSocket configuration. Registers TunnelWebSocketHandler at path /ws with allowedOrigins("*").


Filter

TunnelIngressFilter β€” filter

OncePerRequestFilter. Intercepts every HTTP request. Routes tunnel traffic by matching the Host header against *.tunnel.rajeshbandi.site. For non-tunnel traffic, passes through via filterChain.doFilter(). For tunnel traffic:

  • Reads full body bytes upfront
  • Looks up tunnel and client connection
  • Creates a CompletableFuture, enqueues the message, blocks for up to 30 seconds, then writes the response.

Handler

MessageHandler (interface) β€” handler

Unused in production code (server uses a direct switch statement). Defined for potential extensibility.

public interface MessageHandler {
    void handleMessage(TunnelMessage message);
    boolean canHandle(TunnelMessage message);
}

Model

TunnelInfo β€” model

@Data @Builder
public class TunnelInfo {
    String tunnelId;   // 8-char random hex
    String clientId;   // UUID of the connected client
    int localPort;     // local port the client is exposing
}

Service Layer

ClientConnection β€” service

Data holder for one connected client. Created by ClientManager.register().

public class ClientConnection {
    final WebSocketSession session;
    final BlockingQueue<TunnelMessage> outboundQueue = new LinkedBlockingQueue<>();
}

ClientManager β€” service

Two ConcurrentHashMap indexes:

  • Map<String clientId, ClientConnection> β€” forward lookup
  • Map<WebSocketSession, String clientId> β€” reverse lookup for disconnect events
Method Description
register(clientId, session) Creates ClientConnection, stores in both maps
unregister(session) Removes from both maps
getClientId(session) Reverse lookup
getConnection(clientId) Forward lookup
isConnected(clientId) Checks session is non-null and isOpen()
getConnectedClientCount() Returns clients.size()

TunnelManager β€” service

ConcurrentHashMap<tunnelId, TunnelInfo>.

Method Description
createTunnel(clientId, localPort) Generates 8-char tunnelId, builds TunnelInfo, stores it
getTunnel(tunnelId) Lookup by tunnelId
removeTunnel(tunnelId) Delete by tunnelId
removeTunnelByClientId(clientId) Removes ALL tunnels belonging to a client (on disconnect)
contains(tunnelId) Existence check
getTunnelCount() Map size

HttpRequestMapper β€” service

Converts HttpServletRequest + byte[] body β†’ HttpRequestMessage. Copies all headers via Enumeration<String> headerNames.

OutboundMessageSender β€” service

Starts one Java virtual thread per client. Drains the client's BlockingQueue<TunnelMessage> infinitely, serializing and sending each message over the client's WebSocketSession. Exits on session close or exception.

PendingRequestManager β€” service

ConcurrentHashMap<requestId, CompletableFuture<HttpResponseMessage>>.

Method Description
register(requestId) Creates and stores a new CompletableFuture
complete(requestId, response) Removes and completes the future
fail(requestId, throwable) Removes and completes exceptionally

TunnelProtocolService β€” service

Factory for all outgoing server-side TunnelMessage objects:

Method Output Type
createPingMessage() PING
createPongMessage(requestId) PONG
createClientRegisteredMessage(clientId) CLIENT_REGISTERED wrapping ClientRegisteredResponse
createTunnelCreatedMessage(requestId, tunnelId, publicUrl) TUNNEL_CREATED wrapping TunnelCreatedResponse
createHttpRequestTunnelMessage(request, requestId, tunnelId) HTTP_REQUEST wrapping HttpRequestMessage

WebSocket

TunnelWebSocketHandler β€” websocket

Extends TextWebSocketHandler. The heart of the server. Dispatches all incoming WebSocket text messages via a switch on TunnelMessage.type:

MessageType Action
CLIENT_REGISTER Assign UUID clientId, register in ClientManager, start OutboundMessageSender virtual thread, send CLIENT_REGISTERED
CREATE_TUNNEL Validate clientId, call TunnelManager.createTunnel(), build public URL, send TUNNEL_CREATED
HTTP_RESPONSE Deserialize HttpResponseMessage, call PendingRequestManager.complete() to unblock TunnelIngressFilter
PONG Log only
default Warn: unsupported type

On disconnect (afterConnectionClosed): cleans up tunnels and client registration.


10. Async Coordination β€” CompletableFuture Map

Manager Future Type Who Creates Who Resolves Timeout
PendingRequestManager CompletableFuture<HttpResponseMessage> TunnelIngressFilter.doFilterInternal() TunnelWebSocketHandler (HTTP_RESPONSE case) 30 seconds

11. Data Models (Protocol Layer used by Server)

Class Fields
TunnelMessage requestId, type (MessageType), clientId, tunnelId, payload (JSON String)
MessageType (enum) CLIENT_REGISTER, CLIENT_REGISTERED, CREATE_TUNNEL, TUNNEL_CREATED, DELETE_TUNNEL, TUNNEL_DELETED, PING, PONG, HTTP_REQUEST, HTTP_RESPONSE
ClientRegisterRequest machineName, os, version
ClientRegisteredResponse clientId
CreateTunnelRequest localPort
TunnelCreatedResponse tunnelId, publicUrl
HttpRequestMessage method, path, headers Map<String,List<String>>, body byte[], query
HttpResponseMessage status, headers Map<String,List<String>>, body byte[]

12. Hop-by-Hop Header Filtering (Both Sides)

Both TunnelIngressFilter (server) and LocalHttpForwarder (client) strip the same headers to avoid HTTP/1.1 proxy protocol violations:

Header Reason
Transfer-Encoding Re-chunking handled by Tomcat
Connection Connection-scoped, not end-to-end
Keep-Alive Per-hop persistence hint
Upgrade Per-hop protocol upgrade
Proxy-Authenticate / Proxy-Authorization Proxy-specific auth
TE / Trailer Chunked trailer extensions
host Rewritten by forwarder
content-length Recalculated by HttpClient

🎯 Interview Guide

TunnelFlow β€” Interview Preparation Guide


Table of Contents

  1. How to Explain This Project in an Interview
  2. Key Talking Points to Highlight
  3. Interview Questions & Answers β€” System Design
  4. Interview Questions & Answers β€” Java & Spring Internals
  5. Interview Questions & Answers β€” Concurrency & Async
  6. Interview Questions & Answers β€” Networking & Protocols
  7. Interview Questions & Answers β€” Error Handling & Edge Cases
  8. Interview Questions & Answers β€” Scalability & Production Readiness
  9. Interview Questions & Answers β€” Behavioral / Project-Based

1. How to Explain This Project in an Interview

🎯 The 30-Second Pitch

"I built TunnelFlow β€” a reverse tunneling system similar to ngrok. It lets developers expose their local development servers to the internet through a public URL. For example, if I'm running a React app on localhost:3000, TunnelFlow gives me a URL like https://abc123.tunnel.rajeshbandi.site that anyone on the internet can use to access my local app. It's built with Java, Spring Boot, and WebSockets, following a clean client-server architecture with a shared protocol module."

🎯 The 2-Minute Structured Explanation

Use the Problem β†’ Solution β†’ Architecture β†’ Technical Highlights framework:

Problem

"During development, you often need to share your local work with teammates, test webhooks from third-party services like Stripe or GitHub, or demo your app to clients β€” but localhost isn't accessible from the internet."

Solution

"TunnelFlow solves this by establishing a persistent WebSocket connection between a CLI client on your machine and a public cloud server. The server assigns a unique subdomain. When external HTTP traffic hits that subdomain, the server wraps the HTTP request into a JSON message, sends it to the client over WebSocket, the client forwards it to localhost, gets the response, and sends it back the same way."

Architecture

"The system is split into three Maven modules:

  • A protocol module β€” shared DTOs and message types used by both sides.
  • A server β€” a Spring Boot app deployed on the cloud. It handles WebSocket connections, manages client registrations and tunnels, and intercepts inbound HTTP traffic using a servlet filter.
  • A client β€” a Spring Boot CLI app using Picocli. It connects via WebSocket, registers itself, requests tunnels, and forwards incoming requests to the local application using Java's HttpClient."

Technical Highlights (pick 2–3 based on the role)

  • "I used CompletableFuture to bridge the async WebSocket protocol with synchronous HTTP request-response β€” the server's filter thread blocks on a future that gets resolved when the client sends back the response."
  • "The server uses Java virtual threads to drain an outbound message queue per client, which keeps WebSocket writes serialized and avoids concurrency issues with WebSocketSession."
  • "I designed a custom application-layer protocol with typed message envelopes (TunnelMessage) that wrap different payloads like HttpRequestMessage, ClientRegisterRequest, etc."
  • "The client supports a multi-app mode where you define multiple services in a YAML config, and it provisions a separate tunnel for each, resolving cross-service URL placeholders before launching the processes."

2. Key Talking Points to Highlight

Topic What to Say
Why you built it "I wanted to deeply understand how tools like ngrok work under the hood β€” reverse proxying, WebSocket multiplexing, protocol design β€” so I built my own from scratch."
Design decisions "I chose WebSockets over raw TCP because they work through corporate proxies/firewalls and Spring Boot has first-class support. I used a shared protocol module to enforce type safety across client and server."
Async coordination "The trickiest part was bridging sync HTTP with async WebSocket. I used CompletableFuture with a ConcurrentHashMap keyed by request ID. The filter registers a future, the WebSocket handler resolves it when the response arrives."
Thread safety "Spring's WebSocketSession is not thread-safe for concurrent writes. I solved this by giving each client a LinkedBlockingQueue drained by a single virtual thread, ensuring serialized writes."
Configuration system "The client reads a tunnelflow.yaml file, validates it, provisions tunnels for each declared app, resolves ${app.url} placeholders across services, then launches each app as an OS process with the resolved environment."
Clean architecture "I separated concerns into layers β€” CLI commands, configuration, handlers, services, runtime, and WebSocket β€” making it easy to test and extend."

3. Interview Questions & Answers β€” System Design

Q1. Can you draw the high-level architecture of TunnelFlow?

A: The system has three components:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”       WebSocket (persistent)       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  TunnelFlow  │◄──────────────────────────────────►│  TunnelFlow  β”‚
β”‚    Client    β”‚    TunnelMessage JSON protocol      β”‚    Server    β”‚
β”‚  (your PC)   β”‚                                     β”‚  (cloud VM)  β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜                                     β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
       β”‚ HttpClient                                         β”‚ Servlet Filter
       β–Ό                                                    β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  localhost:  β”‚                                    β”‚ External Userβ”‚
β”‚    3000      β”‚                                    β”‚  (browser)   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The protocol module is a shared Maven dependency that defines TunnelMessage, MessageType, and all request/response DTOs. Both client and server depend on it.


Q2. How does a request travel from an external user to the local app and back?

A: Nine steps:

  1. External user sends HTTP request to https://abc123.tunnel.rajeshbandi.site/api/data
  2. TunnelIngressFilter intercepts it, extracts tunnelId from Host header
  3. Filter looks up TunnelInfo β†’ clientId β†’ ClientConnection
  4. Filter creates a CompletableFuture<HttpResponseMessage> in PendingRequestManager
  5. Filter wraps the HTTP request as a TunnelMessage(HTTP_REQUEST) and enqueues it in the client's BlockingQueue
  6. OutboundMessageSender virtual thread takes the message and sends it over WebSocket
  7. Client's TunnelWebSocketClient receives it β†’ TunnelMessageReceiver β†’ TunnelMessageDispatcher β†’ HttpRequestMessageHandler
  8. LocalHttpForwarder sends the request to http://localhost:3000/api/data via Java HttpClient, gets the response, and sends it back as TunnelMessage(HTTP_RESPONSE) via WebSocket
  9. Server's TunnelWebSocketHandler receives it, calls PendingRequestManager.complete(), the future resolves, and the filter writes the response to the external user

Q3. Why did you choose WebSockets instead of raw TCP sockets or HTTP long polling?

A:


Q4. Why did you create a separate protocol module?

A: To enforce type safety and consistency between client and server. Both sides serialize/deserialize the same DTOs (TunnelMessage, HttpRequestMessage, etc.). If I change a field, both modules get the update at compile time. Without it, I'd risk silent deserialization mismatches at runtime.


Q5. How does the server route incoming HTTP requests to the correct client?

A: Through subdomain-based routing. Each tunnel gets a unique 8-character ID. The public URL is https://{tunnelId}.tunnel.rajeshbandi.site. When a request comes in, TunnelIngressFilter extracts the Host header, parses the subdomain to get the tunnelId, looks it up in TunnelManager's ConcurrentHashMap to get the TunnelInfo, and from there gets the clientId to find the ClientConnection with its WebSocket session.


Q6. What is the TunnelMessage protocol format?

A: It's a JSON envelope with five fields:

public class TunnelMessage {
    String requestId;      // UUID to correlate request-response pairs
    MessageType type;      // enum: CLIENT_REGISTER, HTTP_REQUEST, HTTP_RESPONSE, etc.
    String clientId;       // identifies which client this is for
    String tunnelId;       // identifies which tunnel this is for
    String payload;        // JSON string of the inner message (e.g., HttpRequestMessage)
}

The payload is a double-encoded JSON string β€” the outer TunnelMessage is JSON, and payload is another JSON string that gets deserialized into the appropriate DTO based on the type field. This avoids polymorphic deserialization and keeps things simple.


Q7. Why is the payload stored as a JSON string rather than a nested object?

A: To avoid Jackson polymorphic type handling. If payload were Object, Jackson wouldn't know which class to deserialize it into without @JsonTypeInfo annotations, which adds complexity and security risks (deserialization attacks). By keeping it as a String, the receiver can explicitly choose the target class based on the MessageType enum:

case HTTP_REQUEST -> objectMapper.readValue(message.getPayload(), HttpRequestMessage.class);
case CLIENT_REGISTER -> objectMapper.readValue(message.getPayload(), ClientRegisterRequest.class);

4. Interview Questions & Answers β€” Java & Spring Internals

Q8. What Spring Boot features did you use and why?

A:

Feature Where Why
@SpringBootApplication Server entry point Auto-configuration, component scanning
WebSocketConfigurer WebSocketConfig Register WebSocket handler at /ws
TextWebSocketHandler TunnelWebSocketHandler Handle JSON text messages
OncePerRequestFilter TunnelIngressFilter Intercept all HTTP for tunnel routing
@Configuration + @Bean BeansConfig Declare ObjectMapper, HttpClient
Picocli + Spring Client CLI Dependency injection in CLI commands
@Value TunnelConnectionManager Inject tunnelflow.server.websocket-url
@Service, @Component Everywhere Spring-managed beans with DI

Q9. How does the CLI framework (Picocli) integrate with Spring?

A: Through PicocliFactory, which implements CommandLine.IFactory. When Picocli needs to instantiate a command class, it calls PicocliFactory.create(Class), which delegates to applicationContext.getBean(cls). This means all @Component command classes get their dependencies injected by Spring:

public <K> K create(Class<K> cls) throws Exception {
    return applicationContext.getBean(cls);
}

Q10. Why do you have two ObjectMapper beans in the client?

A: One for JSON (default) and one for YAML (via YAMLFactory):

@Bean public ObjectMapper objectMapper() { return new ObjectMapper(); }
@Bean public ObjectMapper yamlObjectMapper() { return new ObjectMapper(new YAMLFactory()); }

The JSON mapper is used for WebSocket message serialization. The YAML mapper is used by ConfigLoader to parse tunnelflow.yaml.


Q11. Why did you set destroyMethod = "" on the HttpClient bean?

A: Java's HttpClient doesn't have a standard close() or destroy() method. Spring tries to call a destroy method when the application shuts down. Without destroyMethod = "", Spring would throw an error trying to find a default destroy method. This suppresses that behavior.


Q12. How does TunnelMessageDispatcher work?

A: It uses the Strategy Pattern. At construction, it receives all MessageHandler beans via Spring's list injection. It builds a Map<MessageType, MessageHandler> by calling getSupportedType() on each handler:

public TunnelMessageDispatcher(List<MessageHandler> messageHandlers) {
    this.handlers = messageHandlers.stream()
        .collect(Collectors.toMap(MessageHandler::getSupportedType, Function.identity()));
}

When a message arrives, it simply does handlers.get(message.getType()).handle(message). This makes it trivially extensible β€” adding a new message type only requires a new MessageHandler implementation.


5. Interview Questions & Answers β€” Concurrency & Async

Q13. How do you bridge synchronous HTTP with asynchronous WebSocket?

A: Using CompletableFuture + ConcurrentHashMap as a correlation mechanism:

  1. TunnelIngressFilter generates a unique requestId
  2. It creates a CompletableFuture<HttpResponseMessage> and stores it in PendingRequestManager (a ConcurrentHashMap<String, CompletableFuture>)
  3. The filter thread calls future.get(30, TimeUnit.SECONDS) β€” blocking
  4. The request travels over WebSocket to the client and back
  5. When the HTTP_RESPONSE message arrives on the WebSocket thread, PendingRequestManager.complete(requestId, response) resolves the future
  6. The filter thread unblocks and writes the HTTP response

This is conceptually the same as the Correlation ID pattern used in message-driven architectures.


Q14. Why is WebSocketSession.sendMessage() not called from multiple threads?

A: Spring's WebSocketSession is not thread-safe for concurrent writes. If two threads call sendMessage() simultaneously, the messages can get interleaved or corrupt each other. I solved this by using a single-writer pattern:


Q15. Why did you use virtual threads instead of platform threads for the OutboundMessageSender?

A: Because each connected client gets its own sender thread. With traditional platform threads, 1000 clients = 1000 OS threads, consuming ~1 MB stack memory each. Virtual threads (Project Loom) are extremely lightweight β€” they're scheduled on a small pool of carrier threads and use minimal memory when blocked on queue.take(). This is a textbook use case: I/O-bound work (waiting for queue items, then doing a WebSocket write) with high concurrency.


Q16. What happens if the client takes longer than 30 seconds to respond?

A: The future.get(30, TimeUnit.SECONDS) in TunnelIngressFilter throws a TimeoutException. The filter catches it and returns HTTP 504 Gateway Timeout to the external user. The orphaned future is left in the PendingRequestManager map β€” in production, I'd add a cleanup mechanism, but for the current scope, the 30-second timeout is sufficient.


Q17. Why did you use ExecutorService with a fixed thread pool of 10 in HttpRequestMessageHandler?

A: The WebSocket onMessage callback must return quickly β€” it runs on the WebSocket I/O thread, and blocking it would prevent other messages from being received. By submitting the local HTTP forwarding work to a separate thread pool, the WebSocket thread is freed immediately. The pool size of 10 limits the concurrent local HTTP requests per client, providing backpressure.


Q18. Is there a race condition between PendingRegistrationManager.register() and complete()?

A: No. The register() call always happens before the CLIENT_REGISTER message is sent, so the CompletableFuture is in place before the server can possibly respond with CLIENT_REGISTERED. The sequence is:

  1. registrationFuture = new CompletableFuture<>() (stored)
  2. tunnelSender.send(CLIENT_REGISTER) (sent)
  3. Server processes and sends CLIENT_REGISTERED
  4. ClientRegisteredHandler.handle() calls complete(clientId) (resolved)
  5. future.get() returns

Since step 1 happens-before step 2, the future is always registered before a response can arrive.


6. Interview Questions & Answers β€” Networking & Protocols

Q19. What are hop-by-hop headers and why do you filter them?

A: Hop-by-hop headers are HTTP headers meant for a single transport-level connection, not for the end-to-end communication. They must not be forwarded by proxies. Examples:

If I forwarded Transfer-Encoding: chunked from the local app to the external user, the server (Tomcat) would try to re-chunk an already-chunked response, corrupting it. So both TunnelIngressFilter (server) and LocalHttpForwarder (client) strip these headers.


Q20. How does subdomain-based routing work at the DNS level?

A: I have a wildcard DNS record: *.tunnel.rajeshbandi.site β†’ server IP. This means any subdomain like abc123.tunnel.rajeshbandi.site resolves to the same server. The server then uses the Host header to differentiate which tunnel the request is for.


Q21. Why do you send the HTTP body as byte[] instead of String?

A: Because HTTP bodies can be binary β€” images, PDFs, protobuf, gzip-compressed data. Converting to String would corrupt non-text data. byte[] preserves the raw bytes. Jackson serializes byte[] fields as Base64-encoded strings in JSON, so they travel safely over the text-based WebSocket.


Q22. What's the maximum message size your WebSocket supports?

A: I configured it to 10 MB in WebSocketBufferConfig:

container.setMaxTextMessageBufferSize(10 * 1024 * 1024);
container.setMaxBinaryMessageBufferSize(10 * 1024 * 1024);

This is necessary because the entire HTTP request/response body is serialized into the WebSocket text message. For production, I'd implement chunked transfer β€” splitting large payloads into multiple WebSocket frames.


7. Interview Questions & Answers β€” Error Handling & Edge Cases

Q23. What happens if the client disconnects mid-request?

A: Two things happen:

  1. Server side: TunnelWebSocketHandler.afterConnectionClosed() fires. It calls TunnelManager.removeTunnelByClientId(clientId) to clean up all tunnels, and ClientManager.unregister(session) to clean up the client. Any future HTTP requests to the tunnel will get 404 Tunnel not found.
  2. In-flight requests: If there are pending futures in PendingRequestManager, they'll time out after 30 seconds and return 504 to the external user. The OutboundMessageSender virtual thread will also exit because session.isOpen() returns false.

Q24. What if two requests arrive at the same tunnel simultaneously?

A: They're handled correctly because:


Q25. What if the outbound queue is full?

A: It can't be, because LinkedBlockingQueue has unbounded capacity by default. The offer() call always succeeds. However, in production, I'd use a bounded queue with a capacity limit and handle the offer() returning false by sending 503 Service Unavailable.


Q26. What if the tunnelflow.yaml has a port that no local app is listening on?

A: The tunnel will be created successfully, but when an external request arrives, LocalHttpForwarder will make an HttpClient.send() call to http://localhost:{port}, which will throw a ConnectException. The catch block in HttpRequestMessageHandler logs the error, and the server-side future will eventually timeout with 504.


Q27. How do you handle an unregistered client sending a CREATE_TUNNEL message?

A: In TunnelWebSocketHandler, the CREATE_TUNNEL case first calls clientManager.getClientId(session). If it returns null, the code logs a warning and breaks out of the switch β€” the message is silently ignored.


8. Interview Questions & Answers β€” Scalability & Production Readiness

Q28. How would you scale this to handle thousands of concurrent tunnels?

A:


Q29. What's missing for production readiness?

A:

Gap Solution
No authentication Add API keys or JWT tokens during client registration
No rate limiting Apply rate limits per tunnel or per client
No HTTPS termination Use Nginx/Caddy as a reverse proxy for TLS
No reconnection logic Implement exponential backoff reconnect in the client
No chunked transfer for large bodies Split large payloads into multiple WebSocket frames
No persistence Tunnels are in-memory; server restart loses all state
No monitoring Add metrics (Prometheus), health checks, request logging dashboards
No request size limit Add max body size validation in the filter
Single point of failure Horizontal scaling with shared state (Redis)
No graceful shutdown Drain in-flight requests before server restart

Q30. How would you add authentication?

A: Add an API key system:

  1. Client includes an apiKey field in ClientRegisterRequest
  2. Server validates it against a database/Redis during the CLIENT_REGISTER handler
  3. If invalid, close the WebSocket with a 4001 close code
  4. If valid, proceed with registration
  5. For HTTP ingress, the tunnel is already authenticated by being created by a validated client

Q31. How would you add reconnection to the client?

A: In TunnelWebSocketClient.onClose():

@Override
public void onClose(int code, String reason, boolean remote) {
    if (remote) {  // server initiated
        scheduleReconnect();  // exponential backoff: 1s, 2s, 4s, 8s... max 60s
    }
}

On reconnect, the client would need to re-register and re-create its tunnels. The server would assign new IDs, so the client would need to update its local TunnelRuntimeRegistry.


Q32. How would you implement request/response streaming for large files?

A: Instead of sending the entire body in one TunnelMessage, I'd implement chunked transfer:

  1. Add a STREAM_START, STREAM_CHUNK, and STREAM_END message type
  2. TunnelIngressFilter reads the body in chunks (e.g., 64 KB) and sends each as a separate STREAM_CHUNK
  3. Client reassembles chunks before forwarding to the local app
  4. Same in reverse for the response
  5. This keeps WebSocket frame sizes small and avoids the 10 MB buffer limit

9. Interview Questions & Answers β€” Behavioral / Project-Based

Q33. What was the hardest part of building this project?

A: "The hardest part was bridging synchronous HTTP with asynchronous WebSocket. The server's servlet thread must block and wait for a response that arrives on a completely different thread β€” the WebSocket handler thread. I solved it using CompletableFuture with a ConcurrentHashMap keyed by request ID. Getting the correlation right, handling timeouts, and ensuring no futures leaked was challenging."


Q34. What design patterns did you use?

A:

Pattern Where
Strategy MessageHandler interface + TunnelMessageDispatcher map
Builder Lombok @Builder on all protocol DTOs and runtime objects
Factory TunnelProtocolService creates TunnelMessage objects; PicocliFactory creates CLI commands
Observer / Callback WebSocket event handlers (onOpen, onMessage, onClose)
Producer-Consumer LinkedBlockingQueue in ClientConnection + OutboundMessageSender drain thread
Correlation ID requestId in TunnelMessage to match async request-response pairs
Registry TunnelRuntimeRegistry, ApplicationRuntimeRegistry, TunnelManager, ClientManager
Template ConfigTemplateService generates starter YAML

Q35. If you had to start over, what would you change?

A:


Q36. How did you test this project?

A: "I tested it end-to-end by:

  1. Starting the server on a cloud VM
  2. Running the client on my local machine with a React app on port 3000
  3. Accessing the public tunnel URL from my phone's browser
  4. Testing with various HTTP methods (GET, POST with JSON body, file uploads)
  5. Testing edge cases: killing the client mid-request, slow local servers, large response bodies"

Q37. Why Java and Spring Boot instead of Go or Node.js?

A: "I chose Java because:


Q38. Walk me through what happens if I run tunnelflow expose 3000 right now.

A:

  1. Picocli parses the command, PicocliFactory creates ExposeCommand from Spring context
  2. ExposeCommand.run() calls ClientTunnelManager.expose(3000)
  3. TunnelConnectionManager.connect():
    • Creates TunnelWebSocketClient with the server URL
    • Calls TunnelSender.attach(client) β€” links the sender to the live WS client
    • client.connectBlocking() β€” TCP + WebSocket handshake
  4. ClientRegistrationService.register():
    • Creates ClientRegisterRequest (machine name, OS, version)
    • PendingRegistrationManager creates a CompletableFuture<String>
    • Sends CLIENT_REGISTER message via TunnelSender β†’ TunnelWebSocketClient.send()
    • Blocks on future.get() until server responds with CLIENT_REGISTERED
    • ClientRegisteredHandler resolves the future with the clientId
  5. TunnelCreationService.createTunnel(3000):
    • PendingTunnelRequestManager creates a CompletableFuture<TunnelCreatedResponse>
    • Sends CREATE_TUNNEL message with localPort: 3000
    • Blocks on future.get() until server responds with TUNNEL_CREATED
    • TunnelCreatedHandler resolves the future with { tunnelId, publicUrl }
  6. TunnelRuntime is built with { tunnelId, localPort: 3000, publicUrl }
  7. Registered in TunnelRuntimeRegistry (so LocalHttpForwarder can look it up later)
  8. Prints: Tunnel URL: https://abc123.tunnel.rajeshbandi.site
  9. The WebSocket stays open, TunnelMessageReceiver listens for incoming HTTP_REQUEST messages

Q39. What's the difference between tunnelflow app up and tunnelflow expose?

A:

Feature app up expose
Config file Reads tunnelflow.yaml No config file
Multiple apps Provisions tunnels for ALL apps in the config Single port only
Validation Validates version, project, apps No validation
Process launch Starts each app as an OS process via ProcessLauncher Does NOT start any process (app must already be running)
Env resolution Resolves ${app.url} placeholders across services No placeholder support
Orchestrator ApplicationManager ClientTunnelManager

Q40. How does PlaceholderResolver work?

A: It enables cross-service URL injection. If your tunnelflow.yaml looks like:

apps:
  backend:
    port: 8080
    command: "java -jar app.jar"
    env:
      FRONTEND_URL: "${frontend.url}"
  frontend:
    port: 3000
    command: "npm start"
    env:
      API_URL: "${backend.url}"

After provisioning tunnels, PlaceholderResolver scans all env values. When it finds ${frontend.url}, it looks up the frontend deployment's TunnelRuntime.publicUrl and substitutes it. So the backend process starts with FRONTEND_URL=https://xyz.tunnel.rajeshbandi.site in its environment.


Q41. Explain the ClientConnection class and its role.

A: ClientConnection is a data holder that bundles two things per connected client:

  1. The WebSocketSession β€” the live connection to the client
  2. A LinkedBlockingQueue<TunnelMessage> β€” an outbound message buffer

This design decouples message production (any thread can enqueue messages) from message consumption (a single virtual thread drains the queue and writes to the session). Without this, multiple threads writing to the session simultaneously would corrupt the WebSocket stream.


Q42. Why ConcurrentHashMap everywhere instead of synchronized?

A: ConcurrentHashMap provides lock-free reads and fine-grained segment locking for writes. In a tunneling server, reads vastly outnumber writes:

Using synchronized on a regular HashMap would serialize ALL operations behind a single lock, creating a bottleneck. ConcurrentHashMap lets concurrent reads proceed without blocking.


Q43. What's the role of HttpRequestMapper?

A: It converts a jakarta.servlet.http.HttpServletRequest into the portable HttpRequestMessage DTO:

This abstraction lets the protocol message be a simple POJO, completely decoupled from the servlet API.


Q44. What would happen if the server restarts?

A: All state is lost:

The client's WebSocket connection would close, triggering onClose(). Currently there's no reconnect logic, so the user would have to restart the client. For production, I'd persist tunnel state in Redis and add auto-reconnect on the client.


Q45. How many concurrent requests can one tunnel handle?

A: Bounded by several factors:

  1. Client-side: HttpRequestMessageHandler has a thread pool of 10, so 10 local HTTP calls in parallel
  2. Server-side: OutboundMessageSender is single-threaded per client (serialized WebSocket writes), but enqueuing is concurrent
  3. WebSocket bandwidth: 10 MB max message size, network throughput
  4. Filter threads: Tomcat's thread pool (default 200), each blocking on future.get()

The practical bottleneck is the client's 10-thread pool for local forwarding.


Q46. Describe the complete lifecycle of a TunnelMessage.

A: Taking HTTP_REQUEST as an example:

  1. Created by TunnelProtocolService.createHttpRequestTunnelMessage() on the server
  2. Enqueued into ClientConnection.outboundQueue by TunnelIngressFilter
  3. Dequeued by OutboundMessageSender virtual thread
  4. Serialized to JSON via ObjectMapper.writeValueAsString()
  5. Sent over WebSocket as a TextMessage
  6. Received by TunnelWebSocketClient.onMessage(json)
  7. Deserialized by TunnelMessageReceiver β†’ ObjectMapper.readValue(json, TunnelMessage.class)
  8. Dispatched by TunnelMessageDispatcher β†’ HttpRequestMessageHandler
  9. Payload extracted β€” ObjectMapper.readValue(payload, HttpRequestMessage.class)
  10. Processed β€” forwarded to localhost by LocalHttpForwarder
  11. Discarded β€” no further reference

Q47. What's the purpose of ApplicationDeployment?

A: It's a mutable deployment descriptor that enriches the immutable ApplicationConfig (parsed from YAML) with runtime information:

It acts as a staging area between configuration parsing and process launch.


Q48. How does the server differentiate between tunnel traffic and regular traffic?

A: In TunnelIngressFilter.doFilterInternal():

  1. /ws paths β†’ pass through (WebSocket upgrade)
  2. /error paths β†’ pass through (Spring error handling)
  3. Host doesn't end with .tunnel.rajeshbandi.site β†’ pass through (regular API endpoints)
  4. Host does end with .tunnel.rajeshbandi.site β†’ tunnel traffic, extract tunnelId and process

Q49. If you were asked to add WebSocket binary frame support, how would you do it?

A:

  1. Change TunnelWebSocketHandler to extend BinaryWebSocketHandler instead of TextWebSocketHandler
  2. Use a binary serialization format like Protocol Buffers or MessagePack instead of JSON
  3. Send HTTP bodies directly as binary instead of Base64-encoded JSON strings
  4. This would reduce payload size by ~33% and improve throughput
  5. On the client, switch TunnelWebSocketClient to use binary frames via the java-websocket library's send(byte[]) method

Q50. Summarize your project in one sentence for a resume.

A: "Built a reverse tunneling system (like ngrok) in Java/Spring Boot that exposes local development servers to the internet via WebSocket-based full-duplex communication, using CompletableFuture correlation for bridging synchronous HTTP with asynchronous message passing."