Breaking Rails: Adventures in Route Handling
When I set out to replace Rails’ view layer with Astro, I didn’t expect to end up rewriting the entire routing system. But that’s exactly what happened. Here’s the story of how a simple view layer replacement turned into an adventure in request routing and middleware manipulation.
The Initial Break: The Astro Concern
It started innocently enough. I created an Astro concern to handle the transformation of Rails’ view handling:
module Astro
extend ActiveSupport::Concern
included do
around_action :handle_astro_response
rescue_from(ActionController::MissingExactTemplate) do |_exception|
action = params[:action]
controller = controller_name
props = instance_variables.select { |v|
!v.to_s.start_with?('@_') &&
v.to_s != '@rendered_format' &&
v.to_s != '@marked_for_same_origin_verification'
}
props_hash = props.map { |v| [v.to_s[1..-1], instance_variable_get(v)] }.to_h
response.headers['X-Astro-View'] = "#{controller}/#{action}"
render json: props_hash, content_type: 'application/json'
end
before_action do
request.format = :json if request.xhr?
end
end
private
def handle_astro_response
request.format = :json if request.headers['X-Requested-With'] == 'XMLHttpRequest'
yield
if response.body.blank? && request.format.json?
props = instance_variables.select { |v|
!v.to_s.start_with?('@_') &&
v.to_s != '@rendered_format' &&
v.to_s != '@marked_for_same_origin_verification'
}
props_hash = props.map { |v| [v.to_s[1..-1], instance_variable_get(v)] }.to_h
response.headers['X-Astro-View'] = "#{controller_name}/#{action_name}"
render json: props_hash
end
end
end
This concern did something interesting: it intercepted Rails’ normal view rendering and instead returned JSON with a special header indicating which Astro view should render the response. But this was just the beginning of our routing adventure.
The Routing Challenge
The real complexity emerged when we needed to handle routing between Rails and Astro. We couldn’t just use Rails’ routing system anymore - we needed something that could coordinate between two different servers. Enter the custom Astro adapter:
import { defineConfig } from "astro/config";
import adapter from "./adapter/index.mjs";
import { experimental_AstroContainer } from "astro/container";
export default defineConfig({
output: "server",
adapter: adapter(),
srcDir: "./app/views",
integrations: [
{
name: "aor:dev",
hooks: {
async "astro:server:setup"({ server }) {
const container = await experimental_AstroContainer.create();
server.middlewares.use(async function middleware(
incomingMessage,
res,
next
) {
const request = toRequest(incomingMessage);
if (!request.url) return next();
const { searchParams } = new URL(request.url);
const stringifiedProps = searchParams.get("props");
const view = searchParams.get("view");
if (!view) {
return writeResponse(new Response(null, { status: 400 }), res);
}
let props = { message: "Placeholder" };
if (stringifiedProps) {
props = JSON.parse(stringifiedProps);
}
try {
const page = await server.ssrLoadModule(
`./app/views/${view}.astro`
);
const response = await container.renderToResponse(page.default, {
request,
props,
});
writeResponse(response, res);
} catch (e) {
const message = e instanceof Error ? e.message : `${e}`;
writeResponse(new Response(message, { status: 400 }), res);
}
});
},
},
},
],
});
The Plot Thickens: Request Transformation
One of the trickiest parts was handling the transformation of requests between Node.js and Rails formats. We needed to carefully preserve headers, handle body content, and manage different types of requests:
function toRequest(req: NodeRequest) {
const protocol = req.headers["x-forwarded-proto"] ??
("encrypted" in req.socket && req.socket.encrypted ? "https" : "http");
const hostname = req.headers["x-forwarded-host"] ??
req.headers.host ??
req.headers[":authority"];
const port = req.headers["x-forwarded-port"];
const portInHostname = typeof hostname === "string" &&
typeof port === "string" &&
hostname.endsWith(port);
const hostnamePort = portInHostname ?
hostname :
hostname + (port ? `:${port}` : "");
const url = `${protocol}://${hostnamePort}${req.url}`;
const options: RequestInit = {
method: req.method || "GET",
headers: makeRequestHeaders(req),
};
if (options.method !== "HEAD" && options.method !== "GET") {
Object.assign(options, makeRequestBody(req));
}
const request = new Request(url, options);
// Handle client IP address
const clientIp = req.headers["x-forwarded-for"];
if (clientIp) {
Reflect.set(request, clientAddressSymbol, clientIp);
} else if (req.socket?.remoteAddress) {
Reflect.set(request, clientAddressSymbol, req.socket.remoteAddress);
}
return request;
}
Lessons Learned
This adventure in breaking Rails taught me several important lessons:
-
Everything is Connected: In Rails, the routing system is intimately connected to the view layer. You can’t just replace one without affecting the other.
-
Middleware Matters: Much of the complexity in our solution came from properly handling middleware and request transformation. The devil is in the details of headers, body content, and request formats.
-
Error Handling is Critical: When you’re dealing with two different servers, error handling becomes twice as important. You need to catch and properly handle errors at multiple levels.
-
Performance Considerations: While our solution worked, it introduced additional network hops between Astro and Rails. This meant we needed to be extra careful about performance optimization.
The Unexpected Benefits
Despite the challenges, this architectural change brought some surprising benefits:
-
Better Separation of Concerns: Our frontend and backend became truly separate, making it easier to reason about each part of the system.
-
Improved Development Experience: Once set up, developers could work on frontend and backend components independently.
-
Enhanced Type Safety: With explicit JSON interfaces between Rails and Astro, we got better type checking and clearer contracts between components.
Looking Forward
This experiment showed that while Rails’ conventions are powerful, there’s value in thoughtfully breaking them when needed. The key is understanding the implications and being prepared to handle the cascading effects of such changes.
For those considering a similar architecture, here are some tips:
- Plan your routing strategy carefully
- Consider the performance implications of cross-server communication
- Invest time in proper error handling
- Document your conventions thoroughly
Breaking Rails isn’t always bad - sometimes it leads to better architectures than we initially imagined.