From 02c04bdb09c1a01eaf29129950be0202a46fc1d3 Mon Sep 17 00:00:00 2001 From: Lee Sanghyun <59863112+dev-ant@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:03:52 +0900 Subject: [PATCH 1/3] fix: recover sitemap projection deadlines and deployment probe --- .github/workflows/pr-verify.yml | 2 +- .github/workflows/push-cd-prod.yml | 2 +- package-lock.json | 4 +- package.json | 2 +- scripts/test-production-workflow.mjs | 2 +- scripts/workflow-contract.test.mjs | 4 +- src/app/sitemap.test.ts | 1 + src/lib/seo/public-api.ts | 7 ++- src/lib/seo/sitemaps-timeout.test.ts | 92 ++++++++++++++++++++++++++++ src/lib/seo/sitemaps.ts | 5 +- 10 files changed, 110 insertions(+), 11 deletions(-) create mode 100644 src/lib/seo/sitemaps-timeout.test.ts 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/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/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 || From 084647b5cf2dca673c5c7648d9dc9356dba8d8b7 Mon Sep 17 00:00:00 2001 From: Lee Sanghyun <59863112+dev-ant@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:09:00 +0900 Subject: [PATCH 2/3] fix: preserve homepage item links with public revalidation --- src/components/seo/PopularItemLinks.test.tsx | 1 + src/components/seo/PopularItemLinks.tsx | 1 + src/lib/items/data.test.ts | 16 ++++++++++++++++ src/lib/items/data.ts | 3 ++- 4 files changed, 20 insertions(+), 1 deletion(-) 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 || From 69fd1f390ba06e4e2eb01e1a2abcc28917c0f36c Mon Sep 17 00:00:00 2001 From: Lee Sanghyun <59863112+dev-ant@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:21:15 +0900 Subject: [PATCH 3/3] fix: revalidate homepage fallback after container startup --- src/app/(main)/page.tsx | 3 +++ 1 file changed, 3 insertions(+) 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: