diff --git a/deploy.mts b/deploy.mts index b595327..6c87c4d 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" | "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[]; @@ -65,8 +70,30 @@ interface DeploymentResult { } const ROOT = dirname(fileURLToPath(import.meta.url)); -const CUSTOM_HOST = "app@external.simpleanalytics.com"; -const CUSTOM_ROOT = "/var/www/default"; +// 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. Escape the + // decoded values before inserting them into the script's string literal. + [ + '', + '{{js (.Req.URL.Query.Get "hostname")}}', + ], + [ + '', + '{{js (.Req.URL.Query.Get "path")}}', + ], +]; + const CDN_PUBLIC_ROOT = "https://scripts.simpleanalyticscdn.com"; const CDN_STORAGE_ROOT = "https://storage.bunnycdn.com/sa-cdn"; const CDN_PURGE_URL = @@ -127,8 +154,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 +177,17 @@ export function createManifest( selections.variants.includes(variant); const needsSri = hasScript("default") && hasVariant("sri"); + const addCaddyPair = ( + input: Omit[1], "destination">, + ) => { + if (!hasDestination("caddy")) return; + addScriptPair(files, { + ...input, + destination: "caddy", + transform: "caddy-ssi", + }); + }; + if (needsSri && version === undefined) { throw new Error("An SRI version is required when SRI is selected."); } @@ -169,24 +212,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, - }); - } + addCaddyPair({ + localPath: "dist/latest/custom/latest.js", + remotePath: "latest.js", + }); + addCaddyPair({ + localPath: "dist/latest/custom/e.js", + remotePath: "events.js", + }); + addCaddyPair({ + localPath: "dist/latest/custom/latest.dev.js", + remotePath: "latest.dev.js", + sourceMap: false, + }); } if (needsSri) { @@ -201,15 +239,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, - }); - } + addCaddyPair({ + immutable: true, + localPath: `dist/v${version}/custom/app.js`, + remotePath: `v${version}/app.js`, + version, + }); } if (hasScript("default") && hasVariant("light")) { @@ -221,39 +256,33 @@ export function createManifest( }); } - if (hasDestination("custom")) { - addScriptPair(files, { - destination: "custom", - localPath: "dist/latest/custom/light.js", - remotePath: "light.js", - }); + addCaddyPair({ + 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) { + addCaddyPair({ + immutable: true, + localPath: `dist/v${version}/custom/light.js`, + remotePath: `v${version}/light.js`, + version, + }); } } if ( hasScript("default") && hasVariant("proxy") && - hasDestination("custom") + hasDestination("caddy") ) { - addScriptPair(files, { - destination: "custom", + addCaddyPair({ localPath: "dist/latest/custom/proxy.js", remotePath: "proxy.js", }); if (needsSri) { - addScriptPair(files, { - destination: "custom", + addCaddyPair({ immutable: true, localPath: `dist/v${version}/custom/proxy.js`, remotePath: `v${version}/proxy.js`, @@ -271,22 +300,18 @@ export function createManifest( }); } - if (hasDestination("custom")) { - addScriptPair(files, { - destination: "custom", - localPath: "dist/latest/custom/auto-events.js", - remotePath: "auto-events.js", - }); + addCaddyPair({ + 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) { + addCaddyPair({ + immutable: true, + localPath: `dist/v${version}/custom/auto-events.js`, + remotePath: `v${version}/auto-events.js`, + version, + }); } } @@ -314,6 +339,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}}","{{js (.Req.URL.Query.Get "hostname")}}","{{js (.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/, + ); + }); +});