Add subdirectory routing mode for public domain deployments

Apps can now be served at domain.com/appname/ instead of requiring
subdomain DNS records (appname.domain.com). Supports three subpath
modes per template: native (URL base env var), strip (handle_path),
and none (incompatible warning). Tested on Linux with deploy/removal
lifecycle verified.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 03:03:17 -08:00
parent f61e85d9a7
commit 77030931b7
13 changed files with 407 additions and 41 deletions

View File

@@ -109,6 +109,18 @@ module.exports = function(ctx) {
processed.docker.environment.PLEX_CLAIM = config.plexClaimToken;
}
// Inject URL base env var for subdirectory routing mode
if (ctx.siteConfig.routingMode === 'subdirectory' && template.urlBaseEnv) {
processed.docker.environment = processed.docker.environment || {};
const basePath = `/${config.subdomain}`;
// Some apps need the full URL, not just the path
if (['GF_SERVER_ROOT_URL', 'GITEA__server__ROOT_URL'].includes(template.urlBaseEnv)) {
processed.docker.environment[template.urlBaseEnv] = ctx.buildServiceUrl(config.subdomain) + '/';
} else {
processed.docker.environment[template.urlBaseEnv] = basePath;
}
}
// Apply custom volume overrides
if (config.customVolumes?.length && processed.docker?.volumes) {
processed.docker.volumes = processed.docker.volumes.map(vol => {
@@ -266,6 +278,105 @@ module.exports = function(ctx) {
await ctx.caddy.verifySite(domain);
}
// Reserved paths that cannot be used as subpath names in subdirectory mode
const RESERVED_SUBPATHS = ['api', 'probe', 'assets', 'health', 'dist', 'js', 'css', 'fonts', 'favicon.ico'];
/** Ensure the main domain block with route markers exists in the Caddyfile.
* Called before the first subdirectory app is deployed. */
async function ensureMainDomainBlock() {
if (ctx.siteConfig.routingMode !== 'subdirectory' || !ctx.siteConfig.domain) return;
const content = await ctx.caddy.read();
const domain = ctx.siteConfig.domain;
const ROUTE_MARKER = '# === DashCaddy App Routes ===';
// Already has markers — nothing to do
if (content.includes(ROUTE_MARKER)) return;
// Domain block exists but lacks markers — inject them
if (content.includes(`${domain} {`)) {
const result = await ctx.caddy.modify(c => {
// Insert markers before the final catch-all handle block inside the domain block
const domainStart = c.indexOf(`${domain} {`);
// Find standalone "handle {" (catch-all SPA fallback) — match tabs or spaces
const searchFrom = domainStart + domain.length;
const handleMatch = c.slice(searchFrom).match(/^([ \t]+)handle\s*\{/m);
if (!handleMatch) return null;
const handleIdx = searchFrom + handleMatch.index;
const indent = handleMatch[1];
const markerBlock = `${indent}${ROUTE_MARKER}\n${indent}# === End App Routes ===\n\n`;
return c.slice(0, handleIdx) + markerBlock + c.slice(handleIdx);
});
if (result.success) {
ctx.log.info('caddy', 'Injected route markers into existing domain block', { domain });
}
return;
}
// No domain block at all — create one with dashboard + markers
const dashboardRoot = platformPaths.sitePath('status');
const apiPort = process.env.PORT || 3001;
const block = `\n${domain} {\n root * ${dashboardRoot}\n encode gzip\n\n handle /api/* {\n reverse_proxy localhost:${apiPort}\n }\n\n handle /probe/* {\n reverse_proxy localhost:${apiPort}\n }\n\n ${ROUTE_MARKER}\n # === End App Routes ===\n\n handle {\n @notFile not file {path}\n rewrite @notFile /index.html\n file_server\n }\n}\n`;
const result = await ctx.caddy.modify(c => c + block);
if (result.success) {
ctx.log.info('caddy', 'Created main domain block with route markers', { domain });
} else {
throw new Error(`[DC-303] Failed to create main domain block for ${domain}: ${result.error}`);
}
}
/** Inject a subpath config block between route markers in the Caddyfile. */
async function addSubpathConfig(subdomain, configBlock) {
const marker = `# --- DashCaddy: ${subdomain} ---`;
const endMarker = `# --- End: ${subdomain} ---`;
const END_ROUTE_MARKER = '# === End App Routes ===';
const result = await ctx.caddy.modify(content => {
if (content.includes(marker)) {
ctx.log.info('caddy', 'Subpath config already exists, skipping', { subdomain });
return null;
}
const endIdx = content.indexOf(END_ROUTE_MARKER);
if (endIdx === -1) {
throw new Error(`Route marker "${END_ROUTE_MARKER}" not found in Caddyfile`);
}
// Detect indentation from the end marker line
const lineStart = content.lastIndexOf('\n', endIdx) + 1;
const indent = content.slice(lineStart, endIdx).match(/^([ \t]*)/)?.[1] || '\t';
const injection = `${indent}${marker}\n${configBlock}\n${indent}${endMarker}\n`;
return content.slice(0, endIdx) + injection + content.slice(endIdx);
});
if (!result.success) {
throw new Error(`[DC-303] Failed to add subpath config for ${subdomain}: ${result.error}`);
}
}
/** Remove a subpath config block from between its markers in the Caddyfile. */
async function removeSubpathConfig(subdomain) {
const marker = `# --- DashCaddy: ${subdomain} ---`;
const endMarker = `# --- End: ${subdomain} ---`;
return await ctx.caddy.modify(content => {
const startIdx = content.indexOf(marker);
if (startIdx === -1) return null;
const endIdx = content.indexOf(endMarker);
if (endIdx === -1) return null;
// Remove from the line start of the marker to the line end of the end marker
const lineStart = content.lastIndexOf('\n', startIdx);
const lineEnd = content.indexOf('\n', endIdx + endMarker.length);
const modified = content.slice(0, lineStart) + content.slice(lineEnd);
return modified.replace(/\n{3,}/g, '\n\n');
});
}
return {
checkPortConflicts,
findExistingContainerByImage,
@@ -273,6 +384,10 @@ module.exports = function(ctx) {
processTemplateVariables,
waitForHealthCheck,
addCaddyConfig,
addSubpathConfig,
removeSubpathConfig,
ensureMainDomainBlock,
RESERVED_SUBPATHS,
generateStaticSiteConfig
};
};