diff --git a/.github/workflows/pr-verify.yml b/.github/workflows/pr-verify.yml
index 07ec0bc..8635a1e 100644
--- a/.github/workflows/pr-verify.yml
+++ b/.github/workflows/pr-verify.yml
@@ -28,7 +28,7 @@ jobs:
ci:
needs: workflow-contract
- uses: devnogi/ci-workflows/.github/workflows/node-ci.yml@edc5b2b8ac5270b60227f995ead0dfa60e04b731
+ uses: devnogi/ci-workflows/.github/workflows/node-ci.yml@75a5268f90bc8b14fa8b56f36cef4a747e2471d9
with:
service-name: react
dockerfile: Dockerfile
diff --git a/.github/workflows/push-cd-prod.yml b/.github/workflows/push-cd-prod.yml
index 36bd098..03b5f96 100644
--- a/.github/workflows/push-cd-prod.yml
+++ b/.github/workflows/push-cd-prod.yml
@@ -29,7 +29,7 @@ jobs:
production:
needs: workflow-contract
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
- uses: devnogi/ci-workflows/.github/workflows/node-prod.yml@edc5b2b8ac5270b60227f995ead0dfa60e04b731
+ uses: devnogi/ci-workflows/.github/workflows/node-prod.yml@75a5268f90bc8b14fa8b56f36cef4a747e2471d9
with:
service-name: react
deployment-target: prod2
diff --git a/package-lock.json b/package-lock.json
index 6ed4916..10d57f5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "devnogi-front",
- "version": "1.0.31",
+ "version": "1.0.32",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "devnogi-front",
- "version": "1.0.31",
+ "version": "1.0.32",
"dependencies": {
"@hookform/resolvers": "^5.1.1",
"@radix-ui/react-checkbox": "^1.3.3",
diff --git a/package.json b/package.json
index c5ce303..0707b4e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "devnogi-front",
- "version": "1.0.31",
+ "version": "1.0.32",
"private": true,
"scripts": {
"dev": "next dev",
diff --git a/scripts/test-production-workflow.mjs b/scripts/test-production-workflow.mjs
index be3d047..064c9d6 100644
--- a/scripts/test-production-workflow.mjs
+++ b/scripts/test-production-workflow.mjs
@@ -64,7 +64,7 @@ test("production Compose requires the immutable image reference", () => {
test("package and lockfile agree on the next release version", () => {
const pkg = JSON.parse(read("package.json"));
const lock = JSON.parse(read("package-lock.json"));
- assert.equal(pkg.version, "1.0.31");
+ assert.equal(pkg.version, "1.0.32");
assert.equal(lock.version, pkg.version);
assert.equal(lock.packages[""].version, pkg.version);
});
diff --git a/scripts/workflow-contract.test.mjs b/scripts/workflow-contract.test.mjs
index 8ad5edf..b85ca40 100644
--- a/scripts/workflow-contract.test.mjs
+++ b/scripts/workflow-contract.test.mjs
@@ -8,7 +8,7 @@ const read = (path) =>
"\n",
);
// prettier-ignore
-const pin = 'edc5b2b8ac5270b60227f995ead0dfa60e04b731';
+const pin = '75a5268f90bc8b14fa8b56f36cef4a747e2471d9';
const ci = read(".github/workflows/pr-verify.yml");
const production = read(".github/workflows/push-cd-prod.yml");
@@ -68,7 +68,7 @@ test("release version and immutable image contract are fixed", () => {
);
const pkg = JSON.parse(read("package.json"));
const lock = JSON.parse(read("package-lock.json"));
- assert.equal(pkg.version, "1.0.31");
+ assert.equal(pkg.version, "1.0.32");
assert.equal(lock.version, pkg.version);
assert.equal(lock.packages[""].version, pkg.version);
assert.match(
diff --git a/src/app/(main)/page.tsx b/src/app/(main)/page.tsx
index bf58c3f..34d8e6e 100644
--- a/src/app/(main)/page.tsx
+++ b/src/app/(main)/page.tsx
@@ -3,6 +3,9 @@ import type { Metadata } from "next";
import HomeLanding from "@/components/seo/HomeLanding";
import { createPageMetadata } from "@/lib/seo/site";
+// Docker builds have no gateway; retry a build-time fallback after deployment.
+export const revalidate = 300;
+
export const metadata: Metadata = createPageMetadata({
title: "마비노기 경매장·시세·아이템 정보",
description:
diff --git a/src/app/sitemap.test.ts b/src/app/sitemap.test.ts
index 201b6fa..6f92f69 100644
--- a/src/app/sitemap.test.ts
+++ b/src/app/sitemap.test.ts
@@ -65,6 +65,7 @@ describe("public sitemap XML", () => {
expect(get).toHaveBeenCalledTimes(1);
expect(get).toHaveBeenCalledWith(
"/dcs/api/posts/sitemap?limit=1000&cursor=99",
+ { timeoutMs: 20000 },
);
expect(xml).toContain("/community/100");
expect(xml).toContain("2026-11-13T09:00:00+09:00");
diff --git a/src/components/seo/PopularItemLinks.test.tsx b/src/components/seo/PopularItemLinks.test.tsx
index d974506..7fbbed6 100644
--- a/src/components/seo/PopularItemLinks.test.tsx
+++ b/src/components/seo/PopularItemLinks.test.tsx
@@ -22,6 +22,7 @@ it("renders links only for exact known catalogue entries", async () => {
expect(renderToStaticMarkup(await PopularItemLinks())).toContain(
itemPath(item),
);
+ expect(getItemSeo).toHaveBeenCalledWith(expect.any(String), 300);
jest.mocked(getItemSeo).mockResolvedValue(null);
expect(await PopularItemLinks()).toBeNull();
});
diff --git a/src/components/seo/PopularItemLinks.tsx b/src/components/seo/PopularItemLinks.tsx
index 4da0a80..a60c836 100644
--- a/src/components/seo/PopularItemLinks.tsx
+++ b/src/components/seo/PopularItemLinks.tsx
@@ -15,6 +15,7 @@ export default async function PopularItemLinks() {
topCategory: item.itemTopCategory,
subCategory: item.itemSubCategory,
}),
+ 300,
),
),
);
diff --git a/src/lib/items/data.test.ts b/src/lib/items/data.test.ts
index 1961095..de352f9 100644
--- a/src/lib/items/data.test.ts
+++ b/src/lib/items/data.test.ts
@@ -11,6 +11,22 @@ beforeEach(() => {
});
afterEach(() => jest.restoreAllMocks());
+it("keeps detail lookups uncached and only opts homepage lookups into five-minute revalidation", async () => {
+ jest
+ .mocked(fetch)
+ .mockResolvedValue(response({ item, seoEligible: true, lastTrade: null }));
+ await getItemSeo(encodeItemKey(item));
+ expect(jest.mocked(fetch).mock.calls[0][1]).toMatchObject({
+ cache: "no-store",
+ });
+ expect(jest.mocked(fetch).mock.calls[0][1]).not.toHaveProperty("next");
+ await getItemSeo(encodeItemKey(item), 300);
+ expect(jest.mocked(fetch).mock.calls[1][1]).toMatchObject({
+ next: { revalidate: 300 },
+ });
+ expect(jest.mocked(fetch).mock.calls[1][1]).not.toHaveProperty("cache");
+});
+
it("does not confuse missing catalogue items and transient errors", async () => {
jest.mocked(fetch).mockResolvedValueOnce(response({}, 404));
await expect(getItemSeo(encodeItemKey(item))).resolves.toBeNull();
diff --git a/src/lib/items/data.ts b/src/lib/items/data.ts
index 67ef0d7..1ed7a91 100644
--- a/src/lib/items/data.ts
+++ b/src/lib/items/data.ts
@@ -140,13 +140,14 @@ export async function getItemStatistics(
}
export const getItemSeo = cache(
- async (key: string): Promise => {
+ async (key: string, revalidate?: 300): Promise => {
const identity = decodeItemKey(key);
if (!identity) return null;
try {
const params = new URLSearchParams({ ...identity });
const result = await getPublicItemJson(
`/oab/api/item-infos/seo?${params}`,
+ revalidate,
);
if (
!result?.item ||
diff --git a/src/lib/seo/public-api.ts b/src/lib/seo/public-api.ts
index ff5694d..9a36d75 100644
--- a/src/lib/seo/public-api.ts
+++ b/src/lib/seo/public-api.ts
@@ -7,12 +7,15 @@ export class PublicApiError extends Error {
}
/** Public rendering must not inherit a visitor's session or cache private data. */
-export async function publicJson(path: string): Promise {
+export async function publicJson(
+ path: string,
+ { timeoutMs = 5000 }: { timeoutMs?: 5000 | 20000 } = {},
+): Promise {
const response = await fetch(new URL(path, selectServerGatewayUrl()), {
cache: "no-store",
credentials: "omit",
headers: { Accept: "application/json" },
- signal: AbortSignal.timeout(5000),
+ signal: AbortSignal.timeout(timeoutMs),
});
if (!response.ok) throw new PublicApiError(response.status);
const payload = await response.json();
diff --git a/src/lib/seo/sitemaps-timeout.test.ts b/src/lib/seo/sitemaps-timeout.test.ts
new file mode 100644
index 0000000..ed35f94
--- /dev/null
+++ b/src/lib/seo/sitemaps-timeout.test.ts
@@ -0,0 +1,92 @@
+import { publicJson } from "./public-api";
+
+const projection = {
+ items: [{ id: 1, updatedAt: null }],
+ nextCursor: null,
+ hasNext: false,
+};
+const request = () => new Request("https://www.memonogi.com/sitemap-posts.xml");
+
+function delayedFetch(delay: number) {
+ jest.spyOn(globalThis, "fetch").mockImplementation(
+ (_url, init) =>
+ new Promise((resolve, reject) => {
+ const timer = setTimeout(
+ () =>
+ resolve(
+ new Response(JSON.stringify({ success: true, data: projection })),
+ ),
+ delay,
+ );
+ init?.signal?.addEventListener(
+ "abort",
+ () => {
+ clearTimeout(timer);
+ reject(init.signal?.reason);
+ },
+ { once: true },
+ );
+ }),
+ );
+}
+
+beforeEach(() => {
+ jest.resetModules();
+ jest.useFakeTimers();
+ process.env.GATEWAY_URL = "http://synthetic-gateway.invalid";
+ // Native AbortSignal timers are outside Jest's clock; retain real abort events.
+ jest.spyOn(AbortSignal, "timeout").mockImplementation((milliseconds) => {
+ const controller = new AbortController();
+ setTimeout(
+ () =>
+ controller.abort(new DOMException("Deadline exceeded", "TimeoutError")),
+ milliseconds,
+ );
+ return controller.signal;
+ });
+});
+afterEach(() => {
+ jest.restoreAllMocks();
+ jest.useRealTimers();
+});
+
+it("keeps ordinary public requests at five seconds", async () => {
+ delayedFetch(8000);
+ const result = publicJson("/dcs/api/posts/feed").catch((error) => error);
+ await jest.advanceTimersByTimeAsync(5000);
+ expect(await result).toMatchObject({ name: "TimeoutError" });
+ expect(AbortSignal.timeout).toHaveBeenCalledWith(5000);
+});
+
+it("accepts a slow successful sitemap projection and caches complete XML", async () => {
+ delayedFetch(8000);
+ const { sitemapResponse } = await import("./sitemaps");
+ const pending = sitemapResponse("posts", request());
+ await jest.advanceTimersByTimeAsync(8000);
+ const response = await pending;
+ expect(response.status).toBe(200);
+ expect(await response.text()).toContain("/community/1");
+ expect(AbortSignal.timeout).toHaveBeenCalledWith(20000);
+ expect((await sitemapResponse("posts", request())).status).toBe(200);
+ expect(fetch).toHaveBeenCalledTimes(1);
+});
+
+it("aborts at twenty seconds without caching an empty result and retries successfully", async () => {
+ delayedFetch(25000);
+ const { sitemapResponse } = await import("./sitemaps");
+ const pending = sitemapResponse("posts", request());
+ await jest.advanceTimersByTimeAsync(20000);
+ const response = await pending;
+ expect(response.status).toBe(503);
+ expect(await response.text()).not.toContain("");
+ expect((await sitemapResponse("posts", request())).status).toBe(200);
+ expect(fetch).toHaveBeenCalledTimes(2);
+});
diff --git a/src/lib/seo/sitemaps.ts b/src/lib/seo/sitemaps.ts
index b805fa1..f933ba4 100644
--- a/src/lib/seo/sitemaps.ts
+++ b/src/lib/seo/sitemaps.ts
@@ -67,7 +67,10 @@ function validCursor(kind: Kind, cursor: string) {
async function projection(kind: Kind, cursor: string): Promise {
const params = new URLSearchParams({ limit: "1000" });
if (cursor) params.set("cursor", cursor);
- const data = await publicJson(`${sources[kind]}?${params}`);
+ // Catalogue projections may exceed the interactive page's five-second budget.
+ const data = await publicJson(`${sources[kind]}?${params}`, {
+ timeoutMs: 20000,
+ });
if (
!Array.isArray(data?.items) ||
data.items.length > 1000 ||