From b7b511be9de0d3221671009c53f7461026a6e2df Mon Sep 17 00:00:00 2001 From: Jim van der Voort Date: Thu, 10 Sep 2026 12:01:43 +0200 Subject: [PATCH] Add Caddy custom-domains deploy destination Add a "caddy" destination alongside "custom" for the new Caddy-based custom-domains setup (elastic-infra ansible/vars/apps/custom-domains.yml). It uploads the same files as the old external nginx server, but rewrites the nginx SSI directives to Caddy template placeholders on upload, since Caddy's templates directive injects the request hostname and the proxy-product query parameters at serve time. The caddy-ssi transform fails the deploy if a build contains stray Go template delimiters or an SSI directive without a known replacement, so a bad build cannot turn into per-request 500s. Uploads go via a temporary file so the transformed bytes are what lands on the server, and the post-upload verification compares against the transformed content. A --caddy-only flag deploys exclusively to the new setup, leaving the CDN and the old external server untouched during the migration. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ApznmYeffWY6YojKXhEJDW --- deploy.mts | 332 +++++++++++++++++++++++++++++-------------- test/deploy.test.mts | 74 ++++++++++ 2 files changed, 303 insertions(+), 103 deletions(-) diff --git a/deploy.mts b/deploy.mts index b595327..3576af4 100644 --- a/deploy.mts +++ b/deploy.mts @@ -10,16 +10,21 @@ import { spinner, } from "@clack/prompts"; import { spawn } from "node:child_process"; -import { readdir, readFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; +import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; export type ScriptChoice = "default" | "auto-events"; -export type Destination = "cdn" | "custom"; +export type Destination = "cdn" | "custom" | "caddy"; export type EmbedVariant = "latest" | "sri" | "light" | "proxy"; export type FileStatus = "create" | "replace" | "unchanged" | "blocked"; type FileKind = "javascript" | "source-map"; -type ContentTransform = "none" | "cdn-sri-javascript" | "cdn-sri-map"; +type ContentTransform = + | "none" + | "cdn-sri-javascript" + | "cdn-sri-map" + | "caddy-ssi"; export interface DeploySelections { scripts: ScriptChoice[]; @@ -67,6 +72,34 @@ interface DeploymentResult { const ROOT = dirname(fileURLToPath(import.meta.url)); const CUSTOM_HOST = "app@external.simpleanalytics.com"; const CUSTOM_ROOT = "/var/www/default"; +// New Caddy-based custom-domains setup (elastic-infra ansible/vars/apps/custom-domains.yml). +// Caddy injects the request hostname at serve time with its templates +// directive, so the nginx SSI directives are rewritten to Go template +// placeholders (or their static defaults) on upload. +const CADDY_HOST = "user@esapp05.simpleanalytics.com"; +const CADDY_ROOT = "/home/user/apps/custom-domains-www"; +const CADDY_SSI_REPLACEMENTS: ReadonlyArray = [ + ['', "{{.Req.Host}}"], + // Substituted from the proxy-product query parameters at serve time + // (docs.simpleanalytics.com/proxy), like nginx's `set $proxy_hostname + // $arg_hostname`. Custom-domain requests carry no such args, so these + // render as "" there — matching the old nginx vhost's defined-but-empty + // variables (its SSI defaults never applied). Go's Query.Get + // percent-decodes, which covers nginx's %2F-rewrite hack too. + ['', '{{.Req.URL.Query.Get "hostname"}}'], + ['', '{{.Req.URL.Query.Get "path"}}'], +]; + +interface RemoteTarget { + host: string; + root: string; +} + +function remoteTarget(destination: "custom" | "caddy"): RemoteTarget { + return destination === "custom" + ? { host: CUSTOM_HOST, root: CUSTOM_ROOT } + : { host: CADDY_HOST, root: CADDY_ROOT }; +} const CDN_PUBLIC_ROOT = "https://scripts.simpleanalyticscdn.com"; const CDN_STORAGE_ROOT = "https://storage.bunnycdn.com/sa-cdn"; const CDN_PURGE_URL = @@ -127,8 +160,13 @@ function addScriptPair( kind: "source-map", localPath: `${localPath}.map`, remotePath: `${remotePath}.map`, + // Source maps carry the SSI directive too, so caddy-ssi applies to both. transform: - transform === "cdn-sri-javascript" ? "cdn-sri-map" : "none", + transform === "cdn-sri-javascript" + ? "cdn-sri-map" + : transform === "caddy-ssi" + ? "caddy-ssi" + : "none", version, }); } @@ -145,6 +183,21 @@ export function createManifest( selections.variants.includes(variant); const needsSri = hasScript("default") && hasVariant("sri"); + // The old (nginx) and new (Caddy) custom-domain servers host the same + // files; Caddy uploads additionally rewrite the SSI directives. + const serverDestinations = (["custom", "caddy"] as const).filter(hasDestination); + const addServerPair = ( + input: Omit[1], "destination">, + ) => { + for (const destination of serverDestinations) { + addScriptPair(files, { + ...input, + destination, + transform: destination === "caddy" ? "caddy-ssi" : input.transform, + }); + } + }; + if (needsSri && version === undefined) { throw new Error("An SRI version is required when SRI is selected."); } @@ -169,24 +222,19 @@ export function createManifest( }); } - if (hasDestination("custom")) { - addScriptPair(files, { - destination: "custom", - localPath: "dist/latest/custom/latest.js", - remotePath: "latest.js", - }); - addScriptPair(files, { - destination: "custom", - localPath: "dist/latest/custom/e.js", - remotePath: "events.js", - }); - addScriptPair(files, { - destination: "custom", - localPath: "dist/latest/custom/latest.dev.js", - remotePath: "latest.dev.js", - sourceMap: false, - }); - } + addServerPair({ + localPath: "dist/latest/custom/latest.js", + remotePath: "latest.js", + }); + addServerPair({ + localPath: "dist/latest/custom/e.js", + remotePath: "events.js", + }); + addServerPair({ + localPath: "dist/latest/custom/latest.dev.js", + remotePath: "latest.dev.js", + sourceMap: false, + }); } if (needsSri) { @@ -201,15 +249,12 @@ export function createManifest( }); } - if (hasDestination("custom")) { - addScriptPair(files, { - destination: "custom", - immutable: true, - localPath: `dist/v${version}/custom/app.js`, - remotePath: `v${version}/app.js`, - version, - }); - } + addServerPair({ + immutable: true, + localPath: `dist/v${version}/custom/app.js`, + remotePath: `v${version}/app.js`, + version, + }); } if (hasScript("default") && hasVariant("light")) { @@ -221,39 +266,33 @@ export function createManifest( }); } - if (hasDestination("custom")) { - addScriptPair(files, { - destination: "custom", - localPath: "dist/latest/custom/light.js", - remotePath: "light.js", - }); + addServerPair({ + localPath: "dist/latest/custom/light.js", + remotePath: "light.js", + }); - if (needsSri) { - addScriptPair(files, { - destination: "custom", - immutable: true, - localPath: `dist/v${version}/custom/light.js`, - remotePath: `v${version}/light.js`, - version, - }); - } + if (needsSri) { + addServerPair({ + immutable: true, + localPath: `dist/v${version}/custom/light.js`, + remotePath: `v${version}/light.js`, + version, + }); } } if ( hasScript("default") && hasVariant("proxy") && - hasDestination("custom") + serverDestinations.length > 0 ) { - addScriptPair(files, { - destination: "custom", + addServerPair({ localPath: "dist/latest/custom/proxy.js", remotePath: "proxy.js", }); if (needsSri) { - addScriptPair(files, { - destination: "custom", + addServerPair({ immutable: true, localPath: `dist/v${version}/custom/proxy.js`, remotePath: `v${version}/proxy.js`, @@ -271,22 +310,18 @@ export function createManifest( }); } - if (hasDestination("custom")) { - addScriptPair(files, { - destination: "custom", - localPath: "dist/latest/custom/auto-events.js", - remotePath: "auto-events.js", - }); + addServerPair({ + localPath: "dist/latest/custom/auto-events.js", + remotePath: "auto-events.js", + }); - if (needsSri) { - addScriptPair(files, { - destination: "custom", - immutable: true, - localPath: `dist/v${version}/custom/auto-events.js`, - remotePath: `v${version}/auto-events.js`, - version, - }); - } + if (needsSri) { + addServerPair({ + immutable: true, + localPath: `dist/v${version}/custom/auto-events.js`, + remotePath: `v${version}/auto-events.js`, + version, + }); } } @@ -314,6 +349,27 @@ async function discoverSriVersion(): Promise { export function transformContent(file: DeployFile, content: Buffer): Buffer { if (file.transform === "none") return content; + + if (file.transform === "caddy-ssi") { + let source = content.toString("utf8"); + // Caddy's templates directive would choke on (or execute) stray actions, + // turning a bad build into per-request 500s — fail the deploy instead. + if (source.includes("{{")) { + throw new Error( + `${file.localPath} contains Go template delimiters ("{{"); Caddy's templates directive cannot serve it safely.`, + ); + } + for (const [pattern, replacement] of CADDY_SSI_REPLACEMENTS) { + source = source.replaceAll(pattern, replacement); + } + if (source.includes("","","")', + ), + ).toString(), + 'a("{{.Req.Host}}","{{.Req.URL.Query.Get "hostname"}}","{{.Req.URL.Query.Get "path"}}")', + ); + }); + + it("passes through files without directives unchanged", () => { + const content = Buffer.from("plain();\n"); + assert.equal(transformContent(caddyFile, content).toString(), "plain();\n"); + }); + + it("rejects stray Go template delimiters", () => { + assert.throws( + () => transformContent(caddyFile, Buffer.from('var a = "{{oops"')), + /Go template delimiters/, + ); + }); + + it("rejects SSI directives without a replacement", () => { + assert.throws( + () => + transformContent( + caddyFile, + Buffer.from('a("")'), + ), + /no Caddy replacement/, + ); + }); +});