feat: add internal path routing and path stripping for domains

- Add internalPath and stripPath fields to domain schema
- Implement UI controls for configuring internal path routing
- Create Traefik middleware for path manipulation (addPrefix/stripPrefix)
- Support different external and internal paths for applications
- Enable path stripping for cleaner URL forwarding

This allows applications to be accessed via external paths while maintaining
different internal routing structures, useful for microservices and legacy
applications that expect specific path prefixes.
This commit is contained in:
Jhonatan Caldeira 2025-06-20 16:36:27 -03:00
parent be91b53c86
commit bb904bb011
8 changed files with 5949 additions and 1 deletions

View File

@ -49,6 +49,8 @@ export const domain = z
.object({
host: z.string().min(1, { message: "Add a hostname" }),
path: z.string().min(1).optional(),
internalPath: z.string().optional(),
stripPath: z.boolean().optional(),
port: z
.number()
.min(1, { message: "Port must be at least 1" })
@ -162,6 +164,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
defaultValues: {
host: "",
path: undefined,
internalPath: undefined,
stripPath: false,
port: undefined,
https: false,
certificateType: undefined,
@ -182,6 +186,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
...data,
/* Convert null to undefined */
path: data?.path || undefined,
internalPath: data?.internalPath || undefined,
stripPath: data?.stripPath || false,
port: data?.port || undefined,
certificateType: data?.certificateType || undefined,
customCertResolver: data?.customCertResolver || undefined,
@ -194,6 +200,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
form.reset({
host: "",
path: undefined,
internalPath: undefined,
stripPath: false,
port: undefined,
https: false,
certificateType: undefined,
@ -469,6 +477,47 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
}}
/>
<FormField
control={form.control}
name="internalPath"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Internal Path</FormLabel>
<FormDescription>
The path where your application expects to receive requests internally (defaults to "/")
</FormDescription>
<FormControl>
<Input placeholder={"/"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="stripPath"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 border rounded-lg shadow-sm">
<div className="space-y-0.5">
<FormLabel>Strip Path</FormLabel>
<FormDescription>
Remove the external path from the request before forwarding to the application
</FormDescription>
<FormMessage />
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="port"

View File

@ -0,0 +1,2 @@
ALTER TABLE "domain" ADD COLUMN "internalPath" text DEFAULT '/';--> statement-breakpoint
ALTER TABLE "domain" ADD COLUMN "stripPath" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@ -659,6 +659,13 @@
"when": 1750397258622,
"tag": "0093_nice_gorilla_man",
"breakpoints": true
},
{
"idx": 94,
"version": "7",
"when": 1750443891684,
"tag": "0094_brief_silver_fox",
"breakpoints": true
}
]
}

View File

@ -51,6 +51,8 @@ export const domains = pgTable("domain", {
{ onDelete: "cascade" },
),
certificateType: certificateType("certificateType").notNull().default("none"),
internalPath: text("internalPath").default("/"),
stripPath: boolean("stripPath").notNull().default(false),
});
export const domainsRelations = relations(domains, ({ one }) => ({
@ -82,6 +84,8 @@ export const apiCreateDomain = createSchema.pick({
serviceName: true,
domainType: true,
previewDeploymentId: true,
internalPath: true,
stripPath: true,
});
export const apiFindDomain = createSchema
@ -112,5 +116,7 @@ export const apiUpdateDomain = createSchema
customCertResolver: true,
serviceName: true,
domainType: true,
internalPath: true,
stripPath: true,
})
.merge(createSchema.pick({ domainId: true }).required());

View File

@ -10,6 +10,7 @@ import { findUserById } from "./admin";
import { findApplicationById } from "./application";
import { detectCDNProvider } from "./cdn";
import { findServerById } from "./server";
import type { ApplicationNested } from "../utils/builders";
export type Domain = typeof domains.$inferSelect;
@ -201,3 +202,44 @@ export const validateDomain = async (
};
}
};
export const createMultiPathDomain = async (
applicationId: string,
domains: Array<{
host: string;
externalPath?: string;
internalPath: string;
port?: number;
https?: boolean;
stripPath?: boolean;
}>
) => {
const app = await findApplicationById(applicationId);
if (!app) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Application not found",
});
}
const createdDomains = [];
for (const domainConfig of domains) {
const domain = await createDomain({
applicationId,
host: domainConfig.host,
path: domainConfig.externalPath || "/",
internalPath: domainConfig.internalPath,
port: domainConfig.port || 3000,
https: domainConfig.https || false,
stripPath: domainConfig.stripPath || false,
serviceName: app.appName,
domainType: "application",
certificateType: domainConfig.https ? "letsencrypt" : "none",
});
createdDomains.push(domain);
}
return createdDomains;
};

