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/, + ); + }); +});