From 22f78da4cb782b866fbcc81a0f828478a54912f5 Mon Sep 17 00:00:00 2001 From: venus Date: Mon, 14 Sep 2026 01:24:07 -0500 Subject: [PATCH] fix(ingestion): trigger deterministic Eleventy builds on publish and unpublish --- sample-docker-compose.yml | 2 +- server.js | 69 +++++++++++++++++++++++++++++--------- test/ingestion-api.test.js | 11 +++++- 3 files changed, 65 insertions(+), 17 deletions(-) diff --git a/sample-docker-compose.yml b/sample-docker-compose.yml index b9095ff..fd6c424 100644 --- a/sample-docker-compose.yml +++ b/sample-docker-compose.yml @@ -20,7 +20,7 @@ services: - "WATCH=${WATCH:-false}" volumes: # Mount external markdown posts directly into the container - - "./posts:/app/src/posts:ro" + - "./posts:/app/src/posts" ports: - "${HOST_PORT:-${PORT:-8080}}:${PORT:-8080}" networks: diff --git a/server.js b/server.js index c192b76..2c73133 100644 --- a/server.js +++ b/server.js @@ -74,6 +74,49 @@ function slugify(text) { .replace(/^-+|-+$/g, ""); } +let isBuilding = false; +let buildQueued = false; + +/** + * Rebuilds the static site using Eleventy. + * Uses a build queue to prevent concurrent build races. + * @returns {Promise} + */ +export function triggerEleventyBuild() { + if (isBuilding) { + buildQueued = true; + return Promise.resolve(); + } + + const root = process.cwd(); + const hasConfig = fs.existsSync(path.join(root, "eleventy.config.js")) || + fs.existsSync(path.join(root, ".eleventy.js")) || + fs.existsSync(path.join(root, "node_modules/@11ty/eleventy")); + + if (!hasConfig) { + return Promise.resolve(); + } + + isBuilding = true; + return new Promise((resolve) => { + exec("npx @11ty/eleventy", (err) => { + isBuilding = false; + if (err) { + console.error("[Ingestion API] Eleventy build error:", err); + } else { + console.log("[Ingestion API] Eleventy build completed successfully."); + } + + if (buildQueued) { + buildQueued = false; + triggerEleventyBuild().then(resolve); + } else { + resolve(); + } + }); + }); +} + /** * Creates the HTTP server instance for serving static files and handling ingestion API * @param {string} [customSiteDir] Optional custom directory for _site @@ -85,8 +128,9 @@ export function createServer(customSiteDir, customPostsDir, customAssetsDir) { const siteDir = path.resolve(customSiteDir || path.join(process.cwd(), "_site")); const postsDir = path.resolve(customPostsDir || path.join(process.cwd(), "src/posts")); const assetsDir = path.resolve(customAssetsDir || path.join(process.cwd(), "src/assets/images/posts")); + const isCustomEnv = Boolean(customSiteDir || customPostsDir || customAssetsDir); - return http.createServer((req, res) => { + return http.createServer(async (req, res) => { // Standard security headers res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("X-Frame-Options", "DENY"); @@ -130,7 +174,7 @@ export function createServer(customSiteDir, customPostsDir, customAssetsDir) { } }); - req.on("end", () => { + req.on("end", async () => { let payload; try { payload = JSON.parse(body); @@ -182,6 +226,11 @@ export function createServer(customSiteDir, customPostsDir, customAssetsDir) { } } + // Rebuild static site with Eleventy if in standard environment + if (!isCustomEnv) { + await triggerEleventyBuild(); + } + res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ status: "published", @@ -228,19 +277,9 @@ export function createServer(customSiteDir, customPostsDir, customAssetsDir) { fs.unlinkSync(compiledHtml); } - // 3. Rebuild static collections (index.html, dispatches, tags) if in full Eleventy environment - try { - if (fs.existsSync(path.join(process.cwd(), ".eleventy.js"))) { - exec("npx @11ty/eleventy", (err) => { - if (err) { - console.error("[Ingestion API] Error rebuilding site after delete:", err); - } else { - console.log(`[Ingestion API] Site rebuild complete after unpublishing '${cleanSlug}'.`); - } - }); - } - } catch (rebuildErr) { - console.error("[Ingestion API] Failed to trigger Eleventy rebuild:", rebuildErr); + // 3. Rebuild static collections (index.html, dispatches, tags) with Eleventy + if (!isCustomEnv) { + await triggerEleventyBuild(); } res.writeHead(200, { "Content-Type": "application/json" }); diff --git a/test/ingestion-api.test.js b/test/ingestion-api.test.js index 84d7fff..554d578 100644 --- a/test/ingestion-api.test.js +++ b/test/ingestion-api.test.js @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; import http from "node:http"; -import { createServer } from "../server.js"; +import { createServer, triggerEleventyBuild } from "../server.js"; const TEST_SECRET = "super-secret-test-token"; @@ -274,3 +274,12 @@ test("Ingestion API: adversarial protection against path traversal in slug and D fs.rmSync(tmpPosts, { recursive: true, force: true }); } }); + +test("Ingestion API: triggerEleventyBuild builds site and handles concurrency", async () => { + assert.strictEqual(typeof triggerEleventyBuild, "function"); + // Trigger two concurrent calls to assert queuing does not crash or throw + const [b1, b2] = await Promise.all([triggerEleventyBuild(), triggerEleventyBuild()]); + assert.strictEqual(b1, undefined); + assert.strictEqual(b2, undefined); +}); +