View File

@ -10,6 +10,7 @@ import {
writeTraefikConfigRemote,
} from "./application";
import type { FileConfig, HttpRouter } from "./file-types";
import { createPathMiddlewares, removePathMiddlewares } from "./middleware";
export const manageDomain = async (app: ApplicationNested, domain: Domain) => {
const { appName } = app;
@ -46,6 +47,8 @@ export const manageDomain = async (app: ApplicationNested, domain: Domain) => {
config.http.services[serviceName] = createServiceConfig(appName, domain);
await createPathMiddlewares(app, domain);
if (app.serverId) {
await writeTraefikConfigRemote(config, appName, app.serverId);
} else {
@ -80,6 +83,8 @@ export const removeDomain = async (
delete config.http.services[serviceKey];
}
await removePathMiddlewares(application, uniqueKey);
// verify if is the last router if so we delete the router
if (
config?.http?.routers &&
@ -107,7 +112,7 @@ export const createRouterConfig = async (
const { appName, redirects, security } = app;
const { certificateType } = domain;
const { host, path, https, uniqueConfigKey } = domain;
const { host, path, https, uniqueConfigKey, internalPath, stripPath } = domain;
const routerConfig: HttpRouter = {
rule: `Host(\`${host}\`)${path !== null && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`,
service: `${appName}-service-${uniqueConfigKey}`,
@ -115,6 +120,17 @@ export const createRouterConfig = async (
entryPoints: [entryPoint],
};
// Add path rewriting middleware if needed
if (internalPath && internalPath !== "/" && internalPath !== path) {
const pathMiddleware = `addprefix-${appName}-${uniqueConfigKey}`;
routerConfig.middlewares?.push(pathMiddleware);
}
if (stripPath && path && path !== "/") {
const stripMiddleware = `stripprefix-${appName}-${uniqueConfigKey}`;
routerConfig.middlewares?.push(stripMiddleware);
}
if (entryPoint === "web" && https) {
routerConfig.middlewares = ["redirect-to-https"];
}

View File

@ -105,3 +105,97 @@ export const writeMiddleware = <T>(config: T) => {
const newYamlContent = dump(config);
writeFileSync(configPath, newYamlContent, "utf8");
};
export const createPathMiddlewares = async (
app: ApplicationNested,
domain: any
) => {
let config: FileConfig;
if (app.serverId) {
try {
config = await loadRemoteMiddlewares(app.serverId);
} catch {
config = { http: { middlewares: {} } };
}
} else {
try {
config = loadMiddlewares<FileConfig>();
} catch {
config = { http: { middlewares: {} } };
}
}
const { appName } = app;
const { uniqueConfigKey, internalPath, stripPath, path } = domain;
if (!config.http) {
config.http = { middlewares: {} };
}
if (!config.http.middlewares) {
config.http.middlewares = {};
}
// Add internal path prefix middleware
if (internalPath && internalPath !== "/" && internalPath !== path) {
const middlewareName = `addprefix-${appName}-${uniqueConfigKey}`;
config.http.middlewares[middlewareName] = {
addPrefix: {
prefix: internalPath
}
};
}
// Strip external path middleware if needed
if (stripPath && path && path !== "/") {
const middlewareName = `stripprefix-${appName}-${uniqueConfigKey}`;
config.http.middlewares[middlewareName] = {
stripPrefix: {
prefixes: [path]
}
};
}
if (app.serverId) {
await writeTraefikConfigRemote(config, "middlewares", app.serverId);
} else {
writeMiddleware(config);
}
};
export const removePathMiddlewares = async (
app: ApplicationNested,
uniqueConfigKey: number
) => {
let config: FileConfig;
if (app.serverId) {
try {
config = await loadRemoteMiddlewares(app.serverId);
} catch {
return;
}
} else {
try {
config = loadMiddlewares<FileConfig>();
} catch {
return;
}
}
const { appName } = app;
if (config.http?.middlewares) {
const addPrefixMiddleware = `addprefix-${appName}-${uniqueConfigKey}`;
const stripPrefixMiddleware = `stripprefix-${appName}-${uniqueConfigKey}`;
delete config.http.middlewares[addPrefixMiddleware];
delete config.http.middlewares[stripPrefixMiddleware];
}
if (app.serverId) {
await writeTraefikConfigRemote(config, "middlewares", app.serverId);
} else {
writeMiddleware(config);
}
};