Skip to content

Commit d0bb43f

Browse files
feat(GLA-1098): asset-library auto-rebuild watcher + chunk-error UI + CI smoke
- watch-and-build.mjs: fs.watch over app/lib/components/pages, 5s debounce, npm run build + pm2 restart asset-library; runs as asset-library-watcher pm2 process - ecosystem.config.js: register asset-library-watcher alongside asset-library - ChunkErrorBanner.tsx: window error listener catches ChunkLoadError, shows "Library is rebuilding — refresh in 10s" overlay with auto-reload countdown - layout.tsx: mount ChunkErrorBanner in root layout - asset-library-smoke.yml: GitHub Actions job triggered on asset-library/** changes; npm ci → npm run build → next start → curl / (200) → curl /asset/:id (200) Co-Authored-By: Paperclip <noreply@paperclip.ing>
1 parent 31be8ee commit d0bb43f

5 files changed

Lines changed: 219 additions & 2 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
name: Asset Library Smoke
2+
3+
on:
4+
pull_request:
5+
branches: [master]
6+
paths:
7+
- "scripts/asset-library/**"
8+
- ".github/workflows/asset-library-smoke.yml"
9+
10+
jobs:
11+
smoke:
12+
runs-on: ubuntu-latest
13+
timeout-minutes: 15
14+
15+
defaults:
16+
run:
17+
working-directory: scripts/asset-library
18+
19+
steps:
20+
- uses: actions/checkout@v4
21+
22+
- uses: actions/setup-node@v4
23+
with:
24+
node-version: 20
25+
cache: npm
26+
cache-dependency-path: scripts/asset-library/package-lock.json
27+
28+
- name: Install
29+
run: npm ci
30+
31+
- name: Build
32+
run: npm run build
33+
env:
34+
NODE_ENV: production
35+
36+
- name: Start
37+
run: node node_modules/.bin/next start -p 7700 &
38+
env:
39+
NODE_ENV: production
40+
41+
- name: Wait for server (30s)
42+
run: |
43+
for i in $(seq 1 30); do
44+
if curl -sf -o /dev/null http://127.0.0.1:7700/; then
45+
echo "server up after ${i}s"
46+
exit 0
47+
fi
48+
sleep 1
49+
done
50+
echo "FAIL: server did not respond within 30s" >&2
51+
exit 1
52+
53+
- name: Smoke — root (expect 200)
54+
run: |
55+
code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:7700/)
56+
[ "$code" = "200" ] || { echo "root: expected 200, got $code" >&2; exit 1; }
57+
58+
- name: Smoke — /asset/:id (expect 200, graceful not-found page)
59+
run: |
60+
code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:7700/asset/ci-smoke-placeholder)
61+
[ "$code" = "200" ] || { echo "/asset/ci-smoke-placeholder: expected 200, got $code" >&2; exit 1; }
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
5+
export default function ChunkErrorBanner() {
6+
const [show, setShow] = useState(false);
7+
const [countdown, setCountdown] = useState(10);
8+
9+
useEffect(() => {
10+
function handleError(event: ErrorEvent) {
11+
const isChunk =
12+
event.error?.name === "ChunkLoadError" ||
13+
String(event.message).includes("Loading chunk") ||
14+
String(event.message).includes("Failed to fetch dynamically imported") ||
15+
String(event.message).includes("ChunkLoadError");
16+
if (isChunk) setShow(true);
17+
}
18+
window.addEventListener("error", handleError);
19+
return () => window.removeEventListener("error", handleError);
20+
}, []);
21+
22+
useEffect(() => {
23+
if (!show) return;
24+
if (countdown <= 0) {
25+
window.location.reload();
26+
return;
27+
}
28+
const t = setTimeout(() => setCountdown((c) => c - 1), 1000);
29+
return () => clearTimeout(t);
30+
}, [show, countdown]);
31+
32+
if (!show) return null;
33+
34+
return (
35+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-neutral-950/90 backdrop-blur-sm">
36+
<div className="max-w-sm w-full mx-4 rounded-xl border border-amber-700/60 bg-amber-950/40 p-8 text-center">
37+
<div className="text-3xl mb-4 animate-spin inline-block"></div>
38+
<h2 className="text-lg font-semibold text-amber-200 mb-2">
39+
Library is rebuilding
40+
</h2>
41+
<p className="text-sm text-amber-300/80 mb-4">
42+
A code update is being applied. Refreshing in{" "}
43+
<span className="font-bold text-amber-200">{countdown}s</span>.
44+
</p>
45+
<button
46+
onClick={() => window.location.reload()}
47+
className="text-xs text-amber-400 underline hover:text-amber-200"
48+
>
49+
Refresh now
50+
</button>
51+
</div>
52+
</div>
53+
);
54+
}
Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,33 @@
11
import type { Metadata } from "next";
22
import "./globals.css";
3+
import Nav from "./components/Nav";
4+
import ChunkErrorBanner from "./components/ChunkErrorBanner";
35

46
export const metadata: Metadata = {
57
title: "Marketing Asset Library",
68
description: "Review and approve marketing assets — Paperclip [review-and-ship] queue.",
79
};
810

9-
1011
export default function RootLayout({
1112
children,
1213
}: Readonly<{ children: React.ReactNode }>) {
1314
return (
1415
<html lang="en">
15-
<body className="antialiased">{children}</body>
16+
<body className="antialiased bg-neutral-950 text-neutral-100 min-h-screen overflow-x-hidden">
17+
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
18+
<header className="mb-6">
19+
<h1 className="text-xl font-semibold tracking-tight text-white mb-1">
20+
OpenRunner · Marketing Asset Library
21+
</h1>
22+
<p className="text-xs text-neutral-500">
23+
Internal review surface — <a href="http://127.0.0.1:7700/" className="underline hover:text-neutral-300">http://127.0.0.1:7700/</a>
24+
</p>
25+
</header>
26+
<Nav />
27+
<ChunkErrorBanner />
28+
<main>{children}</main>
29+
</div>
30+
</body>
1631
</html>
1732
);
1833
}

scripts/asset-library/ecosystem.config.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,5 +55,19 @@ module.exports = {
5555
...notifierEnv,
5656
},
5757
},
58+
{
59+
// Watches source dirs and triggers rebuild + pm2 restart on change.
60+
// Debounced 5s so rapid multi-file saves don't stack builds.
61+
name: "asset-library-watcher",
62+
cwd: path.resolve(__dirname),
63+
script: path.resolve(__dirname, "watch-and-build.mjs"),
64+
interpreter: `/opt/homebrew/opt/node@20/bin/node`,
65+
autorestart: true,
66+
max_restarts: 5,
67+
env: {
68+
NODE_ENV: "production",
69+
PATH: `/opt/homebrew/opt/node@20/bin:${process.env.PATH ?? ""}`,
70+
},
71+
},
5872
],
5973
};
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
#!/usr/bin/env node
2+
// Sibling pm2 process: asset-library-watcher
3+
// Watches source dirs, debounces 5s, rebuilds + restarts asset-library.
4+
import { watch } from "node:fs";
5+
import { execSync } from "node:child_process";
6+
import { resolve, dirname } from "node:path";
7+
import { fileURLToPath } from "node:url";
8+
9+
const __dirname = dirname(fileURLToPath(import.meta.url));
10+
const ROOT = resolve(__dirname);
11+
12+
const WATCH_DIRS = ["app", "lib", "components", "pages"];
13+
const DEBOUNCE_MS = 5000;
14+
15+
const NODE_PATH = `/opt/homebrew/opt/node@20/bin:${process.env.PATH ?? ""}`;
16+
17+
function log(msg) {
18+
process.stdout.write(`[asset-library-watcher] ${new Date().toISOString()} ${msg}\n`);
19+
}
20+
21+
let debounceTimer = null;
22+
let building = false;
23+
24+
function triggerBuild(changedPath) {
25+
if (debounceTimer) clearTimeout(debounceTimer);
26+
debounceTimer = setTimeout(() => {
27+
if (building) {
28+
log("build in progress — skipping");
29+
return;
30+
}
31+
building = true;
32+
log(`change: ${changedPath} — npm run build`);
33+
try {
34+
execSync("npm run build", {
35+
cwd: ROOT,
36+
stdio: "inherit",
37+
env: { ...process.env, NODE_ENV: "production", PATH: NODE_PATH },
38+
});
39+
log("build done — pm2 restart asset-library");
40+
execSync("pm2 restart asset-library", { stdio: "inherit" });
41+
log("restarted");
42+
} catch (err) {
43+
log(`ERROR: ${err.message}`);
44+
} finally {
45+
building = false;
46+
}
47+
}, DEBOUNCE_MS);
48+
}
49+
50+
let watching = 0;
51+
for (const dir of WATCH_DIRS) {
52+
const abs = resolve(ROOT, dir);
53+
try {
54+
watch(abs, { recursive: true }, (_event, filename) => {
55+
triggerBuild(`${abs}/${filename ?? "?"}`);
56+
});
57+
log(`watching ${abs}`);
58+
watching++;
59+
} catch (err) {
60+
if (err.code === "ENOENT") {
61+
log(`skip ${abs} — not found`);
62+
} else {
63+
throw err;
64+
}
65+
}
66+
}
67+
68+
if (watching === 0) {
69+
log("ERROR: no directories found to watch — exiting");
70+
process.exit(1);
71+
}
72+
73+
log("watcher ready");

0 commit comments

Comments
 (0